eagle-viewer/index.html

456 lines
16 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>Eagle Library Viewer</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1a1a1e;
--surface: #26262c;
--surface2: #2f2f38;
--border: #3a3a45;
--accent: #7c6af7;
--accent2: #a89cff;
--text: #e8e8f0;
--text2: #9090a8;
--thumb-size: 180px;
}
body { background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; height: 100vh; overflow: hidden; }
/* Sidebar */
#sidebar {
width: 240px; min-width: 240px; background: var(--surface); border-right: 1px solid var(--border);
display: flex; flex-direction: column; overflow: hidden;
}
#sidebar-header { padding: 16px; border-bottom: 1px solid var(--border); }
#sidebar-header h1 { font-size: 15px; font-weight: 600; color: var(--text); }
#sidebar-header p { font-size: 11px; color: var(--text2); margin-top: 2px; }
#library-list { padding: 8px; overflow-y: auto; }
.lib-btn {
width: 100%; text-align: left; padding: 8px 12px; border-radius: 6px; border: none;
background: transparent; color: var(--text); cursor: pointer; font-size: 13px; margin-bottom: 2px;
transition: background 0.15s;
}
.lib-btn:hover { background: var(--surface2); }
.lib-btn.active { background: var(--accent); color: #fff; }
#folder-tree { padding: 8px; overflow-y: auto; border-top: 1px solid var(--border); flex: 1; }
#folder-tree h3 { font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text2); padding: 4px 8px 8px; }
.folder-item {
padding: 5px 8px 5px calc(8px + var(--depth, 0) * 14px);
border-radius: 5px; cursor: pointer; font-size: 13px; color: var(--text);
transition: background 0.15s; display: flex; align-items: center; gap: 6px;
}
.folder-item:hover { background: var(--surface2); }
.folder-item.active { background: var(--accent); color: #fff; }
.folder-icon { font-size: 12px; opacity: 0.7; }
/* Main */
#main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
#toolbar {
padding: 12px 16px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 12px; background: var(--surface);
}
#search {
flex: 1; background: var(--surface2); border: 1px solid var(--border);
border-radius: 6px; padding: 7px 12px; color: var(--text); font-size: 13px; outline: none;
}
#search:focus { border-color: var(--accent); }
#search::placeholder { color: var(--text2); }
#stats { font-size: 12px; color: var(--text2); white-space: nowrap; }
#thumb-size-slider { width: 80px; accent-color: var(--accent); cursor: pointer; }
#grid-container { flex: 1; overflow-y: auto; padding: 16px; }
#grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(var(--thumb-size), 1fr));
gap: 12px;
}
.card {
background: var(--surface); border-radius: 8px; overflow: hidden; cursor: pointer;
border: 1px solid var(--border); transition: transform 0.15s, border-color 0.15s;
}
.card:hover { transform: translateY(-2px); border-color: var(--accent); }
.thumb-wrap {
width: 100%; aspect-ratio: 16/10; background: var(--surface2);
display: flex; align-items: center; justify-content: center; overflow: hidden; position: relative;
}
.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; }
.thumb-placeholder { font-size: 28px; opacity: 0.4; }
.ext-badge {
position: absolute; bottom: 4px; right: 4px; background: rgba(0,0,0,0.6);
color: #fff; font-size: 9px; padding: 1px 5px; border-radius: 3px; text-transform: uppercase;
}
.card-info { padding: 8px 10px; }
.card-name { font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.card-meta { font-size: 10px; color: var(--text2); margin-top: 2px; }
.card-tags { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 5px; }
.tag { font-size: 10px; background: var(--surface2); border: 1px solid var(--border); border-radius: 3px; padding: 1px 5px; color: var(--text2); }
/* Loading/empty states */
#empty {
flex: 1; display: flex; align-items: center; justify-content: center;
flex-direction: column; gap: 12px; color: var(--text2);
}
#empty .big { font-size: 48px; }
#empty p { font-size: 14px; }
/* Lightbox */
#lightbox {
display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.9); z-index: 100;
align-items: center; justify-content: center; flex-direction: column; padding: 24px;
}
#lightbox.open { display: flex; }
#lightbox-close {
position: absolute; top: 16px; right: 20px; font-size: 28px; cursor: pointer;
color: #fff; opacity: 0.7; border: none; background: none;
}
#lightbox-close:hover { opacity: 1; }
#lightbox-content { max-width: 90vw; max-height: 80vh; object-fit: contain; border-radius: 6px; }
#lightbox-info { margin-top: 16px; text-align: center; }
#lightbox-name { font-size: 16px; font-weight: 500; }
#lightbox-meta { font-size: 12px; color: var(--text2); margin-top: 4px; }
#lightbox-open-btn {
margin-top: 12px; padding: 6px 16px; background: var(--accent); color: #fff;
border: none; border-radius: 6px; cursor: pointer; font-size: 13px;
}
/* Spinner */
.spinner {
width: 36px; height: 36px; border: 3px solid var(--border);
border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite;
margin: 60px auto;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<div id="sidebar">
<div id="sidebar-header">
<h1>🦅 Eagle Viewer</h1>
<p>Synology NAS</p>
</div>
<div id="library-list"></div>
<div id="folder-tree"></div>
</div>
<div id="main">
<div id="toolbar">
<input id="search" type="text" placeholder="Search by name or tag…" oninput="filterImages()">
<span id="stats"></span>
<label style="font-size:12px;color:var(--text2);white-space:nowrap">Size</label>
<input id="thumb-size-slider" type="range" min="100" max="300" value="180" oninput="updateThumbSize(this.value)">
</div>
<div id="grid-container">
<div id="empty">
<div class="big">🦅</div>
<p>Select a library to get started</p>
</div>
<div id="grid" style="display:none"></div>
</div>
</div>
<div id="lightbox">
<button id="lightbox-close" onclick="closeLightbox()">✕</button>
<img id="lightbox-content" src="" alt="">
<div id="lightbox-info">
<div id="lightbox-name"></div>
<div id="lightbox-meta"></div>
<button id="lightbox-open-btn" onclick="openOriginal()">Open original</button>
</div>
</div>
<script>
let allImages = [];
let filteredImages = [];
let currentLibrary = null;
let currentFolder = null; // null = all
let currentImageId = null;
// --- Virtual scroller state ---
const OVERSCAN = 2; // extra rows to render above/below viewport
let vCols = 1;
let vRowHeight = 0;
let vScrollRAF = null;
// Init: load library list
async function init() {
const res = await fetch('/api/libraries');
const libs = await res.json();
const list = document.getElementById('library-list');
libs.forEach(lib => {
const btn = document.createElement('button');
btn.className = 'lib-btn';
btn.textContent = lib;
btn.onclick = () => loadLibrary(lib, btn);
list.appendChild(btn);
});
// Auto-load Courses if present
const coursesBtn = Array.from(list.querySelectorAll('.lib-btn')).find(b => b.textContent === 'Courses');
if (coursesBtn) coursesBtn.click();
}
async function loadLibrary(name, btn) {
document.querySelectorAll('.lib-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
currentLibrary = name;
currentFolder = null;
document.getElementById('grid').style.display = 'none';
document.getElementById('empty').innerHTML = '<div class="spinner"></div>';
document.getElementById('empty').style.display = 'flex';
try {
const res = await fetch(`/api/library/${name}`);
const data = await res.json();
if (data.error) throw new Error(data.error);
allImages = data.images;
renderFolderTree(data.folders, name);
filterImages();
} catch (e) {
document.getElementById('empty').innerHTML = `<div class="big">⚠️</div><p>Error: ${e.message}</p>`;
}
}
function renderFolderTree(folders, libName) {
const tree = document.getElementById('folder-tree');
tree.innerHTML = '<h3>Folders</h3>';
function addFolder(folder, depth) {
const div = document.createElement('div');
div.className = 'folder-item';
div.style.setProperty('--depth', depth);
div.innerHTML = `<span class="folder-icon">📁</span>${folder.name} <span style="color:var(--text2);font-size:10px;margin-left:auto">${countInFolder(folder.id)}</span>`;
div.onclick = () => {
document.querySelectorAll('.folder-item').forEach(f => f.classList.remove('active'));
div.classList.add('active');
currentFolder = folder.id;
filterImages();
};
tree.appendChild(div);
(folder.children || []).forEach(child => addFolder(child, depth + 1));
}
// "All" entry
const allDiv = document.createElement('div');
allDiv.className = 'folder-item active';
allDiv.innerHTML = `<span class="folder-icon">🗂</span>All <span style="color:var(--text2);font-size:10px;margin-left:auto">${allImages.length}</span>`;
allDiv.onclick = () => {
document.querySelectorAll('.folder-item').forEach(f => f.classList.remove('active'));
allDiv.classList.add('active');
currentFolder = null;
filterImages();
};
tree.appendChild(allDiv);
folders.forEach(f => addFolder(f, 0));
}
function countInFolder(folderId) {
return allImages.filter(img => img.folders && img.folders.includes(folderId)).length;
}
function filterImages() {
const query = document.getElementById('search').value.toLowerCase().trim();
filteredImages = allImages.filter(img => {
if (img.isDeleted) return false;
if (currentFolder && !(img.folders || []).includes(currentFolder)) return false;
if (query) {
const nameMatch = (img.name || '').toLowerCase().includes(query);
const tagMatch = (img.tags || []).some(t => t.toLowerCase().includes(query));
if (!nameMatch && !tagMatch) return false;
}
return true;
});
renderGrid();
}
function renderGrid() {
const grid = document.getElementById('grid');
const empty = document.getElementById('empty');
const container = document.getElementById('grid-container');
const stats = document.getElementById('stats');
stats.textContent = `${filteredImages.length} items`;
if (filteredImages.length === 0) {
grid.style.display = 'none';
empty.style.display = 'flex';
empty.innerHTML = '<div class="big">🔍</div><p>No items match</p>';
return;
}
empty.style.display = 'none';
grid.style.display = 'grid';
container.scrollTop = 0;
// Measure column count and row height from CSS grid
// We do one measurement pass with a dummy card first
function measureGrid() {
const thumbSize = parseInt(getComputedStyle(document.documentElement).getPropertyValue('--thumb-size')) || 180;
const gridWidth = grid.clientWidth || container.clientWidth - 32;
vCols = Math.max(1, Math.floor((gridWidth + 12) / (thumbSize + 12)));
// Card height: thumb aspect 16/10 + info ~60px
vRowHeight = Math.round((thumbSize * 10 / 16)) + 62 + 12; // thumb-h + info + gap
}
measureGrid();
// Detach scroll listener before wiping grid
container.onscroll = null;
function renderVisible() {
measureGrid();
const totalRows = Math.ceil(filteredImages.length / vCols);
const totalHeight = totalRows * vRowHeight;
const scrollTop = container.scrollTop;
const viewHeight = container.clientHeight;
const firstVisRow = Math.max(0, Math.floor(scrollTop / vRowHeight) - OVERSCAN);
const lastVisRow = Math.min(totalRows - 1, Math.ceil((scrollTop + viewHeight) / vRowHeight) + OVERSCAN);
const firstIdx = firstVisRow * vCols;
const lastIdx = Math.min(filteredImages.length - 1, (lastVisRow + 1) * vCols - 1);
// Spacer above
const topH = firstVisRow * vRowHeight;
// Spacer below
const bottomH = Math.max(0, (totalRows - lastVisRow - 1) * vRowHeight);
grid.innerHTML = '';
// Top spacer
if (topH > 0) {
const spacer = document.createElement('div');
spacer.style.cssText = `grid-column: 1 / -1; height: ${topH}px; pointer-events: none;`;
grid.appendChild(spacer);
}
// Render only visible cards
for (let i = firstIdx; i <= lastIdx; i++) {
const img = filteredImages[i];
const card = document.createElement('div');
card.className = 'card';
card.onclick = () => openLightbox(img);
const thumbSrc = img._thumbnail ? `/api/thumbnail/${currentLibrary}/${img.id}` : null;
const extIcon = extToIcon(img.ext);
card.innerHTML = `
<div class="thumb-wrap">
${thumbSrc
? `<img src="${thumbSrc}" alt="${escHtml(img.name)}" onerror="this.style.display='none';this.nextElementSibling.style.display='flex'">`
: ''}
<span class="thumb-placeholder" style="${thumbSrc ? 'display:none' : ''}">${extIcon}</span>
<span class="ext-badge">${img.ext || '?'}</span>
</div>
<div class="card-info">
<div class="card-name" title="${escHtml(img.name)}">${escHtml(img.name)}</div>
<div class="card-meta">${img.ext?.toUpperCase() || ''} ${img.duration ? '· ' + fmtDuration(img.duration) : ''}</div>
${img.tags?.length ? `<div class="card-tags">${img.tags.map(t => `<span class="tag">${escHtml(t)}</span>`).join('')}</div>` : ''}
</div>
`;
grid.appendChild(card);
}
// Bottom spacer
if (bottomH > 0) {
const spacer = document.createElement('div');
spacer.style.cssText = `grid-column: 1 / -1; height: ${bottomH}px; pointer-events: none;`;
grid.appendChild(spacer);
}
}
renderVisible();
// Re-render on scroll (throttled via rAF)
container.onscroll = () => {
if (vScrollRAF) return;
vScrollRAF = requestAnimationFrame(() => {
vScrollRAF = null;
renderVisible();
});
};
}
function openLightbox(img) {
currentImageId = img.id;
const lb = document.getElementById('lightbox');
lb.classList.add('open');
const content = document.getElementById('lightbox-content');
if (img._thumbnail) {
content.src = `/api/thumbnail/${currentLibrary}/${img.id}`;
content.style.display = 'block';
} else {
content.src = '';
content.style.display = 'none';
}
document.getElementById('lightbox-name').textContent = img.name;
const meta = [
img.ext?.toUpperCase(),
img.width && img.height ? `${img.width}×${img.height}` : null,
img.duration ? fmtDuration(img.duration) : null,
img.size ? fmtSize(img.size) : null,
].filter(Boolean).join(' · ');
document.getElementById('lightbox-meta').textContent = meta;
}
function closeLightbox() {
document.getElementById('lightbox').classList.remove('open');
}
function openOriginal() {
if (currentImageId && currentLibrary) {
window.open(`/api/file/${currentLibrary}/${currentImageId}`, '_blank');
}
}
document.getElementById('lightbox').addEventListener('click', e => {
if (e.target === document.getElementById('lightbox')) closeLightbox();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeLightbox(); });
function updateThumbSize(val) {
document.documentElement.style.setProperty('--thumb-size', val + 'px');
if (filteredImages.length > 0) renderGrid();
}
function extToIcon(ext) {
const map = { mp4: '🎬', mov: '🎬', avi: '🎬', jpg: '🖼', jpeg: '🖼', png: '🖼', gif: '🎞', svg: '✏️', pdf: '📄', webp: '🖼' };
return map[(ext || '').toLowerCase()] || '📁';
}
function fmtDuration(s) {
const m = Math.floor(s / 60), sec = Math.round(s % 60);
return `${m}:${String(sec).padStart(2, '0')}`;
}
function fmtSize(bytes) {
if (bytes > 1e9) return (bytes / 1e9).toFixed(1) + ' GB';
if (bytes > 1e6) return (bytes / 1e6).toFixed(1) + ' MB';
return (bytes / 1e3).toFixed(0) + ' KB';
}
function escHtml(s) {
return (s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
init();
</script>
</body>
</html>