1030 lines
49 KiB
HTML
1030 lines
49 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>PHOTON // photo intelligence console</title>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
:root{
|
|
--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;
|
|
}
|
|
*{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)}
|
|
.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}
|
|
|
|
/* ---------- search interface ---------- */
|
|
.search-grid{display:grid;grid-template-columns:1fr 400px;gap:14px;height:700px}
|
|
@media(max-width:960px){.search-grid{grid-template-columns:1fr;height:auto}}
|
|
.search-left{display:flex;flex-direction:column;height:100%}
|
|
.search-bar-row{display:flex;gap:10px;padding:12px;background:var(--panel2);border-bottom:1px solid var(--line)}
|
|
.search-bar-row input{flex:1;background:var(--panel);border:1px solid var(--line);color:var(--txt);padding:8px 12px;border-radius:4px;font-family:var(--font-sans)}
|
|
.search-bar-row input:focus{outline:none;border-color:var(--cyan)}
|
|
.search-results{flex:1;overflow-y:auto;padding:12px;display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:10px;align-content:start}
|
|
.search-results::-webkit-scrollbar{width:6px}
|
|
.search-results::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px}
|
|
|
|
.result-card{background:var(--panel2);border:1px solid var(--line);border-radius:6px;overflow:hidden;display:flex;gap:10px;padding:8px;cursor:pointer;transition:.15s}
|
|
.result-card:hover{border-color:var(--cyan);transform:translateY(-1px)}
|
|
.result-card img{width:70px;height:70px;object-fit:cover;border-radius:4px;background:#000;flex-shrink:0}
|
|
.result-info{display:flex;flex-direction:column;justify-content:space-between;overflow:hidden}
|
|
.result-name{font-size:11px;color:var(--cyan);font-family:var(--font-mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.result-desc{font-size:11px;color:var(--txt);line-height:1.4;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
|
|
.result-cat{font-size:9px;color:var(--mag);text-transform:uppercase;letter-spacing:1px;font-weight:600}
|
|
|
|
.editor-panel{display:flex;flex-direction:column;height:100%;padding:14px;background:var(--panel2);border-left:1px solid var(--line)}
|
|
.editor-img{width:100%;aspect-ratio:1.5;background:#000;border:1px solid var(--line);border-radius:4px;display:flex;align-items:center;justify-content:center;overflow:hidden;margin-bottom:12px}
|
|
.editor-img img{max-width:100%;max-height:100%;object-fit:contain}
|
|
.editor-fields{flex:1;overflow-y:auto;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" value="/Volumes/sanD/allphotos from phone">
|
|
<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>journal search & meta inspector</b></h2>
|
|
<div class="search-grid">
|
|
<div class="search-left">
|
|
<div class="search-bar-row">
|
|
<input id="searchInput" placeholder="Search by name, category, or description keywords..." spellcheck="false">
|
|
<button id="btnSearch" class="btn-primary">Search</button>
|
|
<button id="btnSyncMeta">⟳ Sync Metadata</button>
|
|
</div>
|
|
<div class="search-results" id="searchResults">
|
|
<div style="grid-column:1/-1; text-align:center; padding:50px; color:var(--dim); text-transform:uppercase; letter-spacing:1px">Type a search query or press Search to list all tagged photos.</div>
|
|
</div>
|
|
</div>
|
|
<!-- Inspector / Editor -->
|
|
<div class="editor-panel">
|
|
<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-img">
|
|
<img id="editPreview" style="display:none">
|
|
<div id="editNoSignal" style="color:var(--dim); font-size:10px; letter-spacing:2px">SELECT A PHOTO</div>
|
|
</div>
|
|
<div class="editor-fields">
|
|
<label>File path</label>
|
|
<div class="editor-path" id="editPath">—</div>
|
|
|
|
<label>category</label>
|
|
<select id="editCat"></select>
|
|
|
|
<label>description</label>
|
|
<textarea id="editDesc" placeholder="Photo description..."></textarea>
|
|
</div>
|
|
<div class="editor-actions">
|
|
<button id="btnSaveSingle" class="btn-primary" disabled>✓ Save</button>
|
|
<button id="btnRedoSingle" disabled>⌁ AI Redo</button>
|
|
<button id="btnRevealSingle" style="grid-column:1/-1" disabled>Reveal in Finder</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ==================== BLIND AUDIT TAB ==================== -->
|
|
<div id="tab-audit" class="tab-content">
|
|
<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">
|
|
<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>
|
|
|
|
<script>
|
|
const $=id=>document.getElementById(id);
|
|
let CATS=[], state="idle", scanned=false, currentEditFile=null;
|
|
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') doSearch();
|
|
}
|
|
|
|
/* ---------- 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");
|
|
|
|
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 addLog(e){
|
|
const t=$("term");
|
|
const d=document.createElement("div");
|
|
d.className="ln "+e.level;
|
|
d.innerHTML='<span class="t">'+e.ts+'</span> '+escapeHtml(e.msg);
|
|
t.appendChild(d);
|
|
while(t.children.length>350)t.removeChild(t.firstChild);
|
|
t.scrollTop=t.scrollHeight;
|
|
}
|
|
function escapeHtml(s){return s.replace(/[&<>]/g,c=>({"&":"&","<":"<",">":">"}[c]))}
|
|
|
|
function setBadge(st){
|
|
state=st;
|
|
const b=$("badge"); b.textContent=st.toUpperCase(); b.className="badge "+st;
|
|
$("btnStart").disabled = !(scanned && (st==="idle"||st==="done"));
|
|
$("btnPause").disabled = !(st==="running"||st==="paused");
|
|
$("btnStop").disabled = !(st==="running"||st==="paused");
|
|
$("btnPause").textContent = st==="paused" ? "▶ Resume Pipeline" : "❚❚ Pause";
|
|
$("photoBox").classList.toggle("active", st==="running");
|
|
}
|
|
|
|
function renderCats(counts){
|
|
const box=$("cats");
|
|
const densityBox=$("densityChart");
|
|
|
|
box.innerHTML="";
|
|
densityBox.innerHTML="";
|
|
|
|
// Calculate max count for charts scaling
|
|
let maxCount = 0;
|
|
for(const c in counts){
|
|
if(counts[c] > maxCount) maxCount = counts[c];
|
|
}
|
|
|
|
CATS.forEach(c=>{
|
|
const cnt = counts[c] ?? 0;
|
|
|
|
// Grid box item
|
|
const d=document.createElement("div"); d.className="cat"; d.dataset.cat=c;
|
|
d.innerHTML='<span class="n">'+c+'</span><span class="c">'+cnt+'</span>';
|
|
box.appendChild(d);
|
|
|
|
// Bar row item for chart
|
|
const pct = maxCount ? (cnt / maxCount * 100) : 0;
|
|
const br = document.createElement("div"); br.className="bar-row";
|
|
br.innerHTML=`<div class="bar-lbl" title="${c}">${c}</div>
|
|
<div class="bar-track"><div class="bar-fill" style="width:${pct}%"></div></div>
|
|
<div class="bar-val">${cnt}</div>`;
|
|
densityBox.appendChild(br);
|
|
});
|
|
}
|
|
|
|
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");
|
|
inspectPhoto(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 editCat = $("editCat");
|
|
editCat.innerHTML = "";
|
|
CATS.forEach(c=>{
|
|
const opt = document.createElement("option");
|
|
opt.value = c; opt.textContent = c;
|
|
editCat.appendChild(opt);
|
|
});
|
|
}
|
|
|
|
/* ---------- Search and Inspector ---------- */
|
|
async function doSearch(){
|
|
const q = $("searchInput").value;
|
|
const results = $("searchResults");
|
|
results.innerHTML = `<div style="grid-column:1/-1; text-align:center; padding:30px; color:var(--dim)">Searching...</div>`;
|
|
const j = await api("/api/search?q=" + encodeURIComponent(q));
|
|
if(j.results){
|
|
renderSearchResults(j.results);
|
|
}
|
|
}
|
|
|
|
$("btnSearch").onclick = doSearch;
|
|
$("searchInput").onkeydown = e => { if(e.key === "Enter") doSearch(); };
|
|
|
|
$("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 j = await api("/api/search?re_read=1&q=" + encodeURIComponent($("searchInput").value));
|
|
$("btnSyncMeta").disabled = false;
|
|
if(j.results) renderSearchResults(j.results);
|
|
}
|
|
};
|
|
|
|
function renderSearchResults(recs){
|
|
const box = $("searchResults");
|
|
box.innerHTML = "";
|
|
if(recs.length === 0){
|
|
box.innerHTML = `<div style="grid-column:1/-1; text-align:center; padding:50px; color:var(--dim)">No results found matching your query.</div>`;
|
|
return;
|
|
}
|
|
recs.forEach(rec=>{
|
|
const card = document.createElement("div");
|
|
card.className = "result-card";
|
|
card.onclick = () => inspectPhoto(rec);
|
|
card.innerHTML = `<img src="/api/thumbnail?path=${encodeURIComponent(rec.path)}">
|
|
<div class="result-info">
|
|
<div class="result-name" title="${rec.name}">${rec.name}</div>
|
|
<div class="result-desc" title="${rec.desc}">${rec.desc}</div>
|
|
<div class="result-cat">${rec.category}</div>
|
|
</div>`;
|
|
box.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function inspectPhoto(rec){
|
|
currentEditFile = rec.path;
|
|
$("editPreview").src = "/api/thumbnail?path=" + encodeURIComponent(rec.path);
|
|
$("editPreview").style.display = "block";
|
|
$("editNoSignal").style.display = "none";
|
|
$("editPath").textContent = rec.path;
|
|
$("editDesc").value = rec.desc;
|
|
$("editCat").value = rec.category;
|
|
|
|
$("btnSaveSingle").disabled = false;
|
|
$("btnRedoSingle").disabled = false;
|
|
$("btnRevealSingle").disabled = false;
|
|
}
|
|
|
|
$("btnSaveSingle").onclick = async ()=>{
|
|
if(!currentEditFile) return;
|
|
$("btnSaveSingle").disabled = true;
|
|
const j = await api("/api/write_single", {
|
|
path: currentEditFile,
|
|
desc: $("editDesc").value,
|
|
category: $("editCat").value
|
|
});
|
|
$("btnSaveSingle").disabled = false;
|
|
if(j.ok){
|
|
addLog({level:"ok",msg:`Updated ${os_basename(currentEditFile)} metadata manually.`,ts:new Date().toTimeString().slice(0,8)});
|
|
// Refresh search results
|
|
doSearch();
|
|
}
|
|
};
|
|
|
|
$("btnRedoSingle").onclick = async ()=>{
|
|
if(!currentEditFile) return;
|
|
if(confirm("Request AI model re-tagging for this photo?")){
|
|
$("btnRedoSingle").disabled = true;
|
|
const j = await api("/api/redo_single", {
|
|
path: currentEditFile,
|
|
settings: {
|
|
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")
|
|
}
|
|
});
|
|
$("btnRedoSingle").disabled = false;
|
|
if(j.ok){
|
|
$("editDesc").value = j.desc;
|
|
$("editCat").value = j.category;
|
|
addLog({level:"ok",msg:`AI Re-evaluated ${os_basename(currentEditFile)} successfully.`,ts:new Date().toTimeString().slice(0,8)});
|
|
doSearch();
|
|
}
|
|
}
|
|
};
|
|
|
|
$("btnRevealSingle").onclick = async ()=>{
|
|
if(!currentEditFile) return;
|
|
api("/api/reveal", {path: currentEditFile});
|
|
};
|
|
|
|
function os_basename(path){
|
|
return path.replace(/\\/g, '/').replace(/.*\//, '');
|
|
}
|
|
|
|
/* ---------- 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);
|
|
}
|
|
};
|
|
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();
|
|
connect();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|