From 854fd199bf40db6e0e5253100618f3489c58abb9 Mon Sep 17 00:00:00 2001 From: Spencer Date: Thu, 12 Feb 2026 16:14:06 -0500 Subject: [PATCH] feat: Add image deletion functionality before a specific date and update gallery UI --- lib/database.js | 34 ++ public/app.js | 3 - public/gallery.html | 920 ++++++++++++++++++++++++++++---------------- routes/images.js | 39 ++ 4 files changed, 653 insertions(+), 343 deletions(-) diff --git a/lib/database.js b/lib/database.js index 0476825..c87eee0 100644 --- a/lib/database.js +++ b/lib/database.js @@ -293,6 +293,38 @@ function cleanupOldImages(daysOld = 30) { }); } +/** + * Get images before a specific date (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS) + */ +function getImagesBeforeDate(beforeDate) { + return new Promise((resolve, reject) => { + db.all( + 'SELECT id, file_path FROM images WHERE fetched_at < ? ORDER BY fetched_at ASC', + [beforeDate], + (err, rows) => { + if (err) reject(err); + else resolve(rows || []); + } + ); + }); +} + +/** + * Delete images before a specific date (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS) + */ +function deleteImagesBeforeDate(beforeDate) { + return new Promise((resolve, reject) => { + db.run( + 'DELETE FROM images WHERE fetched_at < ?', + [beforeDate], + function(err) { + if (err) reject(err); + else resolve(this.changes); + } + ); + }); +} + /** * Get images by hash (detect duplicates) */ @@ -337,6 +369,8 @@ module.exports = { getImageCount, deleteImage, cleanupOldImages, + getImagesBeforeDate, + deleteImagesBeforeDate, getImagesByHash, closeDatabase }; diff --git a/public/app.js b/public/app.js index dd95521..ed73d75 100644 --- a/public/app.js +++ b/public/app.js @@ -29,6 +29,3 @@ async function loadStatus() { // Load status on page load document.addEventListener('DOMContentLoaded', loadStatus); - -// Refresh status every 30 seconds -setInterval(loadStatus, 30000); diff --git a/public/gallery.html b/public/gallery.html index 38b4b32..908c6f5 100644 --- a/public/gallery.html +++ b/public/gallery.html @@ -11,43 +11,260 @@ box-sizing: border-box; } + :root { + color-scheme: dark; + --bg: #0f131a; + --bg-accent: #161b24; + --panel: #1b2230; + --panel-strong: #222b3b; + --text: #e6e9ef; + --muted: #aab3c2; + --accent: #7aa2f7; + --accent-strong: #5d87f0; + --danger: #e05b5b; + --danger-strong: #c94b4b; + --border: #2b3446; + } + body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: radial-gradient(circle at top left, #1c2433, var(--bg)); min-height: 100vh; - padding: 20px; + padding: 8px; + color: var(--text); } .container { - max-width: 1200px; + max-width: 1400px; margin: 0 auto; } .header { - text-align: center; - color: white; - margin-bottom: 40px; + text-align: left; + color: var(--text); + margin-bottom: 8px; } .header h1 { - font-size: 2.5em; - margin-bottom: 10px; + font-size: 2em; + margin-bottom: 0; } .header p { - font-size: 1.1em; - opacity: 0.9; + display: none; + } + + .main-content { + display: flex; + gap: 20px; + height: calc(100vh - 200px); + } + + .preview-panel { + flex: 1; + background: var(--panel); + border-radius: 4px; + padding: 20px; + border: 1px solid var(--border); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + overflow: hidden; + } + + .preview-image { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 12px; + min-height: 240px; + } + + .preview-image img { + max-width: 100%; + max-height: 100%; + border-radius: 2px; + object-fit: contain; + } + + .preview-placeholder { + color: var(--muted); + font-size: 1.2em; + text-align: center; + } + + .preview-info { + border-top: 1px solid var(--border); + padding-top: 10px; + } + + .info-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin-bottom: 10px; + font-size: 0.9em; + } + + .info-label { + font-weight: 600; + color: var(--text); + min-width: 80px; + font-size: 0.85em; + } + + .info-value { + flex: 1; + color: var(--muted); + word-break: break-all; + font-family: monospace; + font-size: 0.8em; + } + + .tags-editor { + margin-top: 15px; + padding-top: 15px; + border-top: 1px solid var(--border); + } + + .tags-title { + font-weight: 600; + color: var(--text); + margin-bottom: 10px; + display: flex; + justify-content: space-between; + align-items: center; + } + + .tags-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; + } + + .tag { + background: var(--accent); + color: white; + padding: 6px 12px; + border-radius: 3px; + font-size: 0.9em; + display: flex; + align-items: center; + gap: 6px; + } + + .tag-remove { + cursor: pointer; + font-weight: bold; + opacity: 0.7; + transition: opacity 0.2s; + } + + .tag-remove:hover { + opacity: 1; + } + + .tag-input-container { + display: flex; + gap: 8px; + } + + .tag-input { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border); + border-radius: 3px; + font-size: 0.9em; + background: var(--panel-strong); + color: var(--text); + } + + .tag-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(122, 162, 247, 0.2); + } + + .tag-add-btn { + padding: 8px 15px; + background: var(--accent); + color: white; + border: none; + border-radius: 3px; + cursor: pointer; + font-weight: 500; + transition: background 0.2s; + } + + .tag-add-btn:hover { + background: var(--accent-strong); + } + + .timeline-container { + flex: 0 0 150px; + background: var(--panel); + border-radius: 4px; + padding: 15px; + border: 1px solid var(--border); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); + display: flex; + flex-direction: column; + } + + .timeline-title { + font-weight: 600; + color: var(--text); + margin-bottom: 15px; + font-size: 0.95em; + } + + .timeline-scroll { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + } + + .timeline-item { + padding: 10px; + background: var(--panel-strong); + border-radius: 3px; + cursor: pointer; + transition: all 0.2s; + border: 1px solid transparent; + text-align: center; + font-size: 0.75em; + color: var(--muted); + line-height: 1.4; + white-space: pre-wrap; + word-break: break-word; + } + + .timeline-item:hover { + background: #283246; + } + + .timeline-item.active { + background: var(--accent); + color: white; + border-color: var(--accent); } .controls { - background: white; - border-radius: 8px; - padding: 20px; - margin-bottom: 30px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - display: flex; - gap: 15px; - flex-wrap: wrap; + background: var(--panel); + border-radius: 4px; + padding: 12px; + margin-bottom: 12px; + border: 1px solid var(--border); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; + align-items: center; } .controls label { @@ -55,197 +272,138 @@ align-items: center; gap: 8px; font-weight: 500; - color: #333; + color: var(--text); } .controls select, .controls input { padding: 8px 12px; - border: 1px solid #ddd; - border-radius: 4px; + border: 1px solid var(--border); + border-radius: 3px; font-size: 0.95em; + background: var(--panel-strong); + color: var(--text); } .controls select { cursor: pointer; } - .stats { - background: white; - border-radius: 8px; - padding: 15px 20px; - margin-bottom: 30px; + .danger-btn { + background: var(--danger); + color: white; + border: none; + border-radius: 3px; + padding: 8px 12px; + cursor: pointer; + font-weight: 600; + } + + .danger-btn:hover { + background: var(--danger-strong); + } + + .delete-group { display: flex; - gap: 30px; + gap: 8px; + align-items: center; flex-wrap: wrap; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + justify-content: flex-start; + } + + .delete-status { + font-size: 0.85em; + color: var(--muted); + } + + .stats { + background: var(--panel); + border-radius: 4px; + padding: 10px 16px; + display: flex; + gap: 20px; + flex-wrap: wrap; + border: 1px solid var(--border); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); } .stat { display: flex; flex-direction: column; + align-items: center; } .stat-label { - font-size: 0.9em; - color: #666; + font-size: 0.85em; + color: var(--muted); font-weight: 500; } .stat-value { - font-size: 1.8em; + font-size: 1.4em; font-weight: bold; - color: #667eea; - } - - .timeline { - position: relative; - padding: 20px 0; - } - - .timeline::before { - content: ''; - position: absolute; - left: 50%; - transform: translateX(-50%); - width: 3px; - height: 100%; - background: rgba(255, 255, 255, 0.3); - } - - .timeline-item { - margin-bottom: 40px; - position: relative; - } - - .timeline-item:nth-child(odd) .timeline-content { - margin-left: 0; - margin-right: auto; - width: calc(50% - 20px); - text-align: right; - } - - .timeline-item:nth-child(even) .timeline-content { - margin-left: auto; - margin-right: 0; - width: calc(50% - 20px); - text-align: left; - } - - .timeline-marker { - position: absolute; - left: 50%; - top: 0; - transform: translateX(-50%); - width: 16px; - height: 16px; - background: white; - border: 3px solid #667eea; - border-radius: 50%; - z-index: 10; - } - - .timeline-content { - background: white; - border-radius: 8px; - padding: 20px; - box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); - cursor: pointer; - transition: transform 0.2s, box-shadow 0.2s; - } - - .timeline-content:hover { - transform: translateY(-5px); - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); - } - - .timeline-content img { - width: 100%; - height: auto; - max-height: 300px; - object-fit: cover; - border-radius: 4px; - margin-bottom: 12px; - } - - .timeline-timestamp { - font-weight: 600; - color: #667eea; - font-size: 0.95em; - margin-bottom: 8px; - } - - .timeline-tags { - display: flex; - gap: 6px; - flex-wrap: wrap; - margin-bottom: 8px; - } - - .timeline-tag { - display: inline-block; - background: #f0f0f0; - color: #555; - padding: 4px 10px; - border-radius: 12px; - font-size: 0.85em; - } - - .timeline-tag.active { - background: #667eea; - color: white; - } - - .timeline-size { - font-size: 0.85em; - color: #999; + color: var(--accent); } .loading { text-align: center; - color: white; + color: var(--text); font-size: 1.1em; } .error { - background: #ff6b6b; + background: #4a1d1d; color: white; padding: 15px; - border-radius: 8px; + border-radius: 4px; margin: 20px 0; + border: 1px solid #6b2a2a; } .empty { - background: white; - border-radius: 8px; - padding: 40px; + background: var(--panel); + border-radius: 4px; + padding: 28px; text-align: center; - color: #999; + color: var(--muted); + border: 1px solid var(--border); } - @media (max-width: 768px) { - .timeline::before { - left: 20px; + ::-webkit-scrollbar { + width: 6px; + } + + ::-webkit-scrollbar-track { + background: var(--panel-strong); + border-radius: 3px; + } + + ::-webkit-scrollbar-thumb { + background: var(--accent); + border-radius: 3px; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--accent-strong); + } + + @media (max-width: 1024px) { + .main-content { + flex-direction: column; + height: auto; } - .timeline-marker { - left: 20px; - } - - .timeline-item:nth-child(odd) .timeline-content, - .timeline-item:nth-child(even) .timeline-content { - width: calc(100% - 50px); - margin-left: 50px; - margin-right: 0; - text-align: left; + .timeline-container { + flex: 0 0 auto; + max-height: 200px; } .header h1 { - font-size: 1.8em; + font-size: 1.5em; } .controls { - flex-direction: column; + grid-template-columns: 1fr; } .stats { @@ -253,74 +411,12 @@ gap: 15px; } } - - .modal { - display: none; - position: fixed; - z-index: 1000; - left: 0; - top: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.8); - animation: fadeIn 0.3s; - } - - @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } - } - - .modal-content { - position: relative; - margin: auto; - padding: 0; - max-width: 90vw; - max-height: 90vh; - top: 50%; - transform: translateY(-50%); - } - - .modal-content img { - width: 100%; - height: auto; - border-radius: 8px; - } - - .modal-info { - background: white; - padding: 20px; - border-radius: 0 0 8px 8px; - } - - .close { - position: absolute; - right: 20px; - top: 20px; - font-size: 2em; - font-weight: bold; - color: white; - cursor: pointer; - z-index: 1001; - background: rgba(0, 0, 0, 0.5); - width: 40px; - height: 40px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - } - - .close:hover { - background: rgba(0, 0, 0, 0.7); - }
-

📸 Image Timeline

-

Browse your captured images chronologically

+

Image Gallery

@@ -337,10 +433,14 @@ - +
+ + + +
@@ -358,42 +458,95 @@
-
Loading images...
+
Loading images...
-
-
No images found. Start fetching to see images here!
- - -
-
- × - -
+
+
+
+
Hover over a timestamp to preview
+ +
+
+
+ Time: + -- +
+
+ Size: + -- +
+
+ Hash: + -- +
+
+
+ Tags + +
+
+
+ + +
+
+
+
+ +
+
Timeline
+
+
+ +
No images found. Start fetching to see images here!
diff --git a/routes/images.js b/routes/images.js index e1799f2..f80a7f6 100644 --- a/routes/images.js +++ b/routes/images.js @@ -224,6 +224,45 @@ router.post('/cleanup', async (req, res) => { } }); +/** + * POST /api/cleanup/before - Delete images before a specific date + */ +router.post('/cleanup/before', async (req, res) => { + try { + const { beforeDate } = req.body; + + if (!beforeDate || typeof beforeDate !== 'string') { + return res.status(400).json({ success: false, error: 'beforeDate is required (YYYY-MM-DD or YYYY-MM-DD HH:MM:SS)' }); + } + + const normalizedInput = beforeDate.replace('T', ' '); + const normalized = normalizedInput.length <= 10 + ? `${normalizedInput} 00:00:00` + : (normalizedInput.length === 16 ? `${normalizedInput}:00` : normalizedInput); + + const imagesToDelete = await database.getImagesBeforeDate(normalized); + let filesDeleted = 0; + + for (const image of imagesToDelete) { + const deletedFile = await storage.deleteImageFile(image.file_path); + if (deletedFile) filesDeleted++; + } + + const deletedFromDb = await database.deleteImagesBeforeDate(normalized); + + res.json({ + success: true, + cleanup: { + deletedFromDb, + filesDeleted, + beforeDate: normalized + } + }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + /** * GET /api/fetcher/status - Get fetcher status */