Files
duplicate-finder/templates/index.html
tocmo 356f922940 feat: replace Cancel with Pause/Resume — survives server restarts
- scanner.py: replace cancel_requested with pause_requested throughout;
  pause during walk drains in-flight futures gracefully then saves state;
  phash phase processes in 500-image chunks with pause check between each;
  _save_pause_state() persists files_indexed/phashes_done/last_phase to DB;
  init_db() already detects killed-mid-scan (running→paused) on startup

- main.py: add POST /api/scan/pause and POST /api/scan/resume endpoints;
  /api/scan/cancel kept as alias; scan_status now returns folder_path,
  files_indexed, phashes_done; scan_reset clears all new fields

- index.html: "Cancel" → "⏸ Pause" button; new #paused-area banner shows
  folder, files indexed, phashes done with "▶ Resume" and "Full reset"
  buttons; updateScanUI handles paused status; pauseScan()/resumeScan()
  JS functions added; chip gains .paused amber style

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 02:11:00 -04:00

1613 lines
54 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Duplicate Finder</title>
<style>
:root {
--bg: #0f0f17;
--surface: #1a1a2e;
--surface2: #16213e;
--border: #2a2a4a;
--text: #e0e0f0;
--text-dim: #8888aa;
--accent: #7c6cfc;
--accent2: #9b7de8;
--danger: #e05c5c;
--success: #4caf7d;
--warning: #e2a43a;
--c-sha256: #378ADD;
--c-phash: #9b7de8;
--c-exif: #e2a43a;
--c-size: #888780;
--radius: 8px;
--sidebar-w: 200px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', system-ui, sans-serif;
font-size: 14px;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Topbar ── */
#topbar {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
z-index: 10;
}
#topbar h1 { font-size: 16px; font-weight: 700; letter-spacing: .5px; }
#topbar h1 span { color: var(--accent); }
#scan-chip {
padding: 3px 10px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
background: var(--surface2);
border: 1px solid var(--border);
}
#scan-chip.running { border-color: var(--accent); color: var(--accent); }
#scan-chip.complete { border-color: var(--success); color: var(--success); }
#scan-chip.error { border-color: var(--danger); color: var(--danger); }
#scan-chip.cancelled { border-color: var(--warning); color: var(--warning); }
#scan-chip.paused { border-color: var(--warning); color: var(--warning); }
#topbar-stats { margin-left: auto; display: flex; gap: 20px; font-size: 12px; color: var(--text-dim); }
#topbar-stats span b { color: var(--text); }
/* ── Layout ── */
#layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Sidebar ── */
#sidebar {
width: var(--sidebar-w);
background: var(--surface);
border-right: 1px solid var(--border);
flex-shrink: 0;
display: flex;
flex-direction: column;
padding: 12px 0;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
cursor: pointer;
border-radius: 0;
font-size: 13px;
color: var(--text-dim);
transition: background .15s, color .15s;
position: relative;
}
.nav-item:hover { background: var(--surface2); color: var(--text); }
.nav-item.active { background: rgba(124,108,252,.15); color: var(--accent); }
.nav-item .dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.nav-sep { border-top: 1px solid var(--border); margin: 8px 12px; }
.nav-badge {
margin-left: auto;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 99px;
padding: 1px 7px;
font-size: 11px;
color: var(--text-dim);
}
/* ── Main content ── */
#main {
flex: 1;
overflow-y: auto;
padding: 24px;
}
/* ── View panels ── */
.view { display: none; }
.view.active { display: block; }
/* ── Cards ── */
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px;
margin-bottom: 28px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px 18px;
}
.stat-card .label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: .5px; }
.stat-card .value { font-size: 26px; font-weight: 700; margin-top: 4px; }
.stat-card .sub { font-size: 12px; color: var(--text-dim); margin-top: 2px; }
/* ── Section headers ── */
.section-title {
font-size: 13px;
font-weight: 600;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: .6px;
margin-bottom: 12px;
}
/* ── Scan form ── */
#scan-form-area {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
margin-bottom: 24px;
}
.input-row {
display: flex;
gap: 10px;
align-items: center;
}
input[type=text] {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
padding: 8px 12px;
font-size: 13px;
outline: none;
font-family: monospace;
}
input[type=text]:focus { border-color: var(--accent); }
button {
padding: 8px 16px;
border-radius: var(--radius);
border: none;
cursor: pointer;
font-size: 13px;
font-weight: 600;
transition: opacity .15s, background .15s;
}
button:hover { opacity: .85; }
button:disabled { opacity: .4; cursor: not-allowed; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-secondary { background: var(--surface2); color: var(--text); border: 1px solid var(--border); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-success { background: var(--success); color: #fff; }
.btn-warning { background: var(--warning); color: #000; }
.btn-sm { padding: 5px 12px; font-size: 12px; }
/* ── Progress ── */
#progress-area {
margin-top: 16px;
display: none;
}
#progress-area.show { display: block; }
.progress-bar-wrap {
background: var(--surface2);
border-radius: 99px;
height: 6px;
overflow: hidden;
margin: 8px 0;
}
.progress-bar-fill {
height: 100%;
border-radius: 99px;
background: var(--accent);
transition: width .3s;
}
.progress-bar-fill.indeterminate {
width: 40% !important;
animation: indeterminate 1.4s ease-in-out infinite;
}
@keyframes indeterminate {
0% { transform: translateX(-100%); }
100% { transform: translateX(300%); }
}
.progress-msg { font-size: 12px; color: var(--text-dim); }
.phase-pills {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 10px;
}
.phase-pill {
padding: 3px 10px;
border-radius: 99px;
font-size: 11px;
font-weight: 600;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text-dim);
}
.phase-pill.active { background: rgba(124,108,252,.2); border-color: var(--accent); color: var(--accent); }
.phase-pill.done { background: rgba(76,175,125,.1); border-color: var(--success); color: var(--success); }
/* ── Rescan buttons ── */
#rescan-area { display: none; margin-top: 16px; }
#rescan-area.show { display: block; }
#paused-area { display: none; margin-top: 16px; }
#paused-area.show { display: block; }
.pause-banner {
display: flex; align-items: flex-start; gap: 12px;
background: rgba(226,164,58,.1);
border: 1px solid rgba(226,164,58,.35);
border-radius: var(--radius);
padding: 12px 14px;
margin-bottom: 10px;
}
.pause-icon { font-size: 22px; line-height: 1; }
.pause-title { font-weight: 600; color: var(--warning); margin-bottom: 4px; }
.pause-details { font-size: 12px; color: var(--text-dim); line-height: 1.6; }
.rescan-info { font-size: 12px; color: var(--text-dim); margin-bottom: 10px; }
.rescan-buttons {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: flex-start;
}
.rescan-btn-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.rescan-btn-group .desc { font-size: 11px; color: var(--text-dim); max-width: 160px; }
/* ── Gallery ── */
.filter-bar {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
.filter-pill {
padding: 5px 14px;
border-radius: 99px;
font-size: 12px;
font-weight: 600;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-dim);
cursor: pointer;
transition: all .15s;
}
.filter-pill:hover { border-color: var(--text-dim); color: var(--text); }
.filter-pill.active { background: rgba(124,108,252,.2); border-color: var(--accent); color: var(--accent); }
.filter-pill .dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 5px; }
#auto-resolve-btn {
margin-left: auto;
}
.gallery-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.gallery-cell {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
cursor: pointer;
position: relative;
aspect-ratio: 1;
transition: border-color .15s, transform .1s;
}
.gallery-cell:hover { border-color: var(--accent); transform: scale(1.01); }
.gallery-cell.reviewed { opacity: .55; }
.gallery-cell.active { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
.gallery-cell img, .gallery-cell .vid-placeholder {
width: 100%; height: 100%;
object-fit: cover;
display: block;
}
.vid-placeholder {
background: var(--surface2);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent2);
font-size: 40px;
}
.cell-badge-method {
position: absolute;
top: 6px; left: 6px;
display: flex;
align-items: center;
gap: 4px;
background: rgba(0,0,0,.75);
border-radius: 99px;
padding: 2px 7px;
font-size: 10px;
font-weight: 700;
color: #fff;
}
.cell-badge-method .dot { width: 6px; height: 6px; border-radius: 50%; }
.cell-badge-count {
position: absolute;
top: 6px; right: 6px;
background: rgba(0,0,0,.8);
border-radius: 99px;
padding: 2px 8px;
font-size: 11px;
font-weight: 700;
color: #fff;
}
.cell-hover-info {
position: absolute;
bottom: 0; left: 0; right: 0;
background: linear-gradient(transparent, rgba(0,0,0,.85));
padding: 20px 8px 8px;
opacity: 0;
transition: opacity .15s;
font-size: 11px;
}
.gallery-cell:hover .cell-hover-info { opacity: 1; }
.cell-hover-info .fname { font-weight: 600; word-break: break-all; }
.cell-hover-info .res { color: var(--text-dim); }
.cell-check {
position: absolute;
bottom: 6px; right: 6px;
background: var(--success);
color: #fff;
border-radius: 50%;
width: 20px; height: 20px;
display: none;
align-items: center;
justify-content: center;
font-size: 12px;
}
.gallery-cell.reviewed .cell-check { display: flex; }
/* ── Load more ── */
#load-more-wrap { text-align: center; margin-top: 20px; }
/* ── Detail panel ── */
#detail-panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-top: 12px;
padding: 20px;
display: none;
}
#detail-panel.show { display: block; }
.detail-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.detail-header h3 { font-size: 15px; font-weight: 700; flex: 1; }
.detail-header .method-badge {
padding: 3px 10px;
border-radius: 99px;
font-size: 11px;
font-weight: 700;
color: #fff;
}
#detail-close {
background: none;
border: 1px solid var(--border);
color: var(--text-dim);
padding: 4px 10px;
border-radius: var(--radius);
font-size: 13px;
}
.copy-cards {
display: flex;
gap: 14px;
overflow-x: auto;
padding-bottom: 8px;
}
.copy-card {
background: var(--surface2);
border: 2px solid var(--border);
border-radius: var(--radius);
min-width: 200px;
max-width: 220px;
flex-shrink: 0;
overflow: hidden;
transition: border-color .15s;
}
.copy-card.suggested { border-color: var(--success); }
.copy-card.selected { border-color: var(--accent); }
.copy-card.dimmed { opacity: .4; }
.copy-card-thumb {
width: 100%;
aspect-ratio: 1;
object-fit: contain;
background: #111;
display: block;
}
.copy-card-body { padding: 10px; }
.copy-card-badges { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 6px; }
.badge {
padding: 2px 7px;
border-radius: 99px;
font-size: 10px;
font-weight: 700;
}
.badge-suggested { background: rgba(76,175,125,.2); color: var(--success); border: 1px solid var(--success); }
.badge-selected { background: rgba(124,108,252,.2); color: var(--accent); border: 1px solid var(--accent); }
.badge-takeout { background: rgba(226,164,58,.2); color: var(--warning); border: 1px solid var(--warning); }
.badge-edited { background: rgba(224,92,92,.2); color: var(--danger); border: 1px solid var(--danger); }
.meta-row {
display: flex;
justify-content: space-between;
font-size: 11px;
margin-bottom: 3px;
gap: 6px;
}
.meta-row .mk { color: var(--text-dim); flex-shrink: 0; }
.meta-row .mv { font-family: monospace; text-align: right; word-break: break-all; }
.meta-path {
font-family: monospace;
font-size: 10px;
color: var(--text-dim);
word-break: break-all;
margin-top: 6px;
border-top: 1px solid var(--border);
padding-top: 6px;
}
.keep-btn {
width: 100%;
margin-top: 8px;
background: var(--surface);
color: var(--text);
border: 1px solid var(--border);
padding: 6px;
font-size: 12px;
border-radius: var(--radius);
}
.keep-btn:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
.copy-card.selected .keep-btn { background: var(--accent); border-color: var(--accent); color: #fff; }
.detail-footer {
display: flex;
align-items: center;
gap: 10px;
margin-top: 16px;
flex-wrap: wrap;
}
.recoverable-info { margin-left: auto; font-size: 12px; color: var(--text-dim); }
/* ── Reviewed view ── */
.reviewed-group-row {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 12px 16px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 12px;
}
.reviewed-group-row .thumb-sm {
width: 48px; height: 48px;
object-fit: cover;
border-radius: 4px;
flex-shrink: 0;
}
.reviewed-group-row .info { flex: 1; min-width: 0; }
.reviewed-group-row .info .fname { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.reviewed-group-row .info .sub { font-size: 11px; color: var(--text-dim); }
/* ── Export view ── */
#export-view table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
#export-view th, #export-view td {
border: 1px solid var(--border);
padding: 6px 10px;
text-align: left;
}
#export-view th { background: var(--surface2); font-weight: 600; color: var(--text-dim); }
#export-view tr:hover td { background: rgba(255,255,255,.02); }
/* ── Confirm dialog ── */
/* ── Folder picker ── */
#picker-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,.75);
display: none;
align-items: center;
justify-content: center;
z-index: 110;
}
#picker-overlay.show { display: flex; }
#picker-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
width: 520px;
max-width: 95vw;
display: flex;
flex-direction: column;
max-height: 70vh;
}
#picker-header {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
#picker-header h3 { font-size: 14px; flex: 1; }
#picker-path {
padding: 8px 16px;
font-family: monospace;
font-size: 12px;
color: var(--text-dim);
background: var(--surface2);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#picker-list {
overflow-y: auto;
flex: 1;
padding: 6px 0;
}
.picker-row {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 16px;
cursor: pointer;
font-size: 13px;
transition: background .1s;
}
.picker-row:hover { background: var(--surface2); }
.picker-row .icon { color: var(--warning); font-size: 15px; flex-shrink: 0; }
.picker-row.up-row .icon { color: var(--text-dim); }
#picker-footer {
padding: 12px 16px;
border-top: 1px solid var(--border);
display: flex;
gap: 8px;
align-items: center;
flex-shrink: 0;
}
#picker-selected-path {
flex: 1;
font-family: monospace;
font-size: 12px;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 10px;
}
#confirm-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,.7);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
#confirm-overlay.show { display: flex; }
#confirm-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 28px;
max-width: 420px;
width: 90%;
}
#confirm-box h3 { margin-bottom: 10px; }
#confirm-box p { font-size: 13px; color: var(--text-dim); margin-bottom: 16px; }
#confirm-input { width: 100%; margin-bottom: 14px; }
/* ── Toast ── */
#toast {
position: fixed;
bottom: 24px; right: 24px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 18px;
font-size: 13px;
opacity: 0;
transition: opacity .2s;
z-index: 200;
pointer-events: none;
}
#toast.show { opacity: 1; }
/* ── Empty state ── */
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-dim);
}
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
.empty-state h3 { color: var(--text); margin-bottom: 6px; }
/* ── Utils ── */
.flex { display: flex; }
.gap8 { gap: 8px; }
.mt8 { margin-top: 8px; }
.mt16 { margin-top: 16px; }
.text-dim { color: var(--text-dim); }
.text-success { color: var(--success); }
.text-danger { color: var(--danger); }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
/* scrollbar */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<!-- Topbar -->
<div id="topbar">
<h1>Dup<span>Finder</span></h1>
<div id="scan-chip">idle</div>
<div id="topbar-stats">
<span><b id="ts-files"></b> files</span>
<span><b id="ts-groups"></b> groups</span>
<span><b id="ts-pending"></b> pending</span>
<span><b id="ts-size"></b> recoverable</span>
</div>
</div>
<!-- Layout -->
<div id="layout">
<!-- Sidebar -->
<nav id="sidebar">
<div class="nav-item active" data-view="dashboard">
<span>&#9000;</span> Dashboard
</div>
<div class="nav-sep"></div>
<div class="nav-item" data-view="gallery" data-method="all">
<span class="dot" style="background:var(--accent)"></span> All Groups
<span class="nav-badge" id="nb-all">0</span>
</div>
<div class="nav-item" data-view="gallery" data-method="sha256">
<span class="dot" style="background:var(--c-sha256)"></span> Exact
<span class="nav-badge" id="nb-sha256">0</span>
</div>
<div class="nav-item" data-view="gallery" data-method="phash">
<span class="dot" style="background:var(--c-phash)"></span> Visual
<span class="nav-badge" id="nb-phash">0</span>
</div>
<div class="nav-item" data-view="gallery" data-method="exif">
<span class="dot" style="background:var(--c-exif)"></span> EXIF
<span class="nav-badge" id="nb-exif">0</span>
</div>
<div class="nav-item" data-view="gallery" data-method="filesize">
<span class="dot" style="background:var(--c-size)"></span> Size
<span class="nav-badge" id="nb-size">0</span>
</div>
<div class="nav-sep"></div>
<div class="nav-item" data-view="reviewed">
&#10003; Reviewed
</div>
<div class="nav-item" data-view="export">
&#8659; Export
</div>
</nav>
<!-- Main -->
<main id="main">
<!-- Dashboard -->
<div id="view-dashboard" class="view active">
<div class="stat-cards">
<div class="stat-card">
<div class="label">Total files</div>
<div class="value" id="s-total"></div>
<div class="sub" id="s-total-size"></div>
</div>
<div class="stat-card">
<div class="label">Duplicate groups</div>
<div class="value" id="s-groups"></div>
<div class="sub" id="s-groups-sub"></div>
</div>
<div class="stat-card">
<div class="label">Space recoverable</div>
<div class="value" id="s-recov"></div>
<div class="sub" id="s-recov-sub">if redundant removed</div>
</div>
<div class="stat-card">
<div class="label">Reviewed</div>
<div class="value" id="s-reviewed"></div>
<div class="sub" id="s-reviewed-sub"></div>
</div>
</div>
<div id="scan-form-area">
<div class="section-title">Scan library</div>
<div id="first-scan-ui">
<div class="input-row">
<input type="text" id="folder-input" placeholder="/photos/MyLibrary" value="/photos">
<button class="btn-secondary" onclick="openPicker('folder-input')" title="Browse folders">&#128193;</button>
<button class="btn-primary" id="start-scan-btn" onclick="startScan('incremental')">Start Scan</button>
</div>
</div>
<div id="progress-area">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
<span class="progress-msg" id="progress-msg">Scanning...</span>
<span class="progress-msg" id="progress-count"></span>
</div>
<div class="progress-bar-wrap">
<div class="progress-bar-fill" id="progress-fill" style="width:0%"></div>
</div>
<div class="phase-pills">
<span class="phase-pill" data-phase="takeout">Takeout</span>
<span class="phase-pill" data-phase="indexing">Discover + Index</span>
<span class="phase-pill" data-phase="phash">Phash</span>
<span class="phase-pill" data-phase="grouping">Grouping</span>
</div>
<div class="mt8">
<button class="btn-secondary btn-sm" onclick="pauseScan()">&#9646;&#9646; Pause</button>
</div>
</div>
<div id="paused-area">
<div class="pause-banner">
<div class="pause-icon">&#9646;&#9646;</div>
<div class="pause-info">
<div class="pause-title">Scan paused</div>
<div id="pause-details" class="pause-details"></div>
</div>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn-primary btn-sm" onclick="resumeScan()">&#9654; Resume</button>
<button class="btn-danger btn-sm" onclick="confirmFullReset()">Full reset &#9888;</button>
</div>
</div>
<div id="rescan-area">
<div class="rescan-info" id="rescan-info-text"></div>
<div class="input-row" style="margin-bottom:10px;">
<input type="text" id="rescan-folder-input" placeholder="/photos">
<button class="btn-secondary" onclick="openPicker('rescan-folder-input')" title="Browse folders">&#128193;</button>
</div>
<div class="rescan-buttons">
<div class="rescan-btn-group">
<button class="btn-primary btn-sm" onclick="startScan('incremental')">Scan new &amp; changed</button>
<span class="desc">Only processes files added or modified since last scan. Prior decisions preserved.</span>
</div>
<div class="rescan-btn-group">
<button class="btn-secondary btn-sm" onclick="startScan('new_files')">Add new files</button>
<span class="desc">Indexes newly added files only. Existing decisions untouched.</span>
</div>
<div class="rescan-btn-group">
<button class="btn-secondary btn-sm" onclick="startScan('regroup')">Rebuild groups</button>
<span class="desc">Re-runs duplicate detection on existing index. No re-hashing.</span>
</div>
<div class="rescan-btn-group" style="margin-left:auto;">
<button class="btn-danger btn-sm" onclick="confirmFullReset()">Full reset &#9888;</button>
<span class="desc">Wipes all data and re-scans everything.</span>
</div>
</div>
</div>
</div>
<!-- Method breakdown -->
<div class="section-title">Breakdown by method</div>
<div class="stat-cards" id="method-cards">
<div class="stat-card">
<div class="label" style="color:var(--c-sha256)">&#11044; Exact copies (SHA-256)</div>
<div class="value" id="mc-sha256"></div>
<div class="sub" id="mc-sha256-files">— files</div>
</div>
<div class="stat-card">
<div class="label" style="color:var(--c-phash)">&#11044; Visual match (phash)</div>
<div class="value" id="mc-phash"></div>
<div class="sub" id="mc-phash-files">— files</div>
</div>
<div class="stat-card">
<div class="label" style="color:var(--c-exif)">&#11044; Same moment (EXIF)</div>
<div class="value" id="mc-exif"></div>
<div class="sub" id="mc-exif-files">— files</div>
</div>
<div class="stat-card">
<div class="label" style="color:var(--c-size)">&#11044; Possible (size+dims)</div>
<div class="value" id="mc-size"></div>
<div class="sub" id="mc-size-files">— files</div>
</div>
</div>
</div>
<!-- Gallery -->
<div id="view-gallery" class="view">
<div class="filter-bar">
<span class="filter-pill active" data-method="all">All</span>
<span class="filter-pill" data-method="sha256"><span class="dot" style="background:var(--c-sha256)"></span>Exact</span>
<span class="filter-pill" data-method="phash"><span class="dot" style="background:var(--c-phash)"></span>Visual</span>
<span class="filter-pill" data-method="exif"><span class="dot" style="background:var(--c-exif)"></span>EXIF</span>
<span class="filter-pill" data-method="filesize"><span class="dot" style="background:var(--c-size)"></span>Size</span>
<button class="btn-primary btn-sm" id="auto-resolve-btn" onclick="autoResolveExact()" style="margin-left:auto">
Auto-resolve exact copies
</button>
</div>
<div class="gallery-grid" id="gallery-grid"></div>
<div id="detail-panel">
<div class="detail-header">
<h3 id="detail-title"></h3>
<span class="method-badge" id="detail-method-badge"></span>
<button id="detail-close" onclick="closeDetail()">&#10005;</button>
</div>
<div class="copy-cards" id="copy-cards"></div>
<div class="detail-footer">
<button class="btn-success btn-sm" id="confirm-btn" onclick="confirmDecision()" disabled>Confirm selection</button>
<button class="btn-secondary btn-sm" onclick="skipGroup()">Skip group</button>
<button class="btn-secondary btn-sm" onclick="keepAll()">Keep all</button>
<span class="recoverable-info" id="recoverable-info"></span>
</div>
</div>
<div id="load-more-wrap" style="display:none">
<button class="btn-secondary" onclick="loadMoreGroups()">Load more</button>
</div>
</div>
<!-- Reviewed -->
<div id="view-reviewed" class="view">
<div id="reviewed-list"></div>
</div>
<!-- Export -->
<div id="view-export" class="view">
<div style="margin-bottom:16px;display:flex;gap:10px;align-items:center;">
<button class="btn-primary" onclick="window.location='/api/export/csv'">&#8659; Download CSV</button>
<span class="text-dim" style="font-size:12px;">No files are moved or deleted. This tool only records decisions.</span>
</div>
<div id="export-table-wrap"></div>
</div>
</main>
</div>
<!-- Folder picker -->
<div id="picker-overlay">
<div id="picker-box">
<div id="picker-header">
<h3>Browse for folder</h3>
<button class="btn-secondary btn-sm" onclick="closePicker()">&#10005;</button>
</div>
<div id="picker-path">/</div>
<div id="picker-list"></div>
<div id="picker-footer">
<input type="text" id="picker-selected-path" placeholder="selected path">
<button class="btn-primary btn-sm" onclick="confirmPicker()">Select</button>
<button class="btn-secondary btn-sm" onclick="closePicker()">Cancel</button>
</div>
</div>
</div>
<!-- Confirm dialog -->
<div id="confirm-overlay">
<div id="confirm-box">
<h3 id="confirm-title">Confirm action</h3>
<p id="confirm-desc">Are you sure?</p>
<input type="text" id="confirm-input" class="btn-sm" placeholder='Type RESET to confirm' style="display:none">
<div style="display:flex;gap:8px;">
<button class="btn-danger" id="confirm-ok-btn" onclick="confirmOk()">Confirm</button>
<button class="btn-secondary" onclick="closeConfirm()">Cancel</button>
</div>
</div>
</div>
<!-- Toast -->
<div id="toast"></div>
<script>
// ── State ─────────────────────────────────────────────────────────────────────
const state = {
currentView: 'dashboard',
galleryMethod: 'all',
galleryOffset: 0,
galleryLimit: 50,
galleryTotal: 0,
galleryGroups: [],
activeGroupId: null,
activeGroupData: null,
selectedKeeperId: null,
scanStatus: 'idle',
lastScanInfo: null,
confirmCallback: null,
phaseOrder: ['discovery','takeout','indexing','phash','grouping'],
};
// ── Utils ─────────────────────────────────────────────────────────────────────
function fmt(n) { return n == null ? '—' : n.toLocaleString(); }
function fmtBytes(b) {
if (b == null) return '—';
if (b >= 1e9) return (b/1e9).toFixed(1) + ' GB';
if (b >= 1e6) return (b/1e6).toFixed(1) + ' MB';
if (b >= 1e3) return (b/1e3).toFixed(1) + ' KB';
return b + ' B';
}
function el(id) { return document.getElementById(id); }
function showToast(msg, ms=2500) {
const t = el('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), ms);
}
async function api(method, path, body) {
const opts = { method, headers: {} };
if (body) { opts.body = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; }
const res = await fetch(path, opts);
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(err.detail || res.statusText);
}
return res.json();
}
// ── Navigation ────────────────────────────────────────────────────────────────
document.querySelectorAll('.nav-item').forEach(item => {
item.addEventListener('click', () => {
const view = item.dataset.view;
const method = item.dataset.method;
if (view === 'gallery' && method) {
state.galleryMethod = method;
el('view-gallery').querySelectorAll('.filter-pill').forEach(p => {
p.classList.toggle('active', p.dataset.method === method);
});
}
switchView(view);
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
item.classList.add('active');
});
});
document.querySelectorAll('.filter-pill').forEach(pill => {
pill.addEventListener('click', () => {
document.querySelectorAll('.filter-pill').forEach(p => p.classList.remove('active'));
pill.classList.add('active');
state.galleryMethod = pill.dataset.method;
loadGallery(true);
});
});
function switchView(view) {
state.currentView = view;
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
el(`view-${view}`).classList.add('active');
closeDetail();
if (view === 'gallery') loadGallery(true);
if (view === 'reviewed') loadReviewed();
if (view === 'export') loadExport();
}
// ── Stats + topbar refresh ────────────────────────────────────────────────────
async function refreshStats() {
try {
const s = await api('GET', '/api/stats');
el('s-total').textContent = fmt(s.total_files);
el('s-total-size').textContent = fmtBytes(s.total_size_bytes);
el('s-groups').textContent = fmt(s.reviewed + s.pending);
el('s-groups-sub').textContent = `${fmt(s.pending)} pending`;
el('s-reviewed').textContent = fmt(s.reviewed);
el('s-reviewed-sub').textContent = `of ${fmt(s.reviewed + s.pending)} total`;
el('s-recov').textContent = fmtBytes(s.duplicate_size_bytes);
const gm = s.groups_by_method || {};
const total = (gm.sha256?.groups||0)+(gm.phash?.groups||0)+(gm.exif?.groups||0)+(gm.filesize?.groups||0);
el('nb-all').textContent = total;
el('nb-sha256').textContent = gm.sha256?.groups || 0;
el('nb-phash').textContent = gm.phash?.groups || 0;
el('nb-exif').textContent = gm.exif?.groups || 0;
el('nb-size').textContent = gm.filesize?.groups || 0;
el('mc-sha256').textContent = fmt(gm.sha256?.groups);
el('mc-sha256-files').textContent = fmt(gm.sha256?.files) + ' files';
el('mc-phash').textContent = fmt(gm.phash?.groups);
el('mc-phash-files').textContent = fmt(gm.phash?.files) + ' files';
el('mc-exif').textContent = fmt(gm.exif?.groups);
el('mc-exif-files').textContent = fmt(gm.exif?.files) + ' files';
el('mc-size').textContent = fmt(gm.filesize?.groups);
el('mc-size-files').textContent = fmt(gm.filesize?.files) + ' files';
el('ts-files').textContent = fmt(s.total_files);
el('ts-groups').textContent = fmt(s.reviewed + s.pending);
el('ts-pending').textContent = fmt(s.pending);
el('ts-size').textContent = fmtBytes(s.duplicate_size_bytes);
} catch(e) {}
}
// ── Scan polling ──────────────────────────────────────────────────────────────
let scanPoller = null;
const PHASES = ['takeout','indexing','phash','grouping'];
function startPoller() {
if (scanPoller) return;
scanPoller = setInterval(pollScan, 800);
}
function stopPoller() {
clearInterval(scanPoller);
scanPoller = null;
}
async function pollScan() {
try {
const s = await api('GET', '/api/scan/status');
state.scanStatus = s.status;
updateScanUI(s);
if (s.status !== 'running') {
stopPoller();
refreshStats();
if (s.status === 'complete') {
showToast('Scan complete!');
if (state.currentView === 'gallery') loadGallery(true);
}
}
} catch(e) {}
}
function updateScanUI(s) {
const chip = el('scan-chip');
chip.textContent = s.status;
chip.className = '';
chip.classList.add(s.status);
const isRunning = s.status === 'running';
const isPaused = s.status === 'paused';
el('progress-area').classList.toggle('show', isRunning);
el('paused-area').classList.toggle('show', isPaused);
el('first-scan-ui').style.display = (s.scan_id || isRunning || isPaused) ? 'none' : '';
el('rescan-area').classList.toggle('show', !isRunning && !isPaused && !!s.scan_id);
if (isRunning) {
el('progress-msg').textContent = s.message || '';
const indeterminate = s.phase === 'takeout' || s.total === 0;
const fill = el('progress-fill');
fill.classList.toggle('indeterminate', indeterminate);
if (!indeterminate) {
const pct = Math.round((s.progress / s.total) * 100);
fill.style.width = pct + '%';
}
el('progress-count').textContent = s.total > 0 ? `${fmt(s.progress)} / ${fmt(s.total)}` : '';
const phaseIdx = PHASES.indexOf(s.phase);
document.querySelectorAll('.phase-pill').forEach((pill, i) => {
pill.classList.remove('active','done');
if (i < phaseIdx) pill.classList.add('done');
else if (i === phaseIdx) pill.classList.add('active');
});
}
if (isPaused) {
const parts = [];
if (s.folder_path) parts.push(`Folder: ${s.folder_path}`);
if (s.files_indexed) parts.push(`${fmt(s.files_indexed)} files indexed`);
if (s.phashes_done) parts.push(`${fmt(s.phashes_done)} phashes computed`);
if (s.message) parts.push(s.message);
el('pause-details').textContent = parts.join(' · ') || 'Progress saved';
}
if (s.scan_id && !isRunning && !isPaused) {
// populate rescan folder from last scan
el('rescan-folder-input').value = el('folder-input').value || '/photos';
}
// Update stats in topbar
if (s.stats) {
el('ts-files').textContent = fmt(s.stats.total_files);
el('ts-groups').textContent = fmt(s.stats.total_groups);
el('ts-pending').textContent = fmt(s.stats.pending);
}
}
function getFolderPath() {
const rescanField = el('rescan-folder-input');
const firstField = el('folder-input');
const rescanVisible = el('rescan-area').classList.contains('show');
return rescanVisible ? rescanField.value.trim() : firstField.value.trim();
}
async function startScan(mode) {
const folder = getFolderPath();
if (!folder) { showToast('Enter a folder path first.'); return; }
try {
const r = await api('POST', '/api/scan/start', { folder_path: folder, mode });
state.scanStatus = 'running';
showToast('Scan started');
startPoller();
} catch(e) {
showToast('Error: ' + e.message, 4000);
}
}
async function pauseScan() {
try {
await api('POST', '/api/scan/pause');
showToast('Pausing scan — finishing in-flight work...');
} catch(e) { showToast('Error: ' + e.message, 3000); }
}
async function resumeScan() {
try {
await api('POST', '/api/scan/resume');
state.scanStatus = 'running';
showToast('Resuming scan...');
startPoller();
} catch(e) { showToast('Error: ' + e.message, 4000); }
}
function confirmFullReset() {
el('confirm-title').textContent = 'Full reset';
el('confirm-desc').textContent = 'This will delete ALL scan results and decisions. Type RESET to confirm.';
el('confirm-input').style.display = '';
el('confirm-input').value = '';
state.confirmCallback = async () => {
if (el('confirm-input').value !== 'RESET') { showToast('Type RESET to confirm'); return false; }
await startScan('full_reset');
return true;
};
el('confirm-overlay').classList.add('show');
}
async function confirmOk() {
if (state.confirmCallback) {
const ok = await state.confirmCallback();
if (ok !== false) closeConfirm();
} else {
closeConfirm();
}
}
function closeConfirm() {
el('confirm-overlay').classList.remove('show');
state.confirmCallback = null;
el('confirm-input').style.display = 'none';
}
// ── Gallery ───────────────────────────────────────────────────────────────────
const METHOD_COLOR = { sha256:'#378ADD', phash:'#9b7de8', exif:'#e2a43a', filesize:'#888780' };
const METHOD_LABEL = { sha256:'Exact copy', phash:'Visual match', exif:'Same moment', filesize:'Possible match' };
async function loadGallery(reset=false) {
if (reset) {
state.galleryOffset = 0;
state.galleryGroups = [];
el('gallery-grid').innerHTML = '';
closeDetail();
}
try {
const params = new URLSearchParams({
method: state.galleryMethod,
reviewed: 'false',
sort: 'count',
offset: state.galleryOffset,
limit: state.galleryLimit,
});
const data = await api('GET', `/api/groups?${params}`);
state.galleryTotal = data.total;
state.galleryGroups.push(...data.groups);
renderGallery(data.groups, reset);
state.galleryOffset += data.groups.length;
el('load-more-wrap').style.display =
state.galleryOffset < state.galleryTotal ? '' : 'none';
} catch(e) {}
}
function loadMoreGroups() { loadGallery(false); }
function renderGallery(groups, reset) {
const grid = el('gallery-grid');
if (reset && groups.length === 0) {
grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1">
<div class="icon">&#128247;</div>
<h3>No duplicate groups found</h3>
<p>Run a scan to detect duplicates, or all groups have been reviewed.</p>
</div>`;
return;
}
groups.forEach(g => {
const cell = document.createElement('div');
cell.className = 'gallery-cell' + (g.reviewed ? ' reviewed' : '');
cell.dataset.groupId = g.id;
const color = METHOD_COLOR[g.method] || '#888';
const label = METHOD_LABEL[g.method] || g.method;
const sk = g.suggested_keeper;
const thumbUrl = sk ? sk.thumb_url : '';
const fname = sk ? sk.filename : '';
const res = sk && sk.width ? `${sk.width}×${sk.height}` : '';
cell.innerHTML = `
${thumbUrl
? `<img src="${thumbUrl}" alt="${fname}" loading="lazy" onerror="this.parentElement.querySelector('.vid-placeholder')&&(this.style.display='none')">`
: `<div class="vid-placeholder">&#9654;</div>`
}
<div class="cell-badge-method">
<span class="dot" style="background:${color}"></span>${label}
</div>
<div class="cell-badge-count">${g.member_count} copies</div>
<div class="cell-hover-info">
<div class="fname">${fname}</div>
<div class="res">${res}</div>
</div>
<div class="cell-check">&#10003;</div>
`;
cell.addEventListener('click', () => openGroup(g.id, cell));
grid.appendChild(cell);
});
}
async function openGroup(groupId, cellEl) {
if (state.activeGroupId === groupId) {
closeDetail();
return;
}
document.querySelectorAll('.gallery-cell').forEach(c => c.classList.remove('active'));
if (cellEl) cellEl.classList.add('active');
state.activeGroupId = groupId;
state.selectedKeeperId = null;
el('confirm-btn').disabled = true;
try {
const g = await api('GET', `/api/groups/${groupId}`);
state.activeGroupData = g;
renderDetail(g);
// Insert detail panel after the row containing the clicked cell
const panel = el('detail-panel');
const grid = el('gallery-grid');
if (cellEl) {
// find row end
const cellRect = cellEl.getBoundingClientRect();
const gridRect = grid.getBoundingClientRect();
const cells = Array.from(grid.children).filter(c => c.classList.contains('gallery-cell'));
const cols = Math.round(grid.offsetWidth / (cellEl.offsetWidth + 12));
const cellIdx = cells.indexOf(cellEl);
const rowEnd = Math.min(Math.ceil((cellIdx + 1) / cols) * cols, cells.length);
const afterCell = cells[rowEnd - 1];
grid.parentNode.insertBefore(panel, afterCell.nextSibling || el('load-more-wrap'));
}
panel.classList.add('show');
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch(e) {
showToast('Failed to load group: ' + e.message);
}
}
function renderDetail(g) {
el('detail-title').textContent = g.members[0]?.filename || 'Group ' + g.id;
const badge = el('detail-method-badge');
badge.textContent = METHOD_LABEL[g.method] || g.method;
badge.style.background = METHOD_COLOR[g.method] || '#888';
const cards = el('copy-cards');
cards.innerHTML = '';
g.members.forEach(m => {
const card = document.createElement('div');
card.className = 'copy-card' + (m.is_suggested ? ' suggested' : '');
card.dataset.fileId = m.file_id;
const badges = [];
if (m.is_suggested) badges.push(`<span class="badge badge-suggested">Suggested</span>`);
if (m.is_keeper) badges.push(`<span class="badge badge-selected">Selected &#10003;</span>`);
if (m.is_takeout) badges.push(`<span class="badge badge-takeout">Takeout</span>`);
if (m.is_edited) badges.push(`<span class="badge badge-edited">Edited</span>`);
const res = m.width ? `${m.width}×${m.height}` : '—';
card.innerHTML = `
<img class="copy-card-thumb" src="${m.thumb_url}" alt="${m.filename}" loading="lazy">
<div class="copy-card-body">
<div class="copy-card-badges">${badges.join('')}</div>
<div class="meta-row"><span class="mk">Resolution</span><span class="mv">${res}</span></div>
<div class="meta-row"><span class="mk">Size</span><span class="mv">${fmtBytes(m.file_size)}</span></div>
<div class="meta-row"><span class="mk">Format</span><span class="mv">${m.extension||m.mime_type||'—'}</span></div>
<div class="meta-row"><span class="mk">Device</span><span class="mv">${m.exif_device||'—'}</span></div>
<div class="meta-row"><span class="mk">Date</span><span class="mv">${m.exif_datetime||'—'}</span></div>
<div class="meta-path" title="${m.path}">${m.path}</div>
<button class="keep-btn" onclick="selectKeeper(${m.file_id})">Keep this</button>
</div>
`;
if (m.is_keeper) {
state.selectedKeeperId = m.file_id;
card.classList.add('selected');
el('confirm-btn').disabled = false;
}
cards.appendChild(card);
});
updateRecoverableInfo();
}
function selectKeeper(fileId) {
state.selectedKeeperId = fileId;
el('confirm-btn').disabled = false;
document.querySelectorAll('.copy-card').forEach(card => {
const fid = parseInt(card.dataset.fileId);
card.classList.remove('selected','dimmed');
if (fid === fileId) card.classList.add('selected');
else card.classList.add('dimmed');
});
updateRecoverableInfo();
}
function updateRecoverableInfo() {
const g = state.activeGroupData;
if (!g) return;
const total = g.members.reduce((s,m) => s + (m.file_size||0), 0);
const keeperSize = g.members.find(m => m.file_id === state.selectedKeeperId)?.file_size || 0;
const recoverable = total - keeperSize;
const sel = state.selectedKeeperId ? `Keeping 1 of ${g.members.length}` : `${g.members.length} copies`;
el('recoverable-info').textContent = `${sel} · ${fmtBytes(recoverable)} recoverable`;
}
function closeDetail() {
el('detail-panel').classList.remove('show');
document.querySelectorAll('.gallery-cell').forEach(c => c.classList.remove('active'));
state.activeGroupId = null;
state.activeGroupData = null;
state.selectedKeeperId = null;
}
async function confirmDecision() {
if (!state.activeGroupId || !state.selectedKeeperId) return;
try {
await api('POST', `/api/groups/${state.activeGroupId}/decide`, { keeper_file_id: state.selectedKeeperId });
showToast('Decision saved');
markGroupReviewed(state.activeGroupId);
closeDetail();
refreshStats();
} catch(e) {
showToast('Error: ' + e.message);
}
}
async function skipGroup() {
if (!state.activeGroupId) return;
try {
await api('POST', `/api/groups/${state.activeGroupId}/skip`);
showToast('Group skipped');
markGroupReviewed(state.activeGroupId);
closeDetail();
refreshStats();
} catch(e) {}
}
async function keepAll() {
if (!state.activeGroupId) return;
try {
await api('POST', `/api/groups/${state.activeGroupId}/keep-all`);
showToast('All marked as keepers');
markGroupReviewed(state.activeGroupId);
closeDetail();
refreshStats();
} catch(e) {}
}
function markGroupReviewed(groupId) {
const cell = document.querySelector(`.gallery-cell[data-group-id="${groupId}"]`);
if (cell) cell.classList.add('reviewed');
// Remove from unreviewed list
state.galleryGroups = state.galleryGroups.filter(g => g.id !== groupId);
}
async function autoResolveExact() {
try {
const r = await api('POST', '/api/groups/auto-resolve-exact');
showToast(`Auto-resolved ${r.resolved} exact duplicate groups`);
loadGallery(true);
refreshStats();
} catch(e) {
showToast('Error: ' + e.message);
}
}
// ── Reviewed view ─────────────────────────────────────────────────────────────
async function loadReviewed() {
const list = el('reviewed-list');
list.innerHTML = '<div class="text-dim" style="padding:20px">Loading...</div>';
try {
const data = await api('GET', '/api/groups?reviewed=true&limit=100&offset=0');
if (data.groups.length === 0) {
list.innerHTML = `<div class="empty-state"><div class="icon">&#10003;</div><h3>No reviewed groups yet</h3></div>`;
return;
}
list.innerHTML = '';
for (const g of data.groups) {
const row = document.createElement('div');
row.className = 'reviewed-group-row';
const sk = g.suggested_keeper;
const label = METHOD_LABEL[g.method] || g.method;
const color = METHOD_COLOR[g.method] || '#888';
row.innerHTML = `
${sk ? `<img class="thumb-sm" src="${sk.thumb_url}" alt="">` : ''}
<div class="info">
<div class="fname">${sk?.filename || 'Group ' + g.id}</div>
<div class="sub">
<span style="color:${color}">${label}</span> &middot;
${g.member_count} copies
</div>
</div>
<button class="btn-secondary btn-sm" onclick="unreviewGroup(${g.id}, this)">Undo</button>
`;
list.appendChild(row);
}
} catch(e) {
list.innerHTML = '<div class="text-dim">Failed to load.</div>';
}
}
async function unreviewGroup(groupId, btn) {
try {
await api('POST', `/api/groups/${groupId}/unreview`);
btn.closest('.reviewed-group-row').remove();
showToast('Group un-reviewed');
refreshStats();
} catch(e) {}
}
// ── Export view ───────────────────────────────────────────────────────────────
async function loadExport() {
const wrap = el('export-table-wrap');
wrap.innerHTML = '<div class="text-dim" style="padding:20px">Loading...</div>';
try {
const data = await api('GET', '/api/groups?limit=100&offset=0&reviewed=all');
if (!data.groups.length) {
wrap.innerHTML = '<div class="empty-state"><div class="icon">&#128193;</div><h3>No groups yet</h3></div>';
return;
}
const rows = data.groups.map(g => `
<tr>
<td>${g.id}</td>
<td><span style="color:${g.method_color}">${g.method_label}</span></td>
<td>${g.member_count}</td>
<td>${g.reviewed ? '<span class="text-success">&#10003; Reviewed</span>' : '<span class="text-dim">Pending</span>'}</td>
<td>${g.suggested_keeper?.filename || '—'}</td>
</tr>
`).join('');
wrap.innerHTML = `
<table>
<thead><tr><th>ID</th><th>Method</th><th>Copies</th><th>Status</th><th>Suggested keeper</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<p class="text-dim mt16" style="font-size:12px;">
Showing ${data.groups.length} of ${data.total} groups.
Download the CSV for full details including file paths and decisions.
</p>
`;
} catch(e) {
wrap.innerHTML = '<div class="text-dim">Failed to load.</div>';
}
}
// ── Folder picker ─────────────────────────────────────────────────────────────
let _pickerTargetId = null;
async function openPicker(inputId) {
_pickerTargetId = inputId;
const currentVal = el(inputId).value.trim() || '/';
el('picker-overlay').classList.add('show');
await pickerNavigate(currentVal);
}
function closePicker() {
el('picker-overlay').classList.remove('show');
_pickerTargetId = null;
}
function confirmPicker() {
const path = el('picker-selected-path').value.trim();
if (path && _pickerTargetId) {
el(_pickerTargetId).value = path;
}
closePicker();
}
async function pickerNavigate(path) {
try {
const data = await api('GET', `/api/browse?path=${encodeURIComponent(path)}`);
el('picker-path').textContent = data.current;
el('picker-selected-path').value = data.current;
const list = el('picker-list');
list.innerHTML = '';
// Up button
if (data.parent) {
const row = document.createElement('div');
row.className = 'picker-row up-row';
row.innerHTML = `<span class="icon">&#8593;</span> <span>..</span>`;
row.onclick = () => pickerNavigate(data.parent);
list.appendChild(row);
}
if (data.dirs.length === 0) {
list.innerHTML += `<div class="picker-row text-dim" style="cursor:default">No subfolders</div>`;
}
data.dirs.forEach(name => {
const row = document.createElement('div');
row.className = 'picker-row';
const fullPath = data.current.replace(/\\/g, '/').replace(/\/$/, '') + '/' + name;
row.innerHTML = `<span class="icon">&#128193;</span> <span>${name}</span>`;
row.onclick = () => {
el('picker-selected-path').value = fullPath;
pickerNavigate(fullPath);
};
list.appendChild(row);
});
} catch(e) {
el('picker-list').innerHTML = `<div class="picker-row text-dim">Cannot open this path.</div>`;
}
}
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
if (el('picker-overlay').classList.contains('show')) closePicker();
else if (el('confirm-overlay').classList.contains('show')) closeConfirm();
else closeDetail();
}
});
// ── Init ──────────────────────────────────────────────────────────────────────
async function init() {
await refreshStats();
// Check if there's an existing scan
try {
const s = await api('GET', '/api/scan/status');
updateScanUI(s);
state.scanStatus = s.status;
if (s.status === 'running') startPoller();
} catch(e) {}
}
init();
// Refresh stats every 30s when idle
setInterval(() => {
if (state.scanStatus !== 'running') refreshStats();
}, 30000);
</script>
</body>
</html>