diff --git a/routes/sstv.py b/routes/sstv.py index 74bd47c..1a92772 100644 --- a/routes/sstv.py +++ b/routes/sstv.py @@ -287,6 +287,73 @@ def get_image(filename: str): return send_file(image_path, mimetype='image/png') +@sstv_bp.route('/images//download') +def download_image(filename: str): + """ + Download a decoded SSTV image file. + + Args: + filename: Image filename + + Returns: + Image file as attachment or 404. + """ + decoder = get_sstv_decoder() + + # Security: only allow alphanumeric filenames with .png extension + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + image_path = decoder._output_dir / filename + + if not image_path.exists(): + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename) + + +@sstv_bp.route('/images/', methods=['DELETE']) +def delete_image(filename: str): + """ + Delete a decoded SSTV image. + + Args: + filename: Image filename + + Returns: + JSON confirmation. + """ + decoder = get_sstv_decoder() + + # Security: only allow alphanumeric filenames with .png extension + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + if decoder.delete_image(filename): + return jsonify({'status': 'ok'}) + else: + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + +@sstv_bp.route('/images', methods=['DELETE']) +def delete_all_images(): + """ + Delete all decoded SSTV images. + + Returns: + JSON with count of deleted images. + """ + decoder = get_sstv_decoder() + count = decoder.delete_all_images() + return jsonify({'status': 'ok', 'deleted': count}) + + @sstv_bp.route('/stream') def stream_progress(): """ diff --git a/routes/sstv_general.py b/routes/sstv_general.py index 359e7d1..f12bf38 100644 --- a/routes/sstv_general.py +++ b/routes/sstv_general.py @@ -217,6 +217,52 @@ def get_image(filename: str): return send_file(image_path, mimetype='image/png') +@sstv_general_bp.route('/images//download') +def download_image(filename: str): + """Download a decoded SSTV image file.""" + decoder = get_general_sstv_decoder() + + # Security: only allow alphanumeric filenames with .png extension + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + image_path = decoder._output_dir / filename + + if not image_path.exists(): + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename) + + +@sstv_general_bp.route('/images/', methods=['DELETE']) +def delete_image(filename: str): + """Delete a decoded SSTV image.""" + decoder = get_general_sstv_decoder() + + # Security: only allow alphanumeric filenames with .png extension + if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum(): + return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400 + + if not filename.endswith('.png'): + return jsonify({'status': 'error', 'message': 'Only PNG files supported'}), 400 + + if decoder.delete_image(filename): + return jsonify({'status': 'ok'}) + else: + return jsonify({'status': 'error', 'message': 'Image not found'}), 404 + + +@sstv_general_bp.route('/images', methods=['DELETE']) +def delete_all_images(): + """Delete all decoded SSTV images.""" + decoder = get_general_sstv_decoder() + count = decoder.delete_all_images() + return jsonify({'status': 'ok', 'deleted': count}) + + @sstv_general_bp.route('/stream') def stream_progress(): """SSE stream of SSTV decode progress.""" diff --git a/static/css/modes/sstv-general.css b/static/css/modes/sstv-general.css index 1bbec01..34f5bbc 100644 --- a/static/css/modes/sstv-general.css +++ b/static/css/modes/sstv-general.css @@ -329,12 +329,12 @@ } .sstv-general-image-card { + position: relative; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 6px; overflow: hidden; transition: all 0.15s ease; - cursor: pointer; } .sstv-general-image-card:hover { @@ -343,6 +343,10 @@ box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2); } +.sstv-general-image-card-inner { + cursor: pointer; +} + .sstv-general-image-preview { width: 100%; aspect-ratio: 4/3; @@ -351,6 +355,48 @@ display: block; } +.sstv-general-image-actions { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: flex-end; + gap: 4px; + padding: 6px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); + opacity: 0; + transition: opacity 0.15s; +} + +.sstv-general-image-card:hover .sstv-general-image-actions { + opacity: 1; +} + +.sstv-general-image-actions button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: white; + cursor: pointer; + transition: all 0.15s; +} + +.sstv-general-image-actions button:hover { + background: rgba(255, 255, 255, 0.25); +} + +.sstv-general-image-actions button:last-child:hover { + background: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + .sstv-general-image-info { padding: 8px 10px; border-top: 1px solid var(--border-color); @@ -507,6 +553,40 @@ border-radius: 4px; } +.sstv-general-modal-toolbar { + position: absolute; + top: 20px; + right: 60px; + display: flex; + gap: 8px; + z-index: 1; +} + +.sstv-general-modal-btn { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: white; + cursor: pointer; + transition: all 0.15s; + text-transform: uppercase; +} + +.sstv-general-modal-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +.sstv-general-modal-btn.delete:hover { + background: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + .sstv-general-modal-close { position: absolute; top: 20px; @@ -518,12 +598,33 @@ cursor: pointer; opacity: 0.7; transition: opacity 0.15s; + z-index: 1; } .sstv-general-modal-close:hover { opacity: 1; } +/* Clear All button */ +.sstv-general-gallery-clear-btn { + font-family: var(--font-mono); + font-size: 9px; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 4px; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-dim); + cursor: pointer; + transition: all 0.15s; + margin-left: 8px; +} + +.sstv-general-gallery-clear-btn:hover { + color: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + /* ============================================ RESPONSIVE ============================================ */ diff --git a/static/css/modes/sstv.css b/static/css/modes/sstv.css index 896a740..9c987ce 100644 --- a/static/css/modes/sstv.css +++ b/static/css/modes/sstv.css @@ -388,12 +388,12 @@ } .sstv-image-card { + position: relative; background: var(--bg-secondary); border: 1px solid var(--border-color); border-radius: 6px; overflow: hidden; transition: all 0.15s ease; - cursor: pointer; } .sstv-image-card:hover { @@ -402,6 +402,10 @@ box-shadow: 0 4px 12px rgba(0, 212, 255, 0.2); } +.sstv-image-card-inner { + cursor: pointer; +} + .sstv-image-preview { width: 100%; aspect-ratio: 4/3; @@ -410,6 +414,48 @@ display: block; } +.sstv-image-actions { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: flex-end; + gap: 4px; + padding: 6px; + background: linear-gradient(transparent, rgba(0, 0, 0, 0.8)); + opacity: 0; + transition: opacity 0.15s; +} + +.sstv-image-card:hover .sstv-image-actions { + opacity: 1; +} + +.sstv-image-actions button { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: white; + cursor: pointer; + transition: all 0.15s; +} + +.sstv-image-actions button:hover { + background: rgba(255, 255, 255, 0.25); +} + +.sstv-image-actions button:last-child:hover { + background: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + .sstv-image-info { padding: 8px 10px; border-top: 1px solid var(--border-color); @@ -854,6 +900,40 @@ border-radius: 4px; } +.sstv-modal-toolbar { + position: absolute; + top: 20px; + right: 60px; + display: flex; + gap: 8px; + z-index: 1; +} + +.sstv-modal-btn { + display: flex; + align-items: center; + gap: 6px; + font-family: var(--font-mono); + font-size: 10px; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 4px; + color: white; + cursor: pointer; + transition: all 0.15s; + text-transform: uppercase; +} + +.sstv-modal-btn:hover { + background: rgba(255, 255, 255, 0.2); +} + +.sstv-modal-btn.delete:hover { + background: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + .sstv-modal-close { position: absolute; top: 20px; @@ -865,12 +945,33 @@ cursor: pointer; opacity: 0.7; transition: opacity 0.15s; + z-index: 1; } .sstv-modal-close:hover { opacity: 1; } +/* Clear All button */ +.sstv-gallery-clear-btn { + font-family: var(--font-mono); + font-size: 9px; + text-transform: uppercase; + padding: 3px 8px; + border-radius: 4px; + background: transparent; + border: 1px solid var(--border-color); + color: var(--text-dim); + cursor: pointer; + transition: all 0.15s; + margin-left: 8px; +} + +.sstv-gallery-clear-btn:hover { + color: var(--accent-red, #ff3366); + border-color: var(--accent-red, #ff3366); +} + /* ============================================ RESPONSIVE ============================================ */ diff --git a/static/js/modes/sstv-general.js b/static/js/modes/sstv-general.js index 7db7ab9..0b89efe 100644 --- a/static/js/modes/sstv-general.js +++ b/static/js/modes/sstv-general.js @@ -425,12 +425,22 @@ const SSTVGeneral = (function() { } gallery.innerHTML = images.map(img => ` -
- SSTV Image +
+
+ SSTV Image +
${escapeHtml(img.mode || 'Unknown')}
${formatTimestamp(img.timestamp)}
+
+ + +
`).join(''); } @@ -438,19 +448,45 @@ const SSTVGeneral = (function() { /** * Show full-size image in modal */ - function showImage(url) { + let currentModalUrl = null; + let currentModalFilename = null; + + function showImage(url, filename) { + currentModalUrl = url; + currentModalFilename = filename || null; + let modal = document.getElementById('sstvGeneralImageModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'sstvGeneralImageModal'; modal.className = 'sstv-general-image-modal'; modal.innerHTML = ` +
+ + +
SSTV Image `; modal.addEventListener('click', (e) => { if (e.target === modal) closeImage(); }); + modal.querySelector('#sstvGeneralModalDownload').addEventListener('click', () => { + if (currentModalUrl && currentModalFilename) { + downloadImage(currentModalUrl, currentModalFilename); + } + }); + modal.querySelector('#sstvGeneralModalDelete').addEventListener('click', () => { + if (currentModalFilename) { + deleteImage(currentModalFilename); + } + }); document.body.appendChild(modal); } @@ -489,6 +525,55 @@ const SSTVGeneral = (function() { return div.innerHTML; } + /** + * Delete a single image + */ + async function deleteImage(filename) { + if (!confirm('Delete this image?')) return; + try { + const response = await fetch(`/sstv-general/images/${encodeURIComponent(filename)}`, { method: 'DELETE' }); + const data = await response.json(); + if (data.status === 'ok') { + images = images.filter(img => img.filename !== filename); + updateImageCount(images.length); + renderGallery(); + closeImage(); + showNotification('SSTV', 'Image deleted'); + } + } catch (err) { + console.error('Failed to delete image:', err); + } + } + + /** + * Delete all images + */ + async function deleteAllImages() { + if (!confirm('Delete all decoded images?')) return; + try { + const response = await fetch('/sstv-general/images', { method: 'DELETE' }); + const data = await response.json(); + if (data.status === 'ok') { + images = []; + updateImageCount(0); + renderGallery(); + showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`); + } + } catch (err) { + console.error('Failed to delete images:', err); + } + } + + /** + * Download an image + */ + function downloadImage(url, filename) { + const a = document.createElement('a'); + a.href = url + '/download'; + a.download = filename; + a.click(); + } + /** * Show status message */ @@ -508,6 +593,9 @@ const SSTVGeneral = (function() { loadImages, showImage, closeImage, + deleteImage, + deleteAllImages, + downloadImage, selectPreset }; })(); diff --git a/static/js/modes/sstv.js b/static/js/modes/sstv.js index 44da2f3..ec51f87 100644 --- a/static/js/modes/sstv.js +++ b/static/js/modes/sstv.js @@ -860,12 +860,22 @@ const SSTV = (function() { } gallery.innerHTML = images.map(img => ` -
- SSTV Image +
+
+ SSTV Image +
${escapeHtml(img.mode || 'Unknown')}
${formatTimestamp(img.timestamp)}
+
+ + +
`).join(''); } @@ -997,19 +1007,45 @@ const SSTV = (function() { /** * Show full-size image in modal */ - function showImage(url) { + let currentModalUrl = null; + let currentModalFilename = null; + + function showImage(url, filename) { + currentModalUrl = url; + currentModalFilename = filename || null; + let modal = document.getElementById('sstvImageModal'); if (!modal) { modal = document.createElement('div'); modal.id = 'sstvImageModal'; modal.className = 'sstv-image-modal'; modal.innerHTML = ` +
+ + +
SSTV Image `; modal.addEventListener('click', (e) => { if (e.target === modal) closeImage(); }); + modal.querySelector('#sstvModalDownload').addEventListener('click', () => { + if (currentModalUrl && currentModalFilename) { + downloadImage(currentModalUrl, currentModalFilename); + } + }); + modal.querySelector('#sstvModalDelete').addEventListener('click', () => { + if (currentModalFilename) { + deleteImage(currentModalFilename); + } + }); document.body.appendChild(modal); } @@ -1048,6 +1084,55 @@ const SSTV = (function() { return div.innerHTML; } + /** + * Delete a single image + */ + async function deleteImage(filename) { + if (!confirm('Delete this image?')) return; + try { + const response = await fetch(`/sstv/images/${encodeURIComponent(filename)}`, { method: 'DELETE' }); + const data = await response.json(); + if (data.status === 'ok') { + images = images.filter(img => img.filename !== filename); + updateImageCount(images.length); + renderGallery(); + closeImage(); + showNotification('SSTV', 'Image deleted'); + } + } catch (err) { + console.error('Failed to delete image:', err); + } + } + + /** + * Delete all images + */ + async function deleteAllImages() { + if (!confirm('Delete all decoded images?')) return; + try { + const response = await fetch('/sstv/images', { method: 'DELETE' }); + const data = await response.json(); + if (data.status === 'ok') { + images = []; + updateImageCount(0); + renderGallery(); + showNotification('SSTV', `${data.deleted} image${data.deleted !== 1 ? 's' : ''} deleted`); + } + } catch (err) { + console.error('Failed to delete images:', err); + } + } + + /** + * Download an image + */ + function downloadImage(url, filename) { + const a = document.createElement('a'); + a.href = url + '/download'; + a.download = filename; + a.click(); + } + /** * Show status message */ @@ -1068,6 +1153,9 @@ const SSTV = (function() { loadIssSchedule, showImage, closeImage, + deleteImage, + deleteAllImages, + downloadImage, useGPS, updateTLE, stopIssTracking, diff --git a/templates/index.html b/templates/index.html index ed33f2a..661dc9c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -2027,7 +2027,10 @@ Decoded Images
- 0 +
+ 0 + +