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);
|
||||
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 @@
|
||||
<option value="">All Tags</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Filter by source:
|
||||
<select id="sourceSelect">
|
||||
<option value="">All Sources</option>
|
||||
</select>
|
||||
</label>
|
||||
<div class="delete-group">
|
||||
<label>
|
||||
Delete before:
|
||||
@@ -472,6 +497,10 @@
|
||||
<span class="info-label">Time:</span>
|
||||
<span class="info-value" id="previewTime">--</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Source:</span>
|
||||
<span class="info-value" id="previewSource">--</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Size:</span>
|
||||
<span class="info-value" id="previewSize">--</span>
|
||||
@@ -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 = '<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');
|
||||
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
|
||||
function updateSourceFilter() {
|
||||
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;
|
||||
let filtered = allImages;
|
||||
if (selectedTag) {
|
||||
filtered = allImages.filter(img =>
|
||||
img.tags && img.tags.includes(selectedTag)
|
||||
);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// Sort images
|
||||
const sorted = [...filtered];
|
||||
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();
|
||||
|
||||
@@ -45,6 +45,11 @@
|
||||
<h3>Image Gallery</h3>
|
||||
<p>Browse captured images on an interactive timeline</p>
|
||||
</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">
|
||||
<div class="feature-icon">📊</div>
|
||||
<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 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;
|
||||
|
||||
Reference in New Issue
Block a user