feat: Add image sources management and update server middleware
Some checks failed
Deploy to BeePC / deploy (push) Has been cancelled
Some checks failed
Deploy to BeePC / deploy (push) Has been cancelled
This commit is contained in:
@@ -120,7 +120,7 @@
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
font-size: 0.8em;
|
font-size: 0.7em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tags-editor {
|
.tags-editor {
|
||||||
@@ -244,6 +244,25 @@
|
|||||||
word-break: break-word;
|
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 {
|
.timeline-item:hover {
|
||||||
background: #283246;
|
background: #283246;
|
||||||
}
|
}
|
||||||
@@ -257,7 +276,7 @@
|
|||||||
.controls {
|
.controls {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 12px;
|
padding: 10px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||||
@@ -319,7 +338,7 @@
|
|||||||
.stats {
|
.stats {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 10px 16px;
|
padding: 9px 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -340,7 +359,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.stat-value {
|
.stat-value {
|
||||||
font-size: 1.4em;
|
font-size: 1.1em;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -433,6 +452,12 @@
|
|||||||
<option value="">All Tags</option>
|
<option value="">All Tags</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Filter by source:
|
||||||
|
<select id="sourceSelect">
|
||||||
|
<option value="">All Sources</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<div class="delete-group">
|
<div class="delete-group">
|
||||||
<label>
|
<label>
|
||||||
Delete before:
|
Delete before:
|
||||||
@@ -472,6 +497,10 @@
|
|||||||
<span class="info-label">Time:</span>
|
<span class="info-label">Time:</span>
|
||||||
<span class="info-value" id="previewTime">--</span>
|
<span class="info-value" id="previewTime">--</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-label">Source:</span>
|
||||||
|
<span class="info-value" id="previewSource">--</span>
|
||||||
|
</div>
|
||||||
<div class="info-row">
|
<div class="info-row">
|
||||||
<span class="info-label">Size:</span>
|
<span class="info-label">Size:</span>
|
||||||
<span class="info-value" id="previewSize">--</span>
|
<span class="info-value" id="previewSize">--</span>
|
||||||
@@ -516,9 +545,29 @@
|
|||||||
const mainContent = document.getElementById('mainContent');
|
const mainContent = document.getElementById('mainContent');
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
const tagSelect = document.getElementById('tagSelect');
|
const tagSelect = document.getElementById('tagSelect');
|
||||||
|
const sourceSelect = document.getElementById('sourceSelect');
|
||||||
const deleteBeforeDateInput = document.getElementById('deleteBeforeDate');
|
const deleteBeforeDateInput = document.getElementById('deleteBeforeDate');
|
||||||
const deleteBeforeBtn = document.getElementById('deleteBeforeBtn');
|
const deleteBeforeBtn = document.getElementById('deleteBeforeBtn');
|
||||||
const deleteStatus = document.getElementById('deleteStatus');
|
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) {
|
function parseSqliteDate(dateString) {
|
||||||
if (!dateString) return new Date(NaN);
|
if (!dateString) return new Date(NaN);
|
||||||
@@ -576,6 +625,23 @@
|
|||||||
throw new Error(`Failed to fetch images: ${err.message}`);
|
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)
|
// Fetch tags (non-critical)
|
||||||
try {
|
try {
|
||||||
const tagsResponse = await fetch(`${API_BASE}/api/tags`);
|
const tagsResponse = await fetch(`${API_BASE}/api/tags`);
|
||||||
@@ -603,6 +669,7 @@
|
|||||||
|
|
||||||
// Update tag filter
|
// Update tag filter
|
||||||
updateTagFilter();
|
updateTagFilter();
|
||||||
|
updateSourceFilter();
|
||||||
|
|
||||||
// Render timeline
|
// Render timeline
|
||||||
renderTimeline();
|
renderTimeline();
|
||||||
@@ -619,33 +686,75 @@
|
|||||||
function updateTagFilter() {
|
function updateTagFilter() {
|
||||||
const currentValue = tagSelect.value;
|
const currentValue = tagSelect.value;
|
||||||
tagSelect.innerHTML = '<option value="">All Tags</option>';
|
tagSelect.innerHTML = '<option value="">All Tags</option>';
|
||||||
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');
|
const option = document.createElement('option');
|
||||||
option.value = tag;
|
option.value = tag;
|
||||||
option.textContent = tag;
|
option.textContent = tag;
|
||||||
tagSelect.appendChild(option);
|
tagSelect.appendChild(option);
|
||||||
});
|
});
|
||||||
tagSelect.value = currentValue;
|
|
||||||
|
tagSelect.value = Array.from(tagSelect.options).some(option => option.value === currentValue)
|
||||||
|
? currentValue
|
||||||
|
: '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTimeline() {
|
function updateSourceFilter() {
|
||||||
// Filter images
|
const currentValue = sourceSelect.value;
|
||||||
|
sourceSelect.innerHTML = '<option value="">All Sources</option>';
|
||||||
|
|
||||||
|
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 selectedTag = tagSelect.value;
|
||||||
let filtered = allImages;
|
const selectedSource = sourceSelect.value;
|
||||||
if (selectedTag) {
|
|
||||||
filtered = allImages.filter(img =>
|
return allImages.filter((image) => {
|
||||||
img.tags && img.tags.includes(selectedTag)
|
const tagMatch = !selectedTag || (image.tags && image.tags.includes(selectedTag));
|
||||||
);
|
const sourceMatch = !selectedSource || normalizeSourceUrl(image.source_url) === selectedSource;
|
||||||
|
return tagMatch && sourceMatch;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort images
|
function getSortedImages(images) {
|
||||||
const sorted = [...filtered];
|
const sorted = [...images];
|
||||||
if (sortSelect.value === 'oldest') {
|
if (sortSelect.value === 'oldest') {
|
||||||
sorted.reverse();
|
sorted.reverse();
|
||||||
}
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTimeline() {
|
||||||
|
const filtered = getFilteredImages();
|
||||||
|
const sorted = getSortedImages(filtered);
|
||||||
|
|
||||||
// Clear timeline
|
// Clear timeline
|
||||||
timeline.innerHTML = '';
|
timeline.innerHTML = '';
|
||||||
|
activeTimelineItem = null;
|
||||||
|
|
||||||
if (sorted.length === 0) {
|
if (sorted.length === 0) {
|
||||||
empty.style.display = 'block';
|
empty.style.display = 'block';
|
||||||
@@ -654,10 +763,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add items
|
// Add items
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
sorted.forEach((image, index) => {
|
sorted.forEach((image, index) => {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'timeline-item';
|
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));
|
item.addEventListener('mouseenter', () => showPreview(image, item));
|
||||||
|
|
||||||
@@ -666,9 +786,11 @@
|
|||||||
item.classList.add('active');
|
item.classList.add('active');
|
||||||
}
|
}
|
||||||
|
|
||||||
timeline.appendChild(item);
|
fragment.appendChild(item);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
timeline.appendChild(fragment);
|
||||||
|
|
||||||
mainContent.style.display = 'flex';
|
mainContent.style.display = 'flex';
|
||||||
empty.style.display = 'none';
|
empty.style.display = 'none';
|
||||||
}
|
}
|
||||||
@@ -677,8 +799,11 @@
|
|||||||
currentImage = image;
|
currentImage = image;
|
||||||
|
|
||||||
// Update active state
|
// Update active state
|
||||||
document.querySelectorAll('.timeline-item').forEach(i => i.classList.remove('active'));
|
if (activeTimelineItem) {
|
||||||
item.classList.add('active');
|
activeTimelineItem.classList.remove('active');
|
||||||
|
}
|
||||||
|
activeTimelineItem = item;
|
||||||
|
activeTimelineItem.classList.add('active');
|
||||||
|
|
||||||
// Update preview
|
// Update preview
|
||||||
const previewImage = document.getElementById('previewImage');
|
const previewImage = document.getElementById('previewImage');
|
||||||
@@ -687,7 +812,10 @@
|
|||||||
document.querySelector('.preview-placeholder').style.display = 'none';
|
document.querySelector('.preview-placeholder').style.display = 'none';
|
||||||
|
|
||||||
// Update info
|
// Update info
|
||||||
|
const sourceMeta = getSourceMeta(image);
|
||||||
document.getElementById('previewTime').textContent = formatDate(image.fetched_at);
|
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('previewSize').textContent = formatSize(image.filesize);
|
||||||
document.getElementById('previewHash').textContent = image.file_hash.substring(0, 32) + '...';
|
document.getElementById('previewHash').textContent = image.file_hash.substring(0, 32) + '...';
|
||||||
|
|
||||||
@@ -710,6 +838,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function normalizeBeforeDateInput(value) {
|
function normalizeBeforeDateInput(value) {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
const normalized = value.replace('T', ' ');
|
const normalized = value.replace('T', ' ');
|
||||||
@@ -819,6 +948,7 @@
|
|||||||
|
|
||||||
sortSelect.addEventListener('change', renderTimeline);
|
sortSelect.addEventListener('change', renderTimeline);
|
||||||
tagSelect.addEventListener('change', renderTimeline);
|
tagSelect.addEventListener('change', renderTimeline);
|
||||||
|
sourceSelect.addEventListener('change', renderTimeline);
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
loadImages();
|
loadImages();
|
||||||
|
|||||||
@@ -45,6 +45,11 @@
|
|||||||
<h3>Image Gallery</h3>
|
<h3>Image Gallery</h3>
|
||||||
<p>Browse captured images on an interactive timeline</p>
|
<p>Browse captured images on an interactive timeline</p>
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/sources.html" class="feature-card">
|
||||||
|
<div class="feature-icon">📡</div>
|
||||||
|
<h3>Image Sources</h3>
|
||||||
|
<p>Add cameras, edit tags, and manage fetch intervals</p>
|
||||||
|
</a>
|
||||||
<a href="/api/stats" class="feature-card">
|
<a href="/api/stats" class="feature-card">
|
||||||
<div class="feature-icon">📊</div>
|
<div class="feature-icon">📊</div>
|
||||||
<h3>Statistics</h3>
|
<h3>Statistics</h3>
|
||||||
|
|||||||
465
public/sources.html
Normal file
465
public/sources.html
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>HomeBase Sources</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
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: radial-gradient(circle at top left, #1c2433, var(--bg));
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 1.8em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-panel {
|
||||||
|
background: var(--panel);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-title {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-interval {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-interval input {
|
||||||
|
width: 110px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-form input {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 1.4fr 0.8fr 0.6fr auto auto;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--panel-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-edit-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #1d2636;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-add-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0f131a;
|
||||||
|
border: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-add-btn:hover {
|
||||||
|
background: var(--accent-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-save {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-save:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #0f131a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-delete {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
color: var(--danger);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-delete:hover {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-status {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.source-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sources-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="page-title">Image Sources</div>
|
||||||
|
<div class="nav-links">
|
||||||
|
<a href="/index.html">Home</a>
|
||||||
|
<a href="/gallery.html">Gallery</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sources-panel">
|
||||||
|
<div class="sources-header">
|
||||||
|
<div class="sources-title">Manage Sources</div>
|
||||||
|
<div class="sources-interval">
|
||||||
|
Fetch interval (minutes)
|
||||||
|
<input type="number" id="fetchIntervalInput" min="0.01" step="0.01">
|
||||||
|
<button class="tag-add-btn" id="saveIntervalBtn">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sources-form">
|
||||||
|
<input type="text" id="sourceNameInput" placeholder="Name">
|
||||||
|
<input type="url" id="sourceUrlInput" placeholder="Image URL">
|
||||||
|
<input type="text" id="sourceTagsInput" placeholder="Tags (comma separated)">
|
||||||
|
<div class="sources-actions">
|
||||||
|
<label class="source-toggle">
|
||||||
|
<input type="checkbox" id="sourceEnabledInput" checked>
|
||||||
|
Enabled
|
||||||
|
</label>
|
||||||
|
<button class="tag-add-btn" id="addSourceBtn">Add Source</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sources-list" id="sourcesList"></div>
|
||||||
|
<div class="sources-status" id="sourcesStatus"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_BASE = '';
|
||||||
|
|
||||||
|
const fetchIntervalInput = document.getElementById('fetchIntervalInput');
|
||||||
|
const saveIntervalBtn = document.getElementById('saveIntervalBtn');
|
||||||
|
const sourceNameInput = document.getElementById('sourceNameInput');
|
||||||
|
const sourceUrlInput = document.getElementById('sourceUrlInput');
|
||||||
|
const sourceTagsInput = document.getElementById('sourceTagsInput');
|
||||||
|
const sourceEnabledInput = document.getElementById('sourceEnabledInput');
|
||||||
|
const addSourceBtn = document.getElementById('addSourceBtn');
|
||||||
|
const sourcesList = document.getElementById('sourcesList');
|
||||||
|
const sourcesStatus = document.getElementById('sourcesStatus');
|
||||||
|
|
||||||
|
async function loadSources() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/sources`);
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const config = data.config || {};
|
||||||
|
fetchIntervalInput.value = config.fetchInterval ?? '';
|
||||||
|
renderSources(config.sources || []);
|
||||||
|
sourcesStatus.textContent = '';
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading sources:', err);
|
||||||
|
sourcesStatus.textContent = 'Failed to load sources.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSources(sources) {
|
||||||
|
sourcesList.innerHTML = '';
|
||||||
|
if (!sources.length) {
|
||||||
|
sourcesList.innerHTML = '<div class="sources-status">No sources configured yet.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sources.forEach((source, index) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'source-item';
|
||||||
|
|
||||||
|
const nameInput = document.createElement('input');
|
||||||
|
nameInput.className = 'source-edit-input';
|
||||||
|
nameInput.type = 'text';
|
||||||
|
nameInput.value = source.name || '';
|
||||||
|
|
||||||
|
const urlInput = document.createElement('input');
|
||||||
|
urlInput.className = 'source-edit-input';
|
||||||
|
urlInput.type = 'url';
|
||||||
|
urlInput.value = source.url || '';
|
||||||
|
|
||||||
|
const tagsInput = document.createElement('input');
|
||||||
|
tagsInput.className = 'source-edit-input';
|
||||||
|
tagsInput.type = 'text';
|
||||||
|
tagsInput.value = (source.tags || []).join(', ');
|
||||||
|
|
||||||
|
const toggleWrap = document.createElement('label');
|
||||||
|
toggleWrap.className = 'source-toggle';
|
||||||
|
const toggle = document.createElement('input');
|
||||||
|
toggle.type = 'checkbox';
|
||||||
|
toggle.checked = Boolean(source.enabled);
|
||||||
|
toggle.addEventListener('change', () => updateSource(index, { enabled: toggle.checked }));
|
||||||
|
toggleWrap.appendChild(toggle);
|
||||||
|
toggleWrap.appendChild(document.createTextNode('Enabled'));
|
||||||
|
|
||||||
|
const saveBtn = document.createElement('button');
|
||||||
|
saveBtn.className = 'source-save';
|
||||||
|
saveBtn.textContent = 'Save';
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
updateSource(index, {
|
||||||
|
name: nameInput.value.trim(),
|
||||||
|
url: urlInput.value.trim(),
|
||||||
|
tags: parseTagsInput(tagsInput.value)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.className = 'source-delete';
|
||||||
|
deleteBtn.textContent = 'Remove';
|
||||||
|
deleteBtn.addEventListener('click', () => removeSource(index));
|
||||||
|
|
||||||
|
item.appendChild(nameInput);
|
||||||
|
item.appendChild(urlInput);
|
||||||
|
item.appendChild(tagsInput);
|
||||||
|
item.appendChild(toggleWrap);
|
||||||
|
item.appendChild(saveBtn);
|
||||||
|
item.appendChild(deleteBtn);
|
||||||
|
sourcesList.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTagsInput(value) {
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map(tag => tag.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSource() {
|
||||||
|
const name = sourceNameInput.value.trim();
|
||||||
|
const url = sourceUrlInput.value.trim();
|
||||||
|
const tags = parseTagsInput(sourceTagsInput.value);
|
||||||
|
const enabled = sourceEnabledInput.checked;
|
||||||
|
|
||||||
|
if (!name || !url) {
|
||||||
|
sourcesStatus.textContent = 'Name and URL are required.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcesStatus.textContent = 'Saving source...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/sources`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name, url, tags, enabled })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
sourceNameInput.value = '';
|
||||||
|
sourceUrlInput.value = '';
|
||||||
|
sourceTagsInput.value = '';
|
||||||
|
sourceEnabledInput.checked = true;
|
||||||
|
sourcesStatus.textContent = 'Source added.';
|
||||||
|
await loadSources();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error adding source:', err);
|
||||||
|
sourcesStatus.textContent = 'Failed to add source.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSource(index, updates) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/sources/${index}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updates)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
sourcesStatus.textContent = 'Sources updated.';
|
||||||
|
await loadSources();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating source:', err);
|
||||||
|
sourcesStatus.textContent = 'Failed to update source.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSource(index) {
|
||||||
|
const confirmed = window.confirm('Remove this source?');
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/sources/${index}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
sourcesStatus.textContent = 'Source removed.';
|
||||||
|
await loadSources();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error removing source:', err);
|
||||||
|
sourcesStatus.textContent = 'Failed to remove source.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFetchInterval() {
|
||||||
|
const intervalValue = Number(fetchIntervalInput.value);
|
||||||
|
if (!Number.isFinite(intervalValue) || intervalValue <= 0) {
|
||||||
|
sourcesStatus.textContent = 'Fetch interval must be a positive number.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sourcesStatus.textContent = 'Saving interval...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/api/sources/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ fetchInterval: intervalValue.toString() })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
sourcesStatus.textContent = 'Interval updated.';
|
||||||
|
await loadSources();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving interval:', err);
|
||||||
|
sourcesStatus.textContent = 'Failed to update interval.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addSourceBtn.addEventListener('click', addSource);
|
||||||
|
saveIntervalBtn.addEventListener('click', saveFetchInterval);
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', loadSources);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
167
routes/images.js
167
routes/images.js
@@ -1,9 +1,58 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const database = require('../lib/database');
|
const database = require('../lib/database');
|
||||||
const storage = require('../lib/storage');
|
const storage = require('../lib/storage');
|
||||||
const imageFetcher = require('../lib/image-fetcher');
|
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
|
* GET /api/images - Get all images with optional filtering
|
||||||
*/
|
*/
|
||||||
@@ -271,4 +320,122 @@ router.get('/fetcher/status', (req, res) => {
|
|||||||
res.json({ success: true, status });
|
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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const DOMAIN = process.env.DOMAIN || 'homebase.sketchferret.com';
|
|||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: false }));
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
// Initialize database
|
// Initialize database
|
||||||
|
|||||||
Reference in New Issue
Block a user