Files
HomeBase/public/gallery.html
Spencer 854fd199bf
Some checks failed
Deploy to BeePC / deploy (push) Has been cancelled
feat: Add image deletion functionality before a specific date and update gallery UI
2026-02-12 16:14:06 -05:00

828 lines
26 KiB
HTML
Raw 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.8em;
}
.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-item:hover {
background: #283246;
}
.timeline-item.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.controls {
background: var(--panel);
border-radius: 4px;
padding: 12px;
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: 10px 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.4em;
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>
<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">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 deleteBeforeDateInput = document.getElementById('deleteBeforeDate');
const deleteBeforeBtn = document.getElementById('deleteBeforeBtn');
const deleteStatus = document.getElementById('deleteStatus');
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 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();
// 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>';
allTags.forEach(tag => {
const option = document.createElement('option');
option.value = tag;
option.textContent = tag;
tagSelect.appendChild(option);
});
tagSelect.value = currentValue;
}
function renderTimeline() {
// Filter images
const selectedTag = tagSelect.value;
let filtered = allImages;
if (selectedTag) {
filtered = allImages.filter(img =>
img.tags && img.tags.includes(selectedTag)
);
}
// Sort images
const sorted = [...filtered];
if (sortSelect.value === 'oldest') {
sorted.reverse();
}
// Clear timeline
timeline.innerHTML = '';
if (sorted.length === 0) {
empty.style.display = 'block';
mainContent.style.display = 'none';
return;
}
// Add items
sorted.forEach((image, index) => {
const item = document.createElement('div');
item.className = 'timeline-item';
item.textContent = formatCompactDateTime(image.fetched_at);
item.addEventListener('mouseenter', () => showPreview(image, item));
if (index === 0) {
showPreview(image, item);
item.classList.add('active');
}
timeline.appendChild(item);
});
mainContent.style.display = 'flex';
empty.style.display = 'none';
}
function showPreview(image, item) {
currentImage = image;
// Update active state
document.querySelectorAll('.timeline-item').forEach(i => i.classList.remove('active'));
item.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
document.getElementById('previewTime').textContent = formatDate(image.fetched_at);
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);
// Initial load
loadImages();
</script>
</body>
</html>