feat: Add image sources management and update server middleware
Some checks failed
Deploy to BeePC / deploy (push) Has been cancelled

This commit is contained in:
2026-02-17 20:44:53 -05:00
parent 854fd199bf
commit 706d48f549
5 changed files with 789 additions and 21 deletions

View File

@@ -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
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 = '<option value="">All Sources</option>';
// 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();