491 lines
32 KiB
HTML
491 lines
32 KiB
HTML
<!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 & 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=>({'&':'&','<':'<','>':'>','"':'"'}[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>
|