Full project per spec: FastAPI backend, 4-method duplicate detection (SHA-256, phash, EXIF, filesize), Google Takeout pre-processor, 4 scan modes, and dark-theme vanilla JS gallery frontend. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1393 lines
47 KiB
HTML
1393 lines
47 KiB
HTML
<!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); }
|
||
#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-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; }
|
||
.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 ── */
|
||
#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>⌨</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">
|
||
✓ Reviewed
|
||
</div>
|
||
<div class="nav-item" data-view="export">
|
||
⇓ 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-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="discovery">Discovery</span>
|
||
<span class="phase-pill" data-phase="takeout">Takeout</span>
|
||
<span class="phase-pill" data-phase="indexing">Indexing</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="cancelScan()">Cancel</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">
|
||
</div>
|
||
<div class="rescan-buttons">
|
||
<div class="rescan-btn-group">
|
||
<button class="btn-primary btn-sm" onclick="startScan('incremental')">Scan new & 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 ⚠</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)">⬤ 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)">⬤ 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)">⬤ 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)">⬤ 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()">✕</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'">⇓ 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>
|
||
|
||
<!-- 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 = ['discovery','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';
|
||
el('progress-area').classList.toggle('show', isRunning);
|
||
el('first-scan-ui').style.display = (s.scan_id || isRunning) ? 'none' : '';
|
||
el('rescan-area').classList.toggle('show', !isRunning && !!s.scan_id);
|
||
|
||
if (isRunning) {
|
||
el('progress-msg').textContent = s.message || '';
|
||
const pct = s.total > 0 ? Math.round((s.progress / s.total) * 100) : 0;
|
||
el('progress-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 (s.scan_id && !isRunning) {
|
||
// 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 cancelScan() {
|
||
try {
|
||
await api('POST', '/api/scan/cancel');
|
||
showToast('Cancelling scan...');
|
||
} catch(e) {}
|
||
}
|
||
|
||
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">📷</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">▶</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">✓</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 ✓</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">✓</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> ·
|
||
${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">📁</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">✓ 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>';
|
||
}
|
||
}
|
||
|
||
// ── Keyboard shortcuts ────────────────────────────────────────────────────────
|
||
document.addEventListener('keydown', e => {
|
||
if (e.key === 'Escape') {
|
||
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);
|
||
if (s.status === 'running') startPoller();
|
||
} catch(e) {}
|
||
}
|
||
|
||
init();
|
||
// Refresh stats every 30s when idle
|
||
setInterval(() => {
|
||
if (state.scanStatus !== 'running') refreshStats();
|
||
}, 30000);
|
||
</script>
|
||
</body>
|
||
</html>
|