Files
Archive/src/features/browse/ListView.tsx
T
ncakanghanandClaude Opus 5 8aa74c1a8d
CI / windows-x64 (push) Canceled after 0s
CI / windows-arm64-cross (push) Canceled after 0s
CI / windows-arm64-native (push) Canceled after 0s
CI / macos (push) Canceled after 0s
영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
중복 탐지
- 이미지 근접쌍 밴드 색인 + 스트리밍 클러스터, 후보 검증(타일 다수결·크롭 해시)
- 색 지문으로 색만 다른 오탐 제거, 회전·반전본 탐지(8변형 질의, 선택 옵션)
- 오디오 지문(chromaprint)을 같은 디코드 패스에서 뽑아 레터박스 사본을 잡는다.
  배경음만 같은 남남은 시각 확인 패스(트림 축 프레임 거리)로 걸러낸다 — 실측 9 vs 29.
- 비교 재생(CompareView): 여러 사본을 offset 정렬해 나란히 재생
- 지문 생성 속도 조절(느림·보통·빠름) — 재시작 없이 반영

간단 편집 (원본을 직접 고침, Ctrl+Z로 되돌림)
- 회전·반전: JPEG은 EXIF 방향 태그 2바이트만, MP4는 회전 행렬만 — 화질 손실 0.
  방향값 전이표는 군의 성질(90도x4=제자리)로 검산하고, 실제 화소를 transpose=1과
  비교해 "시계 방향"이 정말 시계인지 확인한다.
- 자르기: 화면 좌표 기준(회전 메타가 붙어 있어도 어긋나지 않는다)
- 구간 자르기: 스트림 복사라 무손실. 필름스트립 타임라인 + 손잡이 끌기 + 구간 미리보기.
- 되돌릴 수 없는 편집(자르기·반전)은 원본을 .archive_trash/edits로 옮겨 보관
- 편집이 수정시각을 바꾸지 않는다 — 기본 정렬이 촬영일이라 항목이 튀면 다시 찾아야 한다

동시 재생
- 고른 영상을 최대 9개 격자에 놓고 함께 재생. 소리는 한 칸만.
- 실시간 변환이 필요한 코덱은 2개까지만 — 그 이상은 ffmpeg가 기계를 멈춘다

그 외
- 별점(스냅샷 flags 남는 비트에 얹어 항목당 바이트 증가 0)
- 태그: 검색 없이 클릭으로 붙이기, 세트+단축키, 이름 수정 시 붙은 항목에 전파
- 파일 로깅(tracing-appender) — 릴리스는 콘솔이 없어 stdout만으로는 원인을 못 찾았다
- ARM64 크로스 컴파일 스크립트(관리자 권한 없이 VS 카탈로그에서 조립)
- DB 마이그레이션 v7~v10

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:18:24 +09:00

440 lines
17 KiB
TypeScript
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.
// 리스트(표) 보기 — 고정 행 높이 가상 스크롤. 이름·크기·날짜를 나란히 비교하고
// 열 머리글로 바로 정렬한다. 선택·정렬·필터·파일 작업은 격자 보기와 완전히 공유한다.
import { IconArrowDown, IconArrowUp, IconTrash } from "../../ui/icons";
import {
createEffect,
createMemo,
createSignal,
For,
on,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import { createStore, produce, reconcile } from "solid-js/store";
import {
anchorIndex,
info,
markedIds,
openLightbox,
registerItemViewFocus,
selected,
selectRange,
selectSingle,
setSort,
snapshot,
sortDesc,
sortKey,
tagVersion,
thumbVersions,
toggleSelect,
trashTag,
} from "../../state/store";
import {
fileDetails,
fileTagsBulk,
setThumbPriority,
thumbUrl,
type FileDetails,
type Snapshot,
type SortKey,
} from "../../ipc/commands";
import { ko } from "../../i18n/ko";
import { openContextMenu } from "../fileops/ContextMenu";
import { handleItemKeys } from "./itemKeys";
const ROW_H = 38;
/** 화면 위·아래로 더 그려 두는 행 수 (스크롤 시 빈칸 방지) */
const OVERSCAN = 10;
/** 한 번에 메타/태그를 채워 넣는 최대 행 수 (백엔드 file_details 상한 500 이내) */
const BATCH_MAX = 400;
/**
* 머리글과 행이 같은 열 정의를 공유한다.
* 부가 정보(태그·해상도·길이)는 minmax(0,…)로 두어 창이 좁아지면 그 열부터 줄어든다 —
* 이름·크기·날짜를 지키면서 가로 스크롤 없이 한 줄에 들어가게 하기 위함이다.
*/
const COLS =
"2.75rem minmax(5rem,1fr) minmax(0,11rem) 3rem 4.75rem minmax(0,5.5rem) minmax(0,3.75rem) 8.5rem";
function fmtSize(bytes: number): string {
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`;
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)} MB`;
if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)} KB`;
return `${bytes} B`;
}
function fmtDate(ms: number | null | undefined): string {
if (!ms) return "—";
const d = new Date(ms);
const p = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function fmtDuration(ms: number | null | undefined): string {
if (!ms) return "";
const s = Math.round(ms / 1000);
const m = Math.floor(s / 60);
const h = Math.floor(m / 60);
return h > 0
? `${h}:${String(m % 60).padStart(2, "0")}:${String(s % 60).padStart(2, "0")}`
: `${m}:${String(s % 60).padStart(2, "0")}`;
}
export const ListView: Component = () => {
let containerRef: HTMLDivElement | undefined;
const [scrollTop, setScrollTop] = createSignal(0);
const [viewportH, setViewportH] = createSignal(600);
const count = () => snapshot()?.count ?? 0;
// ── 가상 스크롤 ──
const visibleRange = createMemo<[number, number]>(() => {
const n = count();
if (n === 0) return [0, -1];
const first = Math.max(0, Math.floor(scrollTop() / ROW_H) - OVERSCAN);
const last = Math.min(n - 1, Math.ceil((scrollTop() + viewportH()) / ROW_H) + OVERSCAN);
return [first, last];
});
const indices = createMemo(() => {
const [first, last] = visibleRange();
const out: number[] = [];
for (let i = first; i <= last; i++) out.push(i);
return out;
});
let rafPending = false;
const onScroll = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
if (containerRef) setScrollTop(containerRef.scrollTop);
});
};
/** 행을 화면 안으로 (키보드 이동·보기 전환용) */
const revealRow = (index: number) => {
const el = containerRef;
if (!el) return;
const top = index * ROW_H;
if (top < el.scrollTop) el.scrollTop = top;
else if (top + ROW_H > el.scrollTop + el.clientHeight)
el.scrollTop = top + ROW_H - el.clientHeight;
};
onMount(() => {
const el = containerRef!;
// 크롬(툴바·브레드크럼·폴더 칩·사이드바)이 클릭 뒤 방향키를 여기로 되돌린다
onCleanup(registerItemViewFocus(() => containerRef?.focus({ preventScroll: true })));
// 높이는 폴링을 단일 진실원으로 — 값이 같으면 시그널이 갱신되지 않으므로 비용은 없다
const tick = () => setViewportH(el.clientHeight);
tick();
const ro = new ResizeObserver(tick);
ro.observe(el);
const poll = setInterval(tick, 250);
// 격자에서 넘어왔을 때 보고 있던 항목을 그대로 보여준다
const anchor = anchorIndex();
if (anchor >= 0) requestAnimationFrame(() => revealRow(anchor));
onCleanup(() => {
ro.disconnect();
clearInterval(poll);
});
});
// ── 지연 로드: 파일 메타 (이름·크기·날짜·길이) ──
// 이름/경로는 이름변경·이동으로 바뀌므로 스냅샷이 갈릴 때 캐시를 버린다.
const [detailsCache, setDetailsCache] = createStore<Record<number, FileDetails>>({});
let detTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(on(snapshot, () => setDetailsCache(reconcile({})), { defer: true }));
createEffect(() => {
const [first, last] = visibleRange();
const snap = snapshot();
if (!snap || last < first) return;
clearTimeout(detTimer);
detTimer = setTimeout(() => {
const need: number[] = [];
for (let i = first; i <= last && need.length < BATCH_MAX; i++) {
const id = snap.id(i);
if (detailsCache[id] === undefined) need.push(id);
}
if (need.length === 0) return;
void fileDetails(need)
.then((ds) =>
setDetailsCache(
produce((s) => {
for (const d of ds) s[d.id] = d;
}),
),
)
.catch(() => {});
}, 120);
});
onCleanup(() => clearTimeout(detTimer));
// ── 지연 로드: 태그 (격자 오버레이와 같은 배치 조회) ──
const [tagCache, setTagCache] = createStore<Record<number, string[]>>({});
let tagTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(on(tagVersion, () => setTagCache(reconcile({})), { defer: true }));
createEffect(() => {
const [first, last] = visibleRange();
const snap = snapshot();
tagVersion(); // 태그가 바뀌면 다시 읽는다
if (!snap || last < first) return;
clearTimeout(tagTimer);
tagTimer = setTimeout(() => {
const need: number[] = [];
for (let i = first; i <= last && need.length < BATCH_MAX; i++) {
const id = snap.id(i);
if (tagCache[id] === undefined) need.push(id);
}
if (need.length === 0) return;
void fileTagsBulk(need)
.then((links) =>
setTagCache(
produce((s) => {
for (const id of need) s[id] = [];
for (const l of links) {
if (l.tagId === trashTag()) continue; // 삭제 예정은 🗑 배지가 담당
(s[l.fileId] ??= []).push(l.name);
}
}),
),
)
.catch(() => {});
}, 150);
});
onCleanup(() => clearTimeout(tagTimer));
// 화면에 보이는데 썸네일이 없는 항목 우선 생성 요청
let prioTimer: ReturnType<typeof setTimeout> | undefined;
createEffect(() => {
const [first, last] = visibleRange();
const snap = snapshot();
if (!snap || last < first) return;
clearTimeout(prioTimer);
prioTimer = setTimeout(() => {
const ids: number[] = [];
for (let i = first; i <= last && ids.length < BATCH_MAX; i++) {
if (!snap.hasThumb(i)) ids.push(snap.id(i));
}
if (ids.length > 0) void setThumbPriority(ids);
}, 200);
});
onCleanup(() => clearTimeout(prioTimer));
// ── 상호작용 ──
const onRowClick = (e: MouseEvent, snap: Snapshot, index: number) => {
const id = snap.id(index);
if (e.shiftKey) selectRange(snap, index);
else if (e.ctrlKey || e.metaKey) toggleSelect(id, index);
else selectSingle(id, index);
};
const onRowContextMenu = (e: MouseEvent, snap: Snapshot, index: number) => {
e.preventDefault();
const id = snap.id(index);
if (!selected().has(id)) selectSingle(id, index);
void fileDetails([id]).then((d) => {
openContextMenu({ x: e.clientX, y: e.clientY, fileId: id, fileName: d[0]?.name });
});
};
const onRowDragStart = (e: DragEvent, snap: Snapshot, index: number) => {
const id = snap.id(index);
if (!selected().has(id)) selectSingle(id, index);
e.dataTransfer?.setData("application/x-archive-move", "1");
if (e.dataTransfer) e.dataTransfer.effectAllowed = "move";
};
const onKeyDown = (e: KeyboardEvent) => {
const snap = snapshot();
if (!snap) return;
handleItemKeys(e, snap, {
neighbor: (from, key) =>
key === "ArrowUp" || key === "ArrowLeft"
? from > 0
? from - 1
: undefined
: from < snap.count - 1
? from + 1
: undefined,
onMove: revealRow,
});
};
// ── 날짜 열: 정렬이 '수정일'이면 수정일을, 그 밖에는 촬영일을 보여준다 ──
const dateKey = (): SortKey => (sortKey() === "mtime" ? "mtime" : "taken");
const dateLabel = () => (sortKey() === "mtime" ? ko.list.modified : ko.list.taken);
const dateOf = (d: FileDetails | undefined) =>
d ? (sortKey() === "mtime" ? d.mtimeMs : (d.takenAt ?? d.mtimeMs)) : undefined;
/** 열 머리글 — 누르면 그 열로 정렬, 다시 누르면 방향 반전 */
const SortHead: Component<{ label: string; col: SortKey; right?: boolean }> = (props) => (
<button
class="flex min-w-0 items-center gap-1 hover:text-[var(--text-primary)]"
classList={{
"justify-end": props.right,
"text-[var(--text-primary)]": sortKey() === props.col,
}}
onClick={() => setSort(props.col, sortKey() === props.col ? !sortDesc() : true)}
title={ko.list.sortHint(props.label)}
>
<span class="truncate">{props.label}</span>
<Show when={sortKey() === props.col}>
<span class="shrink-0">
{sortDesc() ? <IconArrowDown size={12} /> : <IconArrowUp size={12} />}
</span>
</Show>
</button>
);
return (
<div class="flex min-h-0 flex-1 flex-col">
{/* 머리글 — 열 폭은 행과 같은 정의를 쓴다 */}
<div
class="grid shrink-0 items-center gap-2 border-b border-[var(--border)] px-2 py-1.5 text-[11px] text-[var(--text-muted)]"
style={{ "grid-template-columns": COLS }}
>
<span />
<SortHead label={ko.list.name} col="name" />
<span class="truncate">{ko.list.tags}</span>
<span class="truncate">{ko.list.kind}</span>
<SortHead label={ko.list.size} col="size" right />
<span class="truncate text-right">{ko.list.dims}</span>
<span class="truncate text-right">{ko.list.duration}</span>
<SortHead label={dateLabel()} col={dateKey()} right />
</div>
<div
ref={containerRef}
onScroll={onScroll}
onKeyDown={onKeyDown}
tabindex={0}
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden outline-none"
>
<Show
when={count() > 0}
fallback={
<div class="grid h-full place-items-center text-sm text-[var(--text-muted)]">
{ko.grid.empty}
</div>
}
>
<div class="relative" style={{ height: `${count() * ROW_H}px` }}>
<For each={indices()}>
{(i) => {
// 모든 파생값은 반응형으로 — For가 같은 인덱스의 DOM을 재사용해도
// 뷰 전환으로 스냅샷이 바뀌면 즉시 갱신되도록 한다.
const id = () => snapshot()?.id(i) ?? -1;
const d = () => detailsCache[id()];
const isSel = () => selected().has(id());
const marked = () => markedIds().has(id());
const isVideo = () => snapshot()?.kind(i) === 1;
const w = () => snapshot()?.width(i) ?? 0;
const h = () => snapshot()?.height(i) ?? 0;
const version = () => thumbVersions[id()] ?? 0;
const showImg = () => {
const s = snapshot();
return !!s && (s.hasThumb(i) || version() > 0);
};
const rowTags = () => tagCache[id()] ?? [];
return (
<div
class="list-row absolute inset-x-0 grid cursor-pointer items-center gap-2 px-2 text-xs"
classList={{ "is-sel": isSel() }}
style={{
transform: `translateY(${i * ROW_H}px)`,
height: `${ROW_H}px`,
"grid-template-columns": COLS,
}}
onClick={(e) => {
const s = snapshot();
if (s) onRowClick(e, s, i);
}}
onDblClick={() => openLightbox(i)}
onContextMenu={(e) => {
const s = snapshot();
if (s) onRowContextMenu(e, s, i);
}}
draggable={true}
onDragStart={(e) => {
const s = snapshot();
if (s) onRowDragStart(e, s, i);
}}
>
{/* 썸네일 — 미디어 면이 아니라 28×36 크롬 슬롯이다.
--grid-cell-bg(고정 다크)를 쓰면 흰 카드 안에 검은 사각형이 박힌다. */}
<div class="h-7 w-9 shrink-0 overflow-hidden rounded-[var(--r-xs)] bg-[var(--bg-panel-raised)]">
<Show when={showImg() && info()}>
<img
src={`${thumbUrl(info()!, id())}&v=${version()}`}
class="h-full w-full object-cover"
decoding="async"
draggable={false}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
</Show>
</div>
{/* 이름 (+ 삭제 예정 배지) */}
<div class="flex min-w-0 items-center gap-1.5">
<Show when={marked()}>
<span class="chip chip--danger chip--xs shrink-0" title={ko.trash.badge}>
<IconTrash size={11} />
</span>
</Show>
<span
class="truncate text-[var(--text-primary)]"
title={d() ? `${d()!.dir}\\${d()!.name}` : undefined}
>
{d()?.name ?? "…"}
</span>
</div>
{/* 태그 */}
<div class="flex min-w-0 items-center gap-1 overflow-hidden">
<For each={rowTags().slice(0, 3)}>
{(name) => (
<span class="chip chip--accent chip--xs min-w-0" title={name}>
<span>{name}</span>
</span>
)}
</For>
<Show when={rowTags().length > 3}>
<span class="shrink-0 text-[10px] text-[var(--text-muted)]">
+{rowTags().length - 3}
</span>
</Show>
</div>
<span class="truncate text-[var(--text-muted)]">
{isVideo() ? ko.filter.video : ko.filter.image}
</span>
<span class="text-right tabular-nums text-[var(--text-secondary)]">
{d() ? fmtSize(d()!.size) : ""}
</span>
{/* 좁아지면 0폭까지 줄어드는 열 — 넘친 글자가 옆 열을 침범하지 않게 자른다 */}
<span class="truncate text-right tabular-nums text-[var(--text-muted)]">
{w() > 0 && h() > 0 ? `${w()}×${h()}` : "—"}
</span>
<span class="truncate text-right tabular-nums text-[var(--text-muted)]">
{fmtDuration(d()?.durationMs)}
</span>
<span class="text-right tabular-nums text-[var(--text-muted)]">
{fmtDate(dateOf(d()))}
</span>
</div>
);
}}
</For>
</div>
</Show>
</div>
</div>
);
};