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
+
+
+
+
+
+
+
+
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