- Search & Edit becomes a multi-view library: Tagged/Untagged/Failed/Videos/Duplicates - Bulk select (click, shift-click range, select-all-matching) with bulk recategorize, CSV/JSON export, and original-quality ZIP download (verified byte-identical) - Video browsing/tagging via ffmpeg frame extraction, native playback with range support - Duplicate detection via a lightweight perceptual-hash index (no new dependencies) - Full-resolution photo/video viewer with editable metadata, prev/next navigation - Date range, file-type, and category quick-filters; saved searches (localStorage) - Appearance settings: accent color picker, grid density, results-per-page; full dark mode - Auto-detects its photo folder (CLI arg > env var > last used > parent directory), so the app can be dropped into any photo collection and just work
1864 lines
85 KiB
HTML
1864 lines
85 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>PHOTON // photo intelligence console</title>
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root{
|
||
color-scheme: dark;
|
||
--bg:#03050a; --panel:#080d1a; --panel2:#0c1328; --line:#162447;
|
||
--cyan:#22d3ee; --cyan-dim:#0891b2; --mag:#e879f9; --mag-dim:#c084fc;
|
||
--grn:#4ade80; --grn-dim:#16a34a; --amber:#fbbf24; --red:#f87171;
|
||
--txt:#d1e2ff; --dim:#627a9f;
|
||
--font-sans:'Outfit', sans-serif;
|
||
--font-mono:'JetBrains Mono', monospace;
|
||
--thumb-min:190px;
|
||
}
|
||
input[type=date]::-webkit-calendar-picker-indicator{filter:invert(.7) brightness(1.6)}
|
||
::-webkit-scrollbar{width:8px;height:8px}
|
||
::-webkit-scrollbar-track{background:var(--bg)}
|
||
::-webkit-scrollbar-thumb{background:var(--line);border-radius:4px}
|
||
::-webkit-scrollbar-thumb:hover{background:var(--cyan-dim)}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{
|
||
background:var(--bg); color:var(--txt); font-family:var(--font-sans);
|
||
font-size:13px; min-height:100vh; overflow-x:hidden;
|
||
background-image:
|
||
radial-gradient(ellipse 80% 50% at 50% -10%, rgba(34,211,238,.07), transparent),
|
||
repeating-linear-gradient(0deg, transparent 0 2px, rgba(255,255,255,.004) 2px 4px);
|
||
}
|
||
.wrap{max-width:1600px;margin:0 auto;padding:14px 20px 30px}
|
||
|
||
/* ---------- header & tabs ---------- */
|
||
header{display:flex;align-items:center;gap:18px;padding:10px 4px 16px;flex-wrap:wrap;border-bottom:1px solid var(--line);margin-bottom:14px}
|
||
.logo{font-size:28px;font-weight:800;letter-spacing:6px;color:#fff;font-family:var(--font-sans);
|
||
text-shadow:0 0 12px var(--cyan),0 0 40px rgba(34,211,238,.3)}
|
||
.logo span{color:var(--cyan)}
|
||
.sub{color:var(--dim);letter-spacing:1px;font-size:10px;text-transform:uppercase;margin-top:2px}
|
||
.badge{margin-left:auto;padding:5px 14px;border:1px solid var(--line);border-radius:4px;
|
||
letter-spacing:2px;font-size:11px;text-transform:uppercase;color:var(--dim);font-family:var(--font-mono);font-weight:bold}
|
||
.badge.running{color:var(--grn);border-color:var(--grn);box-shadow:0 0 12px rgba(74,222,128,.2);animation:pulse 1.6s infinite}
|
||
.badge.paused{color:var(--amber);border-color:var(--amber)}
|
||
.badge.scanning{color:var(--cyan);border-color:var(--cyan)}
|
||
.badge.done{color:var(--mag);border-color:var(--mag)}
|
||
@keyframes pulse{50%{opacity:.55}}
|
||
|
||
.tabs{display:flex;gap:4px;margin-bottom:16px;background:rgba(8,13,26,0.6);padding:4px;border-radius:6px;border:1px solid var(--line);width:fit-content}
|
||
.tab-btn{
|
||
background:transparent;border:none;color:var(--dim);padding:8px 18px;font-family:var(--font-sans);
|
||
font-size:13px;font-weight:600;letter-spacing:1px;border-radius:4px;cursor:pointer;transition:.15s;
|
||
}
|
||
.tab-btn:hover{color:#fff;background:rgba(255,255,255,0.03)}
|
||
.tab-btn.active{color:var(--cyan);background:var(--panel2);box-shadow:inset 0 0 8px rgba(34,211,238,0.1);border:1px solid var(--line)}
|
||
|
||
.tab-content{display:none}
|
||
.tab-content.active{display:block}
|
||
|
||
/* ---------- general components ---------- */
|
||
.panel{background:var(--panel);border:1px solid var(--line);border-radius:6px;overflow:hidden;margin-bottom:14px;box-shadow:0 8px 24px rgba(0,0,0,0.3)}
|
||
.panel h2{font-size:11px;letter-spacing:2px;text-transform:uppercase;color:var(--dim);
|
||
padding:10px 14px;border-bottom:1px solid var(--line);background:var(--panel2);font-weight:600;display:flex;align-items:center;justify-content:between}
|
||
.panel h2 b{color:var(--cyan);font-weight:600}
|
||
button{
|
||
font-family:var(--font-sans);font-size:11px;letter-spacing:1.5px;text-transform:uppercase;font-weight:600;
|
||
background:var(--panel2);color:var(--txt);border:1px solid var(--line);
|
||
padding:8px 18px;border-radius:4px;cursor:pointer;transition:.15s;
|
||
}
|
||
button:hover:not(:disabled){border-color:var(--cyan);color:var(--cyan);box-shadow:0 0 12px rgba(34,211,238,0.2)}
|
||
button:disabled{opacity:.3;cursor:not-allowed}
|
||
.btn-primary{border-color:var(--grn);color:var(--grn)}
|
||
.btn-primary:hover:not(:disabled){box-shadow:0 0 14px rgba(74,222,128,0.25)}
|
||
.btn-danger{border-color:var(--red);color:var(--red)}
|
||
.btn-danger:hover:not(:disabled){box-shadow:0 0 14px rgba(248,113,113,0.25)}
|
||
|
||
/* ---------- console view layout ---------- */
|
||
.controls{display:flex;gap:10px;flex-wrap:wrap;margin-bottom:14px;align-items:center}
|
||
.folderRow{display:flex;gap:10px;flex:1;min-width:340px}
|
||
.folderRow input{flex:1;background:var(--panel);border:1px solid var(--line);color:var(--txt);
|
||
font-family:var(--font-mono);font-size:12px;padding:9px 12px;border-radius:4px}
|
||
.folderRow input:focus{outline:none;border-color:var(--cyan)}
|
||
|
||
.grid{display:grid;grid-template-columns:330px 1fr;gap:14px}
|
||
.grid-three{display:grid;grid-template-columns:330px 1fr 320px;gap:14px}
|
||
@media(max-width:1200px){.grid,.grid-three{grid-template-columns:1fr}}
|
||
|
||
.photoBox{position:relative;aspect-ratio:1;background:#000;display:flex;align-items:center;justify-content:center}
|
||
.photoBox img{max-width:100%;max-height:100%;object-fit:contain}
|
||
.photoBox .scan{position:absolute;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--cyan),transparent);
|
||
box-shadow:0 0 10px var(--cyan);opacity:0;top:0}
|
||
.photoBox.active .scan{opacity:1;animation:scanline 2.2s linear infinite}
|
||
@keyframes scanline{0%{top:0}100%{top:100%}}
|
||
.photoBox .corner{position:absolute;width:16px;height:16px;border:2px solid var(--cyan);opacity:.5}
|
||
.c1{top:6px;left:6px;border-right:0;border-bottom:0}.c2{top:6px;right:6px;border-left:0;border-bottom:0}
|
||
.c3{bottom:6px;left:6px;border-right:0;border-top:0}.c4{bottom:6px;right:6px;border-left:0;border-top:0}
|
||
.photoMeta{padding:12px;border-top:1px solid var(--line);min-height:115px}
|
||
.photoName{color:var(--cyan);font-size:11px;font-family:var(--font-mono);word-break:break-all;margin-bottom:6px}
|
||
.photoDesc{color:var(--txt);font-size:12px;line-height:1.5;margin-bottom:8px}
|
||
.photoCat{display:inline-block;border:1px solid var(--mag);color:var(--mag);font-size:9px;
|
||
letter-spacing:1.5px;padding:3px 9px;border-radius:3px;text-transform:uppercase;font-weight:600}
|
||
|
||
.term{height:390px;overflow-y:auto;padding:10px 12px;font-family:var(--font-mono);font-size:11px;line-height:1.6;
|
||
background:linear-gradient(rgba(34,211,238,.01),transparent)}
|
||
.term::-webkit-scrollbar{width:6px}
|
||
.term::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}
|
||
.ln{white-space:pre-wrap;word-break:break-word;margin-bottom:4px}
|
||
.ln .t{color:var(--dim)}
|
||
.ln.info{color:var(--cyan)} .ln.ok{color:var(--grn)}
|
||
.ln.warn{color:var(--amber)} .ln.error{color:var(--red)}
|
||
.cursor{display:inline-block;width:6px;height:12px;background:var(--cyan);animation:cursorPulse 1s infinite;vertical-align:text-bottom}
|
||
@keyframes cursorPulse{50%{opacity:0}}
|
||
|
||
/* ---------- recent results strip ---------- */
|
||
.recent-strip{display:flex;gap:8px;padding:10px;overflow-x:auto;background:var(--panel2);min-height:96px}
|
||
.recent-item{width:76px;height:76px;border:1px solid var(--line);border-radius:4px;overflow:hidden;cursor:pointer;position:relative;flex-shrink:0;transition:.15s}
|
||
.recent-item:hover{border-color:var(--cyan);transform:scale(1.04)}
|
||
.recent-item img{width:100%;height:100%;object-fit:cover}
|
||
.recent-item .cat-badge{position:absolute;bottom:2px;right:2px;font-size:8px;background:rgba(0,0,0,0.8);color:var(--mag);padding:1px 3px;border-radius:2px;font-weight:bold}
|
||
|
||
/* ---------- parameters & settings ---------- */
|
||
.set{padding:14px}
|
||
.set label{display:block;font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--dim);margin:14px 0 5px;font-weight:600}
|
||
.set label:first-child{margin-top:0}
|
||
.set select,.set input[type=number]{width:100%;background:var(--panel2);border:1px solid var(--line);
|
||
color:var(--txt);font-family:var(--font-sans);font-size:12px;padding:8px 10px;border-radius:4px}
|
||
.set select:focus,.set input:focus{outline:none;border-color:var(--cyan)}
|
||
.accent-row{display:flex;align-items:center;gap:12px;margin-top:6px;flex-wrap:wrap}
|
||
input[type=color]{width:46px;height:32px;border:1px solid var(--line);border-radius:4px;background:var(--panel2);cursor:pointer;padding:2px}
|
||
.accent-swatches{display:flex;gap:7px}
|
||
.accent-swatch{width:22px;height:22px;border-radius:50%;border:2px solid var(--line);cursor:pointer;flex-shrink:0}
|
||
.accent-swatch:hover{border-color:#fff;transform:scale(1.1)}
|
||
.seg{display:flex;gap:4px}
|
||
.seg button{flex:1;padding:6px 2px;font-size:10px;letter-spacing:1px}
|
||
.seg button.on{border-color:var(--cyan);color:var(--cyan);background:rgba(34,211,238,.08)}
|
||
.tog{display:flex;justify-content:space-between;align-items:center;margin-top:12px}
|
||
.tog span{font-size:10px;letter-spacing:2px;text-transform:uppercase;color:var(--dim);font-weight:600}
|
||
.sw{width:38px;height:18px;border:1px solid var(--line);border-radius:10px;position:relative;cursor:pointer;background:var(--panel2)}
|
||
.sw i{position:absolute;top:2px;left:2px;width:12px;height:12px;border-radius:50%;background:var(--dim);transition:.15s}
|
||
.sw.on{border-color:var(--grn)} .sw.on i{left:22px;background:var(--grn);box-shadow:0 0 8px var(--grn)}
|
||
.hint{font-size:9px;color:var(--dim);margin-top:3px;letter-spacing:.3px;line-height:1.4}
|
||
|
||
/* ---------- stats & counters ---------- */
|
||
.statsRow{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:14px}
|
||
.stat{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:12px;text-align:center}
|
||
.stat .v{font-size:24px;color:#fff;text-shadow:0 0 8px rgba(34,211,238,.4);font-family:var(--font-mono);font-weight:bold}
|
||
.stat .k{font-size:9px;letter-spacing:2px;text-transform:uppercase;color:var(--dim);margin-top:4px}
|
||
.progressWrap{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:12px 14px;margin-bottom:14px}
|
||
.pbar{height:12px;background:var(--panel2);border:1px solid var(--line);border-radius:3px;overflow:hidden;position:relative}
|
||
.pbar .fill{height:100%;width:0%;background:linear-gradient(90deg,var(--cyan-dim),var(--cyan));
|
||
box-shadow:0 0 10px var(--cyan);transition:width .4s}
|
||
.pbar .glint{position:absolute;inset:0;background:repeating-linear-gradient(-45deg,transparent 0 8px,rgba(255,255,255,.04) 8px 16px)}
|
||
.plabel{display:flex;justify-content:space-between;font-size:10px;color:var(--dim);letter-spacing:1px;margin-top:6px}
|
||
|
||
/* ---------- library view tabs ---------- */
|
||
.view-tabs{display:flex;gap:4px;padding:10px 12px 0;flex-wrap:wrap}
|
||
.view-btn{background:transparent;border:1px solid var(--line);color:var(--dim);padding:6px 14px;font-size:10px;letter-spacing:1.5px;text-transform:uppercase;border-radius:4px 4px 0 0;cursor:pointer}
|
||
.view-btn:hover{color:#fff}
|
||
.view-btn.active{color:var(--cyan);background:var(--panel2);border-bottom-color:var(--panel2);box-shadow:0 -2px 8px rgba(34,211,238,.1)}
|
||
.lib-view{display:none;border-top:1px solid var(--line)}
|
||
.lib-view.active{display:block}
|
||
|
||
/* ---------- category chips / saved searches ---------- */
|
||
.cat-chip-row{display:flex;gap:6px;padding:10px 12px;flex-wrap:wrap;background:var(--panel2);border-bottom:1px solid var(--line)}
|
||
.cat-chip{background:var(--panel);border:1px solid var(--line);color:var(--dim);font-size:9px;letter-spacing:1px;text-transform:uppercase;
|
||
padding:5px 10px;border-radius:12px;cursor:pointer;font-family:var(--font-sans);font-weight:600;white-space:nowrap}
|
||
.cat-chip:hover{color:var(--txt);border-color:var(--cyan)}
|
||
.cat-chip.on{color:var(--mag);border-color:var(--mag);background:rgba(232,121,249,.08)}
|
||
.saved-search-row{display:flex;gap:6px;padding:8px 12px;flex-wrap:wrap;align-items:center;border-bottom:1px solid var(--line)}
|
||
.saved-chip{display:flex;align-items:center;gap:6px;background:var(--panel);border:1px solid var(--line);color:var(--dim);
|
||
font-size:10px;padding:4px 6px 4px 10px;border-radius:12px;cursor:pointer}
|
||
.saved-chip:hover{border-color:var(--cyan);color:var(--txt)}
|
||
.saved-chip b{font-weight:400;cursor:pointer;color:var(--red);padding:2px 4px}
|
||
.saved-chip b:hover{color:#fff}
|
||
|
||
/* ---------- bulk selection ---------- */
|
||
.bulk-bar{display:flex;gap:8px;align-items:center;flex-wrap:wrap;padding:10px 12px;background:rgba(34,211,238,.06);border-bottom:1px solid var(--line)}
|
||
.bulk-bar select{background:var(--panel);border:1px solid var(--line);color:var(--txt);padding:7px 10px;border-radius:4px;font-size:11px}
|
||
.result-card2{position:relative}
|
||
.result-checkbox{position:absolute;top:6px;left:6px;width:18px;height:18px;border-radius:3px;border:2px solid rgba(255,255,255,.6);
|
||
background:rgba(3,5,10,.5);z-index:2;display:none}
|
||
.result-checkbox.show{display:block}
|
||
.result-checkbox.checked{background:var(--cyan);border-color:var(--cyan)}
|
||
.result-card2.checked{outline:2px solid var(--cyan)}
|
||
|
||
/* ---------- untagged / failed / videos card variants ---------- */
|
||
.tag-now-btn{width:100%;margin-top:6px;padding:6px;font-size:9px}
|
||
.video-badge{position:absolute;top:5px;right:5px;font-size:8px;background:rgba(3,5,10,.8);color:var(--cyan);padding:2px 5px;border-radius:3px;letter-spacing:1px}
|
||
|
||
/* ---------- duplicate groups ---------- */
|
||
.dupe-group{background:var(--panel2);border:1px solid var(--line);border-radius:6px;margin-bottom:12px;overflow:hidden}
|
||
.dupe-group-header{padding:8px 12px;font-size:10px;letter-spacing:1px;text-transform:uppercase;color:var(--mag);background:rgba(232,121,249,.06);border-bottom:1px solid var(--line)}
|
||
.dupe-thumb-row{display:flex;gap:10px;padding:10px;overflow-x:auto}
|
||
.dupe-thumb{flex-shrink:0;width:130px;text-align:center}
|
||
.dupe-thumb img{width:130px;height:130px;object-fit:cover;border-radius:4px;border:1px solid var(--line);background:#000;cursor:pointer}
|
||
.dupe-thumb .name{font-size:9px;color:var(--dim);font-family:var(--font-mono);margin-top:4px;word-break:break-all}
|
||
.dupe-thumb button{width:100%;margin-top:4px;padding:4px;font-size:8px}
|
||
|
||
/* ---------- search interface ---------- */
|
||
.search-toolbar{display:flex;gap:10px;padding:12px;background:var(--panel2);border-bottom:1px solid var(--line);flex-wrap:wrap;align-items:center}
|
||
.search-toolbar input[type=text]{flex:2;min-width:220px;background:var(--panel);border:1px solid var(--line);color:var(--txt);padding:9px 12px;border-radius:4px;font-family:var(--font-sans);font-size:13px}
|
||
.search-toolbar input[type=text]:focus{outline:none;border-color:var(--cyan);box-shadow:0 0 10px rgba(34,211,238,.15)}
|
||
.search-toolbar select{background:var(--panel);border:1px solid var(--line);color:var(--txt);padding:9px 10px;border-radius:4px;font-size:12px;font-family:var(--font-sans)}
|
||
.search-toolbar select:focus{outline:none;border-color:var(--cyan)}
|
||
.search-count{font-size:11px;color:var(--dim);letter-spacing:.5px;white-space:nowrap;margin-left:auto;font-family:var(--font-mono)}
|
||
|
||
.search-results-grid{padding:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--thumb-min),1fr));gap:12px;align-content:start;min-height:200px}
|
||
.search-empty{grid-column:1/-1;text-align:center;padding:60px 20px;color:var(--dim);text-transform:uppercase;letter-spacing:1px;font-size:11px}
|
||
|
||
.result-card2{background:var(--panel2);border:1px solid var(--line);border-radius:6px;overflow:hidden;cursor:pointer;transition:.15s;display:flex;flex-direction:column}
|
||
.result-card2:hover{border-color:var(--cyan);transform:translateY(-2px);box-shadow:0 6px 18px rgba(0,0,0,.35)}
|
||
.result-thumb{position:relative;aspect-ratio:1;background:#000}
|
||
.result-thumb img{width:100%;height:100%;object-fit:cover;display:block}
|
||
.result-cat-badge{position:absolute;bottom:5px;left:5px;right:5px;font-size:8px;letter-spacing:1px;text-transform:uppercase;font-weight:700;
|
||
color:var(--mag);background:rgba(3,5,10,.78);border:1px solid rgba(232,121,249,.4);padding:3px 6px;border-radius:3px;
|
||
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.result-body{padding:8px 9px 10px}
|
||
.result-name{font-size:10px;color:var(--cyan);font-family:var(--font-mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:4px}
|
||
.result-desc2{font-size:11px;color:var(--txt);line-height:1.4;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
|
||
mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
|
||
|
||
.load-more-row{display:flex;justify-content:center;padding:6px 14px 20px}
|
||
|
||
/* ---------- fullscreen viewer / editor lightbox ---------- */
|
||
.lightbox{position:fixed;inset:0;background:rgba(2,4,10,.97);z-index:1000;display:none;flex-direction:column;backdrop-filter:blur(2px)}
|
||
.lightbox.active{display:flex}
|
||
.lightbox-topbar{display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0}
|
||
.lightbox-pos{font-family:var(--font-mono);color:var(--dim);font-size:12px;letter-spacing:1px}
|
||
.lightbox-topbar-actions{display:flex;gap:8px}
|
||
.lightbox-body{flex:1;display:grid;grid-template-columns:1fr 380px;overflow:hidden;min-height:0}
|
||
@media(max-width:900px){.lightbox-body{grid-template-columns:1fr;overflow-y:auto}}
|
||
.lightbox-imgwrap{position:relative;display:flex;align-items:center;justify-content:center;background:#000;overflow:hidden;min-height:0}
|
||
.lightbox-imgwrap img{max-width:100%;max-height:100%;object-fit:contain;display:none}
|
||
.lightbox-loading{color:var(--dim);font-size:10px;letter-spacing:2px;text-transform:uppercase;position:absolute}
|
||
.lightbox-nav-btn{position:absolute;top:50%;transform:translateY(-50%);background:rgba(8,13,26,.7);border:1px solid var(--line);color:var(--txt);
|
||
width:40px;height:56px;font-size:18px;cursor:pointer;border-radius:4px;transition:.15s}
|
||
.lightbox-nav-btn:hover:not(:disabled){border-color:var(--cyan);color:var(--cyan)}
|
||
.lightbox-nav-btn:disabled{opacity:.15;cursor:not-allowed}
|
||
.lightbox-nav-btn.prev{left:12px}
|
||
.lightbox-nav-btn.next{right:12px}
|
||
.lightbox-sidebar{background:var(--panel2);border-left:1px solid var(--line);padding:16px;overflow-y:auto;display:flex;flex-direction:column}
|
||
.lb-meta-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:11px;color:var(--dim);background:rgba(0,0,0,.15);padding:10px;border-radius:4px;margin-top:4px}
|
||
.lb-meta-grid div b{color:var(--txt);display:block;font-weight:600;font-size:12px;font-family:var(--font-mono)}
|
||
|
||
.editor-fields{flex:1;display:flex;flex-direction:column;gap:10px}
|
||
.editor-fields label{font-size:9px;letter-spacing:1.5px;text-transform:uppercase;color:var(--dim);font-weight:600}
|
||
.editor-fields textarea{background:var(--panel);border:1px solid var(--line);color:var(--txt);padding:8px 10px;border-radius:4px;font-family:var(--font-sans);font-size:12px;resize:none;height:100px}
|
||
.editor-fields textarea:focus{outline:none;border-color:var(--cyan)}
|
||
.editor-fields select{background:var(--panel);border:1px solid var(--line);color:var(--txt);padding:8px 10px;border-radius:4px;font-size:12px}
|
||
.editor-fields select:focus{outline:none;border-color:var(--cyan)}
|
||
.editor-path{font-family:var(--font-mono);font-size:10px;color:var(--dim);word-break:break-all;background:rgba(0,0,0,0.15);padding:8px;border-radius:4px;border:1px solid rgba(255,255,255,0.02)}
|
||
.editor-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:14px}
|
||
|
||
/* ---------- blind audit view ---------- */
|
||
.audit-container{max-width:900px;margin:30px auto;min-height:500px;display:flex;flex-direction:column;justify-content:center}
|
||
.audit-start-card{text-align:center;padding:50px 30px;background:var(--panel);border:1px solid var(--line);border-radius:8px}
|
||
.audit-start-card h3{font-size:24px;color:#fff;margin-bottom:14px;letter-spacing:1px}
|
||
.audit-start-card p{font-size:13px;color:var(--dim);margin-bottom:24px;line-height:1.6;max-width:500px;margin-left:auto;margin-right:auto}
|
||
.audit-card{display:grid;grid-template-columns:1fr 1fr;gap:20px;background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:20px;box-shadow:0 12px 36px rgba(0,0,0,0.4)}
|
||
@media(max-width:768px){.audit-card{grid-template-columns:1fr}}
|
||
.audit-img-box{aspect-ratio:1;background:#000;border:1px solid var(--line);border-radius:6px;display:flex;align-items:center;justify-content:center;overflow:hidden}
|
||
.audit-img-box img{max-width:100%;max-height:100%;object-fit:contain}
|
||
.audit-qa-box{display:flex;flex-direction:column;justify-content:space-between;height:100%}
|
||
.audit-qa-content{display:flex;flex-direction:column;gap:14px}
|
||
.audit-label{font-size:9px;color:var(--dim);letter-spacing:1.5px;text-transform:uppercase;font-weight:600}
|
||
.audit-text{font-size:14px;color:#fff;line-height:1.5;background:var(--panel2);padding:12px;border-radius:4px;border-left:3px solid var(--cyan)}
|
||
.audit-cat-tag{align-self:start;border:1px solid var(--mag);color:var(--mag);font-size:10px;padding:3px 10px;border-radius:3px;text-transform:uppercase;font-weight:bold;letter-spacing:1px}
|
||
.audit-actions{margin-top:20px}
|
||
.audit-grade-buttons{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:14px}
|
||
.audit-notes{background:var(--panel2);border:1px solid var(--line);color:var(--txt);padding:8px 10px;border-radius:4px;width:100%;resize:none;font-family:var(--font-sans);font-size:12px;height:60px}
|
||
.audit-progress-bar{height:6px;background:var(--panel2);border-radius:3px;overflow:hidden;margin-top:14px}
|
||
.audit-progress-fill{height:100%;width:0%;background:var(--cyan);transition:width .2s}
|
||
|
||
.audit-results-card{padding:30px;background:var(--panel);border:1px solid var(--line);border-radius:8px;text-align:center}
|
||
.audit-score-circle{width:140px;height:140px;border-radius:50%;border:4px solid var(--cyan);display:flex;flex-direction:column;align-items:center;justify-content:center;margin:20px auto;box-shadow:0 0 20px rgba(34,211,238,0.2)}
|
||
.audit-score-circle .score-num{font-size:36px;font-weight:800;color:#fff;font-family:var(--font-mono)}
|
||
.audit-score-circle .score-lbl{font-size:9px;color:var(--dim);text-transform:uppercase;letter-spacing:1px}
|
||
.audit-results-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin:24px 0;text-align:left}
|
||
.audit-res-item{background:var(--panel2);border:1px solid var(--line);border-radius:4px;padding:12px}
|
||
.audit-res-item .val{font-size:18px;color:#fff;font-weight:600}
|
||
.audit-res-item .lbl{font-size:9px;color:var(--dim);text-transform:uppercase;letter-spacing:1px;margin-top:2px}
|
||
|
||
/* ---------- categories manager ---------- */
|
||
.cat-edit-row{display:flex;gap:8px;margin-bottom:14px}
|
||
.cat-edit-row input{flex:1;background:var(--panel2);border:1px solid var(--line);color:var(--txt);padding:8px 12px;border-radius:4px;font-family:var(--font-sans)}
|
||
.cat-edit-row input:focus{outline:none;border-color:var(--cyan)}
|
||
.cat-list{display:flex;flex-direction:column;gap:6px;max-height:300px;overflow-y:auto;border:1px solid var(--line);border-radius:4px;padding:8px;background:var(--panel2)}
|
||
.cat-list-item{display:flex;justify-content:space-between;align-items:center;background:var(--panel);padding:6px 10px;border-radius:3px;border:1px solid rgba(255,255,255,0.02)}
|
||
.cat-list-item span{font-family:var(--font-mono);font-size:12px}
|
||
.cat-list-item button{padding:4px 10px;font-size:9px}
|
||
|
||
/* ---------- safety panel ---------- */
|
||
.safety-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
|
||
@media(max-width:768px){.safety-grid{grid-template-columns:1fr}}
|
||
|
||
/* ---------- CSS Charts ---------- */
|
||
.chart-container{padding:14px;background:var(--panel2);border-radius:4px;border:1px solid rgba(255,255,255,0.02)}
|
||
.bar-chart{display:flex;flex-direction:column;gap:8px;margin-top:10px}
|
||
.bar-row{display:flex;align-items:center;gap:10px}
|
||
.bar-lbl{width:110px;font-size:10px;color:var(--dim);text-transform:uppercase;letter-spacing:0.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.bar-track{flex:1;height:8px;background:rgba(255,255,255,0.02);border-radius:4px;overflow:hidden;position:relative}
|
||
.bar-fill{height:100%;background:linear-gradient(90deg,var(--cyan-dim),var(--cyan));border-radius:4px;width:0%;transition:width 0.4s}
|
||
.bar-val{width:30px;text-align:right;font-size:10px;color:var(--cyan);font-family:var(--font-mono)}
|
||
|
||
/* ---------- matrices & categories ---------- */
|
||
.cats{display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:6px;padding:12px}
|
||
.cat{border:1px solid var(--line);border-radius:3px;padding:8px;display:flex;justify-content:space-between;align-items:center;transition:.2s}
|
||
.cat .n{font-size:9px;letter-spacing:1px;text-transform:uppercase;color:var(--dim);font-weight:600}
|
||
.cat .c{font-size:14px;color:var(--cyan);font-family:var(--font-mono)}
|
||
.cat.hot{border-color:var(--mag);box-shadow:0 0 8px rgba(232,121,249,.2)}
|
||
.cat.hot .n{color:var(--mag)}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<header>
|
||
<div>
|
||
<div class="logo">PH<span>O</span>TON</div>
|
||
<div class="sub">local photo intelligence console · pipelined vision engine</div>
|
||
</div>
|
||
<div class="badge" id="badge">IDLE</div>
|
||
</header>
|
||
|
||
<div class="tabs">
|
||
<button class="tab-btn active" id="btn-tab-console" onclick="showTab('console')">Console</button>
|
||
<button class="tab-btn" id="btn-tab-search" onclick="showTab('search')">Search & Edit</button>
|
||
<button class="tab-btn" id="btn-tab-audit" onclick="showTab('audit')">Blind Audit</button>
|
||
<button class="tab-btn" id="btn-tab-settings" onclick="showTab('settings')">Settings & Safety</button>
|
||
</div>
|
||
|
||
<!-- ==================== CONSOLE TAB ==================== -->
|
||
<div id="tab-console" class="tab-content active">
|
||
<div class="controls">
|
||
<div class="folderRow">
|
||
<input id="folder" spellcheck="false" placeholder="/path/to/your/photos">
|
||
<button id="btnScan">◈ Scan Directory</button>
|
||
</div>
|
||
<button id="btnStart" class="btn-primary" disabled>▶ Start Pipeline</button>
|
||
<button id="btnPause" disabled>❚❚ Pause</button>
|
||
<button id="btnStop" disabled>■ Stop</button>
|
||
</div>
|
||
|
||
<div class="statsRow">
|
||
<div class="stat"><div class="v" id="sCount">0 / 0</div><div class="k">processed</div></div>
|
||
<div class="stat"><div class="v" id="sRate">—</div><div class="k">photos / min</div></div>
|
||
<div class="stat"><div class="v" id="sAvg">—</div><div class="k">sec / photo</div></div>
|
||
<div class="stat"><div class="v" id="sEta">—</div><div class="k">eta</div></div>
|
||
<div class="stat"><div class="v" id="sFail">0</div><div class="k">failed queue</div></div>
|
||
<div class="stat"><div class="v" id="sTotalDb">0</div><div class="k">tagged total</div></div>
|
||
</div>
|
||
|
||
<div class="progressWrap">
|
||
<div class="pbar"><div class="fill" id="pfill"></div><div class="glint"></div></div>
|
||
<div class="plabel"><span id="plabelL">awaiting scan</span><span id="plabelR">0%</span></div>
|
||
</div>
|
||
|
||
<div class="grid-three">
|
||
<!-- Current Optical Feed -->
|
||
<div class="panel">
|
||
<h2>◉ <b>optical feed</b> — pipeline frame</h2>
|
||
<div class="photoBox" id="photoBox">
|
||
<div class="corner c1"></div><div class="corner c2"></div>
|
||
<div class="corner c3"></div><div class="corner c4"></div>
|
||
<img id="preview" alt="" style="display:none">
|
||
<div class="scan"></div>
|
||
<div id="noSignal" style="color:var(--dim);letter-spacing:3px;font-size:10px">NO SIGNAL</div>
|
||
</div>
|
||
<div class="photoMeta">
|
||
<div class="photoName" id="pName">—</div>
|
||
<div class="photoDesc" id="pDesc">model output will appear here</div>
|
||
<span class="photoCat" id="pCat" style="display:none"></span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Live Logs -->
|
||
<div class="panel">
|
||
<h2>▚ <b>engine log</b> — telemetry stream</h2>
|
||
<div class="term" id="term"><div class="ln info"><span class="t">--:--:--</span> PHOTON pipeline ready <span class="cursor"></span></div></div>
|
||
</div>
|
||
|
||
<!-- Live Category distribution chart -->
|
||
<div class="panel">
|
||
<h2>◫ <b>category density</b></h2>
|
||
<div class="chart-container" style="height: 480px; overflow-y: auto;">
|
||
<div class="bar-chart" id="densityChart"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recent activity strip -->
|
||
<div class="panel" style="margin-top:14px">
|
||
<h2>◸ <b>recent results</b> — last 8 photos (click to inspect/edit)</h2>
|
||
<div class="recent-strip" id="recentStrip">
|
||
<div style="color:var(--dim); padding: 30px; font-size:11px; text-transform:uppercase; letter-spacing:1px; width:100%; text-align:center;">No recent activity yet. Start the pipeline to populate.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel" style="margin-top:14px">
|
||
<h2>◫ <b>classification matrix</b> — categories total</h2>
|
||
<div class="cats" id="cats"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== SEARCH & EDIT TAB ==================== -->
|
||
<div id="tab-search" class="tab-content">
|
||
<div class="panel">
|
||
<h2>⌕ <b>photo library</b> — search, browse & bulk edit</h2>
|
||
<div class="view-tabs" id="viewTabs">
|
||
<button class="view-btn active" data-view="tagged">Tagged</button>
|
||
<button class="view-btn" data-view="untagged">Untagged</button>
|
||
<button class="view-btn" data-view="failed">Failed</button>
|
||
<button class="view-btn" data-view="videos">Videos</button>
|
||
<button class="view-btn" data-view="dupes">Duplicates</button>
|
||
</div>
|
||
|
||
<!-- TAGGED -->
|
||
<div id="view-tagged" class="lib-view active">
|
||
<div class="search-toolbar">
|
||
<input type="text" id="searchInput" placeholder="Search any word in the description, category, filename or path…" spellcheck="false">
|
||
<select id="searchCat"><option value="">All categories</option></select>
|
||
<select id="searchFtype">
|
||
<option value="">Photos & videos</option>
|
||
<option value="photo">Photos only</option>
|
||
<option value="video">Videos only</option>
|
||
</select>
|
||
<select id="searchSort">
|
||
<option value="recent">Newest tagged</option>
|
||
<option value="name">Filename A–Z</option>
|
||
<option value="category">Category</option>
|
||
</select>
|
||
<input type="date" id="dateFrom" title="From date">
|
||
<input type="date" id="dateTo" title="To date">
|
||
<button id="btnSyncMeta">⟳ Sync Metadata</button>
|
||
<button id="btnSelectMode" title="In select mode: click to toggle a photo, shift-click to select a range">☐ Select</button>
|
||
<span class="search-count" id="searchCount">—</span>
|
||
</div>
|
||
<div class="cat-chip-row" id="catChipRow"></div>
|
||
<div class="saved-search-row" id="savedSearchRow">
|
||
<button id="btnSaveSearch">☆ Save current search</button>
|
||
</div>
|
||
<div class="bulk-bar" id="bulkBar" style="display:none">
|
||
<span id="bulkCount">0 selected</span>
|
||
<button id="btnSelectLoaded">Select all loaded</button>
|
||
<button id="btnSelectAllMatching">Select all N matching</button>
|
||
<button id="btnClearSelection">Clear selection</button>
|
||
<select id="bulkCatSelect"></select>
|
||
<button id="btnBulkApply" class="btn-primary">Apply category to selected</button>
|
||
<button id="btnDownloadSelected" class="btn-primary">⬇ Download selected (ZIP)</button>
|
||
<button id="btnExportSelected">Export selected (JSON)</button>
|
||
<button id="btnExportAllCsv">Export all matches (CSV)</button>
|
||
<button id="btnDownloadAllZip">⬇ Download all matches (ZIP)</button>
|
||
</div>
|
||
<div class="search-results-grid" id="searchResultsGrid">
|
||
<div class="search-empty">Loading tagged photos…</div>
|
||
</div>
|
||
<div class="load-more-row">
|
||
<button id="btnLoadMore" style="display:none">Load more results ▾</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- UNTAGGED -->
|
||
<div id="view-untagged" class="lib-view">
|
||
<div class="search-toolbar"><span class="search-count" id="untaggedCount">—</span></div>
|
||
<div class="search-results-grid" id="untaggedGrid"></div>
|
||
<div class="load-more-row"><button id="btnLoadMoreUntagged" style="display:none">Load more ▾</button></div>
|
||
</div>
|
||
|
||
<!-- FAILED -->
|
||
<div id="view-failed" class="lib-view">
|
||
<div class="search-toolbar">
|
||
<span class="search-count" id="failedCount">—</span>
|
||
<button id="btnRetryAllFailed" class="btn-primary">⟳ Retry all failed</button>
|
||
</div>
|
||
<div class="search-results-grid" id="failedGrid"></div>
|
||
</div>
|
||
|
||
<!-- VIDEOS -->
|
||
<div id="view-videos" class="lib-view">
|
||
<div class="search-toolbar"><span class="search-count" id="videosCount">—</span></div>
|
||
<div class="search-results-grid" id="videosGrid"></div>
|
||
<div class="load-more-row"><button id="btnLoadMoreVideos" style="display:none">Load more ▾</button></div>
|
||
</div>
|
||
|
||
<!-- DUPLICATES -->
|
||
<div id="view-dupes" class="lib-view">
|
||
<div class="search-toolbar">
|
||
<span class="search-count" id="dupesStatus">—</span>
|
||
<button id="btnScanDupes" class="btn-primary">⟳ Scan for duplicates</button>
|
||
</div>
|
||
<div id="dupesProgress" class="hint" style="padding:0 14px 10px; display:none"></div>
|
||
<div id="dupesGroups" style="padding:14px"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== BLIND AUDIT TAB ==================== -->
|
||
<div id="tab-audit" class="tab-content">
|
||
<div class="panel" id="auditHistoryPanel" style="max-width:900px;margin:20px auto 0">
|
||
<h2>◷ <b>audit history</b> — accuracy over time</h2>
|
||
<div class="statsRow" style="padding:14px 14px 0">
|
||
<div class="stat"><div class="v" id="ahTotal">0</div><div class="k">total graded</div></div>
|
||
<div class="stat"><div class="v" id="ahAccuracy">—</div><div class="k">overall accuracy</div></div>
|
||
<div class="stat"><div class="v" id="ahPassed">0</div><div class="k">passed</div></div>
|
||
<div class="stat"><div class="v" id="ahFailed">0</div><div class="k">failed</div></div>
|
||
</div>
|
||
<div class="chart-container" style="margin:14px">
|
||
<div class="bar-chart" id="ahDayChart"></div>
|
||
</div>
|
||
</div>
|
||
<div class="audit-container">
|
||
<!-- Start View -->
|
||
<div class="audit-start-card" id="auditStartCard">
|
||
<h3>Blind Accuracy Grader</h3>
|
||
<p>This mode selects up to 100 random tagged images from your database. You will grade their description and category accuracy blindly to calculate a quality score for your prompt/model configurations.</p>
|
||
<button id="btnStartAudit" class="btn-primary">▶ Start Accuracy Audit</button>
|
||
</div>
|
||
|
||
<!-- Grader View -->
|
||
<div class="audit-card" id="auditCard" style="display:none">
|
||
<div class="audit-img-box">
|
||
<img id="auditImg">
|
||
</div>
|
||
<div class="audit-qa-box">
|
||
<div class="audit-qa-content">
|
||
<div class="audit-label">Model Description</div>
|
||
<div class="audit-text" id="auditDescText">—</div>
|
||
|
||
<div class="audit-label">Model Category</div>
|
||
<div class="audit-cat-tag" id="auditCatText">—</div>
|
||
|
||
<div class="audit-label">Audit Notes & Corrections (Optional)</div>
|
||
<textarea class="audit-notes" id="auditNotes" placeholder="Write any critiques or correct description here..."></textarea>
|
||
</div>
|
||
<div class="audit-actions">
|
||
<div class="audit-grade-buttons">
|
||
<button id="btnAuditPass" class="btn-primary" style="height:44px">✓ Correct (Pass)</button>
|
||
<button id="btnAuditFail" class="btn-danger" style="height:44px">✗ Incorrect (Fail)</button>
|
||
</div>
|
||
<div style="display:flex; justify-content:between; font-size:10px; color:var(--dim)">
|
||
<span id="auditCountLabel">Photo 0 of 0</span>
|
||
<span id="auditPercentLabel">0% Complete</span>
|
||
</div>
|
||
<div class="audit-progress-bar">
|
||
<div class="audit-progress-fill" id="auditProgressFill"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Results View -->
|
||
<div class="audit-results-card" id="auditResultsCard" style="display:none">
|
||
<h3>Audit Results</h3>
|
||
<div class="audit-score-circle">
|
||
<div class="score-num" id="auditScoreVal">0%</div>
|
||
<div class="score-lbl">accuracy</div>
|
||
</div>
|
||
<p style="color:var(--dim); font-size:12px; margin-bottom:20px" id="auditSummaryText">0 of 0 photos graded correctly.</p>
|
||
<div class="audit-results-grid">
|
||
<div class="audit-res-item">
|
||
<div class="val" id="auditTotalGraded">0</div>
|
||
<div class="lbl">Total Graded</div>
|
||
</div>
|
||
<div class="audit-res-item">
|
||
<div class="val" id="auditPassedCount">0</div>
|
||
<div class="lbl">Passed (Correct)</div>
|
||
</div>
|
||
<div class="audit-res-item">
|
||
<div class="val" id="auditFailedCount">0</div>
|
||
<div class="lbl">Failed (Incorrect)</div>
|
||
</div>
|
||
</div>
|
||
<button id="btnRestartAudit">Start New Audit</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== SETTINGS & SAFETY TAB ==================== -->
|
||
<div id="tab-settings" class="tab-content">
|
||
<!-- Appearance -->
|
||
<div class="panel">
|
||
<h2>◐ <b>appearance</b> — accent color & browsing preferences</h2>
|
||
<div class="set">
|
||
<label>accent color</label>
|
||
<div class="accent-row">
|
||
<input type="color" id="accentColor" value="#22d3ee">
|
||
<div class="accent-swatches" id="accentSwatches"></div>
|
||
</div>
|
||
<div class="hint">picks the highlight color used across borders, buttons and badges. saved locally on this browser.</div>
|
||
|
||
<label>photo grid density</label>
|
||
<div class="seg" id="segDensity">
|
||
<button data-v="150">compact</button>
|
||
<button data-v="190" class="on">comfortable</button>
|
||
<button data-v="260">large</button>
|
||
</div>
|
||
|
||
<label>search results per page</label>
|
||
<div class="seg" id="segPageSize">
|
||
<button data-v="30">30</button>
|
||
<button data-v="60" class="on">60</button>
|
||
<button data-v="100">100</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="grid">
|
||
<!-- Engine Options -->
|
||
<div class="panel">
|
||
<h2>⚙ <b>engine configurations</b></h2>
|
||
<div class="set">
|
||
<label>vision model</label>
|
||
<select id="model"></select>
|
||
<div class="hint">qwen3.5:9b has best quality (thinking disabled). glm-ocr is fastest scout classifier.</div>
|
||
|
||
<div class="tog"><span>smart router</span><div class="sw" id="swRouter"><i></i></div></div>
|
||
<div class="hint">scout classifies screenshot vs real photo, routing to specialized model branches.</div>
|
||
<div id="routerCfg" style="display:none; margin-top:8px; border-left:1px solid var(--line); padding-left:10px">
|
||
<label>scout (classifier)</label>
|
||
<select id="routerModel"></select>
|
||
<label>screenshot describer</label>
|
||
<select id="shotModel"></select>
|
||
<label>photo describer</label>
|
||
<select id="photoModel"></select>
|
||
<div class="tog"><span>ocr text embed</span><div class="sw on" id="swOcr"><i></i></div></div>
|
||
<div class="hint">glm-ocr also transcribes the screenshot text directly into the description.</div>
|
||
</div>
|
||
|
||
<label>image feed resolution</label>
|
||
<div class="seg" id="segPx">
|
||
<button data-v="512">512</button>
|
||
<button data-v="896" class="on">896</button>
|
||
<button data-v="1280">1280</button>
|
||
<button data-v="0">FULL</button>
|
||
</div>
|
||
<div class="hint">photos downscaled before analysis. lower = faster, original image files untouched.</div>
|
||
|
||
<label>description length</label>
|
||
<div class="seg" id="segLen">
|
||
<button data-v="brief">brief</button>
|
||
<button data-v="standard" class="on">std</button>
|
||
<button data-v="detailed">detail</button>
|
||
</div>
|
||
|
||
<label>temperature</label>
|
||
<input type="number" id="temperature" value="0.1" min="0" max="1" step="0.1">
|
||
<div class="hint">0 = deterministic classification, higher = more descriptive variety.</div>
|
||
|
||
<label>model keep-alive</label>
|
||
<select id="keepAlive">
|
||
<option value="10m" selected>10 min</option>
|
||
<option value="30m">30 min</option>
|
||
<option value="-1">forever (fastest)</option>
|
||
</select>
|
||
|
||
<div class="tog"><span>skip already tagged</span><div class="sw on" id="swSkip"><i></i></div></div>
|
||
<div class="tog"><span>preserve file dates</span><div class="sw on" id="swDate"><i></i></div></div>
|
||
<div class="tog"><span>keep _original backups</span><div class="sw" id="swBackup"><i></i></div></div>
|
||
<div class="hint">off = atomic metadata write. on = copies originals (doubles disk usage).</div>
|
||
<div class="tog"><span>dry run (no writes)</span><div class="sw" id="swDry"><i></i></div></div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Custom categories & Safety tools -->
|
||
<div style="display:flex; flex-direction:column; gap:14px">
|
||
<!-- Categories Manager -->
|
||
<div class="panel">
|
||
<h2>◫ <b>custom categories manager</b></h2>
|
||
<div class="set">
|
||
<div class="cat-edit-row">
|
||
<input id="newCatInput" placeholder="Add custom category..." spellcheck="false">
|
||
<button id="btnAddCat" class="btn-primary">Add</button>
|
||
</div>
|
||
<div class="cat-list" id="catList"></div>
|
||
<button id="btnSaveCats" class="btn-primary" style="margin-top:12px; width:100%">✓ Save Category Structure</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Safety Panel -->
|
||
<div class="panel">
|
||
<h2>🛡 <b>trust & safety console</b></h2>
|
||
<div class="set">
|
||
<div class="tog">
|
||
<span>verify pixel integrity</span>
|
||
<div class="sw" id="swIntegrity"><i></i></div>
|
||
</div>
|
||
<div class="hint" style="margin-bottom:14px">checks image pixels pre/post write via double-BMP double-hash. Restores backup on mismatch. 100% corruption proof.</div>
|
||
|
||
<div class="tog">
|
||
<span>organize aliases</span>
|
||
<div class="sw" id="swOrganize"><i></i></div>
|
||
</div>
|
||
<div class="hint" style="margin-bottom:20px">creates Portable Relative Symlinks inside folder categorized in <code>_organized/</code>. Originals remain completely untouched.</div>
|
||
|
||
<div style="border-top: 1px solid var(--line); padding-top:14px">
|
||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px">
|
||
<span style="font-size:10px; font-weight:600; color:var(--dim); text-transform:uppercase; letter-spacing:1px">telemetry failures</span>
|
||
<span id="failBadge" style="background:rgba(248,113,113,0.1); color:var(--red); padding:2px 8px; border-radius:3px; font-family:var(--font-mono); font-weight:bold">0 FAILED</span>
|
||
</div>
|
||
<button id="btnRetryFailed" style="width:100%; margin-bottom:12px" disabled>⟳ Retry Failed Queue</button>
|
||
</div>
|
||
|
||
<div style="border-top: 1px solid var(--line); padding-top:14px">
|
||
<label style="color:var(--red); font-weight:bold; margin-top:0">destructive rollbacks</label>
|
||
<p class="hint" style="margin-bottom:10px">Removes all embedded PHOTON descriptions, categories, and keyword markers recursively. Clears organized symlinks and journals.</p>
|
||
<button id="btnUndoAll" class="btn-danger" style="width:100%">☣ Remove All PHOTON Tags</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ==================== FULLSCREEN VIEWER / EDITOR ==================== -->
|
||
<div class="lightbox" id="lightbox">
|
||
<div class="lightbox-topbar">
|
||
<div class="lightbox-pos" id="lbPos">1 / 1</div>
|
||
<div class="lightbox-topbar-actions">
|
||
<button id="lbReveal">Reveal in Finder</button>
|
||
<button id="lbClose" class="btn-danger">✕ Close</button>
|
||
</div>
|
||
</div>
|
||
<div class="lightbox-body">
|
||
<div class="lightbox-imgwrap">
|
||
<button class="lightbox-nav-btn prev" id="lbPrev">‹</button>
|
||
<img id="lbImg" alt="">
|
||
<video id="lbVideo" controls style="max-width:100%;max-height:100%;display:none"></video>
|
||
<div class="lightbox-loading" id="lbLoading">loading full image…</div>
|
||
<button class="lightbox-nav-btn next" id="lbNext">›</button>
|
||
</div>
|
||
<div class="lightbox-sidebar">
|
||
<h3 style="font-size:11px; text-transform:uppercase; letter-spacing:1px; color:var(--dim); margin-bottom:12px; border-bottom:1px solid var(--line); padding-bottom:6px">photo inspector</h3>
|
||
<div class="editor-fields">
|
||
<label>file path</label>
|
||
<div class="editor-path" id="lbPath">—</div>
|
||
<button id="lbCopyPath" style="margin-top:6px">⧉ Copy path</button>
|
||
|
||
<label>category</label>
|
||
<select id="lbCat"></select>
|
||
|
||
<label>description</label>
|
||
<textarea id="lbDesc" placeholder="Photo description..."></textarea>
|
||
|
||
<label>tag details</label>
|
||
<div class="lb-meta-grid" id="lbMeta"></div>
|
||
</div>
|
||
<div class="editor-actions">
|
||
<button id="lbSave" class="btn-primary">✓ Save</button>
|
||
<button id="lbRedo">⌁ AI Redo</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const $=id=>document.getElementById(id);
|
||
let CATS=[], state="idle", scanned=false;
|
||
let recentFiles=[];
|
||
|
||
/* ---------- Tab Management ---------- */
|
||
function showTab(tabId){
|
||
document.querySelectorAll(".tab-content").forEach(el=>el.classList.remove("active"));
|
||
document.querySelectorAll(".tab-btn").forEach(el=>el.classList.remove("active"));
|
||
$("tab-"+tabId).classList.add("active");
|
||
$("btn-tab-"+tabId).classList.add("active");
|
||
if(tabId==='search') switchView(currentView);
|
||
if(tabId==='audit') loadAuditHistory();
|
||
}
|
||
|
||
/* ---------- helpers ---------- */
|
||
function seg(el){ el.querySelectorAll("button").forEach(b=>b.onclick=()=>{
|
||
el.querySelectorAll("button").forEach(x=>x.classList.remove("on")); b.classList.add("on");});}
|
||
seg($("segPx")); seg($("segLen"));
|
||
document.querySelectorAll(".sw").forEach(s=>s.onclick=()=>{
|
||
s.classList.toggle("on");
|
||
if(s.id==="swRouter") $("routerCfg").style.display = s.classList.contains("on") ? "block" : "none";
|
||
});
|
||
const segVal=el=>el.querySelector("button.on").dataset.v;
|
||
const swOn=id=>$(id).classList.contains("on");
|
||
|
||
/* ---------- appearance settings (accent color, density, page size) ---------- */
|
||
const ACCENT_PRESETS = ["#22d3ee","#e879f9","#4ade80","#fbbf24","#f87171","#818cf8","#fb923c"];
|
||
|
||
function hexToRgb(hex){
|
||
const m = hex.replace("#","");
|
||
return [parseInt(m.slice(0,2),16), parseInt(m.slice(2,4),16), parseInt(m.slice(4,6),16)];
|
||
}
|
||
function shade(hex, factor){
|
||
const [r,g,b] = hexToRgb(hex).map(v=>Math.max(0, Math.min(255, Math.round(v*factor))));
|
||
return "#" + [r,g,b].map(v=>v.toString(16).padStart(2,"0")).join("");
|
||
}
|
||
function applyAccentColor(hex, persist){
|
||
document.documentElement.style.setProperty("--cyan", hex);
|
||
document.documentElement.style.setProperty("--cyan-dim", shade(hex, 0.65));
|
||
$("accentColor").value = hex;
|
||
if(persist !== false) localStorage.setItem("photonAccentColor", hex);
|
||
}
|
||
function renderAccentSwatches(){
|
||
const row = $("accentSwatches");
|
||
row.innerHTML = "";
|
||
ACCENT_PRESETS.forEach(c=>{
|
||
const sw = document.createElement("div");
|
||
sw.className = "accent-swatch";
|
||
sw.style.background = c;
|
||
sw.title = c;
|
||
sw.onclick = ()=>applyAccentColor(c);
|
||
row.appendChild(sw);
|
||
});
|
||
}
|
||
$("accentColor").oninput = (e)=>applyAccentColor(e.target.value);
|
||
|
||
seg($("segDensity"));
|
||
$("segDensity").querySelectorAll("button").forEach(b=>b.addEventListener("click", ()=>{
|
||
const px = b.dataset.v + "px";
|
||
document.documentElement.style.setProperty("--thumb-min", px);
|
||
localStorage.setItem("photonGridDensity", b.dataset.v);
|
||
}));
|
||
|
||
seg($("segPageSize"));
|
||
$("segPageSize").querySelectorAll("button").forEach(b=>b.addEventListener("click", ()=>{
|
||
SEARCH_LIMIT = parseInt(b.dataset.v, 10);
|
||
localStorage.setItem("photonPageSize", b.dataset.v);
|
||
}));
|
||
|
||
function loadAppearanceSettings(){
|
||
renderAccentSwatches();
|
||
const savedColor = localStorage.getItem("photonAccentColor");
|
||
if(savedColor) applyAccentColor(savedColor, false);
|
||
|
||
const savedDensity = localStorage.getItem("photonGridDensity");
|
||
if(savedDensity){
|
||
document.documentElement.style.setProperty("--thumb-min", savedDensity + "px");
|
||
$("segDensity").querySelectorAll("button").forEach(b=>b.classList.toggle("on", b.dataset.v === savedDensity));
|
||
}
|
||
|
||
const savedPageSize = localStorage.getItem("photonPageSize");
|
||
if(savedPageSize){
|
||
SEARCH_LIMIT = parseInt(savedPageSize, 10);
|
||
$("segPageSize").querySelectorAll("button").forEach(b=>b.classList.toggle("on", b.dataset.v === savedPageSize));
|
||
}
|
||
}
|
||
|
||
function fmtEta(s){ if(s==null)return "—";
|
||
if(s<3600)return Math.floor(s/60)+"m "+(s%60)+"s";
|
||
const h=Math.floor(s/3600); return h+"h "+Math.floor((s%3600)/60)+"m"; }
|
||
|
||
function fmtBytes(n){
|
||
if(n == null) return "—";
|
||
if(n < 1024) return n + " B";
|
||
if(n < 1024*1024) return (n/1024).toFixed(1) + " KB";
|
||
return (n/(1024*1024)).toFixed(1) + " MB";
|
||
}
|
||
|
||
function addLog(e){
|
||
const t=$("term");
|
||
const d=document.createElement("div");
|
||
d.className="ln "+e.level;
|
||
d.innerHTML='<span class="t">'+e.ts+'</span> '+escapeHtml(e.msg);
|
||
t.appendChild(d);
|
||
while(t.children.length>350)t.removeChild(t.firstChild);
|
||
t.scrollTop=t.scrollHeight;
|
||
}
|
||
function escapeHtml(s){return s.replace(/[&<>]/g,c=>({"&":"&","<":"<",">":">"}[c]))}
|
||
|
||
function setBadge(st){
|
||
state=st;
|
||
const b=$("badge"); b.textContent=st.toUpperCase(); b.className="badge "+st;
|
||
$("btnStart").disabled = !(scanned && (st==="idle"||st==="done"));
|
||
$("btnPause").disabled = !(st==="running"||st==="paused");
|
||
$("btnStop").disabled = !(st==="running"||st==="paused");
|
||
$("btnPause").textContent = st==="paused" ? "▶ Resume Pipeline" : "❚❚ Pause";
|
||
$("photoBox").classList.toggle("active", st==="running");
|
||
}
|
||
|
||
function renderCats(counts){
|
||
const box=$("cats");
|
||
const densityBox=$("densityChart");
|
||
|
||
box.innerHTML="";
|
||
densityBox.innerHTML="";
|
||
|
||
// Calculate max count for charts scaling
|
||
let maxCount = 0;
|
||
for(const c in counts){
|
||
if(counts[c] > maxCount) maxCount = counts[c];
|
||
}
|
||
|
||
CATS.forEach(c=>{
|
||
const cnt = counts[c] ?? 0;
|
||
|
||
// Grid box item
|
||
const d=document.createElement("div"); d.className="cat"; d.dataset.cat=c;
|
||
d.innerHTML='<span class="n">'+c+'</span><span class="c">'+cnt+'</span>';
|
||
box.appendChild(d);
|
||
|
||
// Bar row item for chart
|
||
const pct = maxCount ? (cnt / maxCount * 100) : 0;
|
||
const br = document.createElement("div"); br.className="bar-row";
|
||
br.innerHTML=`<div class="bar-lbl" title="${c}">${c}</div>
|
||
<div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
|
||
<div class="bar-val">${cnt}</div>`;
|
||
densityBox.appendChild(br);
|
||
});
|
||
lastCatCounts = counts;
|
||
renderCatChips();
|
||
}
|
||
|
||
function applyStats(s){
|
||
$("sCount").textContent = (s.processed + (s.alreadyDone||0)) + " / " + s.total;
|
||
$("sRate").textContent = s.perMin || "—";
|
||
$("sAvg").textContent = s.avgSec || "—";
|
||
$("sEta").textContent = fmtEta(s.etaSec);
|
||
$("sFail").textContent = s.failed;
|
||
$("failBadge").textContent = s.failed + " FAILED";
|
||
$("btnRetryFailed").disabled = (s.failed === 0 || state === "running");
|
||
$("sTotalDb").textContent = s.journalTotal;
|
||
const done = s.processed + (s.alreadyDone||0);
|
||
const pct = s.total ? Math.min(100, done/s.total*100) : 0;
|
||
$("pfill").style.width = pct+"%";
|
||
$("plabelR").textContent = pct.toFixed(1)+"%";
|
||
$("plabelL").textContent = s.total ? done+" of "+s.total+" frames" : "awaiting scan";
|
||
renderCats(s.catCounts||{});
|
||
}
|
||
|
||
/* ---------- Recent results strip ---------- */
|
||
function addToRecentStrip(photo){
|
||
// Check if photo is already in recent strip, remove it
|
||
recentFiles = recentFiles.filter(p=>p.path !== photo.path);
|
||
recentFiles.unshift(photo);
|
||
if(recentFiles.length > 8) recentFiles.pop();
|
||
renderRecentStrip();
|
||
}
|
||
|
||
function renderRecentStrip(){
|
||
const strip = $("recentStrip");
|
||
if(recentFiles.length === 0){
|
||
strip.innerHTML = `<div style="color:var(--dim); padding: 30px; font-size:11px; text-transform:uppercase; letter-spacing:1px; width:100%; text-align:center;">No recent activity yet. Start the pipeline to populate.</div>`;
|
||
return;
|
||
}
|
||
strip.innerHTML = "";
|
||
recentFiles.forEach(photo=>{
|
||
const item = document.createElement("div");
|
||
item.className = "recent-item";
|
||
item.onclick = () => {
|
||
showTab("search");
|
||
openQuickView(photo);
|
||
};
|
||
item.innerHTML = `<img src="/api/thumbnail?path=${encodeURIComponent(photo.path)}">
|
||
<div class="cat-badge">${photo.category}</div>`;
|
||
strip.appendChild(item);
|
||
});
|
||
}
|
||
|
||
/* ---------- API calls ---------- */
|
||
async function api(path, body){
|
||
const r = await fetch(path, body?{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}:{});
|
||
const j = await r.json().catch(()=>({}));
|
||
if(!r.ok) addLog({level:"error",msg:j.error||("http "+r.status),ts:new Date().toTimeString().slice(0,8)});
|
||
return j;
|
||
}
|
||
|
||
$("btnScan").onclick = async ()=>{
|
||
const j = await api("/api/scan",{folder:$("folder").value});
|
||
if(j.images!=null){ scanned=true; setBadge("idle"); }
|
||
};
|
||
|
||
$("btnStart").onclick = ()=>{
|
||
api("/api/start",{
|
||
model: $("model").value,
|
||
router: swOn("swRouter"),
|
||
routerModel: $("routerModel").value,
|
||
shotModel: $("shotModel").value,
|
||
photoModel: $("photoModel").value,
|
||
ocrText: swOn("swOcr"),
|
||
ocrModel: "glm-ocr:latest",
|
||
maxPx: +segVal($("segPx")),
|
||
length: segVal($("segLen")),
|
||
temperature: +$("temperature").value,
|
||
keepAlive: $("keepAlive").value,
|
||
skipDone: swOn("swSkip"),
|
||
preserveDate: swOn("swDate"),
|
||
keepBackup: swOn("swBackup"),
|
||
dryRun: swOn("swDry"),
|
||
organize: swOn("swOrganize"),
|
||
integrity: swOn("swIntegrity")
|
||
});
|
||
};
|
||
|
||
$("btnPause").onclick = ()=>api("/api/pause");
|
||
$("btnStop").onclick = ()=>api("/api/stop");
|
||
|
||
$("btnRetryFailed").onclick = async ()=>{
|
||
const j = await api("/api/retry_failed");
|
||
if(j.ok) addLog({level:"info",msg:"Retrying failures...",ts:new Date().toTimeString().slice(0,8)});
|
||
};
|
||
|
||
$("btnUndoAll").onclick = async ()=>{
|
||
if(confirm("☣ WARNING ☣\nThis will remove all PHOTON embedded descriptions/categories/keywords from ALL files in the scanned folder. It will also clear organized symlinks, reset the database and delete the failures log.\n\nTHIS ACTION CANNOT BE UNDONE.\n\nDo you want to proceed?")){
|
||
$("btnUndoAll").disabled = true;
|
||
const j = await api("/api/undo_all");
|
||
$("btnUndoAll").disabled = false;
|
||
if(j.ok){
|
||
recentFiles = [];
|
||
renderRecentStrip();
|
||
showTab("console");
|
||
}
|
||
}
|
||
};
|
||
|
||
/* ---------- Category List Editor ---------- */
|
||
function renderCategoryListEditor(){
|
||
const list = $("catList");
|
||
list.innerHTML = "";
|
||
CATS.forEach(c=>{
|
||
const item = document.createElement("div");
|
||
item.className = "cat-list-item";
|
||
item.innerHTML = `<span>${c}</span>
|
||
<button class="btn-danger" onclick="removeCategory('${c}')">Delete</button>`;
|
||
list.appendChild(item);
|
||
});
|
||
}
|
||
|
||
$("btnAddCat").onclick = ()=>{
|
||
const val = $("newCatInput").value.trim();
|
||
if(!val) return;
|
||
if(CATS.includes(val)){
|
||
alert("Category already exists."); return;
|
||
}
|
||
CATS.push(val);
|
||
$("newCatInput").value = "";
|
||
renderCategoryListEditor();
|
||
};
|
||
|
||
function removeCategory(cat){
|
||
if(confirm(`Remove "${cat}" category? (This won't untag existing photos until rewritten, but removes it from lists).`)){
|
||
CATS = CATS.filter(c=>c !== cat);
|
||
renderCategoryListEditor();
|
||
}
|
||
}
|
||
|
||
$("btnSaveCats").onclick = async ()=>{
|
||
const j = await api("/api/categories", {categories: CATS});
|
||
if(j.ok){
|
||
alert("Categories updated and synced with database!");
|
||
CATS = j.categories;
|
||
populateCategoryDropdowns();
|
||
renderCategoryListEditor();
|
||
}
|
||
};
|
||
|
||
function populateCategoryDropdowns(){
|
||
const lbCat = $("lbCat");
|
||
lbCat.innerHTML = "";
|
||
CATS.forEach(c=>{
|
||
const opt = document.createElement("option");
|
||
opt.value = c; opt.textContent = c;
|
||
lbCat.appendChild(opt);
|
||
});
|
||
|
||
const searchCat = $("searchCat");
|
||
const prevVal = searchCat.value;
|
||
searchCat.innerHTML = '<option value="">All categories</option>';
|
||
CATS.forEach(c=>{
|
||
const opt = document.createElement("option");
|
||
opt.value = c; opt.textContent = c;
|
||
searchCat.appendChild(opt);
|
||
});
|
||
if([...searchCat.options].some(o=>o.value === prevVal)) searchCat.value = prevVal;
|
||
|
||
const bulkCat = $("bulkCatSelect");
|
||
bulkCat.innerHTML = '<option value="">Choose category…</option>';
|
||
CATS.forEach(c=>{
|
||
const opt = document.createElement("option");
|
||
opt.value = c; opt.textContent = c;
|
||
bulkCat.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
/* ---------- Search ---------- */
|
||
let searchResults = []; // currently loaded page(s) for the main grid
|
||
let searchTotal = 0;
|
||
let searchLoading = false;
|
||
let searchDebounce = null;
|
||
|
||
function engineSettings(){
|
||
return {
|
||
model: $("model").value,
|
||
router: swOn("swRouter"),
|
||
routerModel: $("routerModel").value,
|
||
shotModel: $("shotModel").value,
|
||
photoModel: $("photoModel").value,
|
||
ocrText: swOn("swOcr"),
|
||
ocrModel: "glm-ocr:latest",
|
||
maxPx: +segVal($("segPx")),
|
||
length: segVal($("segLen")),
|
||
temperature: +$("temperature").value,
|
||
keepAlive: $("keepAlive").value,
|
||
keepBackup: swOn("swBackup"),
|
||
preserveDate: swOn("swDate"),
|
||
organize: swOn("swOrganize")
|
||
};
|
||
}
|
||
|
||
let SEARCH_LIMIT = 60;
|
||
|
||
async function doSearch(reset){
|
||
if(reset === undefined) reset = true;
|
||
if(searchLoading) return;
|
||
if(reset) searchResults = [];
|
||
searchLoading = true;
|
||
if(reset){
|
||
$("searchResultsGrid").innerHTML = `<div class="search-empty">Searching…</div>`;
|
||
$("btnLoadMore").style.display = "none";
|
||
}
|
||
const params = currentSearchParams({limit: SEARCH_LIMIT, offset: reset ? 0 : searchResults.length});
|
||
const j = await api("/api/search?" + params.toString());
|
||
searchLoading = false;
|
||
if(!j.results) return;
|
||
searchResults.push(...j.results);
|
||
searchTotal = j.total || 0;
|
||
renderCatChips();
|
||
renderSearchResults();
|
||
}
|
||
|
||
$("searchInput").oninput = ()=>{
|
||
clearTimeout(searchDebounce);
|
||
searchDebounce = setTimeout(()=>doSearch(true), 280);
|
||
};
|
||
$("searchInput").onkeydown = e => { if(e.key === "Enter"){ clearTimeout(searchDebounce); doSearch(true); } };
|
||
$("searchCat").onchange = ()=>{ renderCatChips(); doSearch(true); };
|
||
$("searchSort").onchange = ()=>doSearch(true);
|
||
$("btnLoadMore").onclick = ()=>doSearch(false);
|
||
|
||
$("btnSyncMeta").onclick = async ()=>{
|
||
if(confirm("Re-read all embedded metadata in directory? This will update your journal file with any manually updated photos.")){
|
||
$("btnSyncMeta").disabled = true;
|
||
const params = currentSearchParams({re_read:"1", limit: SEARCH_LIMIT});
|
||
const j = await api("/api/search?" + params.toString());
|
||
$("btnSyncMeta").disabled = false;
|
||
if(j.results){ searchResults = j.results; searchTotal = j.total || 0; renderSearchResults(); }
|
||
}
|
||
};
|
||
|
||
function highlightTerms(text, terms){
|
||
let out = escapeHtml(text || "");
|
||
terms.forEach(t=>{
|
||
if(!t) return;
|
||
const re = new RegExp("(" + t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&") + ")", "ig");
|
||
out = out.replace(re, "<mark>$1</mark>");
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function renderSearchResults(){
|
||
const grid = $("searchResultsGrid");
|
||
$("searchCount").textContent = searchTotal + (searchTotal === 1 ? " match" : " matches");
|
||
if(searchResults.length === 0){
|
||
grid.innerHTML = `<div class="search-empty">No results found matching your query.</div>`;
|
||
$("btnLoadMore").style.display = "none";
|
||
return;
|
||
}
|
||
const terms = $("searchInput").value.trim().split(/\s+/).filter(Boolean);
|
||
grid.innerHTML = "";
|
||
searchResults.forEach((rec, i)=>{
|
||
const checked = selectedPaths.has(rec.path);
|
||
const card = document.createElement("div");
|
||
card.className = "result-card2" + (checked ? " checked" : "");
|
||
card.onclick = (e) => {
|
||
if(selectMode){
|
||
if(e.shiftKey && lastClickedIndex !== null){ selectRange(lastClickedIndex, i); }
|
||
else { toggleSelect(rec.path); }
|
||
lastClickedIndex = i;
|
||
return;
|
||
}
|
||
openLightbox(i);
|
||
};
|
||
card.innerHTML = `<div class="result-thumb">
|
||
<div class="result-checkbox ${selectMode ? 'show' : ''} ${checked ? 'checked' : ''}"></div>
|
||
<img loading="lazy" src="/api/thumbnail?path=${encodeURIComponent(rec.path)}">
|
||
<span class="result-cat-badge">${escapeHtml(rec.category || "—")}</span>
|
||
</div>
|
||
<div class="result-body">
|
||
<div class="result-name" title="${rec.name}">${escapeHtml(rec.name || "")}</div>
|
||
<div class="result-desc2">${highlightTerms(rec.desc, terms)}</div>
|
||
</div>`;
|
||
grid.appendChild(card);
|
||
});
|
||
$("btnLoadMore").style.display = searchResults.length < searchTotal ? "block" : "none";
|
||
if(selectMode) updateBulkBar();
|
||
}
|
||
|
||
function os_basename(path){
|
||
return path.replace(/\\/g, '/').replace(/.*\//, '');
|
||
}
|
||
|
||
/* ---------- Fullscreen viewer / editor ---------- */
|
||
let lbMode = "grid"; // "grid" | "quick"
|
||
let lbIndex = -1;
|
||
let lbQuickRec = null;
|
||
|
||
function openLightbox(i){
|
||
lbMode = "grid";
|
||
lbIndex = i;
|
||
$("lightbox").classList.add("active");
|
||
renderLightbox();
|
||
}
|
||
|
||
function openQuickView(rec){
|
||
lbMode = "quick";
|
||
lbQuickRec = rec;
|
||
$("lightbox").classList.add("active");
|
||
renderLightbox();
|
||
}
|
||
|
||
function closeLightbox(){
|
||
$("lightbox").classList.remove("active");
|
||
$("lbVideo").pause();
|
||
lbIndex = -1;
|
||
lbQuickRec = null;
|
||
}
|
||
|
||
function currentLbRec(){
|
||
return lbMode === "quick" ? lbQuickRec : searchResults[lbIndex];
|
||
}
|
||
|
||
function renderLightbox(){
|
||
const rec = currentLbRec();
|
||
if(!rec) return;
|
||
|
||
if(lbMode === "quick"){
|
||
$("lbPos").textContent = "quick view";
|
||
$("lbPrev").style.display = "none";
|
||
$("lbNext").style.display = "none";
|
||
} else {
|
||
$("lbPos").textContent = (lbIndex+1) + " / " + (searchTotal || searchResults.length);
|
||
$("lbPrev").style.display = "";
|
||
$("lbNext").style.display = "";
|
||
$("lbPrev").disabled = lbIndex <= 0;
|
||
$("lbNext").disabled = false;
|
||
}
|
||
|
||
$("lbPath").textContent = rec.path;
|
||
$("lbDesc").value = rec.desc || "";
|
||
if([...$("lbCat").options].some(o=>o.value === rec.category)) $("lbCat").value = rec.category;
|
||
$("lbMeta").innerHTML = `
|
||
<div><b>${escapeHtml(rec.model || "—")}</b>model</div>
|
||
<div><b>${escapeHtml(rec.route || "—")}</b>route</div>
|
||
<div><b>${rec.ts ? new Date(rec.ts*1000).toLocaleString() : "—"}</b>tagged</div>
|
||
<div><b>${rec.sec != null ? rec.sec+"s" : "—"}</b>infer time</div>
|
||
<div><b>${fmtBytes(rec.sizeBytes)}</b>file size</div>`;
|
||
|
||
const img = $("lbImg");
|
||
const vid = $("lbVideo");
|
||
vid.pause(); vid.src = "";
|
||
if(VIDEO_EXT_RE.test(rec.path)){
|
||
img.style.display = "none";
|
||
$("lbLoading").style.display = "none";
|
||
vid.style.display = "block";
|
||
vid.src = "/api/photo?path=" + encodeURIComponent(rec.path);
|
||
} else {
|
||
vid.style.display = "none";
|
||
img.style.display = "none";
|
||
$("lbLoading").style.display = "block";
|
||
$("lbLoading").textContent = "loading full image…";
|
||
img.onload = ()=>{ img.style.display = "block"; $("lbLoading").style.display = "none"; };
|
||
img.onerror = ()=>{ $("lbLoading").textContent = "failed to load full image"; };
|
||
img.src = "/api/photo?path=" + encodeURIComponent(rec.path) + "&_=" + Date.now();
|
||
}
|
||
}
|
||
|
||
async function lbGoto(delta){
|
||
if(lbMode !== "grid") return;
|
||
const next = lbIndex + delta;
|
||
if(next < 0) return;
|
||
if(next >= searchResults.length){
|
||
if(searchResults.length < searchTotal){
|
||
await doSearch(false);
|
||
if(next >= searchResults.length) return;
|
||
} else return;
|
||
}
|
||
lbIndex = next;
|
||
renderLightbox();
|
||
}
|
||
|
||
$("lbPrev").onclick = ()=>lbGoto(-1);
|
||
$("lbNext").onclick = ()=>lbGoto(1);
|
||
$("lbClose").onclick = closeLightbox;
|
||
|
||
document.addEventListener("keydown", e=>{
|
||
if(!$("lightbox").classList.contains("active")) return;
|
||
if(e.key === "Escape") closeLightbox();
|
||
else if(e.key === "ArrowLeft") lbGoto(-1);
|
||
else if(e.key === "ArrowRight") lbGoto(1);
|
||
});
|
||
|
||
$("lbSave").onclick = async ()=>{
|
||
const rec = currentLbRec();
|
||
if(!rec) return;
|
||
$("lbSave").disabled = true;
|
||
const j = await api("/api/write_single", { path: rec.path, desc: $("lbDesc").value, category: $("lbCat").value });
|
||
$("lbSave").disabled = false;
|
||
if(j.ok){
|
||
rec.desc = j.desc; rec.category = j.category;
|
||
if(lbMode === "grid") renderSearchResults();
|
||
addLog({level:"ok", msg:`Updated ${os_basename(rec.path)} metadata manually.`, ts:new Date().toTimeString().slice(0,8)});
|
||
}
|
||
};
|
||
|
||
$("lbRedo").onclick = async ()=>{
|
||
const rec = currentLbRec();
|
||
if(!rec) return;
|
||
if(!confirm("Request AI model re-tagging for this photo?")) return;
|
||
$("lbRedo").disabled = true;
|
||
const j = await api("/api/redo_single", { path: rec.path, settings: engineSettings() });
|
||
$("lbRedo").disabled = false;
|
||
if(j.ok){
|
||
rec.desc = j.desc; rec.category = j.category; rec.route = j.route;
|
||
renderLightbox();
|
||
if(lbMode === "grid") renderSearchResults();
|
||
addLog({level:"ok", msg:`AI re-evaluated ${os_basename(rec.path)} successfully.`, ts:new Date().toTimeString().slice(0,8)});
|
||
}
|
||
};
|
||
|
||
$("lbReveal").onclick = ()=>{
|
||
const rec = currentLbRec();
|
||
if(rec) api("/api/reveal", {path: rec.path});
|
||
};
|
||
|
||
$("lbCopyPath").onclick = async ()=>{
|
||
const rec = currentLbRec();
|
||
if(!rec) return;
|
||
try{
|
||
await navigator.clipboard.writeText(rec.path);
|
||
const btn = $("lbCopyPath");
|
||
const orig = btn.textContent;
|
||
btn.textContent = "✓ Copied";
|
||
setTimeout(()=>{ btn.textContent = orig; }, 1200);
|
||
}catch(e){
|
||
prompt("Copy path:", rec.path);
|
||
}
|
||
};
|
||
|
||
/* ================== Library sub-views (Tagged/Untagged/Failed/Videos/Duplicates) ================== */
|
||
let currentView = "tagged";
|
||
let lastCatCounts = {};
|
||
const VIDEO_EXT_RE = /\.(mov|mp4|m4v|avi|3gp|mkv)$/i;
|
||
|
||
function switchView(view){
|
||
currentView = view;
|
||
document.querySelectorAll(".view-btn").forEach(b=>b.classList.toggle("active", b.dataset.view === view));
|
||
document.querySelectorAll(".lib-view").forEach(el=>el.classList.remove("active"));
|
||
$("view-"+view).classList.add("active");
|
||
if(view === "tagged") doSearch(true);
|
||
else if(view === "untagged") loadUntagged(true);
|
||
else if(view === "failed") loadFailed();
|
||
else if(view === "videos") loadVideos(true);
|
||
else if(view === "dupes") loadDupeGroups();
|
||
}
|
||
document.querySelectorAll(".view-btn").forEach(b=>b.onclick = ()=>switchView(b.dataset.view));
|
||
|
||
/* ---- category quick-filter chips ---- */
|
||
function renderCatChips(){
|
||
const row = $("catChipRow");
|
||
if(!row) return;
|
||
row.innerHTML = "";
|
||
const activeCat = $("searchCat").value;
|
||
CATS.forEach(c=>{
|
||
const chip = document.createElement("button");
|
||
chip.className = "cat-chip" + (activeCat === c ? " on" : "");
|
||
chip.textContent = c + " (" + (lastCatCounts[c] ?? 0) + ")";
|
||
chip.onclick = ()=>{
|
||
$("searchCat").value = (activeCat === c) ? "" : c;
|
||
renderCatChips();
|
||
doSearch(true);
|
||
};
|
||
row.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
/* ---- date range wiring ---- */
|
||
$("dateFrom").onchange = ()=>doSearch(true);
|
||
$("dateTo").onchange = ()=>doSearch(true);
|
||
|
||
function currentSearchParams(extra){
|
||
return new URLSearchParams(Object.assign({
|
||
q: $("searchInput").value,
|
||
cat: $("searchCat").value,
|
||
sort: $("searchSort").value,
|
||
ftype: $("searchFtype").value,
|
||
date_from: $("dateFrom").value,
|
||
date_to: $("dateTo").value
|
||
}, extra || {}));
|
||
}
|
||
$("searchFtype").onchange = ()=>doSearch(true);
|
||
|
||
/* ---- saved searches (client-side, localStorage) ---- */
|
||
function loadSavedSearches(){
|
||
try{ return JSON.parse(localStorage.getItem("photonSavedSearches") || "[]"); }
|
||
catch(e){ return []; }
|
||
}
|
||
function storeSavedSearches(list){
|
||
localStorage.setItem("photonSavedSearches", JSON.stringify(list));
|
||
}
|
||
function renderSavedSearches(){
|
||
const row = $("savedSearchRow");
|
||
const saved = loadSavedSearches();
|
||
row.querySelectorAll(".saved-chip").forEach(el=>el.remove());
|
||
saved.forEach((s, i)=>{
|
||
const chip = document.createElement("div");
|
||
chip.className = "saved-chip";
|
||
chip.innerHTML = `<span>★ ${escapeHtml(s.name)}</span><b title="remove">✕</b>`;
|
||
chip.querySelector("span").onclick = ()=>{
|
||
$("searchInput").value = s.q || "";
|
||
$("searchCat").value = s.cat || "";
|
||
$("searchSort").value = s.sort || "recent";
|
||
$("dateFrom").value = s.date_from || "";
|
||
$("dateTo").value = s.date_to || "";
|
||
renderCatChips();
|
||
doSearch(true);
|
||
};
|
||
chip.querySelector("b").onclick = (e)=>{
|
||
e.stopPropagation();
|
||
const list = loadSavedSearches();
|
||
list.splice(i, 1);
|
||
storeSavedSearches(list);
|
||
renderSavedSearches();
|
||
};
|
||
row.appendChild(chip);
|
||
});
|
||
}
|
||
$("btnSaveSearch").onclick = ()=>{
|
||
const name = prompt("Name this search:", $("searchInput").value || "Untitled search");
|
||
if(!name) return;
|
||
const list = loadSavedSearches();
|
||
list.push({name, q: $("searchInput").value, cat: $("searchCat").value, sort: $("searchSort").value,
|
||
date_from: $("dateFrom").value, date_to: $("dateTo").value});
|
||
storeSavedSearches(list);
|
||
renderSavedSearches();
|
||
};
|
||
|
||
/* ---- bulk select mode ---- */
|
||
let selectMode = false;
|
||
let selectedPaths = new Set();
|
||
let lastClickedIndex = null;
|
||
|
||
$("btnSelectMode").onclick = ()=>{
|
||
selectMode = !selectMode;
|
||
$("btnSelectMode").textContent = selectMode ? "☑ Exit Select" : "☐ Select";
|
||
if(!selectMode){ selectedPaths.clear(); lastClickedIndex = null; }
|
||
renderSearchResults();
|
||
updateBulkBar();
|
||
};
|
||
|
||
function updateBulkBar(){
|
||
$("bulkBar").style.display = selectMode ? "flex" : "none";
|
||
$("bulkCount").textContent = selectedPaths.size + " selected";
|
||
$("btnSelectAllMatching").textContent = "Select all " + (searchTotal || 0) + " matching";
|
||
}
|
||
|
||
function toggleSelect(path){
|
||
if(selectedPaths.has(path)) selectedPaths.delete(path);
|
||
else selectedPaths.add(path);
|
||
renderSearchResults();
|
||
updateBulkBar();
|
||
}
|
||
|
||
function selectRange(fromIdx, toIdx){
|
||
const [lo, hi] = fromIdx <= toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx];
|
||
for(let i = lo; i <= hi; i++){
|
||
if(searchResults[i]) selectedPaths.add(searchResults[i].path);
|
||
}
|
||
renderSearchResults();
|
||
updateBulkBar();
|
||
}
|
||
|
||
$("btnSelectLoaded").onclick = ()=>{
|
||
searchResults.forEach(r=>selectedPaths.add(r.path));
|
||
renderSearchResults();
|
||
updateBulkBar();
|
||
};
|
||
|
||
$("btnSelectAllMatching").onclick = async ()=>{
|
||
const params = currentSearchParams({});
|
||
const j = await api("/api/search/paths?" + params.toString());
|
||
if(!j.paths) return;
|
||
j.paths.forEach(p=>selectedPaths.add(p));
|
||
if(j.capped) addLog({level:"warn", msg:`Selected the first ${j.paths.length} of ${j.total} matches (download cap is 3000 files).`, ts:new Date().toTimeString().slice(0,8)});
|
||
renderSearchResults();
|
||
updateBulkBar();
|
||
};
|
||
|
||
$("btnClearSelection").onclick = ()=>{
|
||
selectedPaths.clear();
|
||
lastClickedIndex = null;
|
||
renderSearchResults();
|
||
updateBulkBar();
|
||
};
|
||
|
||
$("btnBulkApply").onclick = async ()=>{
|
||
const category = $("bulkCatSelect").value;
|
||
if(!category || selectedPaths.size === 0) return;
|
||
if(!confirm(`Recategorize ${selectedPaths.size} photos to "${category}"?`)) return;
|
||
$("btnBulkApply").disabled = true;
|
||
const j = await api("/api/bulk_recategorize", {paths: [...selectedPaths], category});
|
||
$("btnBulkApply").disabled = false;
|
||
if(j.ok){
|
||
addLog({level:"ok", msg:`Bulk recategorized ${j.updated} photos to '${category}'.`, ts:new Date().toTimeString().slice(0,8)});
|
||
selectedPaths.clear();
|
||
updateBulkBar();
|
||
doSearch(true);
|
||
}
|
||
};
|
||
|
||
$("btnExportSelected").onclick = ()=>{
|
||
if(selectedPaths.size === 0) return;
|
||
const params = currentSearchParams({format:"json", paths:[...selectedPaths].join(",")});
|
||
window.open("/api/export?" + params.toString(), "_blank");
|
||
};
|
||
$("btnExportAllCsv").onclick = ()=>{
|
||
const params = currentSearchParams({format:"csv"});
|
||
window.open("/api/export?" + params.toString(), "_blank");
|
||
};
|
||
|
||
$("btnDownloadSelected").onclick = ()=>{
|
||
if(selectedPaths.size === 0) return;
|
||
if(!confirm(`Download ${selectedPaths.size} photos as a ZIP, in their original quality?`)) return;
|
||
const params = new URLSearchParams({paths: [...selectedPaths].join(",")});
|
||
window.location.href = "/api/download_zip?" + params.toString();
|
||
};
|
||
$("btnDownloadAllZip").onclick = ()=>{
|
||
if(!confirm(`Download all ${searchTotal} matching photos as a ZIP, in their original quality? Large sets can take a while.`)) return;
|
||
const params = currentSearchParams({});
|
||
window.location.href = "/api/download_zip?" + params.toString();
|
||
};
|
||
|
||
/* ---- untagged view ---- */
|
||
let untaggedItems = [], untaggedTotal = 0, untaggedLoading = false;
|
||
async function loadUntagged(reset){
|
||
if(untaggedLoading) return;
|
||
if(reset) untaggedItems = [];
|
||
untaggedLoading = true;
|
||
const j = await api("/api/untagged?limit=48&offset=" + untaggedItems.length);
|
||
untaggedLoading = false;
|
||
if(!j.results) return;
|
||
untaggedItems.push(...j.results);
|
||
untaggedTotal = j.total || 0;
|
||
$("untaggedCount").textContent = untaggedTotal + " untagged photo" + (untaggedTotal===1?"":"s") +
|
||
(untaggedTotal ? " (scan the folder in Console to refresh this list)" : "");
|
||
const grid = $("untaggedGrid");
|
||
grid.innerHTML = untaggedItems.length ? "" : `<div class="search-empty">Nothing untagged — scan a folder from the Console tab, or everything's already tagged.</div>`;
|
||
untaggedItems.forEach(rec=>{
|
||
const card = document.createElement("div");
|
||
card.className = "result-card2";
|
||
card.innerHTML = `<div class="result-thumb"><img loading="lazy" src="/api/thumbnail?path=${encodeURIComponent(rec.path)}"></div>
|
||
<div class="result-body">
|
||
<div class="result-name" title="${rec.name}">${escapeHtml(rec.name)}</div>
|
||
<button class="tag-now-btn btn-primary" data-path="${encodeURIComponent(rec.path)}">⌁ Tag Now</button>
|
||
</div>`;
|
||
card.querySelector(".tag-now-btn").onclick = (e)=>tagNow(rec.path, e.target);
|
||
grid.appendChild(card);
|
||
});
|
||
$("btnLoadMoreUntagged").style.display = untaggedItems.length < untaggedTotal ? "block" : "none";
|
||
}
|
||
$("btnLoadMoreUntagged").onclick = ()=>loadUntagged(false);
|
||
|
||
async function tagNow(path, btnEl){
|
||
btnEl.disabled = true; btnEl.textContent = "tagging…";
|
||
const j = await api("/api/redo_single", {path, settings: engineSettings()});
|
||
if(j.ok){
|
||
addLog({level:"ok", msg:`Tagged ${os_basename(path)} → ${j.category}.`, ts:new Date().toTimeString().slice(0,8)});
|
||
if(currentView === "untagged") loadUntagged(true);
|
||
else if(currentView === "failed") loadFailed();
|
||
else if(currentView === "videos") loadVideos(true);
|
||
} else {
|
||
btnEl.disabled = false; btnEl.textContent = "⌁ Tag Now (retry)";
|
||
}
|
||
}
|
||
|
||
/* ---- failed view ---- */
|
||
async function loadFailed(){
|
||
const j = await api("/api/failed");
|
||
const results = j.results || [];
|
||
$("failedCount").textContent = results.length + " failed photo" + (results.length===1?"":"s");
|
||
$("btnRetryAllFailed").disabled = results.length === 0;
|
||
const grid = $("failedGrid");
|
||
grid.innerHTML = results.length ? "" : `<div class="search-empty">No failures in the queue.</div>`;
|
||
results.forEach(rec=>{
|
||
const card = document.createElement("div");
|
||
card.className = "result-card2";
|
||
card.innerHTML = `<div class="result-thumb"><img loading="lazy" src="/api/thumbnail?path=${encodeURIComponent(rec.path)}"></div>
|
||
<div class="result-body">
|
||
<div class="result-name" title="${rec.name}">${escapeHtml(rec.name)}</div>
|
||
<button class="tag-now-btn" data-path="${encodeURIComponent(rec.path)}">⌁ Retry</button>
|
||
</div>`;
|
||
card.querySelector(".tag-now-btn").onclick = (e)=>tagNow(rec.path, e.target);
|
||
grid.appendChild(card);
|
||
});
|
||
}
|
||
$("btnRetryAllFailed").onclick = async ()=>{
|
||
$("btnRetryAllFailed").disabled = true;
|
||
const j = await api("/api/retry_failed");
|
||
if(j.ok) addLog({level:"info", msg:"Retrying all failed photos…", ts:new Date().toTimeString().slice(0,8)});
|
||
};
|
||
|
||
/* ---- videos view ---- */
|
||
let videoItems = [], videoTotal = 0, videoLoading = false;
|
||
async function loadVideos(reset){
|
||
if(videoLoading) return;
|
||
if(reset) videoItems = [];
|
||
videoLoading = true;
|
||
const j = await api("/api/videos?limit=40&offset=" + videoItems.length);
|
||
videoLoading = false;
|
||
if(!j.results) return;
|
||
videoItems.push(...j.results);
|
||
videoTotal = j.total || 0;
|
||
$("videosCount").textContent = videoTotal + " video" + (videoTotal===1?"":"s") + " found";
|
||
const grid = $("videosGrid");
|
||
grid.innerHTML = videoItems.length ? "" : `<div class="search-empty">No videos found in the last scan.</div>`;
|
||
videoItems.forEach((rec, i)=>{
|
||
const card = document.createElement("div");
|
||
card.className = "result-card2";
|
||
card.onclick = (e)=>{ if(!e.target.closest(".tag-now-btn")) openVideoLightbox(i); };
|
||
card.innerHTML = `<div class="result-thumb"><img loading="lazy" src="/api/thumbnail?path=${encodeURIComponent(rec.path)}">
|
||
<span class="video-badge">▶ VIDEO</span></div>
|
||
<div class="result-body">
|
||
<div class="result-name" title="${rec.name}">${escapeHtml(rec.name)}</div>
|
||
${rec.tagged ? '<div class="result-desc2" style="color:var(--grn)">✓ tagged — open to view</div>' :
|
||
`<button class="tag-now-btn btn-primary" data-path="${encodeURIComponent(rec.path)}">⌁ Tag Now</button>`}
|
||
</div>`;
|
||
if(!rec.tagged) card.querySelector(".tag-now-btn").onclick = (e)=>{ e.stopPropagation(); tagNow(rec.path, e.target); };
|
||
grid.appendChild(card);
|
||
});
|
||
$("btnLoadMoreVideos").style.display = videoItems.length < videoTotal ? "block" : "none";
|
||
}
|
||
$("btnLoadMoreVideos").onclick = ()=>loadVideos(false);
|
||
|
||
function openVideoLightbox(i){
|
||
const rec = videoItems[i];
|
||
openQuickView({path: rec.path, name: rec.name, desc: "", category: CATS[0] || ""});
|
||
}
|
||
|
||
/* ---- duplicates view ---- */
|
||
let dedupePollTimer = null;
|
||
async function loadDupeGroups(){
|
||
const j = await api("/api/dedupe/groups");
|
||
if(j.dedupeStatus === "running"){
|
||
$("dupesStatus").textContent = `Hashing photos… ${j.dedupeProgress.done}/${j.dedupeProgress.total}`;
|
||
$("dupesProgress").style.display = "block";
|
||
$("dupesProgress").textContent = "Duplicate scan running in the background — this view will update automatically.";
|
||
$("btnScanDupes").disabled = true;
|
||
if(!dedupePollTimer) dedupePollTimer = setTimeout(()=>{ dedupePollTimer=null; loadDupeGroups(); }, 3000);
|
||
} else {
|
||
$("dupesProgress").style.display = "none";
|
||
$("btnScanDupes").disabled = false;
|
||
$("dupesStatus").textContent = `${j.hashed} of ${j.totalImages} photos hashed · ${j.groups.length} duplicate group(s) found`;
|
||
}
|
||
const box = $("dupesGroups");
|
||
if(!j.groups.length){
|
||
box.innerHTML = `<div class="search-empty">${j.hashed === 0 ? 'Click "Scan for duplicates" to build the index — a one-time pass over your tagged photos.' : 'No exact-match duplicates found.'}</div>`;
|
||
return;
|
||
}
|
||
box.innerHTML = "";
|
||
j.groups.forEach(g=>{
|
||
const grp = document.createElement("div");
|
||
grp.className = "dupe-group";
|
||
const header = document.createElement("div");
|
||
header.className = "dupe-group-header";
|
||
header.textContent = g.count + " visually identical copies";
|
||
grp.appendChild(header);
|
||
const rowEl = document.createElement("div");
|
||
rowEl.className = "dupe-thumb-row";
|
||
g.items.forEach(rec=>{
|
||
const t = document.createElement("div");
|
||
t.className = "dupe-thumb";
|
||
t.innerHTML = `<img src="/api/thumbnail?path=${encodeURIComponent(rec.path)}">
|
||
<div class="name" title="${rec.path}">${escapeHtml(rec.name)}</div>
|
||
<button>Reveal in Finder</button>`;
|
||
t.querySelector("img").onclick = ()=>openQuickView(rec);
|
||
t.querySelector("button").onclick = ()=>api("/api/reveal", {path: rec.path});
|
||
rowEl.appendChild(t);
|
||
});
|
||
grp.appendChild(rowEl);
|
||
box.appendChild(grp);
|
||
});
|
||
}
|
||
$("btnScanDupes").onclick = async ()=>{
|
||
const j = await api("/api/dedupe/build");
|
||
if(j.ok){
|
||
addLog({level:"info", msg:"Duplicate scan started in the background…", ts:new Date().toTimeString().slice(0,8)});
|
||
loadDupeGroups();
|
||
}
|
||
};
|
||
|
||
/* ---- audit history dashboard ---- */
|
||
async function loadAuditHistory(){
|
||
const j = await api("/api/audit/history");
|
||
$("ahTotal").textContent = j.total || 0;
|
||
$("ahAccuracy").textContent = j.total ? j.accuracy + "%" : "—";
|
||
$("ahPassed").textContent = j.passed || 0;
|
||
$("ahFailed").textContent = j.failed || 0;
|
||
const box = $("ahDayChart");
|
||
box.innerHTML = "";
|
||
if(!j.byDay || !j.byDay.length){
|
||
box.innerHTML = `<div style="color:var(--dim); font-size:11px; padding:10px">No audit history yet — run a Blind Accuracy Audit to build one.</div>`;
|
||
return;
|
||
}
|
||
const maxTotal = Math.max(...j.byDay.map(d=>d.pass+d.fail), 1);
|
||
j.byDay.forEach(d=>{
|
||
const total = d.pass + d.fail;
|
||
const acc = total ? Math.round(d.pass/total*100) : 0;
|
||
const pct = total / maxTotal * 100;
|
||
const br = document.createElement("div"); br.className = "bar-row";
|
||
br.innerHTML = `<div class="bar-lbl" title="${d.day}">${d.day}</div>
|
||
<div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
|
||
<div class="bar-val">${acc}%</div>`;
|
||
box.appendChild(br);
|
||
});
|
||
}
|
||
|
||
/* ---------- Blind Accuracy Grader ---------- */
|
||
let auditList = [];
|
||
let auditIndex = 0;
|
||
let auditPassed = 0;
|
||
|
||
$("btnStartAudit").onclick = async ()=>{
|
||
const j = await api("/api/audit/start");
|
||
if(j.results && j.results.length > 0){
|
||
auditList = j.results;
|
||
auditIndex = 0;
|
||
auditPassed = 0;
|
||
$("auditStartCard").style.display = "none";
|
||
$("auditResultsCard").style.display = "none";
|
||
$("auditCard").style.display = "grid";
|
||
loadAuditPhoto();
|
||
} else {
|
||
alert("No photos in the database to audit. Run the pipeline first!");
|
||
}
|
||
};
|
||
|
||
function loadAuditPhoto(){
|
||
if(auditIndex >= auditList.length){
|
||
finishAudit(); return;
|
||
}
|
||
const rec = auditList[auditIndex];
|
||
$("auditImg").src = "/api/thumbnail?path=" + encodeURIComponent(rec.path);
|
||
$("auditDescText").textContent = rec.desc;
|
||
$("auditCatText").textContent = rec.category;
|
||
$("auditNotes").value = "";
|
||
|
||
$("auditCountLabel").textContent = `Photo ${auditIndex + 1} of ${auditList.length}`;
|
||
const pct = (auditIndex / auditList.length * 100).toFixed(0);
|
||
$("auditPercentLabel").textContent = `${pct}% Complete`;
|
||
$("auditProgressFill").style.width = pct + "%";
|
||
}
|
||
|
||
async function gradeAuditPhoto(passed){
|
||
const rec = auditList[auditIndex];
|
||
if(passed) auditPassed++;
|
||
|
||
await api("/api/audit/grade", {
|
||
path: rec.path,
|
||
grade: passed ? "pass" : "fail",
|
||
notes: $("auditNotes").value
|
||
});
|
||
|
||
auditIndex++;
|
||
loadAuditPhoto();
|
||
}
|
||
|
||
$("btnAuditPass").onclick = () => gradeAuditPhoto(true);
|
||
$("btnAuditFail").onclick = () => gradeAuditPhoto(false);
|
||
|
||
function finishAudit(){
|
||
$("auditCard").style.display = "none";
|
||
$("auditResultsCard").style.display = "block";
|
||
|
||
const score = auditList.length ? (auditPassed / auditList.length * 100).toFixed(1) : 0;
|
||
$("auditScoreVal").textContent = score + "%";
|
||
$("auditSummaryText").textContent = `${auditPassed} of ${auditList.length} photos graded correct.`;
|
||
|
||
$("auditTotalGraded").textContent = auditList.length;
|
||
$("auditPassedCount").textContent = auditPassed;
|
||
$("auditFailedCount").textContent = auditList.length - auditPassed;
|
||
}
|
||
|
||
$("btnRestartAudit").onclick = ()=>{
|
||
$("auditResultsCard").style.display = "none";
|
||
$("auditStartCard").style.display = "block";
|
||
};
|
||
|
||
/* ---------- SSE ---------- */
|
||
function connect(){
|
||
const es = new EventSource("/api/events");
|
||
es.onmessage = ev=>{
|
||
const m = JSON.parse(ev.data);
|
||
if(m.type==="log") addLog(m.data);
|
||
else if(m.type==="stats") applyStats(m.data);
|
||
else if(m.type==="state") setBadge(m.data.status);
|
||
else if(m.type==="preview"){
|
||
const img=$("preview");
|
||
img.src="/api/preview?"+m.data.seq;
|
||
img.style.display="block"; $("noSignal").style.display="none";
|
||
}
|
||
else if(m.type==="photo_start"){
|
||
$("pName").textContent = m.data.idx+"/"+m.data.total+" · "+m.data.name;
|
||
$("pDesc").textContent = "analyzing…"; $("pCat").style.display="none";
|
||
}
|
||
else if(m.type==="photo_done"){
|
||
$("pDesc").textContent = m.data.desc;
|
||
const c=$("pCat");
|
||
c.textContent=(m.data.route?("⌁"+m.data.route+" · "):"")+m.data.category+" · "+m.data.sec+"s";
|
||
c.style.display="inline-block";
|
||
|
||
// Update recent results strip
|
||
addToRecentStrip(m.data);
|
||
}
|
||
else if(m.type==="dedupe"){
|
||
if(currentView === "dupes"){
|
||
$("dupesStatus").textContent = m.data.finished
|
||
? "Hashing complete — rebuilding groups…"
|
||
: `Hashing photos… ${m.data.done}/${m.data.total}`;
|
||
if(m.data.finished) loadDupeGroups();
|
||
}
|
||
}
|
||
};
|
||
es.onerror = ()=>{ es.close(); setTimeout(connect, 2000); };
|
||
}
|
||
|
||
/* ---------- init ---------- */
|
||
(async ()=>{
|
||
const st = await api("/api/state");
|
||
CATS = st.categories||[];
|
||
renderCats(st.catCounts||{});
|
||
(st.log||[]).forEach(addLog);
|
||
if(st.folder) $("folder").value = st.folder;
|
||
applyStats(st);
|
||
setBadge(st.status||"idle");
|
||
if(st.total>0) scanned=true, setBadge(st.status||"idle");
|
||
|
||
const mj = await api("/api/models");
|
||
const sel=$("model");
|
||
const visionModels=(mj.models||[]).filter(m=>m.vision && m.name!=="qwen3.5:4b-mlx"); // mlx build: vision is broken
|
||
[["model","qwen3.5:9b"],["routerModel","glm-ocr:latest"],
|
||
["shotModel","qwen3.5:4b"],["photoModel","qwen3.5:4b"]].forEach(([id,pref])=>{
|
||
const s=$(id);
|
||
visionModels.forEach(m=>{
|
||
const o=document.createElement("option");
|
||
o.value=m.name; o.textContent=m.name+" ("+m.sizeGB+" GB)";
|
||
s.appendChild(o);
|
||
});
|
||
if([...s.options].some(o=>o.value===pref)) s.value=pref;
|
||
});
|
||
if(!sel.children.length){
|
||
addLog({level:"error",msg:"no vision-capable ollama models found — pull one, e.g. `ollama pull qwen3.5:4b`",ts:"--:--:--"});
|
||
}
|
||
|
||
populateCategoryDropdowns();
|
||
renderCategoryListEditor();
|
||
renderSavedSearches();
|
||
loadAppearanceSettings();
|
||
connect();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|