Files
photon---photo-intelligence/index.html

1962 lines
90 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">
<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}
/* ---------- toast notifications ---------- */
.toast-container{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none}
.toast-msg{background:var(--panel2);border:1px solid var(--line);color:var(--txt);padding:10px 18px;border-radius:6px;font-size:12px;font-family:var(--font-sans);font-weight:500;box-shadow:0 8px 24px rgba(0,0,0,0.6);opacity:0;transform:translateY(12px);transition:all .25s ease;pointer-events:auto;display:flex;align-items:center;gap:8px}
.toast-msg.show{opacity:1;transform:translateY(0)}
.toast-msg.ok{border-color:var(--grn);color:var(--grn);box-shadow:0 0 16px rgba(74,222,128,.2)}
.toast-msg.error{border-color:var(--red);color:var(--red);box-shadow:0 0 16px rgba(248,113,113,.2)}
.toast-msg.info{border-color:var(--cyan);color:var(--cyan);box-shadow:0 0 16px rgba(34,211,238,.2)}
.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>
<button id="btnBulkDelete" class="btn-danger">🗑 Delete selected</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="lbDeleteTop" class="btn-danger" title="Delete photo from disk (Delete / Backspace key)">🗑 Delete</button>
<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>
<button id="lbDelete" class="btn-danger" style="grid-column:1/-1">🗑 Delete photo from disk</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=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]))}
function showToast(msg, type="info", duration=3000){
let container = $("toastContainer");
if(!container){
container = document.createElement("div");
container.id = "toastContainer";
container.className = "toast-container";
document.body.appendChild(container);
}
const toast = document.createElement("div");
toast.className = `toast-msg ${type}`;
toast.innerHTML = `<span>${escapeHtml(msg)}</span>`;
container.appendChild(toast);
setTimeout(()=>toast.classList.add("show"), 10);
setTimeout(()=>{
toast.classList.remove("show");
setTimeout(()=>toast.remove(), 300);
}, duration);
}
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();
}
// Instant 0ms prefetching for adjacent photos
if(lbMode === "grid"){
if(lbIndex + 1 < searchResults.length && !VIDEO_EXT_RE.test(searchResults[lbIndex + 1].path)){
new Image().src = "/api/photo?path=" + encodeURIComponent(searchResults[lbIndex + 1].path);
}
if(lbIndex - 1 >= 0 && !VIDEO_EXT_RE.test(searchResults[lbIndex - 1].path)){
new Image().src = "/api/photo?path=" + encodeURIComponent(searchResults[lbIndex - 1].path);
}
}
}
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();
}
async function deleteCurrentPhoto(){
const rec = currentLbRec();
if(!rec) return;
if(!confirm(`Permanently delete this photo from disk?\n\n${rec.path}`)) return;
const j = await api("/api/delete_photo", { path: rec.path });
if(j.ok){
showToast("Deleted " + os_basename(rec.path) + " from disk", "ok");
addLog({level:"ok", msg:`Deleted ${os_basename(rec.path)} from disk.`, ts:new Date().toTimeString().slice(0,8)});
if(lbMode === "grid"){
searchResults.splice(lbIndex, 1);
searchTotal = Math.max(0, searchTotal - 1);
if(searchResults.length === 0){
closeLightbox();
doSearch(true);
} else {
if(lbIndex >= searchResults.length) lbIndex = searchResults.length - 1;
renderLightbox();
renderSearchResults();
}
} else {
closeLightbox();
}
} else {
showToast(j.error || "Failed to delete photo", "error");
}
}
$("lbPrev").onclick = ()=>lbGoto(-1);
$("lbNext").onclick = ()=>lbGoto(1);
$("lbClose").onclick = closeLightbox;
if($("lbDelete")) $("lbDelete").onclick = deleteCurrentPhoto;
if($("lbDeleteTop")) $("lbDeleteTop").onclick = deleteCurrentPhoto;
document.addEventListener("keydown", e=>{
if(!$("lightbox").classList.contains("active")) return;
const tag = (e.target.tagName || "").toLowerCase();
if(tag === "input" || tag === "textarea" || tag === "select") return;
if(e.key === "Escape") closeLightbox();
else if(e.key === "ArrowLeft") lbGoto(-1);
else if(e.key === "ArrowRight") lbGoto(1);
else if(e.key === "Delete" || e.key === "Backspace"){
e.preventDefault();
deleteCurrentPhoto();
}
});
$("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();
showToast("Saved metadata for " + os_basename(rec.path), "ok");
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();
showToast("AI re-evaluated " + os_basename(rec.path), "ok");
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);
showToast("File path copied to clipboard", "ok");
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");
};
if($("btnBulkDelete")){
$("btnBulkDelete").onclick = async ()=>{
if(selectedPaths.size === 0) return;
if(!confirm(`Permanently delete ${selectedPaths.size} selected photo(s) from disk?\n\nThis cannot be undone.`)) return;
$("btnBulkDelete").disabled = true;
const j = await api("/api/delete_photos", { paths: Array.from(selectedPaths) });
$("btnBulkDelete").disabled = false;
if(j.ok){
showToast(`Deleted ${j.deleted} photo(s) from disk`, "ok");
addLog({level:"ok", msg:`Batch deleted ${j.deleted} photos from disk.`, ts:new Date().toTimeString().slice(0,8)});
selectedPaths.clear();
updateBulkBar();
doSearch(true);
} else {
showToast(j.error || "Batch delete failed", "error");
}
};
}
$("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>