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
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user