Files
photoanalyzer/webapp/analyzer.html

491 lines
32 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">
<title>Photo Analyzer · %%TOTAL%% photos</title>
<style>
*{box-sizing:border-box}
:root{
--bg:#0b0d10;--surface:#14171c;--surface-2:#1b1f26;--elev:#20252e;
--border:#262b34;--border-2:#323947;--text:#e8eaee;--muted:#9aa1ae;--faint:#6b727f;
--primary:#10b981;--primary-2:#0ea372;--primary-ink:#04130c;--danger:#ef4444;
--warn:#f59e0b;--ring:#3b82f6;--pink:#e05fa8;
--r:12px;--r-sm:8px}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
html,body{margin:0}
body{background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;
font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif}
svg{width:1em;height:1em;display:block}
button{font:inherit;color:inherit;cursor:pointer}
a{color:var(--ring)}
:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
.tabnum{font-variant-numeric:tabular-nums}
/* ── header ── */
.app{position:sticky;top:0;z-index:30;display:flex;align-items:center;gap:14px;min-height:58px;
padding:11px 18px;background:rgba(11,13,16,.86);backdrop-filter:blur(12px);border-bottom:1px solid var(--border)}
.brand{display:flex;align-items:center;gap:11px}
.logo{display:grid;place-items:center;width:34px;height:34px;border-radius:10px;font-size:18px;
background:linear-gradient(135deg,#1e3a5f,#10b981);color:#fff}
.titles h1{margin:0;font-size:15px;font-weight:600;letter-spacing:-.01em}
.titles p{margin:0;font-size:11px;color:var(--faint)}
.grow{flex:1}
.metrics{display:flex;gap:8px}
.metric{display:flex;flex-direction:column;align-items:center;min-width:70px;padding:5px 12px;
background:var(--surface);border:1px solid var(--border);border-radius:var(--r-sm)}
.metric .num{font-size:16px;font-weight:600;font-variant-numeric:tabular-nums}
.metric .lbl{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
.metric.err .num{color:var(--danger)}
/* ── segmented view switcher ── */
.seg{display:inline-flex;padding:3px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r-sm)}
.seg button{border:0;background:none;color:var(--muted);padding:6px 14px;border-radius:6px;font-size:13px;font-weight:500}
.seg button.on{background:var(--elev);color:var(--text)}
.seg button:hover:not(.on){color:var(--text)}
.navtoggle{display:grid;place-items:center;width:36px;height:36px;font-size:18px;border-radius:var(--r-sm);
background:var(--surface);border:1px solid var(--border);color:var(--muted)}
.navtoggle:hover{background:var(--elev);color:var(--text)}
.navtoggle.on{color:var(--primary);border-color:var(--border-2)}
/* ── toolbar ── */
.toolbar{position:sticky;top:58px;z-index:20;display:flex;flex-wrap:wrap;align-items:center;gap:10px;
padding:10px 18px;background:rgba(16,19,24,.92);backdrop-filter:blur(8px);border-bottom:1px solid var(--border)}
.field{display:flex;align-items:center;gap:8px;height:36px;padding:0 10px;background:var(--surface);
border:1px solid var(--border);border-radius:var(--r-sm)}
.field svg{color:var(--faint);font-size:15px}
.field input{background:none;border:0;outline:none;color:var(--text);height:100%;width:230px}
.field input::placeholder{color:var(--faint)}
.field:focus-within{border-color:var(--border-2);box-shadow:0 0 0 3px rgba(59,130,246,.22)}
.ctl{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12.5px;white-space:nowrap}
select{height:36px;padding:0 8px;background:var(--surface);color:var(--text);border:1px solid var(--border);border-radius:var(--r-sm);outline:none}
select:focus{border-color:var(--border-2);box-shadow:0 0 0 3px rgba(59,130,246,.22)}
input[type=number]{width:64px;height:36px;padding:0 8px;background:var(--surface);color:var(--text);
border:1px solid var(--border);border-radius:var(--r-sm);outline:none;font-variant-numeric:tabular-nums}
input[type=checkbox]{accent-color:var(--primary);width:16px;height:16px}
.btn{display:inline-flex;align-items:center;gap:7px;height:36px;padding:0 14px;border-radius:var(--r-sm);
border:1px solid var(--border);background:var(--surface);color:var(--text);font-weight:500;
transition:background .15s,border-color .15s,transform .05s}
.btn svg{font-size:15px}
.btn.ghost:hover{background:var(--elev);border-color:var(--border-2)}
.btn:active{transform:translateY(1px)}
.btn.primary{background:var(--primary);border-color:transparent;color:var(--primary-ink);font-weight:600}
.btn.primary:hover{background:var(--primary-2)}
.btn.danger{border-color:var(--danger);color:#fca5a5}
.btn.danger:hover{background:rgba(239,68,68,.14)}
.btn:disabled{opacity:.45;cursor:not-allowed}
.count{color:var(--faint);font-size:12.5px;font-variant-numeric:tabular-nums;white-space:nowrap}
/* ── layout shell ── */
.view{display:none}
.view.on{display:block}
.shell{display:flex;align-items:flex-start}
.sidebar{position:sticky;top:104px;align-self:flex-start;flex:0 0 250px;width:250px;
height:calc(100dvh - 104px);overflow:auto;border-right:1px solid var(--border);padding:12px 8px}
.shell.collapsed .sidebar{display:none}
.fitem{display:flex;justify-content:space-between;gap:8px;width:100%;text-align:left;border:0;background:none;
color:var(--muted);padding:7px 10px;border-radius:var(--r-sm);font-size:12.5px}
.fitem:hover{background:var(--surface);color:var(--text)}
.fitem.on{background:var(--elev);color:var(--text)}
.fitem .cn{color:var(--faint);font-variant-numeric:tabular-nums}
.main{flex:1;min-width:0}
/* ── grid + cards ── */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));gap:12px;padding:16px 18px 80px}
.card{position:relative;overflow:hidden;background:var(--surface);border:2px solid var(--border);border-radius:var(--r);
cursor:pointer;transition:border-color .15s,transform .12s,box-shadow .15s}
.card:hover{transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.35)}
.card.err{border-color:var(--danger)}
.thumb{position:relative;aspect-ratio:1/1;background:#000}
.thumb img{width:100%;height:100%;object-fit:cover;display:block}
.badges{position:absolute;left:8px;top:8px;display:flex;gap:6px}
.badge{padding:2px 7px;border-radius:6px;background:rgba(6,8,12,.72);color:#fff;font-size:11.5px;
font-weight:700;font-variant-numeric:tabular-nums;backdrop-filter:blur(3px)}
.badge.warnb{background:var(--warn);color:#241a02}
.dot{position:absolute;right:8px;top:8px;width:10px;height:10px;border-radius:50%;box-shadow:0 0 0 2px rgba(0,0,0,.4)}
.dot.err{background:var(--danger)}.dot.pending{background:var(--faint)}
.info{padding:8px 10px}
.desc{font-size:12px;color:var(--text);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.pathline{font-size:11px;color:var(--faint);margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.empty{padding:80px 20px;text-align:center;color:var(--muted)}
.empty h3{color:var(--text);margin:0 0 6px}
/* ── progress bar ── */
.bar{position:relative;height:8px;border-radius:5px;background:var(--border-2);overflow:hidden}
.bar>span{position:absolute;inset:0 auto 0 0;background:var(--primary);border-radius:5px;transition:width .3s}
.bar.run>span{background:var(--warn)}
/* ── analyze view ── */
.panel{margin:14px 18px;background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:16px}
.panel h2{margin:0 0 12px;font-size:13px;font-weight:600;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
.runbar{display:flex;flex-wrap:wrap;align-items:center;gap:14px}
.runbar label{display:flex;align-items:center;gap:6px;color:var(--muted);font-size:12.5px}
.overall .big{display:flex;align-items:baseline;gap:12px;margin-bottom:8px}
.overall .pct{font-size:26px;font-weight:700;font-variant-numeric:tabular-nums}
.overall .sub{color:var(--muted);font-size:12.5px;font-variant-numeric:tabular-nums}
.cols{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:820px){.cols{grid-template-columns:1fr}}
.albrow{display:grid;grid-template-columns:1fr auto 70px;align-items:center;gap:10px;padding:5px 0;font-size:12.5px}
.albrow .nm{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.albrow .ct{color:var(--faint);font-variant-numeric:tabular-nums}
.feed{max-height:420px;overflow:auto}
.feedline{display:flex;gap:9px;padding:7px 0;border-bottom:1px solid var(--border);font-size:12.5px}
.feedline .ic{flex:0 0 auto}.feedline .ok{color:var(--primary)}.feedline .bad{color:var(--danger)}
.feedline .fn{font-weight:600}.feedline .dz{color:var(--muted)}
/* ── stats view ── */
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;padding:16px 18px 4px}
.kpi{background:var(--surface);border:1px solid var(--border);border-radius:var(--r);padding:14px 16px}
.kpi .num{font-size:24px;font-weight:700;font-variant-numeric:tabular-nums}
.kpi .lbl{color:var(--muted);font-size:12px;margin-top:2px}
.statbar{display:grid;grid-template-columns:120px 1fr 64px;align-items:center;gap:10px;padding:4px 0;font-size:12.5px}
.statbar .lab{color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.statbar .val{text-align:right;color:var(--faint);font-variant-numeric:tabular-nums}
.statbar .track{height:14px;border-radius:4px;background:var(--surface-2);overflow:hidden}
.statbar .track>span{display:block;height:100%;background:var(--primary);opacity:.85}
.years{display:flex;align-items:flex-end;gap:3px;height:90px;padding-top:8px}
.years .yb{flex:1;min-width:4px;background:var(--primary);opacity:.8;border-radius:2px 2px 0 0}
.years .yb:hover{opacity:1}
.tags{display:flex;flex-wrap:wrap;gap:7px}
.tag{padding:4px 10px;border-radius:16px;border:1px solid var(--border);background:var(--surface);
color:var(--muted);font-size:12px;cursor:pointer}
.tag:hover{color:var(--text);border-color:var(--border-2)}
.tag .cn{color:var(--faint)}
.errline{display:flex;justify-content:space-between;gap:10px;padding:7px 0;border-bottom:1px solid var(--border);font-size:12.5px}
.errline .msg{color:#fca5a5}
/* ── lightbox ── */
.lb{position:fixed;inset:0;z-index:50;display:none;background:rgba(6,8,11,.9);backdrop-filter:blur(4px)}
.lb.on{display:flex}
.lb .stage{flex:1;display:flex;align-items:center;justify-content:center;padding:24px;min-width:0}
.lb img{max-width:100%;max-height:100%;object-fit:contain;border-radius:var(--r)}
.lb .side{flex:0 0 340px;max-width:44vw;background:var(--surface);border-left:1px solid var(--border);
padding:18px;overflow:auto}
.lb .side h3{margin:0 0 4px;font-size:14px}
.lb .side .fn2{color:var(--faint);font-size:12px;margin-bottom:14px;word-break:break-all}
.kv{margin:0 0 14px}
.kv dt{color:var(--faint);font-size:10.5px;text-transform:uppercase;letter-spacing:.05em;margin-top:8px}
.kv dd{margin:1px 0 0;font-size:13px}
.lbbtns{position:absolute;top:16px;right:16px;display:flex;gap:8px}
.icon{display:grid;place-items:center;width:36px;height:36px;border:0;border-radius:8px;color:#fff;
background:rgba(6,8,12,.62);backdrop-filter:blur(3px)}
.icon:hover{background:rgba(6,8,12,.9)}
.lbnav{position:absolute;top:50%;transform:translateY(-50%)}
.lbnav.prev{left:16px}.lbnav.next{right:calc(340px + 16px)}
@media(max-width:700px){.lb .side{display:none}.lbnav.next{right:16px}}
/* ── log panel + toast ── */
.logpanel{position:fixed;top:0;right:0;bottom:0;width:380px;max-width:92vw;z-index:60;background:var(--surface);
border-left:1px solid var(--border);display:flex;flex-direction:column;transform:translateX(100%);transition:transform .2s}
.logpanel:not([hidden]){transform:none}
.loghead{display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--border)}
.logbody{flex:1;overflow:auto;padding:8px 16px;font-size:12.5px}
.logline{display:flex;gap:10px;padding:5px 0;border-bottom:1px solid var(--border)}
.logline .lt{color:var(--faint);font-variant-numeric:tabular-nums;flex:0 0 auto}
.toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%) translateY(20px);z-index:70;opacity:0;
padding:10px 16px;background:var(--elev);border:1px solid var(--border-2);border-radius:var(--r-sm);
pointer-events:none;transition:opacity .2s,transform .2s}
.toast.show{opacity:1;transform:translateX(-50%)}
.toast.err{border-color:var(--danger)}.toast.ok{border-color:var(--primary)}
</style></head>
<body>
<header class="app">
<div class="brand"><div class="logo"></div>
<div class="titles"><h1>Photo Analyzer</h1><p>%%LIBRARY%%</p></div></div>
<div class="seg" id="viewseg" role="tablist">
<button data-view="library" class="on">Library</button>
<button data-view="analyze">Analyze</button>
<button data-view="stats">Stats</button></div>
<div class="grow"></div>
<div class="metrics">
<div class="metric"><span class="num tabnum" id="mTotal">%%TOTAL%%</span><span class="lbl">Total</span></div>
<div class="metric"><span class="num tabnum" id="mDone"></span><span class="lbl">Analyzed</span></div>
<div class="metric err"><span class="num tabnum" id="mErr"></span><span class="lbl">Errors</span></div>
</div>
<button class="navtoggle" id="logbtn" aria-label="Activity log" onclick="toggleLog()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M8 6h11M8 12h11M8 18h11"/><path d="M4 6h.01M4 12h.01M4 18h.01"/></svg></button>
</header>
<!-- ══ LIBRARY ══ -->
<section id="view-library" class="view on">
<div class="toolbar">
<button class="navtoggle on" id="sidetoggle" aria-label="Toggle folders" onclick="toggleSidebar()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M9 3v18"/></svg></button>
<label class="field"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
<input id="q" type="search" placeholder="Search description, tags, mood, place…" autocomplete="off"></label>
<label class="ctl">Setting <select id="fSetting"><option value="">any</option></select></label>
<label class="ctl">Time <select id="fTod"><option value="">any</option></select></label>
<label class="ctl">Season <select id="fSeason"><option value="">any</option></select></label>
<label class="ctl">People <select id="fPeople"><option value="">any</option><option value="0">0</option><option value="1">1</option><option value="2">2</option><option value="3+">3+</option></select></label>
<label class="ctl">Year <input id="fYmin" type="number" placeholder="min"> <input id="fYmax" type="number" placeholder="max"></label>
<label class="ctl"><input id="fLoc" type="checkbox"> has place</label>
<label class="ctl">Status <select id="fStatus"><option value="">any</option></select></label>
<div class="grow"></div>
<label class="ctl">Sort <select id="sort">
<option value="relevance">Relevance</option><option value="year">Year ↓</option>
<option value="people">People ↓</option><option value="recent">Recent</option><option value="path">Folder</option></select></label>
<button class="btn ghost" onclick="resetFilters()">Clear</button>
<span class="count" id="resCount"></span>
</div>
<div class="shell" id="shell">
<aside class="sidebar" id="folders"></aside>
<div class="main">
<div class="grid" id="grid"></div>
<div id="sentinel" style="height:1px"></div>
<div class="empty" id="emptyLib" hidden><h3>No matches</h3><p>Try a broader term or clear the filters.</p></div>
</div>
</div>
</section>
<!-- ══ ANALYZE ══ -->
<section id="view-analyze" class="view">
<div class="panel"><h2>Run</h2>
<div class="runbar">
<label class="ctl">Library <input id="runLib" type="text" value="%%LIBRARY%%" style="width:260px"></label>
<label><input type="checkbox" id="opt-dry-run"> Dry-run</label>
<label><input type="checkbox" id="opt-reanalyze"> Re-analyze</label>
<label><input type="checkbox" id="opt-no-exif"> No-EXIF</label>
<label><input type="checkbox" id="opt-exif-only"> EXIF-only</label>
<label><input type="checkbox" id="opt-group-variants"> Variants</label>
<div class="grow"></div>
<button class="btn ghost" onclick="apiBalance()">Balance</button>
<button class="btn ghost" onclick="apiQuota()">Quota check</button>
<button class="btn primary" id="startBtn" onclick="startRun()">▶ Start</button>
<button class="btn danger" id="stopBtn" onclick="stopRun()" hidden>■ Stop</button>
</div>
</div>
<div class="panel"><h2>Duplicates &amp; maintenance</h2>
<div class="runbar">
<span class="ctl" style="max-width:560px;white-space:normal">Content-hash tools — no API calls.
Output appears in the Activity log; photos marked as duplicates show up under the
<b>duplicate</b> status filter in Library.</span>
<div class="grow"></div>
<button class="btn ghost" id="act-backfill-phash" onclick="runAction('backfill-phash')">⧉ Backfill hashes</button>
<button class="btn ghost" id="act-list-dupes" onclick="runAction('list-dupes')">🔍 Find duplicates</button>
<button class="btn ghost" id="act-dedupe" onclick="runAction('dedupe')">✓ Mark duplicates</button>
</div>
</div>
<div class="panel overall"><h2>Overall</h2>
<div class="big"><span class="pct" id="ovPct">Idle</span>
<span class="sub" id="ovCount"></span></div>
<div class="bar" id="ovBar"><span style="width:0"></span></div>
<div class="sub" id="ovTokens" style="margin-top:8px"></div>
</div>
<div class="cols">
<div class="panel"><h2>Albums</h2><div id="albums"><p class="count">No run in progress.</p></div></div>
<div class="panel"><h2>Recent</h2><div class="feed" id="feed"><p class="count">Analyzed photos will appear here.</p></div></div>
</div>
</section>
<!-- ══ STATS ══ -->
<section id="view-stats" class="view">
<div class="kpis" id="kpis"></div>
<div class="cols">
<div class="panel"><h2>Setting</h2><div id="stSetting"></div></div>
<div class="panel"><h2>Time of day</h2><div id="stTod"></div></div>
<div class="panel"><h2>Season</h2><div id="stSeason"></div></div>
<div class="panel"><h2>People</h2><div id="stPeople"></div></div>
</div>
<div class="panel"><h2>By year</h2><div class="years" id="stYears"></div></div>
<div class="panel"><h2>Top tags</h2><div class="tags" id="stTags"></div></div>
<div class="panel"><h2>Errors</h2><div id="stErrors"></div></div>
</section>
<!-- lightbox -->
<div class="lb" id="lb">
<div class="lbbtns"><button class="icon" onclick="closeLb()" aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
<button class="icon lbnav prev" onclick="stepLb(-1)" aria-label="Previous">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m15 18-6-6 6-6"/></svg></button>
<button class="icon lbnav next" onclick="stepLb(1)" aria-label="Next">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="m9 18 6-6-6-6"/></svg></button>
<div class="stage"><img id="lbImg" alt=""></div>
<div class="side"><h3 id="lbName"></h3><div class="fn2" id="lbPath"></div>
<dl class="kv" id="lbAnalysis"></dl><dl class="kv" id="lbExif"></dl></div>
</div>
<!-- activity log -->
<aside class="logpanel" id="logpanel" hidden>
<div class="loghead"><b>Activity</b><button class="btn ghost" onclick="toggleLog()">Close</button></div>
<div class="logbody" id="logbody"></div>
</aside>
<div class="toast" id="toast" hidden></div>
<script>
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
const esc=s=>String(s??'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const enc=encodeURIComponent;
function toast(m,c){const t=$('#toast');t.textContent=m;t.hidden=false;t.className='toast show '+(c||'');
clearTimeout(t._t);t._t=setTimeout(()=>t.classList.remove('show'),3200);}
/* ── view switching (deep-linked via hash) ── */
function setView(v){
if(!['library','analyze','stats'].includes(v))v='library';
$$('.view').forEach(s=>s.classList.toggle('on',s.id==='view-'+v));
$$('#viewseg button').forEach(b=>b.classList.toggle('on',b.dataset.view===v));
if(location.hash.slice(1).split('?')[0]!==v)history.replaceState(null,'','#'+v);
if(v==='stats')loadStats();
if(v==='analyze')pollProgress();
}
$$('#viewseg button').forEach(b=>b.onclick=()=>setView(b.dataset.view));
addEventListener('hashchange',()=>setView(location.hash.slice(1).split('?')[0]));
/* ── LIBRARY ── */
let offset=0,total=0,loading=false,done=true,curAlbum='';
const filt=()=>({q:$('#q').value.trim(),setting:$('#fSetting').value,tod:$('#fTod').value,
season:$('#fSeason').value,people:$('#fPeople').value,year_min:$('#fYmin').value,
year_max:$('#fYmax').value,has_location:$('#fLoc').checked?'1':'',status:$('#fStatus').value,
album:curAlbum,sort:$('#sort').value});
function qs(o){return Object.entries(o).filter(([,v])=>v!==''&&v!=null).map(([k,v])=>k+'='+enc(v)).join('&');}
async function runSearch(reset=true){
if(loading)return; loading=true;
if(reset){offset=0;$('#grid').innerHTML='';done=false;}
const r=await fetch('/search?'+qs({...filt(),offset})).then(r=>r.json());
total=r.total; $('#resCount').textContent=total.toLocaleString()+' photo'+(total===1?'':'s');
$('#emptyLib').hidden=total>0;
$('#grid').insertAdjacentHTML('beforeend',r.rows.map(card).join(''));
offset+=r.rows.length; done=offset>=total || r.rows.length===0;
loading=false;
}
function card(p){
const yr=p.approx_year?`<span class="badge">${p.approx_year}</span>`:'';
const pe=(p.people_count>0)?`<span class="badge">👤 ${p.people_count}</span>`:'';
const du=p.status==='duplicate'?`<span class="badge warnb" title="perceptual duplicate">dup</span>`:'';
const dot=p.status==='error'?'<span class="dot err" title="error"></span>'
:(p.status==='pending'?'<span class="dot pending" title="not analyzed"></span>':'');
return `<div class="card ${p.status==='error'?'err':''}" data-path="${esc(p.path)}" onclick="openLb(this)">
<div class="thumb"><img loading="lazy" src="/img?path=${enc(p.path)}" alt="">
<div class="badges">${yr}${pe}${du}</div>${dot}</div>
<div class="info"><div class="desc">${esc(p.description||(p.status==='error'?'—':'(not analyzed)'))}</div>
<div class="pathline">${esc(p.path.split('/').slice(-2).join('/'))}</div></div></div>`;
}
let searchT;
function debouncedSearch(){clearTimeout(searchT);searchT=setTimeout(()=>runSearch(true),250);}
['#q','#fYmin','#fYmax'].forEach(s=>$(s).addEventListener('input',debouncedSearch));
['#fSetting','#fTod','#fSeason','#fPeople','#fStatus','#sort'].forEach(s=>$(s).addEventListener('change',()=>runSearch(true)));
$('#fLoc').addEventListener('change',()=>runSearch(true));
function resetFilters(){['#q','#fYmin','#fYmax'].forEach(s=>$(s).value='');
['#fSetting','#fTod','#fSeason','#fPeople','#fStatus'].forEach(s=>$(s).value='');
$('#fLoc').checked=false;$('#sort').value='relevance';curAlbum='';
$$('.fitem').forEach(x=>x.classList.remove('on'));runSearch(true);}
new IntersectionObserver(es=>{if(es[0].isIntersecting&&!done&&!loading)runSearch(false);})
.observe($('#sentinel'));
function toggleSidebar(){const c=$('#shell').classList.toggle('collapsed');
$('#sidetoggle').classList.toggle('on',!c);}
function opt(sel,items,none='any'){
$(sel).innerHTML=`<option value="">${none}</option>`+items.map(i=>
`<option value="${esc(i.value)}">${esc(i.value)} (${i.count.toLocaleString()})</option>`).join('');}
async function loadFacets(){
const f=await fetch('/facets').then(r=>r.json());
opt('#fSetting',f.setting);opt('#fTod',f.tod);opt('#fSeason',f.season);opt('#fStatus',f.status);
if(f.year_min){$('#fYmin').placeholder=f.year_min;$('#fYmax').placeholder=f.year_max;}
$('#folders').innerHTML=`<button class="fitem on" onclick="pickAlbum(this,'')"><span>All folders</span></button>`+
f.albums.map(a=>`<button class="fitem" onclick="pickAlbum(this,'${esc(a.album)}')">
<span>${esc(a.album)}</span><span class="cn">${a.count.toLocaleString()}</span></button>`).join('');
// album buttons carry the folder name; map to absolute path server-side via album= param
}
function pickAlbum(btn,album){curAlbum=album?(LIBRARY.replace(/\/$/,'')+'/'+album):'';
$$('.fitem').forEach(x=>x.classList.toggle('on',x===btn));runSearch(true);}
const LIBRARY='%%LIBRARY%%';
/* ── LIGHTBOX ── */
let lbCards=[],lbIdx=0;
function openLb(el){lbCards=$$('#grid .card');lbIdx=lbCards.indexOf(el);showLb();$('#lb').classList.add('on');}
function closeLb(){$('#lb').classList.remove('on');}
function stepLb(d){lbIdx=(lbIdx+d+lbCards.length)%lbCards.length;showLb();}
async function showLb(){
const el=lbCards[lbIdx],path=el.dataset.path;
$('#lbImg').src='/img?path='+enc(path);
$('#lbName').textContent=path.split('/').pop();$('#lbPath').textContent=path;
$('#lbAnalysis').innerHTML='<dt>Analysis</dt><dd class="count">loading…</dd>';
$('#lbExif').innerHTML='';
const p=await fetch('/photo?path='+enc(path)).then(r=>r.json()).catch(()=>null);
$('#lbAnalysis').innerHTML=p?analysisDl(p):'<dt>Analysis</dt><dd>—</dd>';
const ex=await fetch('/exif?path='+enc(path)).then(r=>r.json()).catch(()=>({}));
$('#lbExif').innerHTML='<dt style="margin-top:16px">EXIF</dt>'+Object.entries(ex).map(([k,v])=>
k==='Map'?`<dd><a href="${esc(v)}" target="_blank" rel="noopener">Open in Google Maps ↗</a></dd>`
:`<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
}
function row(k,v){return v==null||v===''?'':`<dt>${k}</dt><dd>${esc(v)}</dd>`;}
function analysisDl(p){
return row('Description',p.description)+
(p.tags&&p.tags.length?`<dt>Tags</dt><dd>${p.tags.map(esc).join(', ')}</dd>`:'')+
row('Mood',p.mood)+row('Setting',p.setting)+row('Time of day',p.time_of_day)+
row('Season',p.season)+row('People',p.people_count)+row('Approx. year',p.approx_year)+
row('Location',p.location_hint)+row('Status',p.status)+row('Duplicate of',p.dup_of)+row('Error',p.error_message);
}
addEventListener('keydown',e=>{if(!$('#lb').classList.contains('on'))return;
if(e.key==='Escape')closeLb();else if(e.key==='ArrowLeft')stepLb(-1);else if(e.key==='ArrowRight')stepLb(1);});
$('#lb').addEventListener('click',e=>{if(e.target.id==='lb')closeLb();});
/* ── STATS ── */
function bars(el,items,max){const m=max||Math.max(1,...items.map(i=>i.count));
$(el).innerHTML=items.map(i=>`<div class="statbar"><span class="lab">${esc(i.value)}</span>
<span class="track"><span style="width:${i.count/m*100}%"></span></span>
<span class="val">${i.count.toLocaleString()}</span></div>`).join('')||'<p class="count">no data</p>';}
async function loadStats(){
const s=await fetch('/stats').then(r=>r.json());
const st=s.status||{};
const done=(st.analyzed||0)+(st.exif_written||0);
$('#mDone').textContent=done.toLocaleString();$('#mErr').textContent=(st.error||0).toLocaleString();
const kpi=[['Total',s.total],['Analyzed',done],['EXIF written',st.exif_written||0],
['Duplicates',st.duplicate||0],['Errors',st.error||0],['Pending',st.pending||0]];
$('#kpis').innerHTML=kpi.map(([l,n])=>`<div class="kpi"><div class="num tabnum">${(n||0).toLocaleString()}</div><div class="lbl">${l}</div></div>`).join('');
bars('#stSetting',s.setting);bars('#stTod',s.time_of_day);bars('#stSeason',s.season);bars('#stPeople',s.people);
const ym=Math.max(1,...s.years.map(y=>y.count));
$('#stYears').innerHTML=s.years.map(y=>`<div class="yb" style="height:${y.count/ym*100}%" title="${y.value}: ${y.count}"></div>`).join('')||'<p class="count">no dated photos</p>';
$('#stTags').innerHTML=s.top_tags.map(t=>`<button class="tag" onclick="searchTag('${esc(t.value)}')">${esc(t.value)} <span class="cn">${t.count}</span></button>`).join('');
$('#stErrors').innerHTML=s.errors.length?s.errors.map(e=>`<div class="errline"><span>${esc(e.path.split('/').pop())}</span><span class="msg">${esc(e.error||'')}</span></div>`).join(''):'<p class="count">No errors 🎉</p>';
}
function searchTag(t){$('#q').value=t;setView('library');runSearch(true);}
/* ── ANALYZE (progress polling; run controls wired to server) ── */
let progT=null;
async function pollProgress(){
clearTimeout(progT);
let s;try{s=await fetch('/progress').then(r=>r.json());}catch(e){progT=setTimeout(pollProgress,2000);return;}
renderProgress(s);
if($('#view-analyze').classList.contains('on'))progT=setTimeout(pollProgress,s.running?1500:4000);
}
function renderProgress(s){
$('#startBtn').hidden=!!s.running;$('#stopBtn').hidden=!s.running;
['act-backfill-phash','act-list-dupes','act-dedupe'].forEach(id=>{const b=$('#'+id);if(b)b.disabled=!!s.running;});
if(!s.running&&!s.totals){$('#ovPct').textContent='Idle';$('#ovCount').textContent='';$('#ovBar').firstElementChild.style.width='0';return;}
const t=s.totals||{done:0,total:0,ok:0,err:0};
const pct=t.total?Math.round(t.done/t.total*100):0;
$('#ovPct').textContent=pct+'%';
$('#ovCount').textContent=`${(t.done||0).toLocaleString()} / ${(t.total||0).toLocaleString()} · ok ${t.ok||0} · err ${t.err||0}`+(t.dup?` · dup ${t.dup.toLocaleString()}`:'');
const bar=$('#ovBar');bar.classList.toggle('run',!!s.running);bar.firstElementChild.style.width=pct+'%';
if(s.tokens!=null)$('#ovTokens').textContent=`tokens ${(s.tokens||0).toLocaleString()}`+(s.eta?` · ETA ${s.eta}`:'');
$('#albums').innerHTML=(s.albums||[]).map(a=>{const p=a.total?Math.round(a.done/a.total*100):0;
return `<div class="albrow"><span class="nm">${esc(a.album)}</span>
<span class="bar" style="width:120px"><span style="width:${p}%"></span></span>
<span class="ct">${a.done===a.total?'✓':p+'%'}</span></div>`;}).join('')||'<p class="count">No run in progress.</p>';
$('#feed').innerHTML=(s.feed||[]).map(f=>`<div class="feedline"><span class="ic ${f.ok?'ok':'bad'}">${f.ok?'✓':'✗'}</span>
<span><span class="fn">${esc(f.name)}</span><div class="dz">${esc(f.msg||'')}</div></span></div>`).join('')||'<p class="count">Analyzed photos will appear here.</p>';
}
async function startRun(){
const opts={};['dry-run','reanalyze','no-exif','exif-only','group-variants'].forEach(k=>opts[k]=$('#opt-'+k).checked);
opts.library=$('#runLib').value;
const r=await fetch('/run',{method:'POST',body:JSON.stringify(opts)}).then(r=>r.json()).catch(e=>({error:''+e}));
if(r.error)toast(r.error,'err');else{toast('Run started','ok');pollProgress();}
}
async function stopRun(){await fetch('/stop',{method:'POST'});toast('Stopping…');pollProgress();}
const ACTION_LABELS={'backfill-phash':'Backfilling hashes','list-dupes':'Finding duplicates','dedupe':'Marking duplicates'};
function ensureLogOpen(){if($('#logpanel').hidden)toggleLog();}
async function runAction(action){
const r=await fetch('/run',{method:'POST',body:JSON.stringify({action,library:$('#runLib').value})})
.then(r=>r.json()).catch(e=>({error:''+e}));
if(r.error){toast(r.error,'err');return;}
toast(ACTION_LABELS[action]+'…','ok');ensureLogOpen();pollProgress(); // output streams to Activity log
}
async function apiBalance(){const r=await fetch('/balance').then(r=>r.json()).catch(()=>({}));
toast(r.text||'Balance unavailable',r.text?'ok':'err');}
async function apiQuota(){toast('Checking quota…');const r=await fetch('/quota-check').then(r=>r.json()).catch(()=>({ok:false}));
toast(r.ok?'Quota available ✓':'Quota check failed',r.ok?'ok':'err');}
/* ── activity log ── */
let logT=null;
function toggleLog(){const p=$('#logpanel'),open=p.hidden;p.hidden=!open;$('#logbtn').classList.toggle('on',open);
if(open){refreshLog();logT=setInterval(refreshLog,2500);}else clearInterval(logT);}
async function refreshLog(){const es=await fetch('/log').then(r=>r.json()).catch(()=>[]);
$('#logbody').innerHTML=es.length?es.slice().reverse().map(e=>`<div class="logline"><span class="lt">${esc(e.t)}</span><span>${esc(e.m)}</span></div>`).join(''):'<p class="count">No activity yet.</p>';}
/* ── boot ── */
loadFacets().then(()=>runSearch(true));
loadStats();
setView(location.hash.slice(1).split('?')[0]||'library');
</script>
</body></html>