Files
chrmc40 8e3dceae99 ComfyUI-PromptChain
A prompt-authoring and image-iteration suite for ComfyUI.
2026-06-17 19:35:25 -07:00

278 lines
10 KiB
Svelte
Raw Permalink 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.
<script>
import { getContext } from "svelte";
import { portal } from "../../lib/portal.js";
import "./modal-shared.css";
let { open, scope, path = "", fetchApi, onSubscribeDedup, onClose, onDeleted } = $props();
const apiURL = getContext("pcr-apiURL");
let phase = $state("idle"); // idle | scanning | results | deleting | error
let progress = $state(null); // { done, total }
let clusters = $state([]);
let totalImages = $state(0);
let checked = $state(new Set());
let threshold = $state(5);
let errorMsg = $state("");
let scanId = 0;
const THRESHOLDS = [
{ v: 0, label: "Identical only" },
{ v: 2, label: "Strict" },
{ v: 5, label: "Normal" },
{ v: 7, label: "Loose" },
];
$effect(() => {
if (open) {
scan();
} else {
scanId++;
phase = "idle";
clusters = [];
checked = new Set();
progress = null;
}
});
async function scan() {
const id = ++scanId;
phase = "scanning";
progress = null;
errorMsg = "";
const unsub = onSubscribeDedup?.((d) => { if (id === scanId) progress = d; });
try {
const params = new URLSearchParams({ scope, path, threshold: String(threshold) });
const resp = await fetchApi(`/promptchain/browse/duplicates?${params}`);
if (id !== scanId) return;
if (!resp.ok) throw new Error(`scan failed (${resp.status})`);
const data = await resp.json();
if (id !== scanId) return;
clusters = data.clusters;
totalImages = data.totalImages;
checked = new Set(clusters.flatMap(c => c.items.filter(i => !i.keep).map(i => i.path)));
phase = "results";
} catch (e) {
if (id !== scanId) return;
errorMsg = e?.message || "scan failed";
phase = "error";
} finally {
unsub?.();
}
}
function setThreshold(v) {
threshold = v;
if (phase !== "scanning") scan();
}
function toggleChecked(p) {
const next = new Set(checked);
next.has(p) ? next.delete(p) : next.add(p);
checked = next;
}
function thumbSrc(item) {
return apiURL(`/promptchain/browse/preview?scope=${scope}&path=${encodeURIComponent(item.path)}&thumb=1`);
}
function fmtSize(b) {
if (!b) return "-";
if (b < 1048576) return (b / 1024).toFixed(0) + " KB";
return (b / 1048576).toFixed(1) + " MB";
}
let dupCount = $derived(clusters.reduce((a, c) => a + c.items.length - 1, 0));
async function deleteChecked() {
if (!checked.size || phase === "deleting") return;
phase = "deleting";
try {
const resp = await fetchApi("/promptchain/browse/delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scope, paths: [...checked] }),
});
const result = await resp.json();
const deleted = new Set(result.deleted || []);
if (deleted.size) {
window.dispatchEvent(new CustomEvent("promptchain:file-deleted", {
detail: { scope, paths: [...deleted] },
}));
onDeleted?.([...deleted]);
}
clusters = clusters
.map(c => ({ items: c.items.filter(i => !deleted.has(i.path)) }))
.filter(c => c.items.length >= 2);
checked = new Set([...checked].filter(p => !deleted.has(p)));
} catch { /* keep current state visible */ }
phase = "results";
}
function handleKeydown(e) {
if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onClose?.(); }
}
function handleBackdrop(e) {
if (e.target === e.currentTarget) onClose?.();
}
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div use:portal class="pcr-modal-backdrop" onclick={handleBackdrop} onkeydown={handleKeydown}>
<div class="pcr-modal pcr-dup-modal" role="dialog" aria-modal="true">
<div class="pcr-modal-header">
<span class="pcr-modal-title">Find Duplicates{path ? ` — ${path}` : ""}</span>
<div class="pcr-dup-thresholds">
{#each THRESHOLDS as t}
<button
class="pcr-dup-th" class:active={threshold === t.v}
disabled={phase === "scanning"}
onclick={() => setThreshold(t.v)}
>{t.label}</button>
{/each}
</div>
<button class="pcr-modal-close" onclick={onClose} aria-label="Close">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="pcr-modal-body pcr-dup-body">
{#if phase === "scanning" || phase === "deleting"}
<div class="pcr-dup-progress">
{#if phase === "deleting"}
<span>Deleting…</span>
{:else if progress}
<span>Hashing images… {progress.done} / {progress.total}</span>
<div class="pcr-dup-bar"><div class="pcr-dup-bar-fill" style="width:{(progress.done / Math.max(1, progress.total)) * 100}%"></div></div>
{:else}
<span>Scanning…</span>
{/if}
</div>
{:else if phase === "error"}
<div class="pcr-dup-progress pcr-dup-error">{errorMsg}</div>
{:else if phase === "results" && clusters.length === 0}
<div class="pcr-dup-progress">No duplicates found across {totalImages} images.</div>
{:else if phase === "results"}
<div class="pcr-dup-summary">
{clusters.length} group{clusters.length === 1 ? "" : "s"} · {dupCount} duplicate{dupCount === 1 ? "" : "s"} across {totalImages} images — largest file in each group is kept by default
</div>
<div class="pcr-dup-list">
{#each clusters as cluster, ci (cluster.items[0]?.path ?? ci)}
<div class="pcr-dup-cluster">
{#each cluster.items as item (item.path)}
{@const isChecked = checked.has(item.path)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="pcr-dup-card" class:marked={isChecked} onclick={() => toggleChecked(item.path)}>
<div class="pcr-dup-imgwrap">
<img src={thumbSrc(item)} alt={item.name} loading="lazy" decoding="async" />
{#if item.keep}<span class="pcr-dup-keep">keep</span>{/if}
<span class="pcr-dup-check" class:on={isChecked}></span>
</div>
<div class="pcr-dup-meta" title={item.path}>
<span class="pcr-dup-name">{item.name}</span>
<span class="pcr-dup-info">{item.width && item.height ? `${item.width}×${item.height} · ` : ""}{fmtSize(item.size)}</span>
</div>
</div>
{/each}
</div>
{/each}
</div>
{/if}
</div>
<div class="pcr-modal-footer">
<button class="pcr-modal-btn pcr-modal-btn-secondary" onclick={onClose}>Close</button>
<button
class="pcr-modal-btn pcr-modal-btn-danger"
disabled={!checked.size || phase !== "results"}
onclick={deleteChecked}
>Delete {checked.size} file{checked.size === 1 ? "" : "s"}</button>
</div>
</div>
</div>
{/if}
<style>
:global(.pcr-dup-modal) {
max-width: min(880px, 94vw) !important;
width: min(880px, 94vw);
display: flex; flex-direction: column;
max-height: 86vh;
}
.pcr-dup-body {
flex: 1; min-height: 120px; overflow-y: auto;
scrollbar-width: thin;
}
.pcr-dup-thresholds { display: flex; gap: 4px; margin: 0 12px; }
.pcr-dup-th {
padding: 3px 10px; border: 1px solid #3a3a3a; border-radius: 10px;
background: transparent; color: #999; font-size: 11px; cursor: pointer;
}
.pcr-dup-th:hover { color: #fff; }
.pcr-dup-th.active { color: #ff8a25; border-color: rgba(243, 107, 0, 0.6); background: rgba(243, 107, 0, 0.1); }
.pcr-dup-th:disabled { opacity: 0.5; cursor: default; }
.pcr-dup-progress {
display: flex; flex-direction: column; gap: 10px;
align-items: center; justify-content: center;
min-height: 100px; color: #aaa; font-size: 13px;
}
.pcr-dup-error { color: #c45050; }
.pcr-dup-bar {
width: 70%; height: 6px; border-radius: 3px;
background: rgba(255, 255, 255, 0.08); overflow: hidden;
}
.pcr-dup-bar-fill { height: 100%; background: #f36b00; transition: width 0.2s; }
.pcr-dup-summary { font-size: 12px; color: #999; margin-bottom: 12px; }
.pcr-dup-list { display: flex; flex-direction: column; gap: 10px; }
.pcr-dup-cluster {
display: flex; gap: 8px; padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.07); border-radius: 6px;
overflow-x: auto; scrollbar-width: thin;
}
.pcr-dup-card {
flex: 0 0 132px; cursor: pointer;
border: 2px solid transparent; border-radius: 4px; padding: 3px;
}
.pcr-dup-card:hover { border-color: rgba(255, 255, 255, 0.15); }
.pcr-dup-card.marked { border-color: rgba(180, 42, 42, 0.7); background: rgba(180, 42, 42, 0.08); }
.pcr-dup-imgwrap {
position: relative; width: 100%; aspect-ratio: 1;
border-radius: 3px; overflow: hidden; background: rgba(0, 0, 0, 0.25);
}
.pcr-dup-imgwrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
.pcr-dup-keep {
position: absolute; top: 4px; left: 4px;
padding: 1px 6px; border-radius: 7px;
background: rgba(36, 120, 50, 0.9); color: #fff; font-size: 9px;
}
.pcr-dup-check {
position: absolute; top: 4px; right: 4px;
width: 15px; height: 15px; border-radius: 3px;
border: 1.5px solid rgba(255, 255, 255, 0.6);
background: rgba(0, 0, 0, 0.4);
}
.pcr-dup-check.on {
background: #b42a2a; border-color: #b42a2a;
}
.pcr-dup-check.on::after {
content: ""; position: absolute; left: 4px; top: 1px;
width: 4px; height: 8px;
border: solid #fff; border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.pcr-dup-meta { display: flex; flex-direction: column; margin-top: 3px; }
.pcr-dup-name {
font-size: 10px; color: #ccc;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.pcr-dup-info { font-size: 9px; color: #777; }
</style>