diff --git a/public/gallery.html b/public/gallery.html index 908c6f5..c05eb71 100644 --- a/public/gallery.html +++ b/public/gallery.html @@ -120,7 +120,7 @@ color: var(--muted); word-break: break-all; font-family: monospace; - font-size: 0.8em; + font-size: 0.7em; } .tags-editor { @@ -244,6 +244,25 @@ word-break: break-word; } + .timeline-time { + display: block; + } + + .timeline-source { + display: inline-block; + margin-top: 6px; + padding: 2px 6px; + border: 1px solid var(--border); + border-radius: 999px; + font-size: 0.85em; + color: var(--text); + background: rgba(255, 255, 255, 0.04); + max-width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .timeline-item:hover { background: #283246; } @@ -257,7 +276,7 @@ .controls { background: var(--panel); border-radius: 4px; - padding: 12px; + padding: 10px; margin-bottom: 12px; border: 1px solid var(--border); box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25); @@ -319,7 +338,7 @@ .stats { background: var(--panel); border-radius: 4px; - padding: 10px 16px; + padding: 9px 16px; display: flex; gap: 20px; flex-wrap: wrap; @@ -340,7 +359,7 @@ } .stat-value { - font-size: 1.4em; + font-size: 1.1em; font-weight: bold; color: var(--accent); } @@ -433,6 +452,12 @@ +
+
+ Source: + -- +
Size: -- @@ -516,9 +545,29 @@ const mainContent = document.getElementById('mainContent'); const sortSelect = document.getElementById('sortSelect'); const tagSelect = document.getElementById('tagSelect'); + const sourceSelect = document.getElementById('sourceSelect'); const deleteBeforeDateInput = document.getElementById('deleteBeforeDate'); const deleteBeforeBtn = document.getElementById('deleteBeforeBtn'); const deleteStatus = document.getElementById('deleteStatus'); + const previewSource = document.getElementById('previewSource'); + + let sourceNameByUrl = new Map(); + let activeTimelineItem = null; + + function normalizeSourceUrl(url) { + if (!url) return ''; + return String(url).trim().replace(/\/+$/, ''); + } + + function getSourceMeta(image) { + const sourceUrl = image?.source_url || ''; + const normalized = normalizeSourceUrl(sourceUrl); + const configuredName = sourceNameByUrl.get(normalized); + return { + name: configuredName || sourceUrl || 'Unknown source', + url: sourceUrl + }; + } function parseSqliteDate(dateString) { if (!dateString) return new Date(NaN); @@ -576,6 +625,23 @@ throw new Error(`Failed to fetch images: ${err.message}`); } + // Fetch source config (non-critical) + try { + const sourcesResponse = await fetch(`${API_BASE}/api/sources`); + if (sourcesResponse.ok) { + const sourcesData = await sourcesResponse.json(); + const configuredSources = sourcesData?.config?.sources || []; + sourceNameByUrl = new Map( + configuredSources + .filter(source => source && source.url) + .map(source => [normalizeSourceUrl(source.url), source.name || source.url]) + ); + } + } catch (err) { + console.warn('Warning: Could not fetch source config:', err); + sourceNameByUrl = new Map(); + } + // Fetch tags (non-critical) try { const tagsResponse = await fetch(`${API_BASE}/api/tags`); @@ -603,6 +669,7 @@ // Update tag filter updateTagFilter(); + updateSourceFilter(); // Render timeline renderTimeline(); @@ -619,33 +686,75 @@ function updateTagFilter() { const currentValue = tagSelect.value; tagSelect.innerHTML = ''; - allTags.forEach(tag => { + + const mergedTags = new Set(allTags); + allImages.forEach((image) => { + (image.tags || []).forEach((tag) => mergedTags.add(tag)); + }); + + Array.from(mergedTags).sort().forEach(tag => { const option = document.createElement('option'); option.value = tag; option.textContent = tag; tagSelect.appendChild(option); }); - tagSelect.value = currentValue; + + tagSelect.value = Array.from(tagSelect.options).some(option => option.value === currentValue) + ? currentValue + : ''; } - function renderTimeline() { - // Filter images - const selectedTag = tagSelect.value; - let filtered = allImages; - if (selectedTag) { - filtered = allImages.filter(img => - img.tags && img.tags.includes(selectedTag) - ); - } + function updateSourceFilter() { + const currentValue = sourceSelect.value; + sourceSelect.innerHTML = ''; - // Sort images - const sorted = [...filtered]; + const sourceOptions = new Map(); + allImages.forEach((image) => { + const normalized = normalizeSourceUrl(image.source_url); + if (!normalized) return; + sourceOptions.set(normalized, getSourceMeta(image).name); + }); + + Array.from(sourceOptions.entries()) + .sort((a, b) => a[1].localeCompare(b[1])) + .forEach(([sourceKey, sourceLabel]) => { + const option = document.createElement('option'); + option.value = sourceKey; + option.textContent = sourceLabel; + sourceSelect.appendChild(option); + }); + + sourceSelect.value = Array.from(sourceSelect.options).some(option => option.value === currentValue) + ? currentValue + : ''; + } + + function getFilteredImages() { + const selectedTag = tagSelect.value; + const selectedSource = sourceSelect.value; + + return allImages.filter((image) => { + const tagMatch = !selectedTag || (image.tags && image.tags.includes(selectedTag)); + const sourceMatch = !selectedSource || normalizeSourceUrl(image.source_url) === selectedSource; + return tagMatch && sourceMatch; + }); + } + + function getSortedImages(images) { + const sorted = [...images]; if (sortSelect.value === 'oldest') { sorted.reverse(); } + return sorted; + } + + function renderTimeline() { + const filtered = getFilteredImages(); + const sorted = getSortedImages(filtered); // Clear timeline timeline.innerHTML = ''; + activeTimelineItem = null; if (sorted.length === 0) { empty.style.display = 'block'; @@ -654,10 +763,21 @@ } // Add items + const fragment = document.createDocumentFragment(); sorted.forEach((image, index) => { const item = document.createElement('div'); item.className = 'timeline-item'; - item.textContent = formatCompactDateTime(image.fetched_at); + + const timeText = document.createElement('span'); + timeText.className = 'timeline-time'; + timeText.textContent = formatCompactDateTime(image.fetched_at); + + const sourceText = document.createElement('span'); + sourceText.className = 'timeline-source'; + sourceText.textContent = getSourceMeta(image).name; + + item.appendChild(timeText); + item.appendChild(sourceText); item.addEventListener('mouseenter', () => showPreview(image, item)); @@ -666,9 +786,11 @@ item.classList.add('active'); } - timeline.appendChild(item); + fragment.appendChild(item); }); + timeline.appendChild(fragment); + mainContent.style.display = 'flex'; empty.style.display = 'none'; } @@ -677,8 +799,11 @@ currentImage = image; // Update active state - document.querySelectorAll('.timeline-item').forEach(i => i.classList.remove('active')); - item.classList.add('active'); + if (activeTimelineItem) { + activeTimelineItem.classList.remove('active'); + } + activeTimelineItem = item; + activeTimelineItem.classList.add('active'); // Update preview const previewImage = document.getElementById('previewImage'); @@ -687,7 +812,10 @@ document.querySelector('.preview-placeholder').style.display = 'none'; // Update info + const sourceMeta = getSourceMeta(image); document.getElementById('previewTime').textContent = formatDate(image.fetched_at); + previewSource.textContent = sourceMeta.name; + previewSource.title = sourceMeta.url || sourceMeta.name; document.getElementById('previewSize').textContent = formatSize(image.filesize); document.getElementById('previewHash').textContent = image.file_hash.substring(0, 32) + '...'; @@ -710,6 +838,7 @@ } } + function normalizeBeforeDateInput(value) { if (!value) return ''; const normalized = value.replace('T', ' '); @@ -819,6 +948,7 @@ sortSelect.addEventListener('change', renderTimeline); tagSelect.addEventListener('change', renderTimeline); + sourceSelect.addEventListener('change', renderTimeline); // Initial load loadImages(); diff --git a/public/index.html b/public/index.html index 3f2c4f7..ae85511 100644 --- a/public/index.html +++ b/public/index.html @@ -45,6 +45,11 @@

Image Gallery

Browse captured images on an interactive timeline

+ +
📡
+

Image Sources

+

Add cameras, edit tags, and manage fetch intervals

+
📊

Statistics

diff --git a/public/sources.html b/public/sources.html new file mode 100644 index 0000000..5ae4675 --- /dev/null +++ b/public/sources.html @@ -0,0 +1,465 @@ + + + + + + HomeBase Sources + + + +
+ + +
+
+
Manage Sources
+
+ Fetch interval (minutes) + + +
+
+ +
+ + + +
+ + +
+
+ +
+
+
+
+ + + + diff --git a/routes/images.js b/routes/images.js index f80a7f6..4723c32 100644 --- a/routes/images.js +++ b/routes/images.js @@ -1,9 +1,58 @@ const express = require('express'); +const path = require('path'); +const fs = require('fs'); const router = express.Router(); const database = require('../lib/database'); const storage = require('../lib/storage'); const imageFetcher = require('../lib/image-fetcher'); +const CONFIG_PATH = path.join(__dirname, '..', 'image-sources.json'); + +function readSourcesConfig() { + const defaultConfig = { sources: [], fetchInterval: 2.5 }; + try { + if (!fs.existsSync(CONFIG_PATH)) { + return defaultConfig; + } + + const content = fs.readFileSync(CONFIG_PATH, 'utf8'); + const parsed = JSON.parse(content); + return { + sources: Array.isArray(parsed.sources) ? parsed.sources : [], + fetchInterval: typeof parsed.fetchInterval === 'number' ? parsed.fetchInterval : 2.5, + comments: parsed.comments || undefined + }; + } catch (err) { + console.warn('Failed to read image-sources.json:', err.message); + return defaultConfig; + } +} + +function writeSourcesConfig(config) { + const payload = { + ...config, + comments: config.comments || { + sources: 'Array of image sources to fetch from', + name: 'Human-readable name for the source', + url: 'Full URL to the image', + tags: 'Array of tags to apply to fetched images (useful for ML training)', + enabled: 'Set to true to include this source in fetching', + fetchInterval: 'Minutes between fetch cycles. Examples: 0.033 = 2 seconds, 0.05 = 3 seconds, 0.083 = 5 seconds, 1 = 1 minute, 2.5 = 2.5 minutes (default)' + } + }; + + fs.writeFileSync(CONFIG_PATH, JSON.stringify(payload, null, 2)); +} + +function restartFetcherFromConfig(config) { + imageFetcher.stopFetcher(); + const enabledSources = (config.sources || []).filter((source) => source.enabled); + const interval = config.fetchInterval || 2.5; + if (enabledSources.length > 0) { + imageFetcher.startFetcher(enabledSources, interval); + } +} + /** * GET /api/images - Get all images with optional filtering */ @@ -271,4 +320,122 @@ router.get('/fetcher/status', (req, res) => { res.json({ success: true, status }); }); +/** + * GET /api/sources - Get image sources configuration + */ +router.get('/sources', (req, res) => { + const config = readSourcesConfig(); + res.json({ success: true, config }); +}); + +/** + * POST /api/sources - Add a new image source + */ +router.post('/sources', (req, res) => { + try { + const { name, url, tags = [], enabled = true } = req.body; + + if (!name || !url) { + return res.status(400).json({ success: false, error: 'name and url are required' }); + } + + const config = readSourcesConfig(); + const newSource = { + name: String(name).trim(), + url: String(url).trim(), + tags: Array.isArray(tags) ? tags.map(tag => String(tag).trim()).filter(Boolean) : [], + enabled: Boolean(enabled) + }; + + config.sources = [...(config.sources || []), newSource]; + writeSourcesConfig(config); + restartFetcherFromConfig(config); + + res.status(201).json({ success: true, config }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * PUT /api/sources/config - Update fetch interval + */ +router.put('/sources/config', (req, res) => { + try { + const { fetchInterval } = req.body; + const intervalValue = Number(fetchInterval); + + if (!Number.isFinite(intervalValue) || intervalValue <= 0) { + return res.status(400).json({ success: false, error: 'fetchInterval must be a positive number' }); + } + + const config = readSourcesConfig(); + config.fetchInterval = intervalValue; + writeSourcesConfig(config); + restartFetcherFromConfig(config); + + res.json({ success: true, config }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * PUT /api/sources/:index - Update an existing source + */ +router.put('/sources/:index', (req, res) => { + try { + const index = Number(req.params.index); + if (!Number.isInteger(index)) { + return res.status(400).json({ success: false, error: 'Invalid source index' }); + } + + const config = readSourcesConfig(); + if (!config.sources || !config.sources[index]) { + return res.status(404).json({ success: false, error: 'Source not found' }); + } + + const current = config.sources[index]; + const updated = { + ...current, + name: req.body.name !== undefined ? String(req.body.name).trim() : current.name, + url: req.body.url !== undefined ? String(req.body.url).trim() : current.url, + tags: Array.isArray(req.body.tags) + ? req.body.tags.map(tag => String(tag).trim()).filter(Boolean) + : current.tags, + enabled: req.body.enabled !== undefined ? Boolean(req.body.enabled) : current.enabled + }; + + config.sources[index] = updated; + writeSourcesConfig(config); + restartFetcherFromConfig(config); + + res.json({ success: true, config }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + +/** + * DELETE /api/sources/:index - Remove a source + */ +router.delete('/sources/:index', (req, res) => { + try { + const index = Number(req.params.index); + const config = readSourcesConfig(); + + if (!Number.isInteger(index) || !config.sources || !config.sources[index]) { + return res.status(404).json({ success: false, error: 'Source not found' }); + } + + config.sources.splice(index, 1); + writeSourcesConfig(config); + restartFetcherFromConfig(config); + + res.json({ success: true, config }); + } catch (err) { + res.status(500).json({ success: false, error: err.message }); + } +}); + module.exports = router; diff --git a/server.js b/server.js index dcedc4e..87d45f3 100644 --- a/server.js +++ b/server.js @@ -11,6 +11,7 @@ const DOMAIN = process.env.DOMAIN || 'homebase.sketchferret.com'; // Middleware app.use(express.json()); +app.use(express.urlencoded({ extended: false })); app.use(express.static(path.join(__dirname, 'public'))); // Initialize database