Files
HomeBase/public/gallery.html
Spencer 706d48f549
Some checks failed
Deploy to BeePC / deploy (push) Has been cancelled
feat: Add image sources management and update server middleware
2026-02-17 20:44:53 -05:00

958 lines
31 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HomeBase Image Gallery</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: 8px;
color: var(--text);
}
.container {
max-width: 1400px;
margin: 0 auto;
}
.header {
text-align: left;
color: var(--text);
margin-bottom: 8px;
}
.header h1 {
font-size: 2em;
margin-bottom: 0;
}
.header p {
display: none;
}
.main-content {
display: flex;
gap: 20px;
height: calc(100vh - 200px);
}
.preview-panel {
flex: 1;
background: var(--panel);
border-radius: 4px;
padding: 20px;
border: 1px solid var(--border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
overflow: hidden;
}
.preview-image {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
min-height: 240px;
}
.preview-image img {
max-width: 100%;
max-height: 100%;
border-radius: 2px;
object-fit: contain;
}
.preview-placeholder {
color: var(--muted);
font-size: 1.2em;
text-align: center;
}
.preview-info {
border-top: 1px solid var(--border);
padding-top: 10px;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin-bottom: 10px;
font-size: 0.9em;
}
.info-label {
font-weight: 600;
color: var(--text);
min-width: 80px;
font-size: 0.85em;
}
.info-value {
flex: 1;
color: var(--muted);
word-break: break-all;
font-family: monospace;
font-size: 0.7em;
}
.tags-editor {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid var(--border);
}
.tags-title {
font-weight: 600;
color: var(--text);
margin-bottom: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.tags-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.tag {
background: var(--accent);
color: white;
padding: 6px 12px;
border-radius: 3px;
font-size: 0.9em;
display: flex;
align-items: center;
gap: 6px;
}
.tag-remove {
cursor: pointer;
font-weight: bold;
opacity: 0.7;
transition: opacity 0.2s;
}
.tag-remove:hover {
opacity: 1;
}
.tag-input-container {
display: flex;
gap: 8px;
}
.tag-input {
flex: 1;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 3px;
font-size: 0.9em;
background: var(--panel-strong);
color: var(--text);
}
.tag-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 2px rgba(122, 162, 247, 0.2);
}
.tag-add-btn {
padding: 8px 15px;
background: var(--accent);
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
font-weight: 500;
transition: background 0.2s;
}
.tag-add-btn:hover {
background: var(--accent-strong);
}
.timeline-container {
flex: 0 0 150px;
background: var(--panel);
border-radius: 4px;
padding: 15px;
border: 1px solid var(--border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
display: flex;
flex-direction: column;
}
.timeline-title {
font-weight: 600;
color: var(--text);
margin-bottom: 15px;
font-size: 0.95em;
}
.timeline-scroll {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.timeline-item {
padding: 10px;
background: var(--panel-strong);
border-radius: 3px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
text-align: center;
font-size: 0.75em;
color: var(--muted);
line-height: 1.4;
white-space: pre-wrap;
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;
}
.timeline-item.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.controls {
background: var(--panel);
border-radius: 4px;
padding: 10px;
margin-bottom: 12px;
border: 1px solid var(--border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
align-items: center;
}
.controls label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
color: var(--text);
}
.controls select,
.controls input {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 3px;
font-size: 0.95em;
background: var(--panel-strong);
color: var(--text);
}
.controls select {
cursor: pointer;
}
.danger-btn {
background: var(--danger);
color: white;
border: none;
border-radius: 3px;
padding: 8px 12px;
cursor: pointer;
font-weight: 600;
}
.danger-btn:hover {
background: var(--danger-strong);
}
.delete-group {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
justify-content: flex-start;
}
.delete-status {
font-size: 0.85em;
color: var(--muted);
}
.stats {
background: var(--panel);
border-radius: 4px;
padding: 9px 16px;
display: flex;
gap: 20px;
flex-wrap: wrap;
border: 1px solid var(--border);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-label {
font-size: 0.85em;
color: var(--muted);
font-weight: 500;
}
.stat-value {
font-size: 1.1em;
font-weight: bold;
color: var(--accent);
}
.loading {
text-align: center;
color: var(--text);
font-size: 1.1em;
}
.error {
background: #4a1d1d;
color: white;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
border: 1px solid #6b2a2a;
}
.empty {
background: var(--panel);
border-radius: 4px;
padding: 28px;
text-align: center;
color: var(--muted);
border: 1px solid var(--border);
}
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--panel-strong);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: var(--accent);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--accent-strong);
}
@media (max-width: 1024px) {
.main-content {
flex-direction: column;
height: auto;
}
.timeline-container {
flex: 0 0 auto;
max-height: 200px;
}
.header h1 {
font-size: 1.5em;
}
.controls {
grid-template-columns: 1fr;
}
.stats {
flex-direction: column;
gap: 15px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Image Gallery</h1>
</div>
<div class="controls">
<label>
Sort by:
<select id="sortSelect">
<option value="newest">Newest First</option>
<option value="oldest">Oldest First</option>
</select>
</label>
<label>
Filter by tag:
<select id="tagSelect">
<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:
<input type="datetime-local" id="deleteBeforeDate">
</label>
<button class="danger-btn" id="deleteBeforeBtn">Delete</button>
<span class="delete-status" id="deleteStatus"></span>
</div>
</div>
<div class="stats">
<div class="stat">
<span class="stat-label">Total Images</span>
<span class="stat-value" id="totalCount">0</span>
</div>
<div class="stat">
<span class="stat-label">Total Storage</span>
<span class="stat-value" id="totalSize">0 GB</span>
</div>
<div class="stat">
<span class="stat-label">Last Updated</span>
<span class="stat-value" id="lastUpdated">--:--</span>
</div>
</div>
<div id="loading" class="loading" style="margin-top: 40px;">Loading images...</div>
<div id="error" class="error" style="display:none;"></div>
<div class="main-content" id="mainContent" style="display:none;">
<div class="preview-panel">
<div class="preview-image">
<div class="preview-placeholder">Hover over a timestamp to preview</div>
<img id="previewImage" style="display:none;" src="" alt="">
</div>
<div class="preview-info">
<div class="info-row">
<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>
</div>
<div class="info-row">
<span class="info-label">Hash:</span>
<span class="info-value" id="previewHash">--</span>
</div>
<div class="tags-editor">
<div class="tags-title">
Tags
<span id="tagsSaveStatus" style="font-size: 0.8em; color: #999; font-weight: normal;"></span>
</div>
<div class="tags-list" id="tagsList"></div>
<div class="tag-input-container">
<input type="text" id="newTagInput" class="tag-input" placeholder="Add new tag...">
<button class="tag-add-btn" id="addTagBtn">Add</button>
</div>
</div>
</div>
</div>
<div class="timeline-container">
<div class="timeline-title">Timeline</div>
<div class="timeline-scroll" id="timeline"></div>
</div>
</div>
<div class="empty" id="empty" style="display:none;">No images found. Start fetching to see images here!</div>
</div>
<script>
const API_BASE = '';
let allImages = [];
let allTags = [];
let currentImage = null;
const timeline = document.getElementById('timeline');
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const empty = document.getElementById('empty');
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);
const normalized = `${dateString.replace(' ', 'T')}Z`;
return new Date(normalized);
}
function formatDate(dateString) {
const date = parseSqliteDate(dateString);
if (Number.isNaN(date.getTime())) return 'Invalid date';
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
}
function formatTime(dateString) {
const date = parseSqliteDate(dateString);
if (Number.isNaN(date.getTime())) return '--:--:--';
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
function formatCompactDateTime(dateString) {
const date = parseSqliteDate(dateString);
if (Number.isNaN(date.getTime())) return '--';
const month = date.toLocaleString('en-US', { month: 'short' });
const day = date.getDate();
const time = date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
return `${month} ${day}\n${time}`;
}
function formatSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
async function loadImages() {
try {
loading.style.display = 'block';
error.style.display = 'none';
mainContent.style.display = 'none';
empty.style.display = 'none';
// Fetch images with timeout
try {
const response = await Promise.race([
fetch(`${API_BASE}/api/images?pageSize=999`),
new Promise((_, reject) => setTimeout(() => reject(new Error('Request timeout')), 10000))
]);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const data = await response.json();
allImages = data.images || [];
} catch (err) {
console.error('Error fetching images:', err);
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`);
if (tagsResponse.ok) {
const tagsData = await tagsResponse.json();
allTags = tagsData.tags || [];
}
} catch (err) {
console.warn('Warning: Could not fetch tags:', err);
allTags = [];
}
// Fetch stats (non-critical)
try {
const statsResponse = await fetch(`${API_BASE}/api/stats`);
if (statsResponse.ok) {
const statsData = await statsResponse.json();
document.getElementById('totalCount').textContent = statsData.stats.imageCount || allImages.length;
document.getElementById('totalSize').textContent = statsData.stats.totalSizeGB + ' GB' || '0 GB';
document.getElementById('lastUpdated').textContent = new Date().toLocaleTimeString();
}
} catch (err) {
console.warn('Warning: Could not fetch stats:', err);
}
// Update tag filter
updateTagFilter();
updateSourceFilter();
// Render timeline
renderTimeline();
loading.style.display = 'none';
} catch (err) {
console.error('Error loading images:', err);
error.style.display = 'block';
error.textContent = 'Error: ' + err.message;
loading.style.display = 'none';
}
}
function updateTagFilter() {
const currentValue = tagSelect.value;
tagSelect.innerHTML = '<option value="">All Tags</option>';
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 = Array.from(tagSelect.options).some(option => option.value === currentValue)
? currentValue
: '';
}
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;
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';
mainContent.style.display = 'none';
return;
}
// Add items
const fragment = document.createDocumentFragment();
sorted.forEach((image, index) => {
const item = document.createElement('div');
item.className = 'timeline-item';
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));
if (index === 0) {
showPreview(image, item);
item.classList.add('active');
}
fragment.appendChild(item);
});
timeline.appendChild(fragment);
mainContent.style.display = 'flex';
empty.style.display = 'none';
}
function showPreview(image, item) {
currentImage = image;
// Update active state
if (activeTimelineItem) {
activeTimelineItem.classList.remove('active');
}
activeTimelineItem = item;
activeTimelineItem.classList.add('active');
// Update preview
const previewImage = document.getElementById('previewImage');
previewImage.src = `${API_BASE}/api/images/${image.id}/download`;
previewImage.style.display = 'block';
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) + '...';
// Update tags
renderTagsList();
}
function renderTagsList() {
const tagsList = document.getElementById('tagsList');
tagsList.innerHTML = '';
if (currentImage.tags && currentImage.tags.length > 0) {
currentImage.tags.forEach(tag => {
const tagEl = document.createElement('div');
tagEl.className = 'tag';
tagEl.innerHTML = `${tag} <span class="tag-remove">×</span>`;
tagEl.querySelector('.tag-remove').addEventListener('click', () => removeTag(tag));
tagsList.appendChild(tagEl);
});
}
}
function normalizeBeforeDateInput(value) {
if (!value) return '';
const normalized = value.replace('T', ' ');
return normalized.length === 16 ? `${normalized}:00` : normalized;
}
async function deleteImagesBeforeDate() {
const beforeDateInput = deleteBeforeDateInput.value;
const beforeDate = normalizeBeforeDateInput(beforeDateInput);
if (!beforeDate) {
deleteStatus.textContent = 'Pick a date/time first.';
return;
}
const confirmed = window.confirm(`Delete all images before ${beforeDate}? This cannot be undone.`);
if (!confirmed) return;
deleteStatus.textContent = 'Deleting...';
try {
const response = await fetch(`${API_BASE}/api/cleanup/before`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ beforeDate })
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const deletedCount = data?.cleanup?.deletedFromDb ?? 0;
deleteStatus.textContent = `Deleted ${deletedCount} images.`;
await loadImages();
} catch (err) {
console.error('Error deleting images:', err);
deleteStatus.textContent = 'Delete failed.';
}
}
async function removeTag(tag) {
try {
document.getElementById('tagsSaveStatus').textContent = 'Removing...';
// Get current tags and remove the one we want to delete
const updatedTags = currentImage.tags.filter(t => t !== tag);
// We need to remove the tag by fetching the image and updating
// For now, we'll just show a message since the API doesn't support tag removal directly
document.getElementById('tagsSaveStatus').textContent = 'Tag removal not yet supported';
setTimeout(() => {
document.getElementById('tagsSaveStatus').textContent = '';
}, 2000);
} catch (err) {
console.error('Error removing tag:', err);
}
}
async function addTag() {
if (!currentImage) return;
const input = document.getElementById('newTagInput');
const newTag = input.value.trim().toLowerCase();
if (!newTag) return;
try {
document.getElementById('tagsSaveStatus').textContent = 'Saving...';
const response = await fetch(`${API_BASE}/api/images/${currentImage.id}/tags`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags: [newTag] })
});
if (!response.ok) throw new Error('Failed to add tag');
const data = await response.json();
currentImage = data.image;
input.value = '';
renderTagsList();
// Update all tags
allTags = [...new Set([...allTags, newTag])];
updateTagFilter();
document.getElementById('tagsSaveStatus').textContent = 'Saved!';
setTimeout(() => {
document.getElementById('tagsSaveStatus').textContent = '';
}, 2000);
} catch (err) {
console.error('Error adding tag:', err);
document.getElementById('tagsSaveStatus').textContent = 'Error saving';
setTimeout(() => {
document.getElementById('tagsSaveStatus').textContent = '';
}, 2000);
}
}
document.getElementById('addTagBtn').addEventListener('click', addTag);
document.getElementById('newTagInput').addEventListener('keypress', (e) => {
if (e.key === 'Enter') addTag();
});
deleteBeforeBtn.addEventListener('click', deleteImagesBeforeDate);
sortSelect.addEventListener('change', renderTimeline);
tagSelect.addEventListener('change', renderTimeline);
sourceSelect.addEventListener('change', renderTimeline);
// Initial load
loadImages();
</script>
</body>
</html>