eagle-viewer/index.html
Steven (Zen) 7e4e24120d Initial POC: Eagle library viewer over SSH + SMB mount
- Express server reads Eagle .library format from Synology NAS
- SSH batch metadata load (~3.5s for 372 images) via find+cat
- SMB mount used for thumbnail/file serving with HTTP range support
- Dark grid UI: folder sidebar, search, adjustable thumb size, lightbox
2026-08-10 12:34:49 -06:00

388 lines
14 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>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;
// 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 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';
grid.innerHTML = '';
filteredImages.forEach(img => {
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}" loading="lazy" 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);
});
}
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');
}
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>