영상 중복 탐지 완성 + 간단 편집(회전·자르기) + 동시 재생 + 별점·태그 세트
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>
This commit is contained in:
ncakanghan
2026-08-03 16:18:24 +09:00
co-authored by Claude Opus 5
parent 67d2ddc59e
commit 8aa74c1a8d
67 changed files with 19747 additions and 1283 deletions
+42 -6
View File
@@ -1,4 +1,4 @@
import { onCleanup, onMount, type Component } from "solid-js";
import { onCleanup, onMount, Show, type Component } from "solid-js";
import { Sidebar, onLibraryChangedRefresh } from "./shell/Sidebar";
import { ContentArea } from "./shell/ContentArea";
import { DetailsPanel } from "./shell/DetailsPanel";
@@ -6,15 +6,29 @@ import { StatusBar } from "./shell/StatusBar";
import { Lightbox } from "./features/viewer/Lightbox";
import { ContextMenu } from "./features/fileops/ContextMenu";
import { MoveDialog } from "./features/fileops/MoveDialog";
import { MultiPlayView } from "./features/viewer/MultiPlayView";
import { DedupePanel } from "./features/dedupe/DedupePanel";
import { CompareView } from "./features/dedupe/CompareView";
import { RemoteDialog } from "./features/sources/RemoteDialog";
import { SourceEditDialog } from "./features/sources/SourceEditDialog";
import { ShortcutsOverlay } from "./shell/ShortcutsOverlay";
import { onLibraryChanged, onThumbsReady, type UnlistenFn } from "./ipc/commands";
import { bumpThumbVersions, loadAppInfo, refreshPendingCounts, refreshSnapshot } from "./state/store";
import { ko } from "./i18n/ko";
import { onLibraryChanged, onScanGuarded, onThumbsReady, type UnlistenFn } from "./ipc/commands";
import {
bumpThumbVersions,
detailsOpen,
loadAppInfo,
loadTrashTag,
noteGuarded,
refreshPendingCounts,
refreshSnapshot,
toast,
} from "./state/store";
const App: Component = () => {
onMount(() => {
void loadAppInfo();
void loadTrashTag();
void refreshSnapshot();
refreshPendingCounts();
@@ -23,10 +37,18 @@ const App: Component = () => {
bumpThumbVersions(ids);
refreshPendingCounts();
}),
// 스캔 중에는 2초마다 온다 — 사이드바·스냅샷·삭제표시 갱신은 저 안에서 합쳐진다.
onLibraryChanged(() => {
onLibraryChangedRefresh();
refreshPendingCounts();
}),
// 대량 삭제 보류 — 조용히 지나가면 사용자는 "왜 없는 파일이 목록에 있는지" 알 수 없다.
// 토스트로 알리고, 사이드바 정리 그룹에 '사라진 파일 정리' 진입점을 띄운다
// (토스트는 4초면 사라지지만 보류 상태는 사용자가 확정할 때까지 남는다).
onScanGuarded((g) => {
toast(ko.refresh.guarded(g.count));
noteGuarded(g);
}),
];
onCleanup(() => {
for (const u of unlisteners) void u.then((f) => f());
@@ -34,18 +56,32 @@ const App: Component = () => {
});
return (
<div class="grid h-full grid-rows-[1fr_auto]">
<div class="grid min-h-0 grid-cols-[240px_1fr_280px]">
<div class="grid h-full grid-rows-[1fr_auto] bg-[var(--bg-app)]">
{/* minmax(0,1fr) 필수 — 1fr(=minmax(auto,1fr))이면 본문이 최소 콘텐츠 폭 아래로 줄지 못한다 */}
<div
class="grid min-h-0 gap-2 px-2 pt-2 pb-1"
style={{
"grid-template-columns": detailsOpen()
? "248px minmax(0,1fr) 300px"
: "248px minmax(0,1fr)",
}}
>
<Sidebar />
<ContentArea />
<DetailsPanel />
<Show when={detailsOpen()}>
<DetailsPanel />
</Show>
</div>
<StatusBar />
<Lightbox />
<ContextMenu />
<MoveDialog />
<MultiPlayView />
<DedupePanel />
{/* 비교 뷰는 중복 모달 '위'에 뜬다 — Esc는 위에 있는 것부터 닫힌다 */}
<CompareView />
<RemoteDialog />
<SourceEditDialog />
<ShortcutsOverlay />
</div>
);
+117
View File
@@ -0,0 +1,117 @@
// 본문 카드 안 '한 단계 아래 폴더' 밴드 — 트리를 열지 않고도 내려갈 수 있게 한다.
// 스크롤러 바깥(그리드/리스트 위)에 놓는다. 안에 넣으면 가상 스크롤 좌표계가 어긋난다.
// 보여줄 자식이 없으면 아무것도 렌더하지 않는다 — 높이를 예약하면 그리드가 아래로 튄다.
import { IconChevronDown, IconChevronRight, IconFolder, IconPlus } from "../../ui/icons";
import { createEffect, createSignal, For, on, Show, type Component } from "solid-js";
import { ko } from "../../i18n/ko";
import { childrenOf, rootsOf, sourceIdOf, subtotalOf } from "../../state/folderIndex";
import { chipsOpen, focusItemView, setView, toast, toggleChips, view } from "../../state/store";
import type { FolderNode } from "../../ipc/commands";
import { performMove } from "../fileops/MoveDialog";
/** 처음에 펼쳐 두는 칩 수. 나머지는 '+N개 더'로 접는다 */
const SHOWN_MAX = 8;
export const FolderChips: Component = () => {
const [showAll, setShowAll] = createSignal(false);
const [dropId, setDropId] = createSignal<number | null>(null);
// 뷰가 바뀌면 펼침을 되돌린다 — 이전 폴더에서 펼친 상태가 새 폴더로 새지 않게
createEffect(on(view, () => setShowAll(false)));
const srcId = (): number | undefined => {
const v = view();
if (v.type === "source") return v.sourceId;
if (v.type === "folder") return v.sourceId ?? sourceIdOf(v.folderId);
return undefined;
};
const children = (): FolderNode[] => {
const v = view();
if (v.type === "source") return rootsOf(v.sourceId);
if (v.type === "folder") return childrenOf(v.folderId, v.sourceId);
return [];
};
const shown = () => (showAll() ? children() : children().slice(0, SHOWN_MAX));
const hidden = () => children().length - shown().length;
const onDragOver = (e: DragEvent, folderId: number) => {
if (!e.dataTransfer?.types.includes("application/x-archive-move")) return;
e.preventDefault();
setDropId(folderId);
};
return (
<Show when={children().length > 0}>
<div class="chips-band">
<div class="section-label">
{ko.browse.folders}
<span class="nav-item__count">{children().length.toLocaleString("ko-KR")}</span>
<button
class="section-label__action"
tabindex={-1}
title={chipsOpen() ? ko.browse.collapse : ko.browse.expand}
aria-label={chipsOpen() ? ko.browse.collapse : ko.browse.expand}
onClick={() => {
toggleChips();
focusItemView();
}}
>
{chipsOpen() ? <IconChevronDown size={13} /> : <IconChevronRight size={13} />}
</button>
</div>
<Show when={chipsOpen()}>
<div class="chips-grid">
<For each={shown()}>
{(n) => (
<button
class="chip--folder"
classList={{ "is-drop": dropId() === n.id }}
tabindex={-1}
onClick={() => {
void setView({
type: "folder",
folderId: n.id,
recursive: true,
sourceId: srcId(),
});
focusItemView();
}}
onDragOver={(e) => onDragOver(e, n.id)}
onDragLeave={() => setDropId(null)}
onDrop={(e) => {
e.preventDefault();
setDropId(null);
void performMove(n.id).catch((err) => toast(String(err)));
}}
>
<span class="chip-folder__icon"><IconFolder size={13} /></span>
<span class="chip-folder__name" title={n.name}>
{n.name}
</span>
<span class="chip-folder__count">
{subtotalOf(n.id, srcId()).toLocaleString("ko-KR")}
</span>
</button>
)}
</For>
<Show when={hidden() > 0}>
<button
class="chip--folder"
tabindex={-1}
onClick={() => {
setShowAll(true);
focusItemView();
}}
>
<span class="chip-folder__icon"></span>
<span class="chip-folder__icon"><IconPlus size={11} /></span>
<span class="chip-folder__name">{ko.browse.more(hidden())}</span>
</button>
</Show>
</div>
</Show>
</div>
</Show>
);
};
+439
View File
@@ -0,0 +1,439 @@
// 리스트(표) 보기 — 고정 행 높이 가상 스크롤. 이름·크기·날짜를 나란히 비교하고
// 열 머리글로 바로 정렬한다. 선택·정렬·필터·파일 작업은 격자 보기와 완전히 공유한다.
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>
);
};
+148
View File
@@ -0,0 +1,148 @@
// 격자·리스트 공통 키보드 처리 — 두 보기가 같은 키로 같은 일을 하도록 한곳에 모았다.
// 방향키만 보기마다 기하가 달라(격자는 행/열, 리스트는 위/아래) 이웃 계산을 위임받는다.
import { fileDetails, type Snapshot } from "../../ipc/commands";
import {
anchorIndex,
dedupeOpen,
lightboxIndex,
openLightbox,
openShortcuts,
selected,
selectSingle,
setSelectionFromIds,
toggleViewMode,
} from "../../state/store";
import {
openRenameDialog,
performMarkDelete,
performTrash,
performEdit,
performUndo,
} from "../fileops/ContextMenu";
import { openMoveDialog } from "../fileops/MoveDialog";
import { multiPlayIds } from "../viewer/MultiPlayView";
export type ArrowKey = "ArrowLeft" | "ArrowRight" | "ArrowUp" | "ArrowDown";
/** 방향키가 향할 인덱스. 더 갈 곳이 없으면 undefined. */
export type Neighbor = (from: number, key: ArrowKey) => number | undefined;
export interface ItemKeyOpts {
neighbor: Neighbor;
/** 키보드로 선택이 옮겨간 뒤 — 보기가 그 항목을 화면 안으로 끌어온다 */
onMove?: (index: number) => void;
}
export function handleItemKeys(e: KeyboardEvent, snap: Snapshot, opts: ItemKeyOpts): void {
if (snap.count === 0) return;
// 중복 검토 모달이 떠 있으면 그리드는 키를 받지 않는다. 평소에는 모달이 카드로 포커스를
// 옮겨 이 핸들러까지 오지 않지만, 그 방어는 포커스 하나에만 걸려 있다 — focusItemView()가
// 모달이 열린 사이에 불리면 방향키·Ctrl+A·Delete가 스크림 뒤에서 되살아난다.
if (dedupeOpen()) return;
// 동시 재생 오버레이가 떠 있으면 그리드는 키를 받지 않는다 — 뒤에서 선택이 바뀌면
// 오버레이를 닫았을 때 엉뚱한 항목이 골라져 있다.
if (multiPlayIds().length > 0) return;
// 라이트박스가 떠 있으면 키는 전적으로 라이트박스가 처리한다.
// (뒤에서 선택을 바꾸면 라이트박스의 '현재 항목 = 선택' 전제가 깨진다)
if (lightboxIndex() != null) return;
const anchor = anchorIndex();
const move = (index: number) => {
selectSingle(snap.id(index), index);
opts.onMove?.(index);
};
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
void performUndo();
return;
}
// Ctrl+A 전체 선택 / Ctrl+Shift+A 선택 반전
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
e.preventDefault();
const all: number[] = [];
for (let i = 0; i < snap.count; i++) all.push(snap.id(i));
if (e.shiftKey) {
const cur = selected();
setSelectionFromIds(all.filter((id) => !cur.has(id)));
} else {
setSelectionFromIds(all);
}
return;
}
// ? 단축키 도움말
if (e.key === "?") {
e.preventDefault();
openShortcuts();
return;
}
// Ctrl+[ / Ctrl+] 회전 — 사진 뷰어들이 쓰는 관례를 따른다
if ((e.ctrlKey || e.metaKey) && (e.key === "[" || e.key === "]")) {
if (selected().size === 0) return;
e.preventDefault();
void performEdit(e.key === "]" ? "rotateCw" : "rotateCcw");
return;
}
// Ctrl+M 폴더 이동
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") {
if (selected().size === 0) return;
e.preventDefault();
openMoveDialog();
return;
}
// V 격자 ↔ 리스트
if (!e.ctrlKey && !e.metaKey && !e.altKey && (e.key === "v" || e.key === "V")) {
e.preventDefault();
toggleViewMode();
return;
}
switch (e.key) {
case "Enter":
case " ":
if (anchor >= 0) {
e.preventDefault();
openLightbox(anchor);
}
break;
// Delete는 '삭제 표시'(되돌릴 수 있음), Shift+Delete만 즉시 OS 휴지통
case "Delete":
if (selected().size > 0) {
e.preventDefault();
if (e.shiftKey) void performTrash();
else void performMarkDelete();
}
break;
case "F2":
if (anchor >= 0 && selected().size === 1) {
e.preventDefault();
const id = snap.id(anchor);
void fileDetails([id]).then((d) => {
if (d[0]) openRenameDialog(id, d[0].name);
});
}
break;
case "Home":
e.preventDefault();
move(0);
break;
case "End":
e.preventDefault();
move(snap.count - 1);
break;
case "ArrowLeft":
case "ArrowRight":
case "ArrowUp":
case "ArrowDown": {
e.preventDefault();
// 아직 아무것도 고르지 않았으면 첫 항목부터 — 키보드만으로 시작할 수 있게
if (anchor < 0) {
move(0);
break;
}
const next = opts.neighbor(anchor, e.key);
if (next !== undefined) move(next);
break;
}
}
}
+731
View File
@@ -0,0 +1,731 @@
// 중복 비교 — 두 파일을 나란히 놓고 **같은 내용이 같은 시각에 보이도록** 맞춰 재생한다.
//
// 그냥 0초부터 나란히 돌리면 인트로가 붙은 사본은 끝까지 어긋난 화면을 보여줘서 비교가 안 된다.
// 그래서 탐지 때 오프셋 투표로 알아낸 `offsetMs`(keeper 시각 멤버 시각)를 그대로 쓴다:
// member.currentTime = keeper.currentTime - offsetMs/1000
// offsetMs가 null인 멤버(전이 병합으로 들어와 정렬을 직접 확인하지 못한 경우)는 맞추지 않고
// 그 사실을 화면에 적는다 — 맞춰진 척하는 것보다 낫다.
//
// 시간축은 항상 **keeper 기준**이다(첫 패널). 시크·재생은 keeper 시각 하나로 관리하고
// 나머지는 매 프레임 따라붙는다(오차 0.2초 넘으면 보정).
//
// 보기는 둘이다:
// 1:1 — keeper와 사본 하나. 세부를 크게 본다(기본).
// 전체 — 그룹의 모든 사본을 한 화면에서 동시에. 사본이 5개인데 1:1로 네 번 돌려보는 것보다
// "어느 하나가 유독 흐리다/짧다"를 훨씬 빨리 찾는다. 대신 영상 N개가 동시에 돌고
// 직접 재생이 안 되는 코덱은 각각 ffmpeg 리먹스를 띄우므로 비용이 N배다 —
// 그래서 기본이 아니라 선택이다.
import {
createEffect,
createMemo,
createSignal,
For,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import {
decidePlayback,
mediaUrl,
playbackInfo,
startStream,
stopStream,
thumbUrl,
type DupeGroup,
type DupeMember,
type PlaybackPlan,
} from "../../ipc/commands";
import { closeDedupe, info as appInfoSig, setView } from "../../state/store";
import { fmtDuration } from "./format";
import { ko } from "../../i18n/ko";
import {
IconFolder,
IconPause,
IconPlay,
IconResetZoom,
IconRewind,
IconForward,
IconVolumeHigh,
IconVolumeMute,
IconX,
} from "../../ui/icons";
// ── 열림 상태 (다이얼로그 3종과 같은 방식: 모듈 시그널) ──
const [target, setTarget] = createSignal<{ group: DupeGroup; otherId: number } | undefined>();
export const compareTarget = target;
export const compareOpen = (): boolean => target() !== undefined;
/** 그룹의 keeper와 `otherId`를 나란히 비교한다. */
export function openCompare(group: DupeGroup, otherId: number): void {
setTarget({ group, otherId });
}
export const closeCompare = (): void => setTarget(undefined);
const SKIP = 5;
const DRIFT_TOLERANCE = 0.2;
/** 어느 패널의 소리도 듣지 않는 상태 */
const MUTED = -1;
const ZOOM_MIN = 1;
const ZOOM_MAX = 8;
/** 시크바·구간 표기 — 목록 타일과 같은 규칙을 쓴다(한 시간 넘는 영상도 h:mm:ss로 맞다). */
const fmt = (sec: number): string => fmtDuration(sec * 1000);
/** 부호 있는 초 표기 — "5.0초 늦게 / 2.5초 빠르게" */
function fmtShift(ms: number): string {
const sec = Math.abs(ms) / 1000;
return sec < 0.05 ? ko.compare.aligned : ms < 0 ? ko.compare.later(sec) : ko.compare.earlier(sec);
}
/** 한쪽 패널의 재생 제어 손잡이. 부모가 keeper 시각 하나로 양쪽을 움직인다. */
interface PaneApi {
/**
* keeper 시각(초)으로 이동.
* `hard`는 **사용자가 위치를 옮겼다**는 뜻이다 — 리먹스 스트림은 그때만 다시 띄운다.
* 매 프레임 도는 드리프트 보정으로 ffmpeg를 재시작하면, 재시작에 걸리는 1초 사이
* keeper가 또 움직여서 영원히 따라잡지 못하고 재시작만 반복한다(실측).
*/
seek: (keeperSec: number, hard?: boolean) => void;
play: () => void;
pause: () => void;
/** 현재 keeper 시각 환산값 */
keeperTime: () => number;
}
const VideoPane: Component<{
member: DupeMember;
/** keeper 시각 이 멤버 시각(ms). keeper 자신은 0 */
offsetMs: number | null;
muted: boolean;
/** 부모의 재생 상태 — 범위를 벗어났다 돌아왔을 때 스스로 재생을 되살린다 */
playing: boolean;
/** 열릴 때의 keeper 시각(초) — 스트림 모드는 이 지점에서 ffmpeg를 띄운다 */
startAtSec: number;
register: (api: PaneApi | undefined) => void;
}> = (props) => {
const app = appInfoSig();
let el: HTMLVideoElement | undefined;
const [src, setSrc] = createSignal<string | undefined>();
// 스트림(리먹스) 재생은 시작 시각이 파일 0초가 아니다 — 환산에 쓴다
const [base, setBase] = createSignal(0);
const [dur, setDur] = createSignal(0);
const [oor, setOor] = createSignal(false);
const [plan, setPlan] = createSignal<PlaybackPlan | undefined>();
let jobId: number | undefined;
let restartTimer: ReturnType<typeof setTimeout> | undefined;
/** 예약된 재시작 지점(초). 타이머가 도는 동안에는 이 값만 갱신한다. */
let restartTo: number | undefined;
const off = () => (props.offsetMs ?? 0) / 1000;
const stop = () => {
clearTimeout(restartTimer);
restartTimer = undefined;
if (jobId !== undefined) {
void stopStream(jobId);
jobId = undefined;
}
};
onCleanup(stop);
/** 리먹스 스트림을 `atSec`부터 다시 시작한다 — 스트림은 버퍼 밖으로 시크할 수 없다.
(플레이어와 같은 방식: 위치를 바꾸면 ffmpeg를 그 지점에서 새로 띄운다) */
const openStreamAt = async (atSec: number) => {
const p = plan();
if (!p || p.mode !== "stream") return;
const prev = jobId;
try {
const r = await startStream(props.member.fileId, atSec, p.video, p.audio);
jobId = r.jobId;
setBase(atSec);
setSrc(r.url);
} catch {
return;
}
if (prev !== undefined) void stopStream(prev);
};
// 직접 재생이 가능하면 그대로, 아니면 ffmpeg 리먹스 스트림으로 (플레이어와 같은 판단).
// MKV·HEVC 같은 컨테이너/코덱도 이 경로로 비교할 수 있다.
onMount(() => {
void (async () => {
if (!app) return;
try {
const p = decidePlayback(await playbackInfo(props.member.fileId));
setPlan(p);
if (p.mode === "direct") {
setSrc(mediaUrl(app, props.member.fileId));
} else if (p.mode === "stream") {
// 열릴 때 이미 일치 구간으로 이동해 있을 수 있으니 그 지점에서 시작한다
await openStreamAt(Math.max(0, props.startAtSec - off()));
}
} catch {
/* 재생 불가 — 썸네일만 보여준다 */
}
})();
});
createEffect(() => {
if (el) el.muted = props.muted;
});
props.register({
seek: (keeperSec, hard = false) => {
if (!el) return;
const want = keeperSec - off();
const streaming = plan()?.mode === "stream";
const fileLen = (props.member.durationMs ?? 0) / 1000;
// 스트림 모드의 `el.duration`은 **지금까지 리먹스된 분량**이라 파일 길이가 아니다.
// 그걸 범위 기준으로 쓰면 멀쩡한 지점을 '상대 파일에 없음'으로 잘못 판정한다.
const max = streaming ? fileLen : dur() || fileLen;
const inRange = want >= -0.05 && (max === 0 || want <= max + 0.05);
setOor(!inRange);
if (!inRange) {
el.pause();
return;
}
// 스트림은 버퍼 밖을 시크할 수 없다 — 크게 움직이면 그 지점에서 ffmpeg를 다시 띄운다.
// 작은 보정(부모의 0.2초 드리프트 교정)은 currentTime으로 처리해 재시작 폭주를 막는다.
if (streaming && hard && Math.abs(base() + el.currentTime - want) > 1.5) {
restartTo = Math.max(0, want);
// **타이머를 다시 걸지 않는다.** 사용자가 슬라이더를 끌면 seek이 연속으로 오는데
// 매번 clearTimeout하면 250ms가 영원히 오지 않아 스트림 패널만 멈춰 있게 된다.
if (restartTimer === undefined) {
restartTimer = setTimeout(() => {
restartTimer = undefined;
if (restartTo !== undefined) void openStreamAt(restartTo);
}, 250);
}
return;
}
const t = Math.max(0, want - base());
if (Math.abs(el.currentTime - t) > 0.05) el.currentTime = t;
// 범위를 벗어났다 돌아온 경우 — 부모가 재생 중이면 다시 돌려준다
if (props.playing && el.paused) void el.play().catch(() => {});
},
play: () => {
if (el && !oor()) void el.play().catch(() => {});
},
pause: () => el?.pause(),
keeperTime: () => (el ? base() + el.currentTime + off() : 0),
});
onCleanup(() => props.register(undefined));
return (
<div class="compare-pane">
<Show
when={src()}
fallback={
<img
class="compare-media"
src={app ? thumbUrl(app, props.member.fileId, 1) : undefined}
alt=""
/>
}
>
<video
class="compare-media"
ref={(e) => (el = e)}
src={src()}
preload="auto"
playsinline
muted={props.muted}
onLoadedMetadata={() => {
setDur(el?.duration && isFinite(el.duration) ? el.duration : 0);
// 새로 만들어진 패널은 자기 0초에서 시작한다 — 보기를 바꾸거나 비교 대상을
// 갈아끼우면 재생바는 10초를 가리키는데 화면만 0초인 상태가 된다.
// 스트림 모드는 애초에 그 지점에서 띄우므로(openStreamAt) 여기서 손대지 않는다.
if (el && plan()?.mode !== "stream") {
const want = Math.max(0, props.startAtSec - off());
if (Math.abs(el.currentTime - want) > 0.05) el.currentTime = want;
}
}}
/>
</Show>
<Show when={oor()}>
{/* 한쪽에만 있는 구간 — 검은 화면을 그냥 보여주면 '깨진 것'으로 오해한다 */}
<div class="compare-pane__note">{ko.compare.outOfRange}</div>
</Show>
</div>
);
};
const ImagePane: Component<{ member: DupeMember; zoom: number; pan: [number, number] }> = (
props,
) => {
const app = appInfoSig();
return (
<div class="compare-pane">
<img
class="compare-media"
style={{
transform: `translate(${props.pan[0]}px, ${props.pan[1]}px) scale(${props.zoom})`,
}}
src={app ? mediaUrl(app, props.member.fileId) : undefined}
alt={props.member.name}
draggable={false}
/>
</div>
);
};
export const CompareView: Component = () => {
const [playing, setPlaying] = createSignal(false);
const [pos, setPos] = createSignal(0); // keeper 시각(초)
/**
* 소리를 듣는 패널의 인덱스. 여러 소리를 같이 내면 비교가 안 되므로 **하나 아니면 없음**이다.
* `MUTED`(-1)면 전부 음소거 — 화면만 보고 싶을 때(같은 소리가 계속 반복되면 방해가 된다).
*/
const [audioPane, setAudioPane] = createSignal(0);
/** 음소거를 풀 때 돌아갈 자리. 듣던 것으로 되돌아가야 '껐다 켜기'가 성립한다. */
let lastAudio = 0;
const [showAll, setShowAll] = createSignal(false);
const [zoom, setZoom] = createSignal(1);
const [pan, setPan] = createSignal<[number, number]>([0, 0]);
let apis: (PaneApi | undefined)[] = [];
let raf = 0;
const group = () => target()?.group;
const keeper = (): DupeMember | undefined =>
group()?.members.find((m) => m.isKeeper) ?? group()?.members[0];
const other = (): DupeMember | undefined =>
group()?.members.find((m) => m.fileId === target()?.otherId);
const isVideo = () => (keeper()?.kind ?? 0) === 1;
/** 비교 대상으로 고를 수 있는 멤버들 (keeper 제외) */
const others = () => group()?.members.filter((m) => !m.isKeeper) ?? [];
/**
* 화면에 놓을 패널들. **0번은 언제나 keeper**다 — 시간축의 진실원이고,
* 나머지가 그 시각을 따라오기 때문에 순서가 곧 계약이다.
*/
const panes = createMemo<DupeMember[]>(() => {
const k = keeper();
if (!k) return [];
if (!showAll()) {
const o = other();
return o ? [k, o] : [k];
}
return [k, ...others()];
});
/** 열 수 — 정사각에 가깝게, 최대 4열. 8개면 4×2, 5개면 3×2가 된다. */
const cols = createMemo(() => Math.min(4, Math.ceil(Math.sqrt(panes().length))));
const totalSec = () => {
const k = keeper();
return (k?.durationMs ?? 0) / 1000;
};
// 열릴 때·비교 대상을 바꿀 때 상태 초기화 + 일치 구간이 있으면 그 지점부터
// (거기가 볼 만한 곳이다). `apis`는 비우지 않는다 — 패널이 스스로 등록/해제한다.
createEffect(() => {
const t = target();
if (!t) return;
setZoom(1);
setPan([0, 0]);
setPlaying(false);
lastAudio = 0;
setAudioPane(0);
setPos((other()?.segStartMs ?? 0) / 1000);
});
// 보기를 바꾸면 패널 수가 달라진다 — 사라진 패널의 손잡이가 남아 있으면
// seek/pause가 없는 <video>를 향한다. 소리 인덱스가 범위를 벗어나면 첫 패널로 되돌리되,
// **전체 음소거는 그대로 둔다**(-1은 범위 밖이 아니라 사용자가 고른 상태다).
createEffect(() => {
const n = panes().length;
apis.length = n;
if (audioPane() >= n) setAudio(0);
if (lastAudio >= n) lastAudio = 0;
});
/** 사용자가 위치를 옮겼다 — 스트림 패널은 여기서만 다시 띄운다(hard). */
/** 소리 대상을 바꾼다. 끄기 전에 듣던 자리를 기억해 둔다(다시 켤 때 그리로 돌아간다). */
const setAudio = (i: number) => {
if (i !== MUTED) lastAudio = i;
setAudioPane(i);
};
/** 전체 음소거 ↔ 마지막으로 듣던 패널 */
const toggleMuteAll = () => setAudio(audioPane() === MUTED ? lastAudio : MUTED);
/** 사용자가 위치를 옮겼다 — 스트림 패널은 여기서만 다시 띄운다(hard). */
const seekAll = (t: number) => {
const clamped = Math.max(0, totalSec() > 0 ? Math.min(t, totalSec()) : t);
setPos(clamped);
for (const a of apis) a?.seek(clamped, true);
};
const setPlay = (on: boolean) => {
setPlaying(on);
if (on) {
// 재생을 시작하는 순간 한 번 맞춘다(hard). 리먹스 스트림 패널은 자기가 열린 지점부터
// 독립적으로 흐르므로, 여기서 안 맞추면 처음부터 어긋난 채로 나란히 돈다.
// 재생 중에는 다시 맞추지 않는다 — 재시작에 걸리는 1초 사이 keeper가 또 움직여
// 따라잡지 못하고 재시작만 반복하기 때문이다.
for (const a of apis) a?.seek(pos(), true);
}
for (const a of apis) (on ? a?.play : a?.pause)?.();
};
// keeper를 진실원으로 삼아 진행을 읽고, 벌어진 패널을 다시 맞춘다
onMount(() => {
const tick = () => {
raf = requestAnimationFrame(tick);
if (!playing() || !isVideo()) return;
const k = apis[0];
if (!k) return;
const t = k.keeperTime();
setPos(t);
// 0번(keeper)은 기준이므로 건드리지 않는다. 나머지는 각자 자기 오프셋으로 따라온다.
for (let i = 1; i < apis.length; i++) {
const a = apis[i];
if (a && Math.abs(a.keeperTime() - t) > DRIFT_TOLERANCE) a.seek(t);
}
};
raf = requestAnimationFrame(tick);
onCleanup(() => cancelAnimationFrame(raf));
const onKey = (e: KeyboardEvent) => {
if (!compareOpen()) return;
const k = e.key;
if (k === "Escape") {
e.preventDefault();
e.stopPropagation();
closeCompare();
return;
}
if (!isVideo() && (k === "0" || k === "r")) {
setZoom(1);
setPan([0, 0]);
return;
}
if (!isVideo()) return;
if (k === " ") {
e.preventDefault();
e.stopPropagation();
setPlay(!playing());
} else if (k === "ArrowLeft") {
e.preventDefault();
e.stopPropagation();
seekAll(pos() - SKIP);
} else if (k === "ArrowRight") {
e.preventDefault();
e.stopPropagation();
seekAll(pos() + SKIP);
} else if (k === "m") {
// 순환에 '전체 음소거'도 넣는다 — M을 계속 누르면 각 패널을 거쳐 무음까지 간다.
// (0 → 1 → … → n-1 → 없음 → 0)
const n = Math.max(1, panes().length);
const cur = audioPane();
setAudio(cur === MUTED ? 0 : cur + 1 >= n ? MUTED : cur + 1);
} else if (k === "M") {
// Shift+M — 순환을 거치지 않고 바로 껐다 켠다(패널이 많을 때)
toggleMuteAll();
}
};
window.addEventListener("keydown", onKey, { capture: true });
onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true }));
});
// 이미지 확대/이동 — 두 패널이 같은 값을 쓰므로 항상 같은 부분을 본다
const onWheel = (e: WheelEvent) => {
if (isVideo()) return;
e.preventDefault();
setZoom((z) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z * (e.deltaY < 0 ? 1.15 : 1 / 1.15))));
if (zoom() <= ZOOM_MIN) setPan([0, 0]);
};
let drag: { x: number; y: number; px: number; py: number } | undefined;
const onDown = (e: PointerEvent) => {
if (isVideo() || zoom() <= ZOOM_MIN) return;
drag = { x: e.clientX, y: e.clientY, px: pan()[0], py: pan()[1] };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
};
const onMove = (e: PointerEvent) => {
if (!drag) return;
setPan([drag.px + (e.clientX - drag.x), drag.py + (e.clientY - drag.y)]);
};
const onUp = () => (drag = undefined);
const seg = createMemo(() => {
const o = other();
if (o?.segStartMs == null || o.segEndMs == null) return undefined;
return { start: o.segStartMs / 1000, end: o.segEndMs / 1000 };
});
/**
* 패널마다의 시간축. keeper 시간축 하나만 있으면 "이 사본의 몇 분 몇 초인가"에 답할 수 없다 —
* 잘라낸 사본·인트로가 붙은 사본은 자기 시각이 따로 있고, 사용자가 기억하는 것도 그쪽이다.
*
* 끌면 **그 파일 기준으로** 전체가 따라온다(멤버 시각 → keeper 시각으로 환산해 seekAll).
* 각자 따로 움직이면 나란히 놓은 의미가 없어지므로, 기준만 바뀌고 동기는 유지된다.
*/
const paneTimeline = (m: DupeMember, side: number) => {
const off = () => (side === 0 ? 0 : (m.offsetMs ?? 0) / 1000);
const dur = () => (m.durationMs ?? 0) / 1000;
/** 이 사본의 현재 시각 (keeper 시각 − 오프셋) */
const at = () => pos() - off();
/** keeper 위치가 이 파일에 없는 구간 — 값을 끝에 붙여두고 흐리게 표시한다 */
const oor = () => dur() > 0 && (at() < -0.05 || at() > dur() + 0.05);
const clamped = () => Math.min(Math.max(at(), 0), Math.max(dur(), 0.1));
const pct = () => (dur() > 0 ? (clamped() / dur()) * 100 : 0);
/** 일치 구간을 이 사본의 시간축으로 옮긴 띠 (keeper 자신은 구간 기록이 없다) */
const band = createMemo(() => {
if (m.segStartMs == null || m.segEndMs == null || dur() <= 0) return undefined;
const a = (m.segStartMs / 1000 - off()) / dur();
const b = (m.segEndMs / 1000 - off()) / dur();
const lo = Math.max(0, Math.min(a, b));
const hi = Math.min(1, Math.max(a, b));
return hi > lo ? { left: `${lo * 100}%`, width: `${(hi - lo) * 100}%` } : undefined;
});
return (
<div class="compare-time" classList={{ "is-oor": oor() }}>
<div class="compare-time__wrap">
<Show when={band()}>
{(b) => <div class="compare-time__seg" style={{ left: b().left, width: b().width }} />}
</Show>
<input
type="range"
class="compare-time__bar"
style={{ "--p": `${pct()}%` }}
min={0}
max={Math.max(dur(), 0.1)}
step={0.05}
value={clamped()}
aria-label={m.name}
onInput={(e) => seekAll(Number(e.currentTarget.value) + off())}
/>
</div>
<span class="compare-time__label">
{oor() ? ko.compare.outOfRangeShort : fmt(at())} / {fmt(dur())}
</span>
</div>
);
};
const paneMeta = (m: DupeMember, side: number) => (
<div class="compare-head">
<span class="compare-head__name" title={`${m.dir}\\${m.name}`}>
{m.name}
</span>
<Show when={m.isKeeper}>
<span class="chip chip--xs">{ko.dedupe.keeper}</span>
</Show>
<span class="compare-head__meta">
{m.width && m.height ? `${m.width}×${m.height}` : ""}
{/* 길이는 여기서도 말한다 — 전체 보기에서 '이것만 짧다'가 한눈에 보인다 */}
{m.durationMs != null && m.durationMs > 0 ? ` · ${fmtDuration(m.durationMs)}` : ""}
{m.distance != null ? ` · ${ko.dedupe.distance(m.distance)}` : ""}
</span>
<span class="ml-auto flex flex-none items-center gap-1">
{/* "이게 대체 어디 있는 파일이지" — 검토 중 가장 자주 나오는 질문이다 */}
<button
class="toolbar-btn toolbar-btn--icon"
title={ko.compare.reveal}
onClick={() => {
void setView({
type: "folder",
folderId: m.folderId,
recursive: false,
sourceId: m.sourceId,
});
closeCompare();
closeDedupe();
}}
>
<IconFolder size={14} />
</button>
<Show when={isVideo()}>
{/* 듣고 있는 쪽을 다시 누르면 전부 끈다 — 켠 버튼이 곧 끄는 버튼이다 */}
<button
class="toolbar-btn toolbar-btn--icon"
title={audioPane() === side ? ko.compare.audioOn : ko.compare.audioOff}
onClick={() => setAudio(audioPane() === side ? MUTED : side)}
>
{audioPane() === side ? <IconVolumeHigh size={14} /> : <IconVolumeMute size={14} />}
</button>
</Show>
</span>
</div>
);
return (
<Show when={target() && keeper() && other()}>
<div class="modal-scrim" onClick={closeCompare}>
<div
class="modal-card compare-card"
tabindex={-1}
onClick={(e) => e.stopPropagation()}
ref={(el) => queueMicrotask(() => el.focus())}
>
<div class="modal-head">
<span class="modal-head__title">{ko.compare.title}</span>
{/* 전체 보기에서는 사본마다 정렬값이 달라 한 문장으로 말할 수 없다 —
그때는 각 패널 제목이 자기 값을 말하고, 여기서는 몇 개를 보는지만 말한다 */}
<span class="modal-head__sub">
<Show
when={!showAll()}
fallback={ko.compare.allCount(panes().length)}
>
{other()!.offsetMs == null
? ko.compare.noAlignment
: fmtShift(other()!.offsetMs as number)}
<Show when={seg()}>
{(s) => ` · ${ko.compare.segment(fmt(s().start), fmt(s().end))}`}
</Show>
</Show>
</span>
<button class="icon-btn ml-auto" onClick={closeCompare} title={ko.common.close}>
<IconX size={14} />
</button>
</div>
{/* 사본이 2개 이상이면 보기를 고를 수 있다: 1:1(크게) 또는 전체(한눈에) */}
<Show when={others().length > 1}>
<div class="modal-bar">
<div class="segmented flex-none">
<button classList={{ "is-on": !showAll() }} onClick={() => setShowAll(false)}>
{ko.compare.modePair}
</button>
<button
classList={{ "is-on": showAll() }}
title={ko.compare.modeAllHint(others().length + 1)}
onClick={() => setShowAll(true)}
>
{ko.compare.modeAll}
</button>
</div>
{/* 1:1일 때만 상대를 고른다 — 전체 보기에서는 고를 것이 없다 */}
<Show when={!showAll()}>
<span class="flex-none text-[var(--text-muted)]">{ko.compare.pick}</span>
<For each={others()}>
{(m) => (
<button
class="chip chip--xs"
classList={{ "chip--accent": m.fileId === target()!.otherId }}
onClick={() => setTarget({ group: group()!, otherId: m.fileId })}
>
{m.name}
</button>
)}
</For>
</Show>
</div>
</Show>
<div
class="compare-grid"
style={{ "--compare-cols": String(cols()) }}
onWheel={onWheel}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
classList={{ "is-pannable": !isVideo() && zoom() > ZOOM_MIN }}
>
<For each={panes()}>
{(m, i) => (
<div class="compare-col">
{paneMeta(m, i())}
<Show
when={isVideo()}
fallback={<ImagePane member={m} zoom={zoom()} pan={pan()} />}
>
{/* keyed — 이 자리에 다른 파일이 오면 패널을 **다시 만든다**. 안 그러면
<video>의 src 설정이 onMount에서 한 번만 돌아 이전 파일이 계속 재생된다. */}
<Show when={m} keyed>
{(mm) => (
<VideoPane
member={mm}
// keeper는 기준이므로 오프셋 0. 나머지는 자기 값으로 맞춘다.
offsetMs={i() === 0 ? 0 : mm.offsetMs}
muted={audioPane() !== i()}
playing={playing()}
startAtSec={pos()}
register={(a) => (apis[i()] = a)}
/>
)}
</Show>
</Show>
{/* 자기 시간축 — 영상에만. 사진은 시간이 없다 */}
<Show when={isVideo()}>{paneTimeline(m, i())}</Show>
</div>
)}
</For>
</div>
{/* 컨트롤 — 영상은 재생/시크(keeper 기준), 이미지는 확대 */}
<div class="modal-bar">
<Show
when={isVideo()}
fallback={
<>
<span class="flex-none text-[var(--text-muted)]">{ko.compare.zoomHint}</span>
<span class="flex-none tabular-nums">{Math.round(zoom() * 100)}%</span>
<button
class="toolbar-btn toolbar-btn--icon ml-auto"
title={ko.compare.reset}
onClick={() => {
setZoom(1);
setPan([0, 0]);
}}
>
<IconResetZoom size={14} />
</button>
</>
}
>
<button
class="toolbar-btn toolbar-btn--icon"
title={ko.compare.back(SKIP)}
onClick={() => seekAll(pos() - SKIP)}
>
<IconRewind size={14} />
</button>
<button
class="toolbar-btn toolbar-btn--primary"
onClick={() => setPlay(!playing())}
title={playing() ? ko.compare.pause : ko.compare.play}
>
{playing() ? <IconPause size={14} /> : <IconPlay size={14} />}
</button>
<button
class="toolbar-btn toolbar-btn--icon"
title={ko.compare.fwd(SKIP)}
onClick={() => seekAll(pos() + SKIP)}
>
<IconForward size={14} />
</button>
<input
type="range"
class="compare-seek"
min={0}
max={Math.max(totalSec(), 0.1)}
step={0.05}
value={pos()}
onInput={(e) => seekAll(Number(e.currentTarget.value))}
/>
<span class="flex-none tabular-nums text-[var(--text-muted)]">
{fmt(pos())} / {fmt(totalSec())}
</span>
{/* 전체 음소거는 여기에도 둔다 — 패널이 8개면 '지금 소리 나는 칸'을 찾아
그 버튼을 누르는 것은 조작이 아니라 수색이다 */}
<button
class="toolbar-btn toolbar-btn--icon flex-none"
classList={{ "is-on": audioPane() === MUTED }}
title={audioPane() === MUTED ? ko.compare.unmuteAll : ko.compare.muteAll}
onClick={toggleMuteAll}
>
{audioPane() === MUTED ? <IconVolumeMute size={14} /> : <IconVolumeHigh size={14} />}
</button>
<Show when={seg()}>
{(s) => (
<button class="toolbar-btn flex-none" onClick={() => seekAll(s().start)}>
{ko.compare.toSegment}
</button>
)}
</Show>
</Show>
</div>
</div>
</div>
</Show>
);
};
+532 -190
View File
@@ -1,4 +1,16 @@
// 중복 검토 패널 — 전체화면 오버레이. 그룹별 나란히 비교 + keeper 외 휴지통.
// 중복 검토 — 모달. 기존 모달 3종(MoveDialog·RemoteDialog·SourceEditDialog)과 같은 언어를
// 쓴다: 스크림 + 카드 + Esc + 바깥 클릭. 전에는 불투명 전체화면(fixed inset-0 bg-[--bg-app])
// 이라 '중복 찾기'를 누르면 사용자에게는 창이 바뀐 것처럼 보였다 — 그게 이 변경의 이유다.
// 폭을 좁히는 것은 목적이 아니다. 그룹 목록은 길고 썸네일이 많아 작업 면적이 필요하므로
// 카드는 .modal-card--wide(min(1080px, 100vw64px))이고, 960px 창에서 896px로 줄어
// 가로 스크롤이 0이 된다.
//
// 진행은 이 컴포넌트가 소유하지 않는다. dedupeRunning / dedupeProgress / dedupeJob /
// dedupeCancelPending은 전부 store(모듈 시그널) 소유다. 그래서 모달을 닫아도 탐지는
// 계속되고, 사이드바 작업 행에서 계속 보이며 거기서도 취소된다(커밋 ac3b527의 성질).
// 여기서는 자체 진행 시그널도, 타이머도 만들지 않는다 — 읽기만 한다.
// 취소는 사이드바와 똑같이 requestCancelDedupe() 하나를 부른다(파괴적이지 않으므로 확인 없음).
import { IconCheck, IconWarning, IconX } from "../../ui/icons";
import {
createEffect,
createMemo,
@@ -6,25 +18,38 @@ import {
createSignal,
For,
on,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import { ask } from "@tauri-apps/plugin-dialog";
import { compareOpen, openCompare } from "./CompareView";
import { byTier, tierOf } from "./dupeTier";
import { fmtDuration, fmtSize } from "./format";
import { ko } from "../../i18n/ko";
import {
dismissDupePair,
listDupeGroups,
markDelete,
resolveDupeGroup,
setDupeKeeper,
setSigSpeed,
sigSpeed,
thumbUrl,
trashFiles,
type DupeGroup,
type DupeMember,
type SigSpeed,
} from "../../ipc/commands";
import {
closeDedupe,
dedupeCancelPending,
dedupeJob,
dedupeOpen,
dedupeProgress,
dedupeRunning,
info,
pending,
refreshMarked,
refreshSidebar,
refreshSnapshot,
requestCancelDedupe,
@@ -33,49 +58,129 @@ import {
view,
} from "../../state/store";
function fmtSize(bytes: number): string {
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`;
/** 타일 부제 — 없는 값은 말하지 않는다. 거리는 ko.dedupe.distance가 문장을 만든다. */
function metaLine(m: DupeMember): string {
const parts: string[] = [];
if (m.width && m.height) parts.push(`${m.width}×${m.height}`);
// 길이는 해상도 바로 뒤 — 영상 그룹에서 '잘린 사본'을 고르는 가장 빠른 단서다
if (m.durationMs != null && m.durationMs > 0) parts.push(fmtDuration(m.durationMs));
parts.push(fmtSize(m.size));
if (m.distance != null) parts.push(ko.dedupe.distance(m.distance));
return parts.join(" · ");
}
const KIND_LABEL: Record<number, string> = { 0: "완전 동일", 1: "유사 이미지", 2: "유사 영상" };
/** 한 번에 그리는 그룹 수. 수백 그룹에서 썸네일 수천 장을 한꺼번에 붙이지 않기 위한 상한. */
const PAGE = 20;
const FOCUSABLE =
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
export const DedupePanel: Component = () => {
const [includeVideo, setIncludeVideo] = createSignal(false);
// 회전·반전본은 dHash가 완전히 달라 변형 8개를 따로 조회해야 한다 — 비용이 몇 배라 기본 꺼짐
const [includeRotated, setIncludeRotated] = createSignal(false);
const [threshold, setThreshold] = createSignal(5);
// 지문 생성 속도 — 값의 진실원은 DB다(백필 스레드가 거기서 읽는다). 여기서는 비추기만 한다.
const [speed, setSpeed] = createSignal<SigSpeed>("normal");
createEffect(() => {
if (dedupeOpen()) void sigSpeed().then(setSpeed).catch(() => {});
});
const changeSpeed = async (s: SigSpeed) => {
const prev = speed();
setSpeed(s); // 낙관적 — 버튼이 즉시 반응해야 한다
try {
await setSigSpeed(s);
} catch (e) {
setSpeed(prev);
toast(String(e));
}
};
const [groupsVersion, setGroupsVersion] = createSignal(0);
/** 그룹 액션 중인 그룹 id. 진행 중에는 모든 그룹의 두 버튼을 잠근다(중복 실행 방지). */
const [busyGroup, setBusyGroup] = createSignal<number | undefined>();
const busy = () => busyGroup() != null;
const [shown, setShown] = createSignal(PAGE);
const [groups] = createResource(
let cardEl: HTMLDivElement | undefined;
// 목록 조회 실패를 여기서 삼킨다 — Solid 리소스는 읽는 순간 오류를 다시 던지므로
// 삼키지 않으면 렌더 도중 터진다. 사용자에게는 토스트로 알린다.
const [groups, { mutate: mutateGroups }] = createResource(
() => (dedupeOpen() ? groupsVersion() : undefined),
() => listDupeGroups(),
() =>
listDupeGroups().catch((e: unknown) => {
toast(String(e));
return [] as DupeGroup[];
}),
);
// 목록이 바뀌면(열기·재조회·탐지 완료) 페이징을 처음으로 되돌린다.
createEffect(on(groups, () => setShown(PAGE)));
// 확실한 것을 앞으로 — 몰아서 정리하고 의심스러운 것만 눈으로 보게 한다
const sorted = createMemo(() => [...(groups() ?? [])].sort(byTier));
const visible = () => sorted().slice(0, shown());
const restCount = () => Math.max(0, sorted().length - shown());
const tierCounts = createMemo(() => {
let certain = 0;
for (const g of sorted()) if (tierOf(g) === "certain") certain += 1;
return { certain, suspect: sorted().length - certain };
});
// 현재 뷰가 폴더/소스면 그 범위로 좁힐 수 있다 (그 외 전체/검색/태그/스마트는 전체만).
const currentScope = createMemo<
{ label: string; args: { folderId?: number; recursive?: boolean; sourceId?: number } } | null
>(() => {
const v = view();
if (v.type === "folder") return { label: ko.dedupe.scopeFolder, args: { folderId: v.folderId, recursive: v.recursive } };
if (v.type === "folder")
return { label: ko.dedupe.scopeFolder, args: { folderId: v.folderId, recursive: v.recursive } };
if (v.type === "source") return { label: ko.dedupe.scopeSource, args: { sourceId: v.sourceId } };
return null;
});
// 패널을 열 때: 범위를 좁힐 수 있으면 기본값을 '현재 위치'로.
// 모달을 열 때: 범위를 좁힐 수 있으면 기본값을 '현재 위치'로.
const [useCurrentScope, setUseCurrentScope] = createSignal(true);
createEffect(() => {
if (dedupeOpen()) setUseCurrentScope(currentScope() != null);
});
/** 실행 중이면 '시작 시점에 캡처된 작업' — 그 사이 다른 폴더로 이동해도 이 말은 바뀌지 않는다. */
const runningJob = () => (dedupeRunning() ? dedupeJob() : undefined);
/** 끝난 뒤에는 같은 캡처가 '지금 보이는 결과의 조건'을 말한다. */
const resultJob = () => (dedupeRunning() ? undefined : dedupeJob());
/** 진행률(%) — 총량을 모르는 구간은 undefined이고, 그때 막대는 셔틀(is-indet)로 돈다. */
const pct = (): number | undefined => {
const p = dedupeProgress();
if (!p || p.phase === "done" || p.total <= 0) return undefined;
return Math.min(100, Math.max(0, Math.round((100 * p.current) / p.total)));
};
/** 헤더 한 줄 — 취소를 누른 뒤에는 사이드바 값 칸과 똑같이 '취소 중…' 하나로 통일한다. */
const statusText = (): string => {
if (dedupeCancelPending()) return ko.common.canceling;
const p = dedupeProgress();
return p && p.phase !== "done"
? ko.dedupe.scanning(p.phase, p.current, p.total)
: ko.dedupe.starting;
};
const runScan = async () => {
const scoped = useCurrentScope() ? currentScope() : null;
try {
await runDedupe({ ...(scoped?.args ?? {}), imageThreshold: threshold(), includeVideo: includeVideo() });
// scopeLabel은 IPC로 가지 않는다 — store가 '지금 도는 조건'을 말하기 위한 캡처다.
await runDedupe({
...(scoped?.args ?? {}),
imageThreshold: threshold(),
includeVideo: includeVideo(),
includeRotated: includeRotated(),
scopeLabel: scoped?.label ?? ko.dedupe.scopeAll,
});
} catch (e) {
toast(String(e));
}
};
// 탐지 완료(실행중 true→false) 시 결과 목록 갱신 — 패널이 닫혀 있다 열려도 재조회.
// 탐지 완료(실행중 true→false) 시 결과 목록 갱신 — 모달이 닫혀 있다 열려도 재조회된다.
createEffect(
on(
dedupeRunning,
@@ -86,212 +191,449 @@ export const DedupePanel: Component = () => {
),
);
/** keeper 외 전부 휴지통 */
const resolveGroup = async (g: DupeGroup) => {
const losers = g.members.filter((m) => !m.isKeeper).map((m) => m.fileId);
if (losers.length === 0) {
await resolveDupeGroup(g.id);
setGroupsVersion((v) => v + 1);
return;
/**
* 보관 항목을 바꾼다 — 이 값이 '나머지 휴지통'의 삭제 대상을 정한다.
*
* 목록을 다시 부르지 않고 손안의 데이터를 고친다: 재조회는 페이징을 처음으로 되돌려
* (`on(groups, …)`) 스크롤해 내려온 사용자를 맨 위로 튕겨낸다. 순서도 그대로 둔다 —
* 누른 타일이 갑자기 맨 앞으로 뛰면 무엇을 눌렀는지 놓친다.
*/
const chooseKeeper = async (g: DupeGroup, m: DupeMember) => {
if (busy() || m.isKeeper) return;
try {
// 정렬값(offsetMs·구간)이 새 기준으로 옮겨진 멤버들을 그대로 받아 갈아끼운다.
// 여기서 isKeeper만 고치면 비교 재생이 옛 기준 오프셋으로 어긋난다(실측: 5초).
const members = await setDupeKeeper(g.id, m.fileId);
mutateGroups((prev) => (prev ?? []).map((x) => (x.id === g.id ? { ...x, members } : x)));
} catch (e) {
toast(String(e));
}
const yes = await ask(ko.dedupe.trashLosersConfirm(losers.length), {
title: "Archive",
kind: "warning",
});
if (!yes) return;
await trashFiles(losers);
await resolveDupeGroup(g.id);
toast(ko.ops.trashDone(losers.length));
refreshSidebar();
await refreshSnapshot();
setGroupsVersion((v) => v + 1);
};
const keepAll = async (g: DupeGroup) => {
// 그룹 내 모든 쌍을 dismiss (재탐지 시 제외) + 해결 표시
for (let i = 0; i < g.members.length; i++) {
for (let j = i + 1; j < g.members.length; j++) {
await dismissDupePair(g.members[i].fileId, g.members[j].fileId);
/**
* 보관 항목 외 전부를 **'삭제 예정'으로 표시**한다. 지우지 않는다.
*
* 예전에는 여기서 바로 휴지통으로 보냈다. 두 가지가 문제였다:
* ① 결과(`OpSummary`)를 버리고 무조건 "N개를 휴지통으로 보냈습니다"라고 말해서,
* OS 휴지통이 거부한 경우(네트워크·이동식 드라이브)에도 성공한 것처럼 보였다.
* ② 중복 검토는 한 번에 수십 그룹을 훑는 작업이라, 그 자리에서 지우면 되돌리기가 배치
* 단위로만 가능하다. 표시해 두고 '정리'에서 한 번에 확인하는 편이 실수를 되돌리기 쉽다.
*
* 실제 삭제는 사이드바 '정리 → 비우기'가 맡는다 — 확인창도 용량 집계도 거기 있다.
*/
const resolveGroup = async (g: DupeGroup) => {
if (busy()) return;
const losers = g.members.filter((m) => !m.isKeeper).map((m) => m.fileId);
setBusyGroup(g.id);
try {
if (losers.length > 0) {
// 파일을 건드리지 않고 태그만 붙이므로 확인창을 두지 않는다 — 되돌리기는
// 항목을 골라 표시를 해제하거나 실행 취소로 한다.
const r = await markDelete(losers);
toast(ko.dedupe.markedForDelete(r.count));
}
await resolveDupeGroup(g.id);
if (losers.length > 0) {
refreshSidebar();
await refreshMarked();
await refreshSnapshot();
}
setGroupsVersion((v) => v + 1);
} catch (e) {
toast(String(e));
} finally {
setBusyGroup(undefined);
}
};
/** 그룹 내 모든 쌍을 dismiss(재탐지 시 제외) + 해결 표시. 멤버가 많으면 오래 걸린다. */
const keepAll = async (g: DupeGroup) => {
if (busy()) return;
setBusyGroup(g.id);
try {
for (let i = 0; i < g.members.length; i++) {
for (let j = i + 1; j < g.members.length; j++) {
await dismissDupePair(g.members[i].fileId, g.members[j].fileId);
}
}
await resolveDupeGroup(g.id);
setGroupsVersion((v) => v + 1);
} catch (e) {
toast(String(e));
} finally {
setBusyGroup(undefined);
}
};
// Esc — 모달만 닫는다. 탐지는 계속되고 취소도 계속 가능하다.
// 그룹 액션(busy) 중에도 닫힌다: 백엔드 작업은 창이 떠 있는지와 무관하게 끝난다.
onMount(() => {
const onKey = (e: KeyboardEvent) => {
// 비교 뷰가 위에 떠 있으면 그쪽이 먼저 닫힌다 — 아래 모달까지 같이 닫으면
// 사용자는 결과 목록을 잃는다. (둘 다 capture 단계라 등록 순서로 이 핸들러가 먼저 온다)
if (compareOpen()) return;
if (!dedupeOpen() || e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
closeDedupe();
};
window.addEventListener("keydown", onKey, { capture: true });
onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true }));
});
// Tab 트랩 — 카드 안을 순환한다. 빠져나가면 그리드 스크롤러(tabindex=0)에 착지해
// 방향키·Delete가 뒤에서 살아나므로 반드시 막는다.
const trapTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !cardEl) return;
const stops = [...cardEl.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
(el) => el.offsetParent !== null,
);
if (stops.length === 0) {
e.preventDefault();
cardEl.focus();
return;
}
const active = document.activeElement as HTMLElement | null;
const inside = active != null && active !== cardEl && cardEl.contains(active);
const edge = e.shiftKey ? stops[0] : stops[stops.length - 1];
if (!inside || active === edge) {
e.preventDefault();
(e.shiftKey ? stops[stops.length - 1] : stops[0]).focus();
}
await resolveDupeGroup(g.id);
setGroupsVersion((v) => v + 1);
};
return (
<Show when={dedupeOpen()}>
<div class="fixed inset-0 z-40 flex flex-col bg-[var(--bg-app)]">
{/* 헤더 */}
<div class="flex h-12 shrink-0 items-center gap-4 border-b border-[var(--border)] px-4">
<span class="text-sm font-semibold">{ko.dedupe.title}</span>
<div class="flex items-center gap-3 text-xs text-[var(--text-secondary)]">
<label class="flex items-center gap-1.5">
{ko.dedupe.threshold}
{/* 바깥 클릭으로 닫힘 — 카드는 stopPropagation으로 스크림 클릭을 삼킨다 */}
<div class="modal-scrim" onClick={closeDedupe}>
<div
class="modal-card modal-card--wide"
tabindex={-1}
ref={(el) => {
cardEl = el;
// 열 때 포커스를 카드로 옮긴다 — 안 하면 뒤 그리드가 방향키·Ctrl+A·Delete를 먹는다.
// 마이크로태스크로 미루는 이유: ref는 DOM에 붙기 전에 불린다.
queueMicrotask(() => el.focus());
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={trapTab}
>
{/* 프레임 ① 헤더 — 제목 · 진행 한 줄 · 취소 · 닫기 */}
<div class="modal-head">
<span class="modal-head__title">{ko.dedupe.title}</span>
<Show when={dedupeRunning()}>
<span
class="min-w-0 truncate text-[11px] tabular-nums text-[var(--accent-text)]"
title={statusText()}
>
{statusText()}
</span>
</Show>
<div class="ml-auto flex flex-none items-center gap-2">
<Show when={dedupeRunning()}>
<button
class="toolbar-btn"
disabled={dedupeCancelPending()}
onClick={requestCancelDedupe}
>
{ko.common.cancel}
</button>
</Show>
<button class="icon-btn" onClick={closeDedupe} title={ko.common.close}>
<IconX size={14} />
</button>
</div>
{/* 헤더 하단 border 위에 겹치는 진행 하드라인 */}
<Show when={dedupeRunning()}>
<div class="meter modal-head__progress" classList={{ "is-indet": pct() === undefined }}>
<div
class="meter__fill"
style={pct() !== undefined ? { width: `${pct()!}%` } : undefined}
/>
</div>
</Show>
</div>
{/* 프레임 ② 컨트롤 줄 — 960px에서도 한 줄 */}
<div class="modal-bar">
<Show
when={runningJob()}
fallback={
<>
<span class="flex-none text-[var(--text-muted)]">{ko.dedupe.scopeLabel}</span>
{/* 범위를 좁힐 수 있을 때만 선택 가능, 아니면 전체 라이브러리 고정 */}
<Show
when={currentScope()}
fallback={<span class="flex-none">{ko.dedupe.scopeAll}</span>}
>
{(sc) => (
<div class="segmented">
<button
classList={{ "is-on": useCurrentScope() }}
onClick={() => setUseCurrentScope(true)}
>
{sc().label}
</button>
<button
classList={{ "is-on": !useCurrentScope() }}
onClick={() => setUseCurrentScope(false)}
>
{ko.dedupe.scopeAll}
</button>
</div>
)}
</Show>
</>
}
>
{/* 실행 중에는 시작 시점에 캡처된 범위를 말하고 잠긴다 —
'현재 폴더'라고 말하면서 전체를 훑는 거짓말을 막는다 */}
{(j) => <span class="chip chip--accent flex-none">{ko.dedupe.scopeRunning(j().scopeLabel)}</span>}
</Show>
<label
class="flex flex-none items-center gap-1.5"
classList={{ "opacity-50": dedupeRunning() }}
>
<span>{ko.dedupe.threshold}</span>
<input
type="range"
min={0}
max={12}
value={threshold()}
class="accent-[var(--accent)]"
disabled={dedupeRunning()}
class="w-[88px] accent-[var(--accent)]"
onInput={(e) => setThreshold(Number(e.currentTarget.value))}
/>
<span class="w-4 tabular-nums">{threshold()}</span>
<span class="w-4 text-right tabular-nums">{threshold()}</span>
</label>
<label class="flex items-center gap-1.5">
<label
class="flex flex-none items-center gap-1.5"
classList={{ "opacity-50": dedupeRunning() }}
>
<input
type="checkbox"
checked={includeVideo()}
disabled={dedupeRunning()}
class="accent-[var(--accent)]"
onChange={(e) => setIncludeVideo(e.currentTarget.checked)}
/>
{ko.dedupe.includeVideo}
<span>{ko.dedupe.includeVideo}</span>
</label>
{/* 범위: 현재 뷰가 폴더/소스일 때만 선택 가능, 아니면 전체 라이브러리 고정 */}
<Show
when={currentScope()}
fallback={<span class="text-[var(--text-muted)]">{ko.dedupe.scopeAll}</span>}
>
<div class="flex items-center gap-1.5">
<span>{ko.dedupe.scopeLabel}</span>
<div class="flex overflow-hidden rounded border border-[var(--border)]">
<button
class="px-2 py-0.5"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": useCurrentScope(),
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": !useCurrentScope(),
}}
onClick={() => setUseCurrentScope(true)}
>
{currentScope()!.label}
</button>
<button
class="border-l border-[var(--border)] px-2 py-0.5"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": !useCurrentScope(),
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": useCurrentScope(),
}}
onClick={() => setUseCurrentScope(false)}
>
{ko.dedupe.scopeAll}
</button>
</div>
{/* 지문 속도 — 탐지 옵션이 아니라 **백그라운드 생성** 속도다. 탐지 중에도 바꿀 수 있다
(지문은 탐지와 별개로 상시 돌고, 백필 스레드가 배치마다 다시 읽는다). */}
<label class="flex flex-none items-center gap-1.5" title={ko.dedupe.speedHint}>
<span>{ko.dedupe.speed}</span>
<div class="segmented">
<button
classList={{ "is-on": speed() === "slow" }}
onClick={() => void changeSpeed("slow")}
>
{ko.dedupe.speedSlow}
</button>
<button
classList={{ "is-on": speed() === "normal" }}
onClick={() => void changeSpeed("normal")}
>
{ko.dedupe.speedNormal}
</button>
<button
classList={{ "is-on": speed() === "fast" }}
onClick={() => void changeSpeed("fast")}
>
{ko.dedupe.speedFast}
</button>
</div>
</Show>
</div>
<div class="ml-auto flex items-center gap-2">
<Show when={dedupeRunning()}>
<span class="text-[11px] text-[var(--accent)]">
{dedupeProgress()
? ko.dedupe.scanning(dedupeProgress()!.phase, dedupeProgress()!.current, dedupeProgress()!.total)
: ko.dedupe.starting}
</span>
<button
class="rounded border border-[var(--border)] px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
onClick={requestCancelDedupe}
>
{ko.common.cancel}
</button>
</Show>
<Show when={!dedupeRunning()}>
<button
class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white"
onClick={() => void runScan()}
>
{ko.dedupe.scan}
</button>
</Show>
<button
class="rounded px-2 py-1 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={closeDedupe}
title={ko.common.close}
</label>
<label
class="flex flex-none items-center gap-1.5"
classList={{ "opacity-50": dedupeRunning() }}
title={ko.dedupe.includeRotatedHint}
>
<input
type="checkbox"
checked={includeRotated()}
disabled={dedupeRunning()}
class="accent-[var(--accent)]"
onChange={(e) => setIncludeRotated(e.currentTarget.checked)}
/>
<span>{ko.dedupe.includeRotated}</span>
</label>
{/* 진행 중 재실행 방지 — store도 막지만 버튼에서 먼저 막아 눌리지 않게 한다 */}
<button
class="toolbar-btn toolbar-btn--primary ml-auto"
disabled={dedupeRunning()}
onClick={() => void runScan()}
>
{resultJob() ? ko.dedupe.rescan : ko.dedupe.scan}
</button>
</div>
</div>
{/* 그룹 목록 */}
<div class="min-h-0 flex-1 overflow-y-auto p-4">
<Show
when={groups() && groups()!.length > 0}
fallback={
<div class="grid h-full place-items-center text-sm text-[var(--text-muted)]">
{dedupeRunning() ? ko.dedupe.starting : ko.dedupe.empty}
</div>
}
>
<div class="mb-3 text-xs text-[var(--text-muted)]">
{ko.dedupe.groupCount(groups()!.length)}
</div>
<div class="flex flex-col gap-4">
<For each={groups()}>
{(g) => (
<div class="rounded-lg border border-[var(--border)] bg-[var(--bg-panel)] p-3">
<div class="mb-2 flex items-center gap-2">
<span class="rounded bg-[var(--bg-panel-raised)] px-2 py-0.5 text-[10px] text-[var(--text-secondary)]">
{KIND_LABEL[g.kind]}
</span>
<span class="text-[11px] text-[var(--text-muted)]">
{ko.dedupe.memberCount(g.members.length)}
</span>
<div class="ml-auto flex gap-2">
<button
class="rounded border border-[var(--border)] px-2 py-0.5 text-[11px] hover:bg-[var(--bg-hover)]"
onClick={() => void keepAll(g)}
>
{ko.dedupe.keepAll}
</button>
<button
class="rounded bg-[var(--danger)] px-2 py-0.5 text-[11px] text-white"
onClick={() => void resolveGroup(g)}
>
{ko.dedupe.keepBest}
</button>
</div>
</div>
<div class="flex flex-wrap gap-3">
<For each={g.members}>
{(m) => (
<div
class="w-40 overflow-hidden rounded border"
classList={{
"border-[var(--accent)]": m.isKeeper,
"border-[var(--border)]": !m.isKeeper,
}}
>
<div class="relative aspect-square bg-[var(--grid-cell-bg)]">
<Show when={info()}>
<img
src={thumbUrl(info()!, m.fileId)}
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
/>
</Show>
<Show when={m.isKeeper}>
<span class="absolute left-1 top-1 rounded bg-[var(--accent)] px-1.5 py-0.5 text-[10px] text-white">
{ko.dedupe.keeper}
</span>
</Show>
</div>
<div class="p-1.5">
<div class="truncate text-[11px]" title={`${m.dir}\\${m.name}`}>
{m.name}
</div>
<div class="text-[10px] text-[var(--text-muted)]">
{m.width && m.height ? `${m.width}×${m.height} · ` : ""}
{fmtSize(m.size)}
{m.distance != null ? ` · 거리 ${m.distance}` : ""}
</div>
</div>
</div>
)}
</For>
</div>
</div>
)}
</For>
{/* 지문이 아직 준비 안 된 항목이 있으면 말한다. 유사 탐지는 저장된 지문만
읽으므로 이 수가 0이 아니면 결과가 아직 완전하지 않다. 백그라운드가
채우는 대로 이 줄은 스스로 사라진다. */}
<Show when={pending().sigs > 0}>
<div class="modal-bar text-[var(--text-muted)]">
<IconWarning size={12} />
<span class="min-w-0 truncate">{ko.dedupe.sigPending(pending().sigs)}</span>
</div>
</Show>
{/* 프레임 ③ 본문 우물 — 여기만 스크롤한다 */}
<div class="modal-body">
<Show
when={(groups()?.length ?? 0) > 0}
fallback={
// min-h가 없으면 결과 0에서 카드가 헤더+바 높이로 쪼그라들어
// 결과가 있을 때와 창 크기가 튄다
<div class="grid h-full min-h-[160px] place-items-center px-6 text-center text-[12px] leading-5 text-[var(--text-muted)]">
{dedupeRunning() ? ko.dedupe.starting : ko.dedupe.empty}
</div>
}
>
<div class="mb-2.5 flex items-center gap-1.5 text-[10px] leading-[14px] text-[var(--text-muted)]">
<span class="flex-none tabular-nums">{ko.dedupe.groupCount(groups()!.length)}</span>
<span class="flex-none tabular-nums">
· {ko.dedupe.tierSummary(tierCounts().certain, tierCounts().suspect)}
</span>
<Show when={resultJob()}>
{(j) => <span class="min-w-0 truncate">· {ko.dedupe.scopeResult(j().scopeLabel)}</span>}
</Show>
</div>
<div class="flex flex-col gap-2.5">
<For each={visible()}>
{(g) => (
<div class="dupe-group">
<div class="dupe-group__head">
{/* 등급이 먼저 온다 — 사용자가 카드에서 가장 먼저 봐야 하는 값이다 */}
<span
class="chip chip--xs flex-none"
classList={{
"chip--accent": tierOf(g) === "certain",
"chip--danger": tierOf(g) === "suspect",
}}
title={
tierOf(g) === "certain"
? ko.dedupe.tierCertainWhy
: ko.dedupe.tierSuspectWhy
}
>
{tierOf(g) === "certain" ? ko.dedupe.tierCertain : ko.dedupe.tierSuspect}
</span>
<span class="chip chip--xs flex-none">{ko.dedupe.kindLabel(g.kind)}</span>
<span class="flex-none text-[10px] tabular-nums text-[var(--text-muted)]">
{ko.dedupe.memberCount(g.members.length)}
</span>
<div class="ml-auto flex flex-none gap-2">
{/* 지우기 전에 눈으로 확인할 수단 — 보관 항목과 나란히 놓는다.
영상은 탐지 때 알아낸 오프셋으로 맞춰 동기 재생한다. */}
<button
class="toolbar-btn"
title={ko.dedupe.compareHint}
onClick={() => {
const other = g.members.find((m) => !m.isKeeper);
if (other) openCompare(g, other.fileId);
}}
>
{ko.dedupe.compare}
</button>
<button
class="toolbar-btn"
disabled={busy()}
onClick={() => void keepAll(g)}
>
{ko.dedupe.keepAll}
</button>
{/* 파괴적이지 않다(태그만 붙인다) — 위험 색을 쓰지 않는다.
빨간 버튼은 '되돌릴 수 없음'을 뜻해야 그 신호가 유지된다. */}
<button
class="toolbar-btn"
disabled={busy()}
title={ko.dedupe.keepBestHint}
onClick={() => void resolveGroup(g)}
>
{ko.dedupe.keepBest}
</button>
</div>
</div>
<div class="dupe-grid">
<For each={g.members}>
{(m) => (
<div
class="dupe-tile"
classList={{ "is-keeper": m.isKeeper }}
// 타일을 누르면 그 사본을 보관 항목과 비교한다 (보관 항목 자신은 첫 사본과)
onClick={() => {
const other = m.isKeeper
? g.members.find((x) => !x.isKeeper)
: m;
if (other) openCompare(g, other.fileId);
}}
title={ko.dedupe.compareHint}
>
<div class="dupe-tile__thumb">
<Show when={info()}>
{(i) => (
<img
src={thumbUrl(i(), m.fileId)}
loading="lazy"
decoding="async"
alt=""
/>
)}
</Show>
{/* 보관 배지가 곧 보관을 **정하는** 손잡이다. 별도 버튼을 두면
'표시'와 '선택'이 따로 놀아 어느 쪽이 진짜인지 모호해진다.
타일 클릭(비교)과 겹치므로 전파를 멈춘다. */}
<button
class="dupe-tile__badge"
classList={{ "is-off": !m.isKeeper }}
// 보이는 상태(칠해짐/흐림)를 보조기술에도 그대로 전한다 —
// 안 그러면 모든 타일이 똑같이 "보관"이라고만 읽힌다
aria-pressed={m.isKeeper}
disabled={busy()}
title={
m.isKeeper ? ko.dedupe.keeperCurrent : ko.dedupe.keeperPick
}
onClick={(e) => {
e.stopPropagation();
void chooseKeeper(g, m);
}}
>
<IconCheck size={11} /> {ko.dedupe.keeper}
</button>
</div>
<div class="dupe-tile__body">
<div class="dupe-tile__name" title={`${m.dir}\\${m.name}`}>
{m.name}
</div>
<div class="dupe-tile__meta">{metaLine(m)}</div>
</div>
</div>
)}
</For>
</div>
</div>
)}
</For>
</div>
<Show when={restCount() > 0}>
<button
class="toolbar-btn mt-2.5 w-full"
onClick={() => setShown((n) => n + PAGE)}
>
{ko.dedupe.moreGroups(restCount())}
</button>
</Show>
</Show>
</div>
</div>
</div>
</Show>
+62
View File
@@ -0,0 +1,62 @@
// 중복 그룹 등급 — '확실'과 '의심' 둘로만 나눈다.
//
// 등급은 저장하지 않고 **근거에서 파생**한다(거리·정렬 오프셋·일치 구간). 규칙이 바뀔 때
// 마이그레이션이 필요 없고, 저장된 값이 낡아 근거와 어긋나는 일도 없다.
//
// 왜 두 단계인가: 사용자가 할 결정은 "그냥 정리해도 되나 / 눈으로 봐야 하나" 둘뿐이다.
// 세 단계 이상은 경계에서 무슨 뜻인지 아무도 모른다.
import type { DupeGroup, DupeMember } from "../../ipc/commands";
export type Tier = "certain" | "suspect";
/** 이미지: 평균 거리가 이 점수 이상이면 확실 (score = 1 평균거리/64) */
const IMAGE_CERTAIN_SCORE = 0.95;
/** 영상: 짧은 쪽을 이 비율 이상 덮었으면 확실 */
const VIDEO_CERTAIN_COVERAGE = 0.6;
/** 일치 구간이 짧은 쪽 재생시간의 몇 %를 덮는가. 근거가 없으면 undefined. */
export function coverageOf(keeper: DupeMember, m: DupeMember): number | undefined {
if (m.segStartMs == null || m.segEndMs == null) return undefined;
const shorter = Math.min(keeper.durationMs ?? 0, m.durationMs ?? 0);
if (shorter <= 0) return undefined;
return (m.segEndMs - m.segStartMs) / shorter;
}
/**
* 그룹 등급.
* - 완전 동일(kind 0): 바이트가 같으므로 항상 확실.
* - 유사 이미지: 평균 거리로 본다. 한 멤버가 크롭본이라 멀다고 계열 전체를 의심으로
* 떨어뜨리지 않기 위해 최댓값이 아니라 평균(=score)을 쓴다.
* - 유사 영상: 프레임 거리는 코덱에 따라 크게 흔들려서 기준으로 쓸 수 없다.
* **정렬을 직접 확인했는지(offsetMs)와 얼마나 덮었는지(구간/길이)**가 판단 근거다.
*/
export function tierOf(g: DupeGroup): Tier {
if (g.kind === 0) return "certain";
const keeper = g.members.find((m) => m.isKeeper) ?? g.members[0];
const others = g.members.filter((m) => m.fileId !== keeper.fileId);
if (others.length === 0) return "suspect";
if (g.kind === 1) {
// score가 없으면(v5 이전에 만들어진 그룹) 거리 평균으로 직접 계산한다
const s =
g.score ??
(() => {
const ds = others.map((m) => m.distance).filter((d): d is number => d != null);
if (ds.length !== others.length) return 0;
return 1 - ds.reduce((a, b) => a + b, 0) / ds.length / 64;
})();
return s >= IMAGE_CERTAIN_SCORE ? "certain" : "suspect";
}
// 영상: 전이 병합으로 들어와 정렬을 확인하지 못한 멤버가 하나라도 있으면 의심
const allAligned = others.every((m) => m.offsetMs != null);
const covered = others.every((m) => (coverageOf(keeper, m) ?? 0) >= VIDEO_CERTAIN_COVERAGE);
return allAligned && covered ? "certain" : "suspect";
}
/** 확실을 먼저, 그 안에서 종류 → 멤버 많은 순. 확실한 것부터 몰아서 처리할 수 있게. */
export function byTier(a: DupeGroup, b: DupeGroup): number {
const ta = tierOf(a) === "certain" ? 0 : 1;
const tb = tierOf(b) === "certain" ? 0 : 1;
return ta - tb || a.kind - b.kind || b.members.length - a.members.length;
}
+21
View File
@@ -0,0 +1,21 @@
// 중복 검토 화면의 수치 서식. UI 문장이 아니라 숫자 표기이므로 ko를 거치지 않는다.
//
// 목록 타일과 비교 뷰가 같은 값을 다르게 적으면(한쪽 "1:05", 다른 쪽 "65초") 같은 파일을
// 두 화면에서 볼 때 사용자가 다른 파일로 읽는다 — 그래서 한 곳에 둔다.
/** 재생시간 m:ss (한 시간 넘으면 h:mm:ss). 영상 사본은 길이가 곧 '잘렸는지'의 단서다. */
export function fmtDuration(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = String(s % 60).padStart(2, "0");
return h > 0 ? `${h}:${String(m).padStart(2, "0")}:${sec}` : `${m}:${sec}`;
}
/** 크기 표기 — 타일 부제가 좁아 단위 사이를 띄우지 않는다. */
export 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`;
}
+164 -5
View File
@@ -4,19 +4,37 @@ import { ask } from "@tauri-apps/plugin-dialog";
import { ko } from "../../i18n/ko";
import {
assignTags,
editFiles,
emptyDeleted,
listTags,
pendingDeleteStats,
renameFile,
trashFiles,
undoLast,
type EditOp,
} from "../../ipc/commands";
import {
bumpTagVersion,
clearSelection,
markedIds,
refreshMarked,
refreshSidebar,
refreshSnapshot,
selected,
snapshot,
toast,
toggleMarkDelete,
trashTag,
} from "../../state/store";
import { openMoveDialog } from "./MoveDialog";
import { MAX_PANES, openMultiPlay } from "../viewer/MultiPlayView";
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`;
}
interface MenuState {
x: number;
@@ -32,15 +50,17 @@ export const closeContextMenu = () => setMenu(undefined);
const [renameTarget, setRenameTarget] = createSignal<{ id: number; name: string } | undefined>();
export const openRenameDialog = (id: number, name: string) => setRenameTarget({ id, name });
export async function performTrash(): Promise<void> {
/** 즉시 OS 휴지통으로 (Shift+Delete). ids 미지정 시 현재 선택 항목. */
export async function performTrash(explicitIds?: number[]): Promise<void> {
try {
const ids = [...selected()];
const ids = explicitIds ?? [...selected()];
if (ids.length === 0) return;
const yes = await ask(ko.ops.trashConfirm(ids.length), { title: "Archive", kind: "warning" });
if (!yes) return;
const summary = await trashFiles(ids, (p) => toast(ko.ops.inProgress(p.done, p.total)));
toast(ko.ops.trashDone(summary.done));
clearSelection();
await refreshMarked();
refreshSidebar();
await refreshSnapshot();
} catch (e) {
@@ -49,6 +69,107 @@ export async function performTrash(): Promise<void> {
}
}
/** 선택 항목 삭제 표시 토글 (Delete) — 파일은 그대로, Ctrl+Z로 되돌릴 수 있다. */
export async function performMarkDelete(): Promise<void> {
const ids = [...selected()];
if (ids.length === 0) return;
await toggleMarkDelete(ids);
}
/**
* 비우기 — 삭제 예정 항목을 디스크에서 영구 삭제한다.
* 되돌릴 수 없으므로 개수·총 용량을 보여주고 명시적으로 확인받는다.
*/
export async function performEmptyDeleted(): Promise<void> {
try {
const stats = await pendingDeleteStats();
if (stats.count === 0) {
toast(ko.trash.emptyNothing);
return;
}
const yes = await ask(ko.trash.emptyConfirm(stats.count, fmtSize(stats.bytes)), {
title: ko.trash.emptyTitle,
kind: "warning",
});
if (!yes) return;
const summary = await emptyDeleted((p) => toast(ko.ops.inProgress(p.done, p.total)));
toast(
summary.skipped > 0 || summary.failed > 0
? ko.trash.emptyPartial(summary.done, summary.skipped, summary.failed)
: ko.trash.emptyDone(summary.done),
);
clearSelection();
await refreshMarked();
refreshSidebar();
await refreshSnapshot();
} catch (e) {
toast(String(e));
console.error("performEmptyDeleted", e);
}
}
/**
* 회전·반전 — **원본을 직접 고친다**. ids 미지정 시 현재 선택 항목.
*
* 확인을 받지 않는 이유: 회전은 무손실이고 Ctrl+Z로 즉시 되돌아간다.
* 매번 물어보면 사진 열 장 돌리는 데 열 번 확인을 눌러야 한다.
*/
export async function performEdit(op: EditOp, explicitIds?: number[]): Promise<void> {
const ids = explicitIds ?? [...selected()];
if (ids.length === 0) {
toast(ko.edit.selectFirst);
return;
}
try {
const s = await editFiles(ids, op, (p) => {
// 한 장짜리는 즉시 끝나므로 알리지 않는다 — 토스트만 깜빡인다
if (p.total > 3) toast(ko.edit.working(p.done, p.total));
});
if (s.failed > 0) toast(ko.edit.doneWithFail(s.done, s.failed));
else if (s.reencoded > 0) toast(ko.edit.doneReencoded(s.done, s.reencoded));
else toast(ko.edit.done(s.done));
for (const e of s.errors.slice(0, 3)) toast(e);
await refreshSnapshot();
} catch (e) {
toast(String(e));
}
}
/**
* 고른 영상들을 한 화면에서 함께 재생한다.
*
* 선택 안에 사진이 섞여 있으면 영상만 골라낸다 — 사진은 '재생'할 것이 없어서
* 칸을 차지하면 영상 몫이 줄어든다.
*/
export function performMultiPlay(): void {
const snap = snapshot();
if (!snap) return;
const sel = selected();
const videos: number[] = [];
for (let i = 0; i < snap.count; i++) {
const id = snap.id(i);
if (sel.has(id) && snap.kind(i) === 1) videos.push(id);
}
if (videos.length < 2) {
toast(ko.multi.needTwo);
return;
}
if (videos.length > MAX_PANES) toast(ko.multi.tooMany(MAX_PANES));
openMultiPlay(videos);
}
/** 우클릭 메뉴에 '동시 재생'을 보일지 — 고른 것 중 영상이 둘 이상일 때만 */
export function selectedVideoCount(): number {
const snap = snapshot();
if (!snap) return 0;
const sel = selected();
let n = 0;
for (let i = 0; i < snap.count; i++) {
if (sel.has(snap.id(i)) && snap.kind(i) === 1) n++;
}
return n;
}
export async function performUndo(): Promise<void> {
const summary = await undoLast();
if (!summary) {
@@ -57,6 +178,8 @@ export async function performUndo(): Promise<void> {
}
toast(ko.ops.undone(summary.done));
refreshSidebar();
// 삭제 표시(태그)도 undo 대상 — 배지가 남지 않도록 함께 갱신
await refreshMarked();
await refreshSnapshot();
}
@@ -77,11 +200,18 @@ export const ContextMenu: Component = () => {
() => (menu() ? 1 : undefined),
() => listTags(),
);
// 시스템 '삭제 예정' 태그는 아래 전용 메뉴로 처리 — 태그 목록에서는 감춘다
const userTags = () => (tags() ?? []).filter((t) => t.id !== trashTag());
const allSelectedMarked = () => {
const ids = [...selected()];
return ids.length > 0 && ids.every((id) => markedIds().has(id));
};
const onAssign = async (tagId: number, tagName: string) => {
const ids = [...selected()];
const r = await assignTags(ids, tagId);
toast(ko.tags.assignTo(r.count, tagName));
bumpTagVersion();
refreshSidebar();
};
@@ -95,16 +225,38 @@ export const ContextMenu: Component = () => {
class="fixed z-50 w-52 rounded-md border border-[var(--border-strong)] bg-[var(--bg-panel-raised)] p-1 shadow-xl"
style={{ left: `${Math.min(m().x, window.innerWidth - 220)}px`, top: `${Math.min(m().y, window.innerHeight - 260)}px` }}
>
<Show when={tags() && tags()!.length > 0}>
<Show when={userTags().length > 0}>
<div class="px-3 pt-1 text-[10px] text-[var(--text-muted)]"></div>
<div class="max-h-36 overflow-y-auto">
<For each={tags()}>
<For each={userTags()}>
{(t) => <MenuItem label={`# ${t.name}`} onClick={() => void onAssign(t.id, t.name)} />}
</For>
</div>
<div class="my-1 border-t border-[var(--border)]" />
</Show>
<MenuItem label={ko.ops.moveTo} onClick={openMoveDialog} />
<Show when={selectedVideoCount() >= 2}>
<MenuItem
label={ko.multi.open(selectedVideoCount())}
onClick={performMultiPlay}
/>
</Show>
<div class="my-1 border-t border-[var(--border)]" />
<div class="px-3 pt-1 text-[10px] text-[var(--text-muted)]">
{ko.edit.menu} · {ko.edit.hint}
</div>
<MenuItem
label={`${ko.edit.rotateCw} (Ctrl+])`}
onClick={() => void performEdit("rotateCw")}
/>
<MenuItem
label={`${ko.edit.rotateCcw} (Ctrl+[)`}
onClick={() => void performEdit("rotateCcw")}
/>
<MenuItem label={ko.edit.rotate180} onClick={() => void performEdit("rotate180")} />
<MenuItem label={ko.edit.flipH} onClick={() => void performEdit("flipH")} />
<MenuItem label={ko.edit.flipV} onClick={() => void performEdit("flipV")} />
<div class="my-1 border-t border-[var(--border)]" />
<Show when={selected().size === 1 && m().fileName}>
<MenuItem
label={ko.common.rename}
@@ -112,7 +264,14 @@ export const ContextMenu: Component = () => {
/>
</Show>
<div class="my-1 border-t border-[var(--border)]" />
<MenuItem label={`${ko.common.delete} (휴지통)`} onClick={() => void performTrash()} />
<MenuItem
label={allSelectedMarked() ? ko.trash.unmark : `${ko.trash.mark} (Delete)`}
onClick={() => void performMarkDelete()}
/>
<MenuItem
label={`${ko.common.delete} (휴지통, Shift+Delete)`}
onClick={() => void performTrash()}
/>
</div>
</>
)}
+24 -2
View File
@@ -1,5 +1,14 @@
// 폴더 선택 모달 — 선택 항목을 인덱싱된 폴더로 이동한다.
import { createResource, createSignal, For, Show, type Component } from "solid-js";
import { IconChevronDown, IconChevronRight } from "../../ui/icons";
import {
createResource,
createSignal,
For,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import { ko } from "../../i18n/ko";
import {
folderTree,
@@ -16,6 +25,7 @@ import {
} from "../../state/store";
const [open, setOpen] = createSignal(false);
export { open as moveDialogOpen };
export const openMoveDialog = () => setOpen(true);
interface TreeNode extends FolderNode {
@@ -69,7 +79,7 @@ const FolderPickRow: Component<{
setExpanded(!expanded());
}}
>
<Show when={props.node.children.length > 0}>{expanded() ? "▾" : "▸"}</Show>
<Show when={props.node.children.length > 0}>{expanded() ? <IconChevronDown size={12} /> : <IconChevronRight size={12} />}</Show>
</span>
<span class="truncate">{props.node.name}</span>
</div>
@@ -83,6 +93,18 @@ const FolderPickRow: Component<{
};
export const MoveDialog: Component = () => {
// Esc로 닫기 — 라이트박스 위에 떠 있을 때도 이 모달이 먼저 닫혀야 한다
onMount(() => {
const onKey = (e: KeyboardEvent) => {
if (!open() || e.key !== "Escape") return;
e.preventDefault();
e.stopPropagation();
setOpen(false);
};
window.addEventListener("keydown", onKey, { capture: true });
onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true }));
});
const [trees] = createResource(
() => open(),
async (isOpen) => {
+135 -98
View File
@@ -1,4 +1,5 @@
// 커스텀 justified 가상 그리드 — 레이아웃은 워커에서, 렌더는 뷰포트 ±1화면만.
import { IconImage, IconTrash } from "../../ui/icons";
import {
createEffect,
createMemo,
@@ -10,15 +11,15 @@ import {
Show,
type Component,
} from "solid-js";
import { createStore, produce } from "solid-js/store";
import { createStore, produce, reconcile } from "solid-js/store";
import { computeLayout, type LayoutResult } from "./layout";
import {
anchorIndex,
clearSelection,
gridRowHeight,
info,
markedIds,
openLightbox,
openShortcuts,
registerItemViewFocus,
selected,
selectSingle,
setSelectionFromIds,
@@ -26,20 +27,29 @@ import {
toggleSelect,
selectRange,
snapshot,
tagVersion,
thumbVersions,
trashTag,
zoomGrid,
} from "../../state/store";
import { fileDetails, setThumbPriority, thumbUrl, type Snapshot } from "../../ipc/commands";
import { ko } from "../../i18n/ko";
import {
openContextMenu,
openRenameDialog,
performTrash,
performUndo,
} from "../fileops/ContextMenu";
fileDetails,
fileTagsBulk,
setThumbPriority,
thumbUrl,
type Snapshot,
} from "../../ipc/commands";
import { ko } from "../../i18n/ko";
import { openContextMenu } from "../fileops/ContextMenu";
import { handleItemKeys } from "../browse/itemKeys";
const GAP = 6;
const GAP = 8;
/** 스크롤러 안쪽 여백. 레이아웃 폭·마퀴 좌표가 이 한 상수를 공유한다 —
흩어져 있으면 여백을 바꾼 순간 드래그 사각형이 커서에서 어긋난다. */
const PAD = 8;
const OVERSCAN_PX = 800;
/** 이 폭 미만 셀에서는 태그 라벨을 감춘다 (썸네일을 가림) */
const TAG_MIN_CELL_W = 120;
function fmtSize(bytes: number): string {
if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`;
@@ -74,7 +84,7 @@ export const JustifiedGrid: Component = () => {
setLayout(undefined);
return;
}
const w = containerRef.clientWidth - 16; // 패딩 보정
const w = containerRef.clientWidth - PAD * 2; // 패딩 보정
if (w <= 0) return;
const ratios = snap.aspectRatios();
setLayout(computeLayout(ratios, w, gridRowHeight(), GAP));
@@ -85,6 +95,8 @@ export const JustifiedGrid: Component = () => {
onMount(() => {
const el = containerRef!;
// 크롬(툴바·브레드크럼·폴더 칩·사이드바)이 클릭 뒤 방향키를 여기로 되돌린다
onCleanup(registerItemViewFocus(() => containerRef?.focus({ preventScroll: true })));
let lastW = -1;
let lastRh = -1;
// 폭과 줌(gridRowHeight)을 함께 감시 — 어느 쪽이 바뀌든 재배치.
@@ -165,6 +177,43 @@ export const JustifiedGrid: Component = () => {
});
onCleanup(() => clearTimeout(prioTimer));
// 썸네일 위 태그 라벨 — 가시 범위만 배치 조회. 태그가 바뀌면(tagVersion) 캐시를 버린다.
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 < 512; 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) => {
// 조회한 id는 태그가 없어도 빈 배열로 채워 재조회를 막는다
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));
// 정보 오버레이용 파일명/크기 지연 로드 (showInfo 켜졌을 때 가시 범위만)
const [detailsCache, setDetailsCache] = createStore<Record<number, { name: string; size: number }>>({});
let detTimer: ReturnType<typeof setTimeout> | undefined;
@@ -234,80 +283,29 @@ export const JustifiedGrid: Component = () => {
return best;
};
/** 선택된 셀을 화면 안으로 (키보드 이동용) */
const revealCell = (index: number) => {
const l = layout();
const el = containerRef;
if (!l || !el) return;
const top = l.boxes[index * 4 + 1];
const h = l.boxes[index * 4 + 3];
if (top < el.scrollTop) el.scrollTop = top;
else if (top + h > el.scrollTop + el.clientHeight)
el.scrollTop = top + h - el.clientHeight;
};
const onKeyDown = (e: KeyboardEvent) => {
const snap = snapshot();
if (!snap || snap.count === 0) return;
const anchor = anchorIndex();
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
void performUndo();
return;
}
// Ctrl+A 전체 선택 / Ctrl+Shift+A 선택 반전
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
e.preventDefault();
const all: number[] = [];
for (let i = 0; i < snap.count; i++) all.push(snap.id(i));
if (e.shiftKey) {
const cur = selected();
setSelectionFromIds(all.filter((id) => !cur.has(id)));
} else {
setSelectionFromIds(all);
}
return;
}
// ? 단축키 도움말
if (e.key === "?") {
e.preventDefault();
openShortcuts();
return;
}
switch (e.key) {
case "Enter":
case " ":
if (anchor >= 0) {
e.preventDefault();
openLightbox(anchor);
}
break;
case "Delete":
if (selected().size > 0) {
e.preventDefault();
void performTrash();
}
break;
case "F2":
if (anchor >= 0 && selected().size === 1) {
e.preventDefault();
const id = snap.id(anchor);
void fileDetails([id]).then((d) => {
if (d[0]) openRenameDialog(id, d[0].name);
});
}
break;
case "ArrowLeft":
if (anchor > 0) {
e.preventDefault();
selectSingle(snap.id(anchor - 1), anchor - 1);
}
break;
case "ArrowRight":
if (anchor >= 0 && anchor < snap.count - 1) {
e.preventDefault();
selectSingle(snap.id(anchor + 1), anchor + 1);
}
break;
case "ArrowUp":
case "ArrowDown": {
if (anchor < 0) break;
e.preventDefault();
const next = verticalNeighbor(anchor, e.key === "ArrowDown" ? 1 : -1);
if (next !== undefined) selectSingle(snap.id(next), next);
break;
}
}
if (!snap) return;
handleItemKeys(e, snap, {
neighbor: (from, key) => {
if (key === "ArrowLeft") return from > 0 ? from - 1 : undefined;
if (key === "ArrowRight") return from < snap.count - 1 ? from + 1 : undefined;
return verticalNeighbor(from, key === "ArrowDown" ? 1 : -1);
},
onMove: revealCell,
});
};
const onCellContextMenu = (e: MouseEvent, snap: Snapshot, index: number) => {
@@ -327,12 +325,12 @@ export const JustifiedGrid: Component = () => {
};
// ── 마퀴(드래그 사각형) 선택 ──
// 콘텐츠 좌표 = clientX - 컨테이너rect - 패딩(8) (+ y는 scrollTop)
// 콘텐츠 좌표 = clientX - 컨테이너rect - 패딩(PAD) (+ y는 scrollTop)
const toContent = (clientX: number, clientY: number): [number, number] => {
const rect = containerRef!.getBoundingClientRect();
return [
clientX - rect.left - 8,
clientY - rect.top - 8 + containerRef!.scrollTop,
clientX - rect.left - PAD,
clientY - rect.top - PAD + containerRef!.scrollTop,
];
};
@@ -397,7 +395,8 @@ export const JustifiedGrid: Component = () => {
onKeyDown={onKeyDown}
onMouseDown={onBgMouseDown}
tabindex={0}
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 outline-none"
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden outline-none"
style={{ padding: `${PAD}px` }}
>
<Show
when={snapshot() && snapshot()!.count > 0}
@@ -415,7 +414,7 @@ export const JustifiedGrid: Component = () => {
<Show when={marqueeRect()}>
{(r) => (
<div
class="pointer-events-none absolute z-10 border border-[var(--accent)] bg-[var(--accent-soft)]"
class="marquee pointer-events-none absolute z-10"
style={{
left: `${r().left}px`,
top: `${r().top}px`,
@@ -453,11 +452,23 @@ export const JustifiedGrid: Component = () => {
const s = snapshot();
return !!s && s.kind(i) === 1;
};
const marked = () => markedIds().has(id());
// 별점은 스냅샷 flags 비트2~4에서 온다(추가 조회 없음)
const rating = () => {
const s = snapshot();
return s ? s.rating(i) : 0;
};
// 좁은 셀일수록 적게 — 나머지는 +N으로 접는다
const cellTags = () => tagCache[id()] ?? [];
const tagLimit = () => (box()[2] >= 260 ? 3 : box()[2] >= 180 ? 2 : 1);
const visibleTags = () => cellTags().slice(0, tagLimit());
const hiddenTagCount = () => Math.max(0, cellTags().length - tagLimit());
return (
<div
class="absolute cursor-pointer overflow-hidden rounded-[3px] bg-[var(--grid-cell-bg)] outline-offset-[-2px]"
class="grid-cell"
classList={{
"outline outline-2 outline-[var(--accent)]": selected().has(id()),
"is-sel": selected().has(id()),
"is-marked": marked() && !selected().has(id()),
}}
style={{
transform: `translate(${box()[0]}px, ${box()[1]}px)`,
@@ -487,11 +498,11 @@ export const JustifiedGrid: Component = () => {
when={!failed()}
fallback={
<div class="grid h-full w-full place-items-center text-[var(--text-muted)]">
<span class="text-base opacity-60">🖼</span>
<IconImage size={18} class="opacity-60" />
</div>
}
>
<div class="h-full w-full animate-pulse bg-[var(--bg-hover)]" />
<div class="h-full w-full animate-pulse bg-[var(--grid-skeleton)]" />
</Show>
}
>
@@ -509,16 +520,42 @@ export const JustifiedGrid: Component = () => {
/>
</Show>
<Show when={isVideo()}>
<span class="absolute bottom-1 right-1 rounded bg-black/60 px-1 text-[10px] text-white">
<span class="chip--on-media absolute bottom-1 right-1">{ko.filter.video}</span>
</Show>
{/* 별점 — 매긴 것만 보여준다. 0점까지 빈 별로 그리면 모든 셀에 별 5개가
깔려서 사진을 가린다(별점은 소수의 항목에만 붙는 표시다). */}
<Show when={rating() > 0}>
<span class="cell-stars" title={ko.rating.set(rating())}>
{"★".repeat(rating())}
</span>
</Show>
{/* 상단 라벨 줄 — 삭제 예정 배지 + 태그.
셀이 좁으면 태그가 썸네일을 다 덮으므로 일정 폭부터만 보여준다. */}
<div class="pointer-events-none absolute inset-x-1 top-1 flex flex-wrap gap-0.5">
<Show when={marked()}>
<span class="chip--on-media chip--on-media--danger" title={ko.trash.badge}>
<IconTrash size={11} />
</span>
</Show>
<Show when={box()[2] >= TAG_MIN_CELL_W}>
<For each={visibleTags()}>
{(name) => (
<span class="chip--on-media chip--on-media--tag" title={name}>
{name}
</span>
)}
</For>
<Show when={hiddenTagCount() > 0}>
<span class="chip--on-media chip--on-media--dim">+{hiddenTagCount()}</span>
</Show>
</Show>
</div>
{/* 파일명·크기 오버레이 (툴바 ⓘ 토글) */}
<Show when={showInfo()}>
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 leading-tight">
<div class="truncate text-[10px] text-white/90">{detailsCache[id()]?.name ?? ""}</div>
<div class="info-overlay">
<div class="info-overlay__name">{detailsCache[id()]?.name ?? ""}</div>
<Show when={detailsCache[id()]}>
<div class="text-[9px] text-white/60">{fmtSize(detailsCache[id()]!.size)}</div>
<div class="info-overlay__sub">{fmtSize(detailsCache[id()]!.size)}</div>
</Show>
</div>
</Show>
+5 -2
View File
@@ -1,7 +1,10 @@
// Justified 레이아웃 — 그리디 행 채우기. 메인 스레드 동기 계산.
// (프로덕션 Tauri 빌드에서 module worker가 첫 메시지 후 응답하지 않는 이슈를 피하기 위해
// 워커 대신 동기 계산을 쓴다. 재계산은 리사이즈/줌/데이터 변경 시에만 발생하며
// 5만 항목도 수십 ms 내에 끝나 스크롤 성능에 영향 없다.)
// 워커 대신 동기 계산을 쓴다. 재계산은 리사이즈/줌/데이터 변경 시에만 발생한다.)
//
// 워커로 옮길 이유가 없다 — V8 실측: 1만 0.8ms / 10만 3.1ms / **100만 18.4ms**
// (aspectRatios 언팩 포함 ~20ms). 스크롤 중에는 아예 돌지 않고, 워커로 보내면
// boxes 15MB를 매번 되돌려 받아야 해서 오히려 느려진다.
export interface LayoutResult {
/** n*4: [x, y, w, h] */
+138
View File
@@ -0,0 +1,138 @@
// 소스 수정 — 이름 변경 + 경로 재매핑.
// 폴더를 옮겼거나 드라이브 문자가 바뀌어 '경로 없음'이 된 소스를 되살리는 창구다.
// 경로를 바꾸면 백엔드가 폴더 경로 접두사만 갈아 끼우므로 태그·썸네일이 유지된다.
import { createEffect, createSignal, Show, type Component } from "solid-js";
import { open } from "@tauri-apps/plugin-dialog";
import { ko } from "../../i18n/ko";
import { updateSource, type Source } from "../../ipc/commands";
import { refreshSidebar, refreshSnapshot, toast } from "../../state/store";
const [target, setTarget] = createSignal<Source | undefined>();
export const openSourceEdit = (s: Source) => setTarget(s);
export const SourceEditDialog: Component = () => {
const [name, setName] = createSignal("");
const [root, setRoot] = createSignal("");
const [busy, setBusy] = createSignal(false);
// 창이 열릴 때 현재 값으로 채운다
createEffect(() => {
const s = target();
if (!s) return;
setName(s.name);
setRoot(s.root);
});
const close = () => {
if (busy()) return;
setTarget(undefined);
};
const pickFolder = async () => {
const dir = await open({ directory: true, multiple: false, title: ko.source.browse });
if (typeof dir === "string") setRoot(dir);
};
const submit = async () => {
const s = target();
if (!s) return;
const nextName = name().trim();
const nextRoot = root().trim();
const nameChanged = nextName.length > 0 && nextName !== s.name;
const rootChanged = nextRoot.length > 0 && nextRoot !== s.root;
if (!nameChanged && !rootChanged) {
close();
return;
}
setBusy(true);
try {
const moved = await updateSource(s.id, {
name: nameChanged ? nextName : undefined,
root: rootChanged ? nextRoot : undefined,
});
toast(rootChanged ? ko.source.remapDone(moved) : ko.source.renameDone);
refreshSidebar();
await refreshSnapshot();
setTarget(undefined);
} catch (e) {
toast(String(e));
} finally {
setBusy(false);
}
};
const inputClass =
"w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1.5 text-xs outline-none focus:border-[var(--accent)]";
return (
<Show when={target()}>
{(s) => (
<div class="fixed inset-0 z-50 grid place-items-center bg-black/60" onClick={close}>
<div
class="w-[460px] rounded-lg border border-[var(--border-strong)] bg-[var(--bg-panel)] p-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div class="mb-3 text-sm font-semibold">{ko.source.editTitle}</div>
<label class="mb-2 block">
<div class="mb-0.5 text-[11px] text-[var(--text-muted)]">{ko.source.name}</div>
<input
class={inputClass}
value={name()}
onInput={(e) => setName(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
if (e.key === "Escape") close();
}}
/>
</label>
<label class="mb-1 block">
<div class="mb-0.5 text-[11px] text-[var(--text-muted)]">{ko.source.root}</div>
<div class="flex gap-2">
<input
class={inputClass}
value={root()}
disabled={s().kind !== "local"}
onInput={(e) => setRoot(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
if (e.key === "Escape") close();
}}
/>
<button
class="shrink-0 rounded border border-[var(--border)] px-2 py-1 text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] disabled:opacity-40"
onClick={() => void pickFolder()}
disabled={s().kind !== "local"}
>
</button>
</div>
</label>
<div class="mt-2 text-[10px] leading-4 text-[var(--text-muted)]">
{s().kind === "local" ? ko.source.remapHint : ko.source.remoteNoRoot}
</div>
<div class="mt-3 flex justify-end gap-2">
<button
class="rounded border border-[var(--border)] px-3 py-1 text-xs hover:bg-[var(--bg-hover)]"
onClick={close}
disabled={busy()}
>
{ko.common.cancel}
</button>
<button
class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white disabled:opacity-50"
onClick={() => void submit()}
disabled={busy()}
>
{busy() ? ko.app.loading : ko.common.confirm}
</button>
</div>
</div>
</div>
)}
</Show>
);
};
+208
View File
@@ -0,0 +1,208 @@
// 자르기 오버레이 — 화면에서 사각형을 끌면 원본 화소 좌표로 바꿔 돌려준다.
//
// **좌표 변환이 이 파일의 전부다.** 미디어는 `object-contain`으로 그려지므로 요소 상자와
// 실제 그림 영역이 다르다(위아래나 좌우에 여백이 생긴다). 그 여백을 빼지 않으면
// 사용자가 고른 자리와 실제로 잘리는 자리가 어긋난다 — 되돌릴 수 없는 작업에서 가장 나쁜 실수다.
import { createSignal, For, Show, onMount, onCleanup, type Component } from "solid-js";
import { ko } from "../../i18n/ko";
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
/** 요소 안에서 `object-contain`으로 그려진 **그림 영역**(여백 제외)의 화면 좌표. */
export function contentRect(el: HTMLElement, natW: number, natH: number): DOMRect {
const box = el.getBoundingClientRect();
if (natW <= 0 || natH <= 0) return box;
const scale = Math.min(box.width / natW, box.height / natH);
const w = natW * scale;
const h = natH * scale;
return new DOMRect(box.left + (box.width - w) / 2, box.top + (box.height - h) / 2, w, h);
}
/** 화면 사각형 → 원본 화소 사각형. 원본 밖으로는 나가지 않는다. */
export function toSourceRect(screen: Rect, content: DOMRect, natW: number, natH: number): Rect {
const sx = natW / content.width;
const sy = natH / content.height;
const x = Math.round((screen.x - content.left) * sx);
const y = Math.round((screen.y - content.top) * sy);
const w = Math.round(screen.w * sx);
const h = Math.round(screen.h * sy);
const cx = Math.max(0, Math.min(x, natW - 2));
const cy = Math.max(0, Math.min(y, natH - 2));
return { x: cx, y: cy, w: Math.min(w, natW - cx), h: Math.min(h, natH - cy) };
}
/** 비율 고정 — 끈 사각형을 주어진 가로:세로에 맞춰 줄인다(늘리지 않는다). */
export function fitAspect(r: Rect, aspect: number | undefined): Rect {
if (!aspect || aspect <= 0) return r;
const cur = r.w / Math.max(1, r.h);
if (cur > aspect) return { ...r, w: Math.round(r.h * aspect) };
return { ...r, h: Math.round(r.w / aspect) };
}
const PRESETS: { label: string; a?: number }[] = [
{ label: "자유" },
{ label: "1:1", a: 1 },
{ label: "4:3", a: 4 / 3 },
{ label: "3:4", a: 3 / 4 },
{ label: "16:9", a: 16 / 9 },
{ label: "9:16", a: 9 / 16 },
];
export const CropOverlay: Component<{
/** 자를 대상 요소 (img 또는 video) */
target: () => HTMLElement | undefined;
/** 원본 화소 크기 */
natural: () => { w: number; h: number } | undefined;
busy: boolean;
onCancel: () => void;
onApply: (r: Rect) => void;
}> = (props) => {
const [drag, setDrag] = createSignal<Rect | undefined>();
const [aspect, setAspect] = createSignal<number | undefined>();
let start: { x: number; y: number } | undefined;
const content = () => {
const el = props.target();
const n = props.natural();
return el && n ? contentRect(el, n.w, n.h) : undefined;
};
const onDown = (e: PointerEvent) => {
const c = content();
if (!c) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
start = { x: clampX(e.clientX, c), y: clampY(e.clientY, c) };
setDrag({ x: start.x, y: start.y, w: 0, h: 0 });
};
const onMove = (e: PointerEvent) => {
const c = content();
if (!start || !c) return;
const x2 = clampX(e.clientX, c);
const y2 = clampY(e.clientY, c);
const raw = {
x: Math.min(start.x, x2),
y: Math.min(start.y, y2),
w: Math.abs(x2 - start.x),
h: Math.abs(y2 - start.y),
};
setDrag(fitAspect(raw, aspect()));
};
const onUp = () => {
start = undefined;
};
const clampX = (v: number, c: DOMRect) => Math.max(c.left, Math.min(v, c.right));
const clampY = (v: number, c: DOMRect) => Math.max(c.top, Math.min(v, c.bottom));
/** 지금 고른 영역을 원본 화소로 — 표시용이자 적용값이다(둘이 다르면 안 된다) */
const sourceRect = () => {
const d = drag();
const c = content();
const n = props.natural();
if (!d || !c || !n || d.w < 4 || d.h < 4) return undefined;
return toSourceRect(d, c, n.w, n.h);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
props.onCancel();
}
if (e.key === "Enter") {
const r = sourceRect();
if (r) {
e.preventDefault();
props.onApply(r);
}
}
};
onMount(() => window.addEventListener("keydown", onKey, true));
onCleanup(() => window.removeEventListener("keydown", onKey, true));
return (
<>
{/* 끌기 판 — 미디어 위를 덮는다 */}
<div
class="absolute inset-0 z-20 cursor-crosshair"
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
>
<Show when={drag()}>
{(d) => (
<>
{/* 고른 영역 밖을 어둡게 — 무엇이 남는지 한눈에 보이게 */}
<div
class="pointer-events-none fixed inset-0 bg-black/60"
style={{
"clip-path": `polygon(0 0, 100% 0, 100% 100%, 0 100%, 0 0, ${d().x}px ${d().y}px, ${d().x}px ${d().y + d().h}px, ${d().x + d().w}px ${d().y + d().h}px, ${d().x + d().w}px ${d().y}px, ${d().x}px ${d().y}px)`,
}}
/>
<div
class="pointer-events-none fixed border-2 border-white/90"
style={{
left: `${d().x}px`,
top: `${d().y}px`,
width: `${d().w}px`,
height: `${d().h}px`,
}}
/>
</>
)}
</Show>
</div>
{/* 조작 막대 */}
<div class="absolute inset-x-0 bottom-0 z-30 flex items-center gap-2 border-t border-[var(--border)] bg-[var(--bg-panel)] px-4 py-2">
<span class="text-[11px] text-[var(--text-muted)]">{ko.edit.cropHint}</span>
<div class="flex gap-1">
<For each={PRESETS}>
{(p) => (
<button
class="rounded border px-2 py-0.5 text-[11px]"
classList={{
"border-[var(--accent)] bg-[var(--accent)] text-white": aspect() === p.a,
"border-[var(--border)] hover:bg-[var(--bg-hover)]": aspect() !== p.a,
}}
onClick={() => {
setAspect(p.a);
const d = drag();
if (d) setDrag(fitAspect(d, p.a));
}}
>
{p.label}
</button>
)}
</For>
</div>
<span class="ml-auto text-[11px] tabular-nums text-[var(--text-secondary)]">
<Show when={sourceRect()} fallback={ko.edit.cropNone}>
{(r) => `${r().w} × ${r().h}`}
</Show>
</span>
<button
class="rounded border border-[var(--border)] px-3 py-1 text-xs hover:bg-[var(--bg-hover)]"
onClick={props.onCancel}
>
{ko.common.cancel}
</button>
<button
class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white disabled:opacity-40"
disabled={props.busy || !sourceRect()}
onClick={() => {
const r = sourceRect();
if (r) props.onApply(r);
}}
>
{props.busy ? ko.edit.cropping : ko.edit.cropApply}
</button>
</div>
</>
);
};
+582 -15
View File
@@ -1,23 +1,65 @@
// 라이트박스 — 사진(줌/팬) + 영상 재생 오버레이. Esc 닫기, ←/→ 이동, 휠/+/- 줌.
// 재생·감상 중에도 정리 작업(태그·폴더 이동·삭제 표시)을 끊김 없이 할 수 있도록
// 그리드와 같은 파일 작업을 여기서도 버튼·단축키로 노출한다.
import {
IconCheck,
IconFlipH,
IconCrop,
IconFolder,
IconRotateLeft,
IconRotateRight,
IconTrash,
IconX,
} from "../../ui/icons";
import {
createEffect,
createMemo,
createResource,
createSignal,
For,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import {
bumpTagVersion,
closeLightbox,
dedupeOpen,
info,
lightboxIndex,
markedIds,
navLightbox,
openShortcuts,
refreshSidebar,
refreshSnapshot,
selectSingle,
shortcutsOpen,
snapshot,
toast,
toggleMarkDelete,
trashTag,
} from "../../state/store";
import { fileDetails, mediaUrl, thumbUrl } from "../../ipc/commands";
import {
assignTags,
createTag,
cutFile,
fileDetails,
fileTagsOf,
listTags,
mediaUrl,
setRating,
thumbUrl,
unassignTags,
type EditOp,
type Tag,
} from "../../ipc/commands";
import { ko } from "../../i18n/ko";
import { moveDialogOpen, openMoveDialog } from "../fileops/MoveDialog";
import { performEdit, performTrash, performUndo } from "../fileops/ContextMenu";
import { StarRating } from "../../ui/StarRating";
import { VideoPlayer } from "./VideoPlayer";
import { CropOverlay } from "./CropOverlay";
/** 웹뷰가 <img>로 직접 그릴 수 있는 확장자 (그 외는 썸네일 폴백 — HEIC/RAW 등) */
const IMG_NATIVE = new Set(["jpg", "jpeg", "jfif", "png", "gif", "webp", "bmp", "avif"]);
@@ -32,11 +74,233 @@ export const Lightbox: Component = () => {
return { index: idx, id: snap.id(idx), kind: snap.kind(idx) };
});
const [details] = createResource(
const [details, { refetch: refetchDetails }] = createResource(
() => item()?.id,
async (id) => (await fileDetails([id]))[0],
);
// ── 파일 작업 (그리드와 동일한 동작, 현재 항목 대상) ──
const [tagMenuOpen, setTagMenuOpen] = createSignal(false);
// 태그를 붙이거나 뗄 때마다 올려서 현재 파일 태그를 다시 읽는다
const [tagVersion, setTagVersion] = createSignal(0);
const [tags, { refetch: refetchTags }] = createResource(
() => (lightboxIndex() != null ? tagVersion() : undefined),
() => listTags(),
);
// 지금 보고 있는 파일에 실제로 붙어 있는 태그 — 나갔다 확인할 필요 없게 상단에 상주시킨다
const [fileTags] = createResource(
() => {
const id = item()?.id;
return id != null ? `${id}:${tagVersion()}` : undefined;
},
async (k) => fileTagsOf(Number(k.split(":")[0])),
);
/** 삭제 예정(시스템)은 🗑 배지가 따로 보여주므로 태그 칩에서는 뺀다 */
const shownTags = () => (fileTags() ?? []).filter((t) => t.id !== trashTag());
const assignedIds = () => new Set((fileTags() ?? []).map((t) => t.id));
// 시스템 '삭제 예정' 태그는 🗑 버튼이 담당 — 태그 목록에서는 뺀다
const userTags = () => (tags() ?? []).filter((t) => t.id !== trashTag());
/** 1~9 빠른 지정 대상 — 메뉴를 열지 않아도 쓰는 앞쪽 9개 */
const quickTags = () => userTags().slice(0, 9);
const isMarked = () => {
const it = item();
return !!it && markedIds().has(it.id);
};
const markCurrent = () => {
const it = item();
if (it) void toggleMarkDelete([it.id]);
};
// 회전·반전 — 원본을 직접 고친다.
//
// 고친 뒤 화면을 새로 그리려면 **URL을 바꿔야 한다**. 경로가 그대로면 브라우저가
// 캐시에 있는 옛 바이트를 그대로 쓰고, 사진은 돌지 않은 채로 남는다.
const [editing, setEditing] = createSignal(false);
const [editVersion, setEditVersion] = createSignal(0);
// 자르기 — 되돌릴 수 없으므로 원본은 백업으로 옮겨진다(Ctrl+Z로 복귀).
const [cropping, setCropping] = createSignal(false);
let imgRef: HTMLImageElement | undefined;
const applyCrop = async (r: { x: number; y: number; w: number; h: number }) => {
const it = item();
if (!it || editing()) return;
setEditing(true);
try {
await cutFile(it.id, { kind: "crop", ...r });
toast(ko.edit.cropDone(r.w, r.h));
setCropping(false);
setEditVersion((v) => v + 1);
refetchDetails();
await refreshSnapshot();
} catch (e) {
toast(String(e));
} finally {
setEditing(false);
}
};
const rotateCurrent = async (op: EditOp) => {
const it = item();
if (!it || editing()) return;
setEditing(true);
try {
await performEdit(op, [it.id]);
setEditVersion((v) => v + 1);
// 치수가 바뀌었을 수 있다(90° 회전) — 상세 정보도 다시 읽는다
refetchDetails();
} finally {
setEditing(false);
}
};
/** 태그 토글 — 이미 붙어 있으면 뗀다. 메뉴는 연달아 쓰도록 열어 둔다. */
const toggleTag = async (tagId: number, name: string) => {
const it = item();
if (!it) return;
try {
if (assignedIds().has(tagId)) {
const r = await unassignTags([it.id], tagId);
toast(ko.tags.removedFrom(r.count, name));
} else {
const r = await assignTags([it.id], tagId);
toast(ko.tags.assignTo(r.count, name));
}
setTagVersion((v) => v + 1);
bumpTagVersion(); // 그리드 썸네일 태그 오버레이도 갱신
refreshSidebar();
} catch (e) {
toast(String(e));
}
};
/**
* 별점 — 감상하면서 바로 매긴다(분류의 대부분이 이 화면에서 일어난다).
* 같은 점수를 다시 주면 해제, `Ctrl+0`은 곧바로 해제다.
*/
const [ratingVersion, setRatingVersion] = createSignal(0);
const [rating] = createResource(
() => (item() ? `${item()!.id}:${ratingVersion()}` : undefined),
async () => (await fileDetails([item()!.id]))[0]?.rating ?? 0,
);
const applyRating = async (next: number) => {
const it = item();
if (!it) return;
try {
// 같은 별을 다시 누르면 해제 — 위젯과 단축키가 같은 규칙을 쓴다
const value = rating() === next ? 0 : next;
await setRating([it.id], value);
setRatingVersion((v) => v + 1);
await refreshSnapshot(); // 그리드 셀의 별도 갱신
} catch (e) {
toast(String(e));
}
};
// ── 태그 고르기 (전체 목록 + 검색 + 새로 만들기) ──
// 태그가 열 개를 넘어가면 1~9 빠른 키만으로는 부족하다. 검색으로 좁혀 고르고,
// 없는 이름이면 그 자리에서 만들어 지정한다 — 감상을 멈추고 사이드바로 갈 필요가 없다.
const [tagQuery, setTagQuery] = createSignal("");
const [tagHighlight, setTagHighlight] = createSignal(0);
let tagInputRef: HTMLInputElement | undefined;
let tagListRef: HTMLDivElement | undefined;
const filteredTags = () => {
const q = tagQuery().trim().toLowerCase();
return q ? userTags().filter((t) => t.name.toLowerCase().includes(q)) : userTags();
};
const exactTag = () => {
const q = tagQuery().trim().toLowerCase();
return q ? userTags().find((t) => t.name.toLowerCase() === q) : undefined;
};
/** 이미 있는 이름과 겹치지 않을 때만 '새로 만들기'를 제안한다 */
const canCreateTag = () => tagQuery().trim().length > 0 && !exactTag();
/** 목록 마지막 줄이 '새로 만들기' — 키보드 이동 인덱스를 함께 센다 */
const tagRowCount = () => filteredTags().length + (canCreateTag() ? 1 : 0);
const isCreateRow = (i: number) => canCreateTag() && i === filteredTags().length;
const openTagMenu = () => {
void refetchTags();
setTagQuery("");
setTagHighlight(0);
setTagMenuOpen(true);
};
const closeTagMenu = () => {
setTagMenuOpen(false);
setTagQuery("");
};
const toggleTagMenu = () => (tagMenuOpen() ? closeTagMenu() : openTagMenu());
/** 목록에서 고른 태그를 토글하고, 이어서 다른 태그를 찾을 수 있게 검색어를 비운다 */
const pickTag = async (t: Tag) => {
await toggleTag(t.id, t.name);
setTagQuery("");
setTagHighlight(0);
tagInputRef?.focus();
};
/** 없는 태그면 만들어서 바로 지정 (이름이 이미 있으면 그 태그를 쓴다) */
const createAndAssignTag = async (raw: string) => {
const it = item();
const name = raw.trim();
if (!it || !name) return;
try {
const existing = userTags().find((t) => t.name.toLowerCase() === name.toLowerCase());
const tagId = existing ? existing.id : await createTag(name);
const r = await assignTags([it.id], tagId);
toast(existing ? ko.tags.assignTo(r.count, name) : ko.tags.createdAndAssigned(name));
setTagQuery("");
setTagHighlight(0);
tagInputRef?.focus();
setTagVersion((v) => v + 1);
bumpTagVersion();
refreshSidebar();
} catch (e) {
toast(String(e));
}
};
/** Enter — 강조된 줄을 실행 (태그 토글 또는 새로 만들기) */
const commitTagRow = (i: number) => {
if (isCreateRow(i)) {
void createAndAssignTag(tagQuery());
return;
}
const t = filteredTags()[i];
if (t) void pickTag(t);
};
const onTagInputKey = (e: KeyboardEvent) => {
// 입력 중에는 라이트박스 전역 키가 오지 않으므로(INPUT은 건너뛴다) 여기서 처리한다
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
closeTagMenu();
return;
}
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
e.stopPropagation();
const n = tagRowCount();
if (n === 0) return;
setTagHighlight((h) => (h + (e.key === "ArrowDown" ? 1 : n - 1)) % n);
return;
}
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
commitTagRow(tagHighlight());
}
};
// 강조된 줄을 목록 안에서 보이게 유지
createEffect(() => {
const i = tagHighlight();
if (!tagMenuOpen() || !tagListRef) return;
(tagListRef.children[i] as HTMLElement | undefined)?.scrollIntoView({ block: "nearest" });
});
// ── 사진 줌/팬 상태 ──
const [scale, setScale] = createSignal(1);
const [panX, setPanX] = createSignal(0);
@@ -49,10 +313,18 @@ export const Lightbox: Component = () => {
setPanY(0);
};
// 파일이 바뀌면 줌 리셋
// 파일이 바뀌면 줌 리셋 + 열려 있던 태그 메뉴 닫기
createEffect(() => {
item()?.id;
resetZoom();
closeTagMenu();
});
// 보고 있는 항목 = 선택 항목으로 유지.
// 폴더 이동/휴지통 후 clearSelection()이 불려도 상단 버튼이 계속 동작해야 한다.
createEffect(() => {
const it = item();
if (it) selectSingle(it.id, it.index);
});
/** 커서 위치 기준 줌 (center 기준 상대좌표) */
@@ -100,10 +372,95 @@ export const Lightbox: Component = () => {
const onKey = (e: KeyboardEvent) => {
if (lightboxIndex() == null) return;
// 위에 뜬 모달이 키를 먼저 가진다 (각자 Esc를 처리한다).
// 중복 검토 모달도 이 목록에 있어야 한다 — 라이트박스가 App.tsx에서 먼저 마운트돼
// capture 리스너가 먼저 등록되므로, 양보하지 않으면 Esc가 모달이 아니라 라이트박스를 닫는다.
if (moveDialogOpen() || shortcutsOpen() || dedupeOpen()) return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
const isVideo = item()?.kind === 1;
if (e.key === "?") {
e.preventDefault();
e.stopPropagation();
openShortcuts();
return;
}
// ── 미디어 종류와 무관한 파일 작업 ──
// (이 핸들러가 VideoPlayer보다 먼저 등록돼 capture 단계에서 우선 처리된다)
if (e.key === "Delete") {
e.preventDefault();
e.stopPropagation();
// Shift+Delete만 즉시 OS 휴지통, 기본은 되돌릴 수 있는 삭제 표시
const it = item();
if (!it) return;
if (e.shiftKey) void performTrash([it.id]);
else markCurrent();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "m") {
e.preventDefault();
e.stopPropagation();
openMoveDialog();
return;
}
// 보기 중에 표시했다가 바로 무를 수 있어야 한다 (그리드 핸들러는 여기선 꺼져 있다)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
e.preventDefault();
e.stopPropagation();
void performUndo();
return;
}
// Ctrl+0~5: 별점. 맨숫자는 이미 태그 빠른 지정이 쓰고 있어서 수식키를 붙였다 —
// 태그 쪽 손버릇을 바꾸는 것보다 새 기능이 비켜서는 편이 낫다.
// (다른 Ctrl 단축키들과 같은 높이에 둔다 — 아래 `!e.ctrlKey` 블록 안에 두면 절대 안 걸린다)
if ((e.ctrlKey || e.metaKey) && e.key >= "0" && e.key <= "5") {
e.preventDefault();
e.stopPropagation();
void applyRating(Number(e.key));
return;
}
// Ctrl+←/→ 는 영상에서도 항상 이전·다음 파일 (←/→ 는 시크에 쓰이므로)
if ((e.ctrlKey || e.metaKey) && (e.key === "ArrowLeft" || e.key === "ArrowRight")) {
e.preventDefault();
e.stopPropagation();
navLightbox(e.key === "ArrowRight" ? 1 : -1);
return;
}
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
// 다음/이전 항목 — 영상 재생 중에도 손이 안 꼬이도록 N/P 제공
if (e.key === "n" || e.key === "N" || e.key === "p" || e.key === "P") {
e.preventDefault();
e.stopPropagation();
navLightbox(e.key === "n" || e.key === "N" ? 1 : -1);
return;
}
if (e.key === "t" || e.key === "T") {
e.preventDefault();
e.stopPropagation();
toggleTagMenu();
return;
}
// 1~9: 태그 목록 n번째를 현재 항목에 토글 (빠른 분류 — 잘못 눌러도 같은 키로 취소)
if (e.key >= "1" && e.key <= "9") {
const t = quickTags()[Number(e.key) - 1];
if (t) {
e.preventDefault();
e.stopPropagation();
void toggleTag(t.id, t.name);
return;
}
}
}
switch (e.key) {
case "Escape":
e.preventDefault();
if (tagMenuOpen()) {
closeTagMenu();
break;
}
closeLightbox();
break;
case "ArrowLeft":
@@ -153,32 +510,226 @@ export const Lightbox: Component = () => {
if (!it || !app) return undefined;
const ext = d?.name.split(".").pop()?.toLowerCase() ?? "";
// 네이티브 디코드 불가 형식(HEIC/RAW/TIFF)은 프리뷰 썸네일로 폴백
return IMG_NATIVE.has(ext) ? mediaUrl(app, it.id) : thumbUrl(app, it.id, 0);
const base = IMG_NATIVE.has(ext) ? mediaUrl(app, it.id) : thumbUrl(app, it.id, 0);
// 회전한 뒤에는 URL이 달라야 브라우저가 다시 받아온다 — 경로가 같으면 옛 화면이 그대로 남는다
const v = editVersion();
if (v === 0) return base;
return `${base}${base.includes("?") ? "&" : "?"}e=${v}`;
};
return (
<Show when={item()}>
{(it) => (
<div class="fixed inset-0 z-50 flex flex-col bg-black/90">
// media-surface: 배경이 항상 검으므로 라이트 테마에서도 크롬 색은 다크 값을 쓴다
<div class="media-surface fixed inset-0 z-50 flex flex-col bg-black/90">
<div class="flex h-10 shrink-0 items-center gap-3 px-3">
<span class="truncate text-xs text-[var(--text-primary)]">
{/* 파일명은 길이가 제한 없다 — min-w-0가 없으면 flex 항목이 내용 폭 아래로
줄지 않아서(min-width:auto) 오른쪽 버튼들을 밀어 찌그러뜨린다.
max-w로 상한을 두는 이유: 이름이 아무리 길어도 헤더의 절반을 넘기지 않는다. */}
<span
class="min-w-0 max-w-[42%] shrink truncate text-xs text-[var(--text-primary)]"
title={details()?.name ?? ""}
>
{details()?.name ?? ""}
</span>
<span class="text-[11px] text-[var(--text-muted)]">
<span class="shrink-0 whitespace-nowrap text-[11px] text-[var(--text-muted)]">
{it().index + 1} / {snapshot()?.count.toLocaleString("ko-KR")}
</span>
{/* 별점 — 제목 옆. 감상 중 손이 가장 자주 가는 조작이라 숨기지 않는다 */}
<span class="shrink-0" title={ko.rating.shortcut}>
<StarRating value={rating() ?? 0} size={13} onChange={(n) => void applyRating(n)} />
</span>
<Show when={it().kind !== 1 && scale() !== 1}>
<span class="rounded bg-[var(--bg-panel-raised)] px-1.5 py-0.5 text-[10px] tabular-nums text-[var(--text-secondary)]">
{Math.round(scale() * 100)}%
</span>
</Show>
<button
class="ml-auto rounded px-2 py-0.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={closeLightbox}
title="닫기 (Esc)"
>
</button>
<Show when={isMarked()}>
<span class="rounded bg-[var(--danger)] px-1.5 py-0.5 text-[10px] text-white">
{ko.trash.badge}
</span>
</Show>
{/* 현재 항목에 붙은 태그 — 보기를 벗어나지 않고 바로 확인·해제 */}
<div class="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
<For each={shownTags()}>
{(t) => (
<span class="group/tag flex shrink-0 items-center gap-1 rounded-full bg-[var(--accent-soft)] px-2 py-0.5 text-[11px] text-[var(--accent)]">
# {t.name}
<button
class="opacity-0 transition-opacity hover:text-[var(--danger)] group-hover/tag:opacity-100"
onClick={() => void toggleTag(t.id, t.name)}
title={`"${t.name}" 태그 제거`}
>
×
</button>
</span>
)}
</For>
</div>
{/* 파일 작업 — 감상·재생을 멈추지 않고 정리할 수 있게 상단에 상주.
shrink-0: 이 묶음은 절대 줄이지 않는다. 줄어들면 버튼 안에서 아이콘과 글자가
갈라져 2줄이 된다(제목이 길 때 실제로 그렇게 됐다). 줄일 것은 제목과 태그 줄이다. */}
<div class="relative ml-auto flex shrink-0 items-center gap-1">
{/* inline-flex + whitespace-nowrap: 아이콘과 글자가 한 줄에 붙어 있게 한다.
인라인 배치로 두면 폭이 좁아질 때 그 사이에서 줄바꿈이 일어난다. */}
<button
class="shrink-0 whitespace-nowrap rounded px-2 py-0.5 text-xs hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]"
classList={{
"text-[var(--accent)]": tagMenuOpen(),
"text-[var(--text-secondary)]": !tagMenuOpen(),
}}
onClick={toggleTagMenu}
title={`${ko.tags.add} (T)`}
>
#
</button>
{/* 회전·반전 — 크게 보다가 삐뚤어진 걸 발견하는 자리라 여기 둔다.
원본을 직접 고치지만 무손실이고 Ctrl+Z로 되돌아간다. */}
<div class="mx-1 flex shrink-0 items-center">
<button
class="shrink-0 rounded px-1.5 py-0.5 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)] disabled:opacity-40"
disabled={editing()}
onClick={() => void rotateCurrent("rotateCcw")}
title={`${ko.edit.rotateCcw} (Ctrl+[) · ${ko.edit.hint}`}
>
<IconRotateLeft size={13} />
</button>
<button
class="shrink-0 rounded px-1.5 py-0.5 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)] disabled:opacity-40"
disabled={editing()}
onClick={() => void rotateCurrent("rotateCw")}
title={`${ko.edit.rotateCw} (Ctrl+]) · ${ko.edit.hint}`}
>
<IconRotateRight size={13} />
</button>
<button
class="shrink-0 rounded px-1.5 py-0.5 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)] disabled:opacity-40"
disabled={editing()}
onClick={() => void rotateCurrent("flipH")}
title={`${ko.edit.flipH} · ${ko.edit.hint}`}
>
<IconFlipH size={13} />
</button>
<Show when={item()?.kind !== 1}>
<button
class="shrink-0 rounded px-1.5 py-0.5 hover:bg-[var(--bg-hover)] disabled:opacity-40"
classList={{
"text-[var(--accent)]": cropping(),
"text-[var(--text-secondary)] hover:text-[var(--text-primary)]": !cropping(),
}}
disabled={editing()}
onClick={() => setCropping((v) => !v)}
title={`${ko.edit.crop} · ${ko.edit.cropWarn}`}
>
<IconCrop size={13} />
</button>
</Show>
</div>
<button
class="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded px-2 py-0.5 text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]"
onClick={openMoveDialog}
title={`${ko.ops.moveTo} (Ctrl+M)`}
>
<IconFolder size={13} />
</button>
<button
class="inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded px-2 py-0.5 text-xs hover:bg-[var(--bg-hover)]"
classList={{
"text-[var(--danger)]": isMarked(),
"text-[var(--text-secondary)] hover:text-[var(--text-primary)]": !isMarked(),
}}
onClick={markCurrent}
title={`${isMarked() ? ko.trash.unmark : ko.trash.mark} (Delete)`}
>
<IconTrash size={13} /> {isMarked() ? ko.trash.unmark : ko.trash.mark}
</button>
<button
class="inline-flex shrink-0 items-center rounded px-2 py-0.5 text-sm text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={closeLightbox}
title="닫기 (Esc)"
>
<IconX size={14} />
</button>
{/* 태그 고르기 — 전체 목록에서 검색해 고르거나 그 자리에서 새로 만든다.
앞쪽 9개는 메뉴를 열지 않고도 1~9 키로 토글된다. 붙어 있는 태그는 ✓. */}
<Show when={tagMenuOpen()}>
<div class="absolute right-0 top-8 z-10 w-72 rounded-md border border-[var(--border-strong)] bg-[var(--bg-panel-raised)] p-1.5 shadow-xl">
<input
ref={(el) => {
tagInputRef = el;
queueMicrotask(() => el.focus());
}}
class="mb-1 w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1 text-xs text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
placeholder={ko.tags.searchOrCreate}
value={tagQuery()}
onInput={(e) => {
setTagQuery(e.currentTarget.value);
setTagHighlight(0);
}}
onKeyDown={onTagInputKey}
/>
<div ref={tagListRef} class="max-h-64 overflow-y-auto">
<For each={filteredTags()}>
{(t, idx) => (
<div
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs"
classList={{
"bg-[var(--bg-hover)]": tagHighlight() === idx(),
"text-[var(--accent)]": assignedIds().has(t.id),
}}
onMouseEnter={() => setTagHighlight(idx())}
onClick={() => void pickTag(t)}
>
<kbd
class="w-4 shrink-0 rounded border px-1 text-center font-mono text-[10px]"
classList={{
"border-[var(--border)] text-[var(--text-muted)]":
!tagQuery().trim() && idx() < 9,
"border-transparent text-transparent":
!!tagQuery().trim() || idx() >= 9,
}}
>
{idx() < 9 ? idx() + 1 : ""}
</kbd>
<span class="w-3 shrink-0 text-center">
<Show when={assignedIds().has(t.id)}><IconCheck size={12} /></Show>
</span>
<span class="truncate"># {t.name}</span>
<span class="ml-auto shrink-0 text-[10px] text-[var(--text-muted)]">
{t.fileCount.toLocaleString("ko-KR")}
</span>
</div>
)}
</For>
<Show when={canCreateTag()}>
<div
class="flex cursor-pointer items-center gap-2 rounded px-2 py-1.5 text-xs text-[var(--accent)]"
classList={{
"bg-[var(--bg-hover)]": tagHighlight() === filteredTags().length,
}}
onMouseEnter={() => setTagHighlight(filteredTags().length)}
onClick={() => void createAndAssignTag(tagQuery())}
>
<span class="w-4 shrink-0 text-center">+</span>
<span class="truncate">{ko.tags.createAssign(tagQuery().trim())}</span>
</div>
</Show>
{/* 태그가 하나도 없을 때만 — 검색어가 있으면 위의 '새로 만들기'가 답이다 */}
<Show when={tagRowCount() === 0}>
<div class="px-2 py-2 text-[11px] text-[var(--text-muted)]">
{ko.tags.emptyAll}
</div>
</Show>
</div>
<div class="mt-1 border-t border-[var(--border)] px-2 pt-1 text-[10px] text-[var(--text-muted)]">
{ko.tags.pickerHint}
</div>
</div>
</Show>
</div>
</div>
<div class="min-h-0 flex-1 p-2">
<Show
@@ -192,6 +743,7 @@ export const Lightbox: Component = () => {
onDblClick={() => (scale() > 1 ? resetZoom() : zoomCentered(2.5))}
>
<img
ref={imgRef}
src={imgSrc()}
class="max-h-full max-w-full touch-none select-none object-contain"
style={{
@@ -210,7 +762,22 @@ export const Lightbox: Component = () => {
</div>
}
>
<VideoPlayer fileId={it().id} />
<VideoPlayer fileId={it().id} version={editVersion()} />
</Show>
{/* 자르기 — 사진 위에 사각형을 끌어 남길 부분을 고른다.
영상은 화면 좌표가 재생기 안에 있어 이 오버레이로는 잡을 수 없으므로
지금은 사진만 지원한다(영상 영역 자르기는 백엔드에 있고 UI만 남았다). */}
<Show when={cropping() && it().kind !== 1}>
<CropOverlay
target={() => imgRef}
natural={() =>
imgRef ? { w: imgRef.naturalWidth, h: imgRef.naturalHeight } : undefined
}
busy={editing()}
onCancel={() => setCropping(false)}
onApply={(r) => void applyCrop(r)}
/>
</Show>
</div>
</div>
+286
View File
@@ -0,0 +1,286 @@
// 동시 재생 — 고른 영상 여러 개를 격자에 놓고 함께 재생한다 (최대 9개).
//
// **시간을 맞추지 않는다.** 길이가 서로 다른 영상을 같은 타임라인에 묶으면 대부분의 칸이
// 빈 화면이 된다(그건 중복 비교의 문제이고 CompareView가 따로 한다). 여기서는 각자 자기
// 0초부터 흐르고, 재생/정지만 함께 움직인다.
//
// **소리는 한 칸만 낸다.** 아홉 개가 동시에 울면 아무것도 들리지 않는다. 칸을 누르면
// 그 칸으로 소리가 옮겨간다.
//
// **변환이 필요한 코덱은 상한을 둔다.** 브라우저가 못 읽는 코덱은 ffmpeg가 실시간 변환하는데
// 아홉 개를 동시에 돌리면 기계가 멈춘다. 상한을 넘는 칸은 썸네일과 이유를 보여준다.
import {
createEffect,
createMemo,
createResource,
createSignal,
For,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import { ko } from "../../i18n/ko";
import {
decidePlayback,
fileDetails,
mediaUrl,
playbackInfo,
startStream,
stopStream,
thumbUrl,
type PlaybackPlan,
} from "../../ipc/commands";
import { info as appInfoSig, openLightbox, snapshot } from "../../state/store";
import { IconPause, IconPlay, IconX } from "../../ui/icons";
/** 한 화면에 놓을 수 있는 최대 개수 — 3×3을 넘으면 각 칸이 알아볼 수 없이 작아진다 */
export const MAX_PANES = 9;
/**
* 동시에 돌릴 실시간 변환 개수 상한.
*
* 변환은 한 편당 CPU 코어를 여럿 쓴다 — 서너 개만 겹쳐도 재생이 끊기기 시작한다.
* 그냥 재생되는(변환 불필요) 칸은 이 상한과 무관하다.
*/
const MAX_STREAMS = 2;
const [ids, setIds] = createSignal<number[]>([]);
export const multiPlayIds = ids;
export const openMultiPlay = (fileIds: number[]) => setIds(fileIds.slice(0, MAX_PANES));
export const closeMultiPlay = () => setIds([]);
/** 개수에 맞는 열 수 — 칸이 너무 납작해지지 않는 배치 */
function columnsFor(n: number): number {
if (n <= 1) return 1;
if (n <= 4) return 2;
return 3;
}
interface PaneState {
/** 이 칸이 실시간 변환을 쓰는가 — 상한 판정에 쓴다 */
needsStream: boolean;
}
const Pane: Component<{
fileId: number;
name: string;
/** 소리를 이 칸에서 내는가 */
audio: boolean;
playing: boolean;
/** 변환이 필요한데 상한에 걸려 재생하지 않는 칸 */
blocked: boolean;
onPick: () => void;
onSolo: () => void;
register: (s: PaneState | undefined) => void;
}> = (props) => {
const app = appInfoSig();
const [el, setEl] = createSignal<HTMLVideoElement | undefined>();
const [pb] = createResource(() => props.fileId, playbackInfo);
const plan = createMemo<PlaybackPlan | undefined>(() => {
const p = pb();
return p ? decidePlayback(p) : undefined;
});
let jobId: number | undefined;
const [src, setSrc] = createSignal<string | undefined>();
// 재생 계획이 정해지면 소스를 문다. 변환이 필요한데 막힌 칸은 아무것도 열지 않는다.
//
// `createEffect`여야 한다 — memo는 렌더 도중에 돌아서 아래 `<video>` ref가 아직 없다.
// (부수효과를 memo에 넣는 것은 그 자체로 잘못이기도 하다)
createEffect(() => {
const p = plan();
const a = app;
if (!p || !a) return;
props.register({ needsStream: p.mode === "stream" });
if (props.blocked) {
setSrc(undefined);
return;
}
if (p.mode === "direct") {
setSrc(mediaUrl(a, props.fileId));
} else if (p.mode === "stream") {
void startStream(props.fileId, 0, p.video, p.audio).then((r) => {
jobId = r.jobId;
setSrc(r.url);
});
}
});
onCleanup(() => {
props.register(undefined);
if (jobId != null) void stopStream(jobId);
});
// 부모의 재생/정지를 따른다
createEffect(() => {
const v = el();
if (!v) return;
if (props.playing) void v.play().catch(() => {});
else v.pause();
});
return (
<div
class="group relative min-h-0 cursor-pointer overflow-hidden rounded border bg-black"
classList={{
"border-[var(--accent)]": props.audio,
"border-[var(--border)]": !props.audio,
}}
onClick={props.onPick}
onDblClick={props.onSolo}
title={props.name}
>
<Show
when={src()}
fallback={
// 변환 상한에 걸렸거나 아직 준비 중 — 검은 화면만 두면 고장으로 보인다
<div class="grid h-full w-full place-items-center">
<img
class="absolute inset-0 h-full w-full object-contain opacity-40"
src={app ? thumbUrl(app, props.fileId, 1) : undefined}
alt=""
/>
<span class="relative rounded bg-black/70 px-2 py-1 text-[10px] text-white">
{props.blocked ? ko.multi.blocked : ko.multi.preparing}
</span>
</div>
}
>
<video
ref={setEl}
class="h-full w-full object-contain"
src={src()}
preload="auto"
playsinline
loop
muted={!props.audio}
/>
</Show>
<span class="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/80 to-transparent px-2 pb-1 pt-3 text-[10px] text-white/90">
{props.name}
</span>
<Show when={props.audio}>
<span class="absolute right-1 top-1 rounded bg-[var(--accent)] px-1.5 py-0.5 text-[9px] text-white">
{ko.multi.audioHere}
</span>
</Show>
</div>
);
};
export const MultiPlayView: Component = () => {
const [playing, setPlaying] = createSignal(true);
const [audioAt, setAudioAt] = createSignal(0);
const panes = new Map<number, PaneState>();
const [paneVersion, setPaneVersion] = createSignal(0);
const [details] = createResource(
() => (ids().length > 0 ? ids() : undefined),
(list) => fileDetails(list),
);
const nameOf = (id: number) => details()?.find((d) => d.id === id)?.name ?? "";
/**
* 변환이 필요한 칸 중 상한을 넘어 막을 대상.
*
* 고른 순서대로 앞쪽에 자리를 준다 — 무작위로 고르면 같은 선택을 다시 열 때마다
* 다른 칸이 막혀 이유를 알 수 없다.
*/
const blockedIds = createMemo(() => {
paneVersion();
const streamers = ids().filter((id) => panes.get(id)?.needsStream);
return new Set(streamers.slice(MAX_STREAMS));
});
const onKey = (e: KeyboardEvent) => {
if (ids().length === 0) return;
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
closeMultiPlay();
}
if (e.key === " ") {
e.preventDefault();
e.stopPropagation();
setPlaying((p) => !p);
}
// 1~9 로 소리를 옮긴다
const n = Number(e.key);
if (Number.isInteger(n) && n >= 1 && n <= ids().length) {
e.preventDefault();
setAudioAt(n - 1);
}
};
onMount(() => window.addEventListener("keydown", onKey, true));
onCleanup(() => window.removeEventListener("keydown", onKey, true));
/** 한 칸만 크게 — 라이트박스로 넘긴다 */
const solo = (id: number) => {
const snap = snapshot();
if (!snap) return;
for (let i = 0; i < snap.count; i++) {
if (snap.id(i) === id) {
closeMultiPlay();
openLightbox(i);
return;
}
}
};
return (
<Show when={ids().length > 0}>
<div class="fixed inset-0 z-50 flex flex-col bg-black/95">
<div class="flex items-center gap-3 border-b border-[var(--border)] px-4 py-2 text-white">
<span class="text-sm font-semibold">{ko.multi.title(ids().length)}</span>
<Show when={blockedIds().size > 0}>
<span class="text-[11px] text-[var(--warn,#f0b429)]">
{ko.multi.blockedNote(blockedIds().size, MAX_STREAMS)}
</span>
</Show>
<span class="ml-auto text-[11px] text-white/60">{ko.multi.hint}</span>
<button
class="rounded border border-white/30 px-2 py-1 text-xs hover:bg-white/15"
onClick={() => setPlaying((p) => !p)}
>
<span class="inline-flex items-center gap-1">
{playing() ? <IconPause size={13} /> : <IconPlay size={13} />}
{playing() ? ko.multi.pauseAll : ko.multi.playAll}
</span>
</button>
<button
class="rounded px-2 py-1 text-white/80 hover:bg-white/15"
onClick={closeMultiPlay}
title={ko.common.close}
>
<IconX size={16} />
</button>
</div>
<div
class="grid min-h-0 flex-1 gap-2 p-2"
style={{ "grid-template-columns": `repeat(${columnsFor(ids().length)}, minmax(0, 1fr))` }}
>
<For each={ids()}>
{(id, i) => (
<Pane
fileId={id}
name={`${i() + 1}. ${nameOf(id)}`}
audio={audioAt() === i()}
playing={playing()}
blocked={blockedIds().has(id)}
onPick={() => setAudioAt(i())}
onSolo={() => solo(id)}
register={(s) => {
if (s) panes.set(id, s);
else panes.delete(id);
setPaneVersion((v) => v + 1);
}}
/>
)}
</For>
</div>
</div>
</Show>
);
};
+572 -32
View File
@@ -1,9 +1,11 @@
// 미디어 플레이어 — 통일된 커스텀 컨트롤(직접 재생 + ffmpeg 스트림 공통).
// 재생/정지·10초 스킵·시크바(버퍼)·볼륨·배속·전체화면·자동숨김·키보드 단축키.
import { IconChevronLeft, IconChevronRight, IconFolder, IconForward, IconMaximize, IconMinimize, IconPause, IconPlay, IconRepeat, IconResetZoom, IconRewind, IconScissors, IconTrash, IconVolumeHigh, IconVolumeLow, IconVolumeMute } from "../../ui/icons";
import {
createEffect,
createResource,
createSignal,
For,
onCleanup,
onMount,
Show,
@@ -15,11 +17,25 @@ import {
playbackInfo,
startStream,
stopStream,
cutFile,
filmstrip,
type AudioMode,
type PlaybackPlan,
type StripFrame,
type VideoMode,
} from "../../ipc/commands";
import { info as appInfoSig, navLightbox, repeatMode, toggleRepeat } from "../../state/store";
import {
info as appInfoSig,
markedIds,
navLightbox,
repeatMode,
refreshSnapshot,
toast,
toggleMarkDelete,
toggleRepeat,
} from "../../state/store";
import { ko } from "../../i18n/ko";
import { moveDialogOpen, openMoveDialog } from "../fileops/MoveDialog";
function fmtTime(sec: number): string {
const s = Math.max(0, Math.floor(sec));
@@ -33,13 +49,101 @@ function fmtTime(sec: number): string {
const RATES = [0.5, 1, 1.25, 1.5, 2];
const VOL_KEY = "archive.player.volume";
const FIT_KEY = "archive.player.fit";
export const VideoPlayer: Component<{ fileId: number }> = (props) => {
/**
* 화면 크기 모드.
* - fit : 비율 유지하고 화면을 가득 채움(작은 영상도 확대) — 기본값
* - fill : 화면을 꽉 채우되 넘치는 부분은 잘라냄
* - none : 원본 픽셀 크기 1:1
*/
type FitMode = "fit" | "fill" | "none";
const FIT_LABEL: Record<FitMode, string> = {
fit: "맞춤",
fill: "채움",
none: "원본",
};
const FIT_ORDER: FitMode[] = ["fit", "fill", "none"];
const ZOOM_MIN = 1;
const ZOOM_MAX = 8;
/**
* 편집 가능한 시간 칸 — 재생바로는 못 잡는 정확한 지점을 직접 적는다.
*
* 입력 중에는 화면 값을 덮어쓰지 않는다. 손잡이를 끌면 값이 계속 바뀌는데,
* 그때마다 타이핑하던 글자가 사라지면 아예 적을 수가 없다.
*/
const TimeField: Component<{
label: string;
value: number;
active: boolean;
fmt: (s: number) => string;
onFocus: () => void;
onCommit: (sec: number) => void;
}> = (props) => {
const [editing, setEditing] = createSignal<string | undefined>();
const commit = (raw: string) => {
const parts = raw.trim().split(":");
const ok = parts.length > 0 && parts.every((p) => p !== "" && !Number.isNaN(Number(p)));
if (ok) {
const n = parts.reduce((acc, p) => acc * 60 + Number(p), 0);
if (Number.isFinite(n) && n >= 0) props.onCommit(n);
}
setEditing(undefined);
};
return (
<label class="flex items-center gap-1">
<span class="text-white/60">{props.label}</span>
<input
class="w-16 rounded border bg-black/40 px-1 py-0.5 text-center tabular-nums outline-none"
classList={{
"border-[var(--accent)]": props.active,
"border-white/25": !props.active,
}}
value={editing() ?? props.fmt(props.value)}
onFocus={props.onFocus}
onInput={(e) => setEditing(e.currentTarget.value)}
onBlur={(e) => commit(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit((e.currentTarget as HTMLInputElement).value);
(e.currentTarget as HTMLInputElement).blur();
}
if (e.key === "Escape") {
e.preventDefault();
setEditing(undefined);
(e.currentTarget as HTMLInputElement).blur();
}
}}
/>
</label>
);
};
export const VideoPlayer: Component<{
fileId: number;
/** 라이트박스에서 회전·반전한 횟수 — 올라오면 파일이 바뀐 것이므로 다시 읽는다 */
version?: number;
}> = (props) => {
let videoRef: HTMLVideoElement | undefined;
let containerRef: HTMLDivElement | undefined;
let barRef: HTMLDivElement | undefined;
const [pb] = createResource(() => props.fileId, playbackInfo);
/**
* 편집으로 파일 내용이 바뀌면 올린다.
*
* 파일 id는 그대로라 이것 없이는 아무것도 다시 읽히지 않는다 — 잘라서 7초가 된 영상을
* 재생기는 여전히 30초로 알고, 웹뷰는 같은 URL이라 캐시된 옛 바이트를 쓴다.
* 그 둘이 어긋나면 재생이 아예 안 된다.
*/
const [trimVersion, setTrimVersion] = createSignal(0);
/** 이 재생기가 아는 '파일 내용 세대' — 구간 자르기(내부)와 회전·반전(라이트박스)을 합친다 */
const fileVersion = () => trimVersion() + (props.version ?? 0);
const [pb] = createResource(
() => `${props.fileId}:${fileVersion()}`,
() => playbackInfo(props.fileId),
);
const [plan, setPlan] = createSignal<PlaybackPlan | undefined>();
const [src, setSrc] = createSignal<string | undefined>();
const [seekBase, setSeekBase] = createSignal(0);
@@ -52,15 +156,139 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
const [rate, setRate] = createSignal(1);
const [fullscreen, setFullscreen] = createSignal(false);
const [controlsShown, setControlsShown] = createSignal(true);
// ── 구간 자르기 ──
// 남길 구간만 두고 나머지를 잘라낸다. 스트림 복사라 화질 손실이 없고 30분짜리도 몇 초다.
// 되돌릴 수 없으므로(잘라낸 시간은 되살릴 수 없다) 원본은 백업으로 옮겨 두고
// Ctrl+Z로 돌아온다 — 백엔드가 그 처리를 한다.
const [trimMode, setTrimMode] = createSignal(false);
const [trimStart, setTrimStart] = createSignal(0);
const [trimEnd, setTrimEnd] = createSignal<number | undefined>();
const [trimBusy, setTrimBusy] = createSignal(false);
/** 지금 미세 조정 중인 손잡이 — ← → 키가 이쪽을 움직인다 */
const [activeHandle, setActiveHandle] = createSignal<"start" | "end">("start");
/** 고른 구간만 반복 재생 중인가 */
const [rangePreview, setRangePreview] = createSignal(false);
/**
* 타임라인에 깔 필름스트립 — 아이폰 트리머처럼 어느 장면을 자르는지 바로 보인다.
*
* 자르기 모드에 들어갈 때만 만든다. 영상마다 12번 디코드하는 비용을
* 그냥 재생만 하는 사람에게 물릴 이유가 없다.
*/
const [strip] = createResource(
() => (trimMode() ? props.fileId : undefined),
(id) => filmstrip(id, 12).catch(() => [] as StripFrame[]),
);
/** 끝을 아직 안 찍었으면 영상 끝이 기본값 */
const trimEndOr = () => trimEnd() ?? durationSec();
/**
* 미세 조정 한 칸(초). fps는 DB에 없어 30fps로 가정한다 —
* 정확한 지점이 필요하면 시간 칸에 직접 적는 쪽이 확실하다.
*/
const frameSec = () => 1 / 30;
/** 손잡이를 옮기고 그 지점으로 화면도 옮긴다 — 어디를 자르는지 프레임으로 봐야 한다. */
const moveHandle = (which: "start" | "end", t: number) => {
const d = durationSec();
const gap = 0.1;
if (which === "start") {
const v = Math.max(0, Math.min(t, trimEndOr() - gap));
setTrimStart(v);
seekTo(v);
} else {
const v = Math.min(d || t, Math.max(t, trimStart() + gap));
setTrimEnd(v);
seekTo(v);
}
setActiveHandle(which);
};
const nudge = (deltaFrames: number) => {
const which = activeHandle();
const base = which === "start" ? trimStart() : trimEndOr();
moveHandle(which, base + deltaFrames * frameSec());
};
/** 손잡이 끌기 — 재생바 위에서 직접 구간을 잡는다. */
const onHandleDown = (which: "start" | "end") => (e: PointerEvent) => {
if (durationSec() <= 0) return;
e.preventDefault();
e.stopPropagation(); // 재생바의 시크 처리로 넘어가지 않게
setRangePreview(false);
moveHandle(which, posToTime(e.clientX));
const move = (me: PointerEvent) => moveHandle(which, posToTime(me.clientX));
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
/** 고른 구간만 반복 재생 — 적용하기 전에 결과를 확인한다. */
const toggleRangePreview = () => {
if (rangePreview()) {
setRangePreview(false);
videoRef?.pause();
return;
}
setRangePreview(true);
seekTo(trimStart());
queueMicrotask(() => videoRef?.play().catch(() => {}));
};
/** 편집 가능한 시간 표기 — 소수 첫째 자리까지 보여 정밀 입력이 가능하다 */
const fmtPrecise = (sec: number) => {
const m = Math.floor(sec / 60);
const s = sec - m * 60;
return `${m}:${s.toFixed(1).padStart(4, "0")}`;
};
const applyTrim = async () => {
const [s, e] = [trimStart(), trimEndOr()];
if (e - s < 0.5) return toast(ko.edit.trimTooShort);
setTrimBusy(true);
try {
await cutFile(props.fileId, {
kind: "trim",
startMs: Math.round(s * 1000),
endMs: Math.round(e * 1000),
});
toast(ko.edit.trimDone(e - s));
setTrimMode(false);
setTrimStart(0);
setTrimEnd(undefined);
setRangePreview(false);
// 파일이 바뀌었다 — 재생 정보를 다시 읽고 소스를 새로 문다.
// 이걸 빼먹어서 "자른 뒤 재생이 안 된다"는 신고가 나왔다.
setTrimVersion((v) => v + 1);
await refreshSnapshot();
} catch (err) {
toast(String(err));
} finally {
setTrimBusy(false);
}
};
// <video> 요소가 보고한 길이 (직접 재생 시 메타데이터보다 정확·항상 존재)
const [elDuration, setElDuration] = createSignal(0);
// 스트림 시크 중 표시 위치 고정용 (null이면 실제 재생 위치 사용)
const [seekingTo, setSeekingTo] = createSignal<number | null>(null);
// ── 화면 크기 / 확대 ──
const storedFit = localStorage.getItem(FIT_KEY) as FitMode | null;
const [fit, setFitRaw] = createSignal<FitMode>(
storedFit && FIT_ORDER.includes(storedFit) ? storedFit : "fit",
);
const [zoom, setZoom] = createSignal(1);
const [panX, setPanX] = createSignal(0);
const [panY, setPanY] = createSignal(0);
let jobId: number | undefined;
let hideTimer: ReturnType<typeof setTimeout> | undefined;
let streamSeekTimer: ReturnType<typeof setTimeout> | undefined;
const isStream = () => plan()?.mode === "stream";
const marked = () => markedIds().has(props.fileId);
// 재생 길이: ffprobe 총 길이(메타)를 항상 우선. <video> 요소 duration은 스트림·일부
// 컨테이너(리먹스 fMP4 등)에서 재생 중 실시간으로 늘어나 재생바가 요동치므로,
// 메타가 있으면 그 고정 총길이를 쓰고, 없을 때만 요소 duration으로 폴백한다.
@@ -102,7 +330,9 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
const decided = decidePlayback(p);
setPlan(decided);
if (decided.mode === "direct") {
setSrc(mediaUrl(app, props.fileId));
const base = mediaUrl(app, props.fileId);
// 경로가 같으면 웹뷰가 캐시된 옛 파일을 그대로 쓴다
setSrc(fileVersion() === 0 ? base : `${base}?v=${fileVersion()}`);
} else if (decided.mode === "stream") {
void startStreamAt(0, decided.video, decided.audio);
} else {
@@ -175,6 +405,97 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
localStorage.setItem(VOL_KEY, String(nv));
};
// ── 화면 크기 / 확대 ──
const resetView = () => {
setZoom(1);
setPanX(0);
setPanY(0);
};
const setFit = (m: FitMode) => {
setFitRaw(m);
localStorage.setItem(FIT_KEY, m);
resetView();
};
const cycleFit = () => setFit(FIT_ORDER[(FIT_ORDER.indexOf(fit()) + 1) % FIT_ORDER.length]);
/** 커서 위치를 기준으로 확대 — 보고 싶은 부분이 그 자리에 남는다 */
const zoomAt = (factor: number, cx: number, cy: number) => {
const old = zoom();
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, old * factor));
if (next === old) return;
if (next === 1) {
resetView();
return;
}
const ratio = next / old;
setPanX(cx * (1 - ratio) + panX() * ratio);
setPanY(cy * (1 - ratio) + panY() * ratio);
setZoom(next);
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const rect = containerRef?.getBoundingClientRect();
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
zoomAt(e.deltaY < 0 ? 1.15 : 1 / 1.15, cx, cy);
showControls();
};
/**
* 확대 상태에서 드래그로 이동. 클릭(재생/정지)과 구분해야 하므로
* 일정 거리 이상 움직였을 때만 팬으로 취급하고, 그 경우 이어지는 click을 삼킨다.
*/
const onVideoPointerDown = (e: PointerEvent) => {
if (zoom() <= 1 || e.button !== 0) return;
const startX = e.clientX - panX();
const startY = e.clientY - panY();
const originX = e.clientX;
const originY = e.clientY;
let dragged = false;
const move = (me: PointerEvent) => {
if (Math.abs(me.clientX - originX) + Math.abs(me.clientY - originY) > 4) dragged = true;
if (!dragged) return;
me.preventDefault();
setPanX(me.clientX - startX);
setPanY(me.clientY - startY);
};
const up = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
if (dragged) {
// 드래그 직후의 click(=재생/정지 토글)을 한 번만 취소
const swallow = (ce: MouseEvent) => {
ce.stopPropagation();
ce.preventDefault();
};
window.addEventListener("click", swallow, { capture: true, once: true });
}
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
};
const OBJECT_FIT: Record<FitMode, "contain" | "cover" | "none"> = {
fit: "contain",
fill: "cover",
none: "none",
};
/** object-fit은 모드가, transform은 확대/이동이 담당한다 */
const videoStyle = () => ({
"object-fit": OBJECT_FIT[fit()],
transform: `translate(${panX()}px, ${panY()}px) scale(${zoom()})`,
transition: zoom() === 1 ? "transform 0.15s ease-out" : "none",
});
// 파일이 바뀌면 확대 상태 초기화 (모드는 유지 — 사용자가 고른 설정이다)
createEffect(() => {
props.fileId;
resetView();
});
const toggleFullscreen = () => {
if (!containerRef) return;
if (document.fullscreenElement) void document.exitFullscreen();
@@ -217,15 +538,27 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
setControlsShown(true);
clearTimeout(hideTimer);
hideTimer = setTimeout(() => {
if (playing()) setControlsShown(false);
// 편집 중에는 숨기지 않는다 — 필름스트립과 손잡이가 사라지면 작업 자체가 끊긴다.
// (구간 미리보기로 재생 중일 때가 특히 그렇다: 보고 있는 동안 도구가 없어진다)
if (playing() && !trimMode()) setControlsShown(false);
}, 2600);
};
/**
* 컨트롤바를 지금 보여야 하는가.
*
* 자동 숨김은 '감상 중'을 위한 것이다. 편집 중에는 조건 없이 보인다 —
* 자르기 모드를 켠 시점에 이미 숨어 있었더라도 그렇다.
*/
const barVisible = () => controlsShown() || trimMode();
// ── 키보드 단축키 (플레이어가 마운트된 동안) ──
const onKey = (e: KeyboardEvent) => {
// 입력 요소에 포커스면 무시
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
// 위에 모달이 떠 있으면 재생 키를 먹지 않는다
if (moveDialogOpen()) return;
let handled = true;
switch (e.key) {
case " ":
@@ -236,12 +569,16 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
case "ArrowLeft":
case "j":
case "J":
skip(e.key === "ArrowLeft" ? -5 : -10);
// 구간 자르기 중이면 방향키는 **고른 손잡이를 한 프레임씩** 움직인다.
// 그때 5초씩 건너뛰면 정밀 조정이 불가능하다.
if (trimMode() && e.key === "ArrowLeft") nudge(-1);
else skip(e.key === "ArrowLeft" ? -5 : -10);
break;
case "ArrowRight":
case "l":
case "L":
skip(e.key === "ArrowRight" ? 5 : 10);
if (trimMode() && e.key === "ArrowRight") nudge(1);
else skip(e.key === "ArrowRight" ? 5 : 10);
break;
case "ArrowUp":
changeVolume(volume() + 0.05);
@@ -257,6 +594,22 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
case "F":
toggleFullscreen();
break;
// 화면 크기: 모드 전환 / 확대 / 초기화
case "w":
case "W":
cycleFit();
break;
case "+":
case "=":
zoomAt(1.25, 0, 0);
break;
case "-":
case "_":
zoomAt(1 / 1.25, 0, 0);
break;
case "0":
resetView();
break;
case "PageUp":
navLightbox(-1);
break;
@@ -289,6 +642,10 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
if (seekingTo() == null) {
setCurrent(seekBase() + (videoRef?.currentTime ?? 0));
}
// 구간 미리보기 — 끝에 닿으면 시작으로 되돌린다. 적용 전에 결과를 반복해 확인할 수 있다.
if (rangePreview() && current() >= trimEndOr() - 0.05) {
seekTo(trimStart());
}
// 버퍼 끝(직접 모드)
if (videoRef && videoRef.buffered.length > 0 && !isStream()) {
setBuffered(videoRef.buffered.end(videoRef.buffered.length - 1));
@@ -305,8 +662,9 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
return (
<div
ref={containerRef}
class="relative flex h-full w-full items-center justify-center bg-black"
classList={{ "cursor-none": !controlsShown() }}
class="media-surface relative flex h-full w-full items-center justify-center overflow-hidden bg-black"
classList={{ "cursor-none": !barVisible() }}
onWheel={onWheel}
onMouseMove={showControls}
onMouseLeave={() => playing() && setControlsShown(false)}
>
@@ -321,7 +679,12 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
<video
ref={videoRef}
src={src()}
class="max-h-full max-w-full outline-none"
// 컨테이너를 가득 쓰고 비율은 object-fit이 처리한다.
// (max-*만 쓰면 작은 영상이 화면 한가운데 원본 크기로 조그맣게 나온다)
class="h-full w-full outline-none"
classList={{ "cursor-grab": zoom() > 1 }}
style={videoStyle()}
onPointerDown={onVideoPointerDown}
autoplay
playsinline
onTimeUpdate={onTimeUpdate}
@@ -361,7 +724,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
class="pointer-events-auto absolute grid h-16 w-16 place-items-center rounded-full bg-black/50 text-3xl text-white backdrop-blur transition hover:bg-black/70"
onClick={togglePlay}
>
<IconPlay size={30} />
</button>
</Show>
@@ -370,64 +733,193 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
<button
class="absolute left-2 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full bg-black/40 text-xl text-white/80 hover:bg-black/60 hover:text-white"
onClick={() => navLightbox(-1)}
title="이전 (PageUp)"
title="이전 항목 (P / PageUp / Ctrl+←)"
>
<IconChevronLeft size={22} />
</button>
<button
class="absolute right-2 top-1/2 grid h-10 w-10 -translate-y-1/2 place-items-center rounded-full bg-black/40 text-xl text-white/80 hover:bg-black/60 hover:text-white"
onClick={() => navLightbox(1)}
title="다음 (PageDown)"
title="다음 항목 (N / PageDown / Ctrl+→)"
>
<IconChevronRight size={22} />
</button>
</Show>
{/* 하단 컨트롤바 */}
<div
class="absolute inset-x-0 bottom-0 flex flex-col gap-1 bg-gradient-to-t from-black/80 to-transparent px-3 pb-2 pt-6 transition-opacity duration-200"
classList={{ "opacity-0": !controlsShown(), "opacity-100": controlsShown() }}
classList={{ "opacity-0": !barVisible(), "opacity-100": barVisible() }}
onClick={(e) => e.stopPropagation()}
>
{/* 시크바 */}
{/* 구간 자르기 줄 — 재생바의 손잡이를 끌어 잡고, 여기서 미세 조정한다. */}
<Show when={trimMode()}>
<div class="mb-1 flex items-center gap-2 rounded bg-black/60 px-2 py-1 text-[11px] text-white">
<TimeField
label={ko.edit.trimStart}
value={trimStart()}
active={activeHandle() === "start"}
fmt={fmtPrecise}
onFocus={() => setActiveHandle("start")}
onCommit={(t) => moveHandle("start", t)}
/>
<TimeField
label={ko.edit.trimEnd}
value={trimEndOr()}
active={activeHandle() === "end"}
fmt={fmtPrecise}
onFocus={() => setActiveHandle("end")}
onCommit={(t) => moveHandle("end", t)}
/>
{/* 프레임 단위 미세 조정 — 재생바 클릭으로는 초 단위도 거칠다 */}
<div class="flex items-center gap-0.5">
<button
class="rounded border border-white/30 px-1.5 py-0.5 hover:bg-white/15"
onClick={() => nudge(-1)}
title={ko.edit.nudgeBack}
>
</button>
<button
class="rounded border border-white/30 px-1.5 py-0.5 hover:bg-white/15"
onClick={() => nudge(1)}
title={ko.edit.nudgeFwd}
>
</button>
</div>
<button
class="rounded border px-1.5 py-0.5"
classList={{
"border-[var(--accent)] bg-[var(--accent)]": rangePreview(),
"border-white/30 hover:bg-white/15": !rangePreview(),
}}
onClick={toggleRangePreview}
title={ko.edit.rangePreviewHint}
>
{rangePreview() ? ko.edit.rangePreviewStop : ko.edit.rangePreview}
</button>
<span class="tabular-nums text-white/70">
{ko.edit.trimLength((trimEndOr() - trimStart()) || 0)}
</span>
<button
class="ml-auto rounded bg-[var(--accent)] px-2 py-0.5 disabled:opacity-40"
disabled={trimBusy() || trimEndOr() - trimStart() < 0.5}
onClick={() => void applyTrim()}
>
{trimBusy() ? ko.edit.cropping : ko.edit.trimApply}
</button>
</div>
</Show>
<div
ref={barRef}
class="group relative h-4 cursor-pointer"
class="group relative cursor-pointer transition-[height]"
classList={{ "h-4": !trimMode(), "h-14": trimMode() }}
onPointerDown={onBarPointerDown}
>
<div class="absolute top-1/2 h-1 w-full -translate-y-1/2 rounded bg-white/25" />
{/* 필름스트립 — 자르기 중에는 재생바가 장면 띠가 된다.
아직 안 만들어졌으면 회색 자리만 두고 기다린다(레이아웃이 튀지 않게). */}
<Show when={trimMode()}>
<div class="absolute inset-0 flex overflow-hidden rounded bg-black/40">
<For each={strip() ?? []}>
{(f) => (
<img
src={f.url}
class="h-full min-w-0 flex-1 object-cover"
draggable={false}
alt=""
/>
)}
</For>
</div>
</Show>
<div
class="absolute top-1/2 w-full -translate-y-1/2 rounded bg-white/25"
classList={{ "h-1": !trimMode(), "h-0": trimMode() }}
/>
<Show when={!isStream()}>
<div
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-white/40"
style={{ width: `${bufferedPct()}%` }}
/>
</Show>
<div
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-[var(--accent)]"
style={{ width: `${progressPct()}%` }}
/>
<div
class="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover:opacity-100"
style={{ left: `${progressPct()}%` }}
/>
{/* 자르기 중에는 진행 채움을 숨긴다 — 필름스트립을 가리면 장면이 안 보인다 */}
<Show when={!trimMode()}>
<div
class="absolute top-1/2 h-1 -translate-y-1/2 rounded bg-[var(--accent)]"
style={{ width: `${progressPct()}%` }}
/>
</Show>
{/* 잘려 나갈 앞뒤는 덮어 가리고, 남을 구간은 테두리로 감싼다.
아이폰 트리머와 같은 읽기 방식 — 밝은 쪽이 남는 쪽이다. */}
<Show when={trimMode() && durationSec() > 0}>
<div
class="pointer-events-none absolute inset-y-0 left-0 rounded-l bg-black/70"
style={{ width: `${(trimStart() / durationSec()) * 100}%` }}
/>
<div
class="pointer-events-none absolute inset-y-0 right-0 rounded-r bg-black/70"
style={{ left: `${(trimEndOr() / durationSec()) * 100}%` }}
/>
<div
class="pointer-events-none absolute inset-y-0 rounded border-y-2 border-[var(--warn,#f0b429)]"
style={{
left: `${(trimStart() / durationSec()) * 100}%`,
width: `${((trimEndOr() - trimStart()) / durationSec()) * 100}%`,
}}
/>
{/* 양 끝 손잡이 — 띠 높이만큼 세워야 잡기 쉽다 */}
<For each={["start", "end"] as const}>
{(which) => (
<div
class="absolute inset-y-0 z-10 flex w-4 -translate-x-1/2 cursor-ew-resize items-center justify-center rounded bg-[var(--warn,#f0b429)] shadow"
classList={{ "ring-2 ring-white": activeHandle() === which }}
style={{
left: `${((which === "start" ? trimStart() : trimEndOr()) / durationSec()) * 100}%`,
}}
onPointerDown={onHandleDown(which)}
title={which === "start" ? ko.edit.trimStart : ko.edit.trimEnd}
>
<span class="pointer-events-none h-4 w-0.5 rounded bg-black/40" />
</div>
)}
</For>
</Show>
{/* 재생 위치 — 평소엔 hover 시 나타나는 점, 자르기 중엔 띠를 가로지르는 선.
자를 지점을 맞추려면 지금 어디를 보고 있는지가 항상 보여야 한다. */}
<Show
when={trimMode()}
fallback={
<div
class="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 shadow transition-opacity group-hover:opacity-100"
style={{ left: `${progressPct()}%` }}
/>
}
>
<div
class="pointer-events-none absolute inset-y-0 z-20 w-0.5 -translate-x-1/2 bg-white shadow"
style={{ left: `${progressPct()}%` }}
/>
</Show>
</div>
{/* 버튼 행 */}
<div class="flex items-center gap-2 text-white">
<button class="rounded px-1 text-lg leading-none hover:text-[var(--accent)]" onClick={togglePlay} title="재생/정지 (Space)">
{playing() ? "⏸" : "▶"}
{playing() ? <IconPause size={17} /> : <IconPlay size={17} />}
</button>
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={() => skip(-10)} title="10초 뒤로 (J)">
<IconRewind size={16} />
</button>
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={() => skip(10)} title="10초 앞으로 (L)">
<IconForward size={16} />
</button>
{/* 볼륨 */}
<div class="group/vol flex items-center gap-1">
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={toggleMute} title="음소거 (M)">
{muted() || volume() === 0 ? "🔇" : volume() < 0.5 ? "🔉" : "🔊"}
{muted() || volume() === 0 ? <IconVolumeMute size={16} /> : volume() < 0.5 ? <IconVolumeLow size={16} /> : <IconVolumeHigh size={16} />}
</button>
<input
type="range"
@@ -445,6 +937,36 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
</span>
<div class="ml-auto flex items-center gap-2">
<button
class="rounded px-1 text-sm leading-none"
classList={{
"text-[var(--accent)]": trimMode(),
"text-white hover:text-[var(--accent)]": !trimMode(),
}}
onClick={() => setTrimMode((v) => !v)}
title={`${ko.edit.trim} · ${ko.edit.trimHint}`}
>
<IconScissors size={16} />
</button>
{/* 정리 작업 — 전체화면에서도 재생을 멈추지 않고 쓸 수 있게 컨트롤바에 둔다 */}
<button
class="rounded px-1 text-sm leading-none"
classList={{
"text-[var(--danger)]": marked(),
"text-white hover:text-[var(--accent)]": !marked(),
}}
onClick={() => void toggleMarkDelete([props.fileId])}
title={`${marked() ? ko.trash.unmark : ko.trash.mark} (Delete)`}
>
<IconTrash size={16} />
</button>
<button
class="rounded px-1 text-sm leading-none text-white hover:text-[var(--accent)]"
onClick={openMoveDialog}
title={`${ko.ops.moveTo} (Ctrl+M)`}
>
<IconFolder size={16} />
</button>
<button
class="rounded px-1 text-sm leading-none"
classList={{
@@ -454,7 +976,7 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
onClick={toggleRepeat}
title={repeatMode() ? "반복 켜짐" : "반복 꺼짐 (끝나면 다음 항목)"}
>
🔁
<IconRepeat size={16} />
</button>
<button
class="rounded border border-white/30 px-1.5 py-0.5 text-[10px] leading-none hover:border-[var(--accent)] hover:text-[var(--accent)]"
@@ -463,13 +985,31 @@ export const VideoPlayer: Component<{ fileId: number }> = (props) => {
>
{rateLabel()}
</button>
{/* 화면 크기 모드 — 휠로 확대한 배율도 같이 보여준다 */}
<button
class="rounded border border-white/30 px-1.5 py-0.5 text-[10px] leading-none hover:border-[var(--accent)] hover:text-[var(--accent)]"
onClick={cycleFit}
title="화면 크기 (맞춤 → 채움 → 원본). 휠로 확대, 드래그로 이동, 0으로 초기화"
>
{FIT_LABEL[fit()]}
{zoom() > 1 ? ` ${Math.round(zoom() * 100)}%` : ""}
</button>
<Show when={zoom() > 1}>
<button
class="rounded border border-white/30 px-1.5 py-0.5 text-[10px] leading-none hover:border-[var(--accent)] hover:text-[var(--accent)]"
onClick={resetView}
title="확대 초기화 (0)"
>
<IconResetZoom size={14} />
</button>
</Show>
<Show when={isStream()}>
<span class="rounded bg-white/15 px-1.5 py-0.5 text-[10px] text-white/70">
{(plan() as { video: string }).video === "transcode" ? "변환 재생" : "리먹스 재생"}
</span>
</Show>
<button class="rounded px-1 text-sm leading-none hover:text-[var(--accent)]" onClick={toggleFullscreen} title="전체화면 (F)">
{fullscreen() ? "🗗" : "⛶"}
{fullscreen() ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
</button>
</div>
</div>
+378 -6
View File
@@ -16,6 +16,96 @@ export const ko = {
`소스 "${name}"를 목록에서 제외할까요?\n\n인덱스·태그·썸네일만 제거되며, 디스크의 실제 파일은 삭제되지 않습니다.`,
removed: (name: string, n: number) =>
`"${name}" 제외됨 (${n.toLocaleString("ko-KR")}개 항목 인덱스에서 제거)`,
allItems: "전체 항목",
collections: "컬렉션",
cleanup: "정리",
// 전각 더하기 글리프는 아이콘이 아니다 — 모양은 IconPlus가 맡고 여기엔 말만 둔다
addSourcePrimary: "폴더 추가",
collapse: "접기",
expand: "펼치기",
},
// 사이드바 하단 라이브러리 통계 카드
stats: {
title: "라이브러리",
modeLine: (mode: string, version: string) => `${mode} · v${version}`,
},
// 사이드바 작업 존 — '지금 무엇이 얼마나'까지만 말한다.
// 자세한 사정(어느 폴더를 읽는 중인지, 데이터 위치 등)은 footer(상태바)가 말한다.
tasks: {
zone: "진행 중",
scan: "폴더 읽는 중",
dedupe: "중복 찾는 중",
/**
* 백필 제목 — **지금 실제로 밀려 있는 것**을 말한다. 예전에는 무엇이 남았든 '미리보기
* 준비 중'이라 적었는데, 썸네일이 다 끝나고 지문만 남은 상태에서도 그렇게 보여서
* "미리보기가 멈췄다"는 오해를 만들었다(실제 신고). 셋 다 남았으면 뭉뚱그린다.
*/
backfillTitle: (thumbs: number, meta: number, sigs: number) => {
const kinds = (thumbs > 0 ? 1 : 0) + (meta > 0 ? 1 : 0) + (sigs > 0 ? 1 : 0);
if (kinds > 1) return "라이브러리 준비 중";
if (thumbs > 0) return "미리보기 만드는 중";
if (meta > 0) return "파일 정보 읽는 중";
return "중복 탐지 지문 만드는 중";
},
/** 백필 부제 — 0인 항목은 말하지 않는다. 지문만 남은 상태에서도 '지문 N'이 반드시 보인다. */
backfillDetail: (thumbs: number, meta: number, sigs: number) => {
const p: string[] = [];
if (thumbs > 0) p.push(`썸네일 ${thumbs.toLocaleString("ko-KR")}`);
if (meta > 0) p.push(`메타 ${meta.toLocaleString("ko-KR")}`);
if (sigs > 0) p.push(`지문 ${sigs.toLocaleString("ko-KR")}`);
return p.join(" · ");
},
/** ffmpeg가 없을 때 — 큐가 조용히 건너뛰므로 대기 수만 보면 멈춘 것과 구별되지 않는다 */
needFfmpegTitle: "영상 처리 불가",
needFfmpeg: "ffmpeg.exe가 프로그램 폴더에 없습니다",
more: (n: number) => `작업 ${n.toLocaleString("ko-KR")}개 더`,
cancel: "이 작업 취소",
/** 열거 단계는 총량을 모른다 — 퍼센트 대신 처리 속도를 말한다 */
rate: (perSec: number) =>
perSec >= 10
? `초당 ${Math.round(perSec).toLocaleString("ko-KR")}`
: `초당 ${perSec.toFixed(1)}`,
/** 백필 처리율 — 남은 수 대신 '몇 개 중 몇 개' */
doneOf: (done: number, total: number) =>
`${done.toLocaleString("ko-KR")} / ${total.toLocaleString("ko-KR")}`,
footerHint: "진행 중인 작업 — 자세한 내용은 창 아래 상태바에 있습니다",
},
// 본문 카드 안 폴더 칩 밴드
browse: {
folders: "폴더",
more: (n: number) => `${n}개 더`,
collapse: "폴더 목록 접기",
expand: "폴더 목록 펼치기",
},
// 본문 카드 상단 브레드크럼 밴드
crumb: {
searchResult: (q: string) => `"${q}" 검색 결과`,
searchScope: "전체 라이브러리에서",
exitSearch: "검색 끝내고 이전 위치로",
},
theme: {
label: "테마",
toLight: "밝은 테마로 전환",
toDark: "어두운 테마로 전환",
},
source: {
reload: "다시 불러오기",
reloadStarted: (name: string) => `"${name}" 다시 불러오는 중…`,
edit: "이름·경로 바꾸기",
editTitle: "소스 수정",
name: "이름",
root: "폴더 경로",
browse: "폴더 선택…",
remove: "소스 제외 (파일은 삭제되지 않음)",
unavailable: "경로에 접근할 수 없습니다 — 연결을 확인하거나 '경로 바꾸기'로 다시 매핑하세요",
unavailableBadge: "연결 없음",
overlap: "다른 소스와 경로가 겹칩니다 — 같은 파일이 두 번 보일 수 있으니 하나를 제외하세요",
overlapBadge: "경로 겹침",
alreadyMapped: (name: string) => `이미 등록된 폴더입니다 — "${name}"를 다시 불러옵니다`,
remapHint: "폴더를 옮겼거나 드라이브 문자가 바뀐 경우, 경로만 바꾸면 태그·썸네일이 그대로 유지됩니다.",
remapDone: (n: number) => `경로 재매핑 완료 (폴더 ${n.toLocaleString("ko-KR")}개) — 다시 확인 중…`,
renameDone: "소스 이름을 바꿨습니다",
remoteNoRoot: "네트워크 소스는 경로를 바꿀 수 없습니다",
},
onboarding: {
title: "Archive에 오신 것을 환영합니다",
@@ -29,6 +119,24 @@ export const ko = {
items: (n: number) => `${n.toLocaleString("ko-KR")}개 항목`,
selected: (n: number) => `${n.toLocaleString("ko-KR")}개 선택됨`,
infoToggle: "썸네일에 파일명·크기 표시",
zoomTitle: "썸네일 크기 (Ctrl+휠)",
},
viewMode: {
label: "보기",
grid: "격자",
list: "리스트",
title: "보기 방식 전환 (V)",
},
list: {
name: "이름",
tags: "태그",
kind: "종류",
size: "크기",
dims: "해상도",
duration: "길이",
taken: "촬영일",
modified: "수정일",
sortHint: (col: string) => `${col} 기준 정렬`,
},
filter: {
all: "전체",
@@ -36,6 +144,7 @@ export const ko = {
video: "영상",
},
sort: {
label: "정렬",
taken: "촬영일",
name: "이름",
size: "크기",
@@ -43,6 +152,22 @@ export const ko = {
asc: "오름차순",
desc: "내림차순",
},
refresh: {
action: "새로고침",
title: "디스크를 다시 읽어 목록을 맞춥니다 (F5)",
started: "새로고침 중… 변경된 항목만 반영됩니다",
busy: "이미 스캔이 진행 중입니다",
purgeMissing: "사라진 파일 정리",
purgeMissingConfirm: (n: number) =>
`디스크에 없는 ${n.toLocaleString("ko-KR")}개를 목록에서 지울까요?
실제 파일은 이미 없으며, 태그·썸네일 기록만 제거됩니다.`,
purgeMissingDone: (n: number) => `${n.toLocaleString("ko-KR")}개를 목록에서 정리했습니다`,
purgeMissingNone: "디스크에 없는 파일이 없습니다 — 정리할 것이 없습니다",
/** 대량 삭제 보류 — 사라진 것으로 보이는 파일이 절반을 넘어 지우지 않았다 */
guarded: (n: number) =>
`파일 ${n.toLocaleString("ko-KR")}개가 사라진 것으로 보이지만, 안전을 위해 목록에서 지우지 않았습니다. 드라이브·네트워크 연결을 확인하세요.`,
},
shortcuts: {
title: "키보드 단축키",
close: "닫기 (Esc / ?)",
@@ -50,13 +175,24 @@ export const ko = {
["Space / K", "재생·일시정지 (영상)"],
["← →", "이전·다음 항목 (그리드) / 5초 이동 (영상)"],
["↑ ↓", "위·아래 줄 이동 (그리드) / 볼륨 (영상)"],
["Home / End", "첫·마지막 항목"],
["Enter / Space", "선택 항목 크게 보기"],
["V", "보기 방식 전환 (격자 ↔ 리스트)"],
["N / P", "다음·이전 항목 (보기 중, 영상 포함)"],
["Ctrl+← →", "다음·이전 항목 (영상 재생 중에도)"],
["T", "태그 메뉴 (보기 중) — 검색·새로 만들기, 1~9로 바로 지정"],
["W", "화면 크기 전환 (맞춤 / 채움 / 원본)"],
["휠, + , 0", "확대 (커서 기준) · 초기화 — 확대 후 드래그로 이동"],
["Ctrl+[ / Ctrl+]", "왼쪽·오른쪽 90도 회전 (원본이 바뀜)"],
["Ctrl+M", "폴더로 이동"],
["Delete", "삭제 표시 (파일은 그대로 — 비우기 전까지)"],
["Shift+Delete", "즉시 OS 휴지통으로"],
["Ctrl+A", "전체 선택"],
["Ctrl+Shift+A", "선택 반전"],
["Esc", "선택 해제 / 닫기"],
["Delete", "휴지통으로"],
["Ctrl+Z", "실행 취소"],
["Ctrl+Z", "실행 취소 (삭제 표시 포함)"],
["F2", "이름 바꾸기"],
["F5", "새로고침 (디스크 다시 읽기)"],
["Ctrl+휠", "썸네일 크기"],
["?", "이 도움말"],
] as [string, string][],
@@ -73,6 +209,15 @@ export const ko = {
takenAt: "촬영 일시",
modifiedAt: "수정 일시",
tags: "태그",
tabInfo: "정보",
tabTags: "태그",
close: "상세 패널 닫기",
toggle: "상세 정보 패널",
sectionMedia: "미디어 정보",
sectionBasic: "기본 정보",
kind: "종류",
bulkTag: "태그 편집",
selectedUnit: "개 선택됨", // [C] 다중 선택 타일 — 큰 숫자 아래 라벨
},
statusBar: {
ready: "준비됨",
@@ -82,30 +227,149 @@ export const ko = {
installed: "일반 모드",
thumbPending: (n: number) => `썸네일 생성 중 ${n.toLocaleString("ko-KR")}`,
metaPending: (n: number) => `메타 분석 중 ${n.toLocaleString("ko-KR")}`,
sigPending: (n: number) => `지문 생성 중 ${n.toLocaleString("ko-KR")}`,
sigPendingHint:
"중복 탐지용 지문을 백그라운드에서 만들고 있습니다. 앱을 계속 쓰셔도 되고, 껐다 켜면 이어서 진행됩니다.",
},
search: {
placeholder: "파일명 검색…",
},
common: {
cancel: "취소",
/** 취소를 눌렀지만 아직 멈추지 않은 구간 — 사이드바 값 칸과 모달 헤더가 같은 말을 한다 */
canceling: "취소 중…",
confirm: "확인",
delete: "삭제",
rename: "이름 바꾸기",
move: "이동",
undo: "실행 취소",
create: "만들기",
save: "저장",
close: "닫기",
},
rating: {
label: "별점",
set: (n: number) => `별점 ${n}`,
clearHint: "같은 별을 다시 누르면 해제됩니다",
none: "없음",
/** 툴바 필터 — '이상'이라는 점을 분명히 한다(정확히 N점이 아니다) */
filterAll: "전체",
filterAtLeast: (n: number) => `${n}★ 이상`,
sort: "별점",
/** 여러 항목을 함께 골랐는데 값이 다를 때 */
mixed: "여러 값",
applied: (n: number, stars: number) =>
stars === 0 ? `${n}개 별점 해제` : `${n}개에 별점 ${stars}`,
shortcut: "Ctrl+0~5 로 별점",
},
multi: {
/** 우클릭 메뉴 — 영상을 2개 이상 골랐을 때만 보인다 */
open: (n: number) => `동시 재생 (${n}개)`,
tooMany: (max: number) => `동시 재생은 최대 ${max}개까지입니다 — 앞 ${max}개만 엽니다`,
needTwo: "영상을 2개 이상 고르세요",
title: (n: number) => `동시 재생 · ${n}`,
playAll: "모두 재생",
pauseAll: "모두 정지",
hint: "칸을 누르면 소리가 그 칸으로 · 1~9 키로도 이동 · 두 번 누르면 크게 · Space 재생/정지 · Esc 닫기",
audioHere: "소리",
preparing: "준비 중…",
/** 실시간 변환 상한에 걸린 칸 */
blocked: "변환 필요 — 동시 재생 제외",
blockedNote: (n: number, max: number) =>
`${n}개는 코덱 변환이 필요해 제외했습니다 (동시 변환은 ${max}개까지 — 그 이상은 재생이 끊깁니다)`,
},
edit: {
menu: "회전·반전",
rotateCw: "오른쪽으로 90°",
rotateCcw: "왼쪽으로 90°",
rotate180: "180° 뒤집기",
flipH: "좌우 반전",
flipV: "상하 반전",
/** 원본을 고친다는 걸 메뉴에서부터 알린다 */
hint: "원본이 바뀝니다 · Ctrl+Z로 되돌리기",
working: (done: number, total: number) => `편집 중 ${done}/${total}`,
done: (n: number) => `${n}개 편집 완료`,
/** 재인코딩이 섞였을 때 — 화질 손실이 있었다는 뜻이라 조용히 넘기면 안 된다 */
doneReencoded: (n: number, re: number) =>
`${n}개 완료 (${re}개는 다시 인코딩 — 영상 반전은 무손실이 안 됩니다)`,
doneWithFail: (n: number, f: number) => `${n}개 완료 · ${f}개 실패`,
lossless: "화질 손실 없음",
selectFirst: "먼저 항목을 고르세요",
shortcut: "Ctrl+[ 왼쪽 · Ctrl+] 오른쪽 회전",
crop: "자르기",
cropHint: "남길 부분을 드래그하세요 (Enter 적용 · Esc 취소)",
cropNone: "영역을 고르세요",
cropApply: "잘라내기",
cropping: "자르는 중…",
/** 자르기는 역연산이 없다 — 원본을 옮겨 보관한다는 걸 분명히 말한다 */
cropWarn: "잘라낸 부분은 되살릴 수 없습니다. 원본은 보관되며 Ctrl+Z로 되돌아옵니다.",
cropDone: (w: number, h: number) => `${w}×${h}로 잘랐습니다`,
trim: "구간 자르기",
trimStart: "시작",
trimEnd: "끝",
trimSetStart: "여기를 시작으로",
trimSetEnd: "여기를 끝으로",
trimApply: "이 구간만 남기기",
trimHint: "남길 구간만 두고 나머지는 잘라냅니다 — 화질 손실 없음",
trimLength: (s: number) => `${s.toFixed(1)}`,
/** fps를 모르므로 1/30초 고정 — "한 프레임"이라 적으면 24·60fps 영상에서 거짓말이 된다 */
nudgeBack: "0.03초 뒤로 (←) · 정확한 값은 왼쪽 칸에 직접 입력",
nudgeFwd: "0.03초 앞으로 (→) · 정확한 값은 왼쪽 칸에 직접 입력",
rangePreview: "구간 재생",
rangePreviewStop: "정지",
rangePreviewHint: "고른 구간만 반복 재생해 확인합니다",
trimDone: (secs: number) => `${secs.toFixed(1)}초 구간만 남겼습니다`,
trimTooShort: "구간이 너무 짧습니다",
imageOnly: "사진에만 쓸 수 있습니다",
videoOnly: "영상에만 쓸 수 있습니다",
},
tags: {
add: "태그 추가…",
newPlaceholder: "새 태그 이름",
searchOrCreate: "태그 검색 · 새 태그 이름",
createAssign: (name: string) => `"${name}" 새로 만들어 지정`,
createdAndAssigned: (name: string) => `"${name}" 태그를 만들어 지정했습니다`,
emptyAll: "태그가 없습니다 — 이름을 입력해 새로 만드세요",
pickerHint: "↑↓ 이동 · Enter 지정 · 1~9 빠른 지정 · Esc 닫기",
assignTo: (n: number, tag: string) => `${n}개 항목에 "${tag}" 태그 추가됨`,
removedFrom: (n: number, tag: string) => `${n}개 항목에서 "${tag}" 태그 제거됨`,
deleteConfirm: (tag: string) => `태그 "${tag}"를 삭제할까요? (파일은 삭제되지 않음)`,
exported: (path: string) => `태그를 내보냈습니다: ${path}`,
exportAction: "태그 JSON 내보내기",
// ── 클릭으로 붙이기 ────────────────────────────────────
// 매번 이름을 쳐서 찾는 대신 목록을 눌러 붙인다. 입력창은 '좁히기 + 새로 만들기'다.
noneYet: "붙은 태그 없음",
pick: "태그 고르기",
filterOrCreate: "좁히기 · 새 태그는 이름 입력 후 Enter",
noMatch: "일치하는 태그 없음",
countHint: (n: number) => `${n.toLocaleString("ko-KR")}개 항목에 사용 중`,
// ── 세트 ───────────────────────────────────────────────
sets: "태그 세트",
setHint: (names: string) => `한 번에 붙이기: ${names} (우클릭으로 편집)`,
setApplied: (name: string, n: number) => `"${name}" 세트 적용 — ${n}개 추가됨`,
newSet: "+ 세트",
editSet: "세트 편집",
setName: "세트 이름",
setNamePlaceholder: "예: 여행 정리",
setMembers: (n: number) => `포함 태그 ${n}`,
setHotkey: "단축키",
noHotkey: "없음",
pressKey: "키를 누르세요…",
clearHotkey: "해제",
hotkeyNote:
"항목을 선택한 상태에서 누르면 적용됩니다. 입력창에 커서가 있을 때는 동작하지 않습니다.",
// ── 이름 수정 ──────────────────────────────────────────
// file_tags가 tag_id로 연결돼 있어 이름만 바꾸면 붙어 있던 항목에 그대로 반영된다.
rename: "이름 수정",
renamePrompt: (old: string) => `"${old}"의 새 이름`,
renamed: (from: string, to: string) => `"${from}" → "${to}" (붙어 있던 항목에 모두 반영됨)`,
renameNote: "이미 이 태그가 붙어 있는 항목들도 함께 바뀝니다. 태그를 지웠다 새로 만들면 그 연결이 사라집니다.",
},
ops: {
trash: "휴지통으로 보내기",
moveTo: "폴더로 이동…",
moveDone: (n: number) => `${n}개 이동 완료`,
moveFailed: (n: number) => `${n}개 이동 실패`,
@@ -116,6 +380,24 @@ export const ko = {
nothingToUndo: "되돌릴 작업이 없습니다",
inProgress: (done: number, total: number) => `처리 중 ${done}/${total}`,
},
trash: {
tagName: "삭제 예정",
mark: "삭제 표시",
unmark: "삭제 표시 해제",
marked: (n: number) => `${n}개 항목에 삭제 표시`,
unmarked: (n: number) => `${n}개 항목의 삭제 표시 해제`,
badge: "삭제 예정",
empty: "비우기",
emptyTitle: "삭제 예정 항목 비우기",
emptyDanger: "디스크에서 영구 삭제 (복구 불가)",
emptyNothing: "삭제 예정 항목이 없습니다",
emptyConfirm: (n: number, size: string) =>
`삭제 예정 ${n.toLocaleString("ko-KR")}개(${size})를 디스크에서 영구 삭제합니다.\n\n` +
`휴지통을 거치지 않으므로 되돌릴 수 없습니다. 계속할까요?`,
emptyDone: (n: number) => `${n.toLocaleString("ko-KR")}개 영구 삭제됨`,
emptyPartial: (done: number, skipped: number, failed: number) =>
`${done}개 삭제, ${skipped}개 이미 없음, ${failed}개 실패`,
},
smart: {
create: "스마트 폴더 만들기",
name: "이름",
@@ -155,18 +437,108 @@ export const ko = {
return `${labels[phase] ?? phase} ${cur.toLocaleString("ko-KR")}/${total.toLocaleString("ko-KR")}`;
},
threshold: "유사도 임계값",
/** 지문 생성 속도 — 영상은 ffmpeg 한 프로세스가 코어 하나를 쓰므로 동시 실행 수가 곧 속도다 */
speed: "지문 속도",
speedSlow: "느리게",
speedNormal: "보통",
speedFast: "빠르게",
speedHint:
"중복 탐지용 지문을 만드는 속도입니다. '빠르게'는 코어를 더 쓰므로 다른 작업이 느려질 수 있습니다. 바꾸면 바로 반영됩니다.",
includeVideo: "영상 포함",
/** 회전·반전본 — 후보 생성이 몇 배 비싸므로 기본은 꺼둔다 */
includeRotated: "회전·반전본",
includeRotatedHint:
"90°·180°·좌우반전 사본도 찾습니다. 변형 8종을 따로 조회하므로 시간이 몇 배 걸립니다.",
scopeLabel: "범위:",
scopeFolder: "현재 폴더 (하위 포함)",
scopeSource: "현재 소스",
scopeAll: "전체 라이브러리",
empty: "중복 항목이 없습니다. 상단의 '중복 탐지 시작'을 눌러 검사하세요.",
groupCount: (n: number) => `${n}개 그룹`,
/** 탐지 중 밴드 — 시작 시점에 캡처한 범위를 말한다. 뷰가 바뀌어도 이 말은 바뀌지 않는다 */
scopeRunning: (label: string) => `${label} 기준으로 탐지 중`,
/** 결과 밴드 — 지금 보이는 그룹이 어떤 범위의 결과인지 */
scopeResult: (label: string) => `${label} 결과`,
rescan: "다시 탐지",
empty: "중복 항목이 없습니다. 위의 '중복 탐지 시작'을 눌러 검사하세요.",
groupCount: (n: number) => `${n.toLocaleString("ko-KR")}개 그룹`,
memberCount: (n: number) => `${n}개 항목`,
/** 그룹 종류 (0 완전 동일 · 1 유사 이미지 · 2 유사 영상) */
kindLabel: (kind: number) =>
kind === 0 ? "완전 동일" : kind === 1 ? "유사 이미지" : "유사 영상",
/** 해밍 거리 — 작을수록 닮았다 */
distance: (n: number) => `거리 ${n}`,
/**
* 지문이 아직 없는 항목 안내. 유사 탐지는 저장된 지문만 읽으므로, 백그라운드가
* 채우는 중인 항목은 이번 결과에 없다 — 조용히 빠지면 "왜 안 잡히지"가 된다.
*/
sigPending: (n: number) =>
`지문 준비 중 ${n.toLocaleString("ko-KR")}개 — 이 항목들은 아직 유사 탐지에 포함되지 않습니다`,
moreGroups: (n: number) => `${n.toLocaleString("ko-KR")}개 그룹 더 보기`,
done: "중복 탐지 완료",
canceled: "중복 탐지 취소됨",
keeper: "보관",
keepBest: "최적 보관 (나머지 휴지통)",
/** 배지가 곧 선택 손잡이다 — 지금 상태와 누르면 무슨 일이 나는지를 각각 말한다 */
keeperCurrent: "이 항목을 보관합니다 (나머지가 삭제 대상)",
keeperPick: "이 항목을 보관하도록 바꾸기",
/**
* 보관 항목은 탐지가 정하는 게 아니라 사용자가 바꿀 수 있다 — '최적'이라고 하면 거짓말이 된다.
* 그리고 이 버튼은 **지우지 않는다** — '삭제 예정'으로 표시만 하고, 실제 삭제는
* 사이드바 '정리 → 비우기'가 한다. 버튼이 '휴지통'이라고 하면 안 지워졌을 때 거짓말이 된다.
*/
keepBest: "보관 항목만 남기기 (나머지 삭제 예정)",
keepBestHint:
"나머지를 '삭제 예정'으로 표시합니다. 파일은 아직 지워지지 않으며, 사이드바 '정리'에서 확인 후 비우면 삭제됩니다.",
keepAll: "모두 보관 (무시)",
trashLosersConfirm: (n: number) => `보관 항목을 제외한 ${n}개를 휴지통으로 보낼까요?`,
/** 표시된 실제 개수를 말한다 — 요청 수가 아니라 처리된 수 */
markedForDelete: (n: number) => `${n}개를 '삭제 예정'으로 표시했습니다`,
/** 그룹 카드에서 비교 뷰를 여는 버튼 */
compare: "비교",
compareHint: "보관 항목과 나란히 놓고 비교합니다",
/**
* 등급 — 사용자가 할 결정은 "그냥 정리해도 되나 / 눈으로 봐야 하나" 둘뿐이라 두 단계다.
* 근거(거리·정렬·구간)에서 파생하며 저장하지 않는다.
*/
tierCertain: "확실",
tierSuspect: "의심",
tierCertainWhy: "바이트 동일이거나 근거가 뚜렷합니다 — 바로 정리해도 됩니다",
tierSuspectWhy: "근거가 약합니다 — 비교로 확인한 뒤 결정하세요",
/** 목록 머리 요약 — 확실한 것부터 몰아서 처리할 수 있게 개수를 먼저 말한다 */
tierSummary: (certain: number, suspect: number) =>
`확실 ${certain.toLocaleString("ko-KR")} · 의심 ${suspect.toLocaleString("ko-KR")}`,
},
// 중복 비교 — 두 파일을 나란히 놓고 같은 내용이 같은 시각에 보이도록 맞춰 재생한다.
compare: {
title: "나란히 비교",
pick: "비교 대상:",
/** 보기 전환 — 1:1은 크게, 전체는 한눈에 */
modePair: "1:1",
modeAll: "전체 동시",
modeAllHint: (n: number) =>
`${n}개를 한 화면에서 동시에 재생합니다. 영상이 ${n}개 동시에 돌아가므로 느려질 수 있습니다.`,
allCount: (n: number) => `${n}개 동시 재생 — 각 제목이 자기 맞춤값을 표시합니다`,
play: "재생 (Space)",
pause: "일시정지 (Space)",
back: (n: number) => `${n}초 뒤로 (←)`,
fwd: (n: number) => `${n}초 앞으로 (→)`,
/** 어느 쪽 소리를 들을지 — 두 소리를 같이 내면 비교가 안 된다 */
audioOn: "이 쪽 소리를 듣는 중 — 누르면 전체 음소거 (M으로 전환)",
audioOff: "이 쪽 소리 듣기 (M)",
/** 화면만 보고 싶을 때. 같은 소리가 반복되면 오히려 방해가 된다 */
muteAll: "전체 음소거 (Shift+M)",
unmuteAll: "음소거 해제 — 마지막에 듣던 쪽 (Shift+M)",
aligned: "같은 지점에서 시작",
later: (sec: number) => `이 사본이 ${sec.toFixed(1)}초 늦게 시작`,
earlier: (sec: number) => `이 사본이 ${sec.toFixed(1)}초 빠르게 시작`,
/** 정렬을 직접 확인하지 못한 멤버 — 맞춰진 척하지 않는다 */
noAlignment: "맞춤 정보 없음 — 둘 다 0초부터 재생합니다",
segment: (from: string, to: string) => `일치 구간 ${from}${to}`,
toSegment: "일치 구간으로",
outOfRange: "이 지점은 상대 파일에 없습니다",
/** 패널 시간축의 짧은 표기 — 자리가 좁아 문장을 못 쓴다 */
outOfRangeShort: "—",
zoomHint: "휠로 확대 · 드래그로 이동 (0으로 초기화)",
reset: "확대 초기화 (0)",
/** 검토 중 "이 파일이 어디 있지"에 답한다 — 그 폴더로 이동하고 모달을 닫는다 */
reveal: "이 파일이 있는 폴더 열기",
},
} as const;
+213 -4
View File
@@ -10,6 +10,8 @@ export interface AppInfo {
portable: boolean;
mediaPort: number;
mediaToken: string;
/** ffmpeg 사이드카 유무 — 없으면 영상은 썸네일도 지문도 만들 수 없다 */
hasFfmpeg: boolean;
}
export interface Source {
@@ -38,6 +40,8 @@ export interface FileDetails {
durationMs: number | null;
kind: number;
takenAt: number | null;
/** 0=없음 ~ 5 */
rating: number;
}
export interface ScanProgress {
@@ -52,15 +56,60 @@ export const appInfo = () => invoke<AppInfo>("app_info");
export interface PendingCounts {
thumbs: number;
meta: number;
/** 중복 탐지용 지문 생성 대기 수 */
sigs: number;
/**
* 진행률의 분모 — '가장 많이 밀려 있던 순간'의 대기 수. 백엔드가 DB에 들고 있어서
* 앱을 껐다 켜도 이어진다(화면 메모리에 두면 재시작 때마다 0%로 돌아간다).
*/
baseline: number;
}
export const pendingCounts = () => invoke<PendingCounts>("pending_counts");
export const listSources = () => invoke<Source[]>("list_sources");
/** add_local_source 결과 — existing이면 이미 등록된 소스를 그대로 돌려받은 것 */
export interface AddSourceResult {
source: Source;
existing: boolean;
}
export const addLocalSource = (path: string) =>
invoke<Source>("add_local_source", { path });
invoke<AddSourceResult>("add_local_source", { path });
/** 소스를 인덱스에서 제외 (실제 파일은 삭제하지 않음). 반환: 제거된 항목 수 */
export const removeSource = (sourceId: number) =>
invoke<number>("remove_source", { sourceId });
/** 소스 경로 점검 — 접근 가능 여부와 다른 소스와의 경로 겹침 */
export interface SourceStatus {
sourceId: number;
available: boolean;
overlapsWith: number | null;
}
export const sourceStatus = () => invoke<SourceStatus[]>("source_status");
/**
* 소스 이름·경로 수정. 경로를 바꾸면 태그·썸네일을 유지한 채 새 위치로 재매핑하고
* 이어서 재스캔한다. 반환: 경로가 바뀐 폴더 수.
*/
export const updateSource = (sourceId: number, patch: { name?: string; root?: string }) =>
invoke<number>("update_source", {
sourceId,
name: patch.name ?? null,
root: patch.root ?? null,
});
/** 소스 하나만 다시 불러오기(증분 재스캔). 경로에 접근 못 하면 오류로 알린다. */
export const reloadSource = (sourceId: number) =>
invoke<void>("reload_source", { sourceId });
/**
* 안전장치가 보류한 사라진 파일을 확인 후 목록에서 정리한다.
* 디스크를 다시 확인해 실제로 없는 행만 지우므로, 연결이 끊겼던 것뿐이면 아무것도 지워지지 않는다.
*/
export const purgeMissing = (sourceId: number) =>
invoke<number>("purge_missing", { sourceId });
export const cancelScan = () => invoke<void>("cancel_scan");
/** 수동 새로고침 — 로컬 소스 전체 증분 재스캔 (워처가 못 미치는 SMB/NAS용) */
export const rescanAll = () => invoke<void>("rescan_all");
export const folderTree = (sourceId: number) =>
invoke<FolderNode[]>("folder_tree", { sourceId });
export const fileDetails = (ids: number[]) =>
@@ -113,6 +162,13 @@ export class Snapshot {
thumbFailed(i: number): boolean {
return this.ok(i) && (this.view.getUint8(i * ITEM_BYTES + 9) & 2) !== 0;
}
/**
* 별점 0~5. flags 바이트의 **남는 비트(2~4)**에 얹혀 있다 —
* 항목당 바이트가 늘지 않으므로 100만 건 스냅샷이 10MB 그대로다.
*/
rating(i: number): number {
return this.ok(i) ? (this.view.getUint8(i * ITEM_BYTES + 9) >> 2) & 0b111 : 0;
}
/** 레이아웃 워커 입력용 종횡비 배열 (미지 치수는 1:1) */
aspectRatios(): Float32Array {
const out = new Float32Array(this.count);
@@ -125,18 +181,21 @@ export class Snapshot {
}
}
/** 정렬 키: taken(촬영/기본) | name | size | mtime */
export type SortKey = "taken" | "name" | "size" | "mtime";
/** 스냅샷 공통 옵션 — 정렬/종류 필터 */
/** 정렬 키: taken(촬영/기본) | name | size | mtime | rating */
export type SortKey = "taken" | "name" | "size" | "mtime" | "rating";
/** 스냅샷 공통 옵션 — 정렬/종류/별점 필터 */
export interface SnapOpts {
sortKey?: SortKey;
sortDesc?: boolean;
kind?: number | null; // 0=사진, 1=영상, null=전체
/** 이 별점 **이상**만 (1~5). null=전체 */
minRating?: number | null;
}
const snapArgs = (o?: SnapOpts) => ({
sortKey: o?.sortKey ?? null,
sortDesc: o?.sortDesc ?? null,
kind: o?.kind ?? null,
minRating: o?.minRating ?? null,
});
export async function folderSnapshot(
@@ -181,6 +240,50 @@ export const assignTags = (fileIds: number[], tagId: number) =>
export const unassignTags = (fileIds: number[], tagId: number) =>
invoke<TagOpResult>("unassign_tags", { fileIds, tagId });
export const fileTagsOf = (fileId: number) => invoke<Tag[]>("file_tags_of", { fileId });
/** 별점 0(없음)~5. 반환값은 실제로 바뀐 행 수. */
export const setRating = (fileIds: number[], rating: number) =>
invoke<number>("set_rating", { fileIds, rating });
/**
* 태그 세트 — 자주 함께 붙이는 태그 묶음. 세트는 태그를 **참조**할 뿐 복사하지 않으므로,
* 태그 이름을 바꾸면 세트에도 그대로 반영되고 태그를 지우면 세트에서 빠진다.
*/
export interface TagSet {
id: number;
name: string;
/** 예: "1", "Ctrl+2". 없으면 클릭으로만 쓴다. 한 키는 한 세트에만 붙는다. */
hotkey: string | null;
tags: Tag[];
}
export const listTagSets = () => invoke<TagSet[]>("list_tag_sets");
export const createTagSet = (name: string, tagIds: number[]) =>
invoke<number>("create_tag_set", { name, tagIds });
/** 보내지 않은 항목은 그대로 둔다. hotkey는 3상태: 생략=유지 / null=해제 / 문자열=지정 */
export const updateTagSet = (
setId: number,
patch: { name?: string; hotkey?: string | null; tagIds?: number[] },
) =>
invoke<void>("update_tag_set", {
setId,
name: patch.name ?? null,
// Rust 쪽이 Option<Option<String>>이라, 키가 없으면 '유지'가 되도록 undefined를 넘긴다
hotkey: "hotkey" in patch ? patch.hotkey : undefined,
tagIds: patch.tagIds ?? null,
});
export const deleteTagSet = (setId: number) => invoke<void>("delete_tag_set", { setId });
export const applyTagSet = (fileIds: number[], setId: number) =>
invoke<TagOpResult>("apply_tag_set", { fileIds, setId });
/** 파일-태그 연결 (그리드 오버레이용 배치 조회) */
export interface FileTagLink {
fileId: number;
tagId: number;
name: string;
color: string | null;
}
export const fileTagsBulk = (fileIds: number[]) =>
invoke<FileTagLink[]>("file_tags_bulk", { fileIds });
export const exportTags = (path?: string) =>
invoke<string>("export_tags", { path: path ?? null });
@@ -188,6 +291,28 @@ export async function tagSnapshot(tagId: number, opts?: SnapOpts): Promise<Snaps
return new Snapshot(await invoke<ArrayBuffer>("tag_snapshot", { tagId, ...snapArgs(opts) }));
}
// ── 삭제 대기열 ('삭제 예정' 태그 → 비우기) ─────────────
export interface PendingDelete {
count: number;
bytes: number;
}
/** 시스템 '삭제 예정' 태그 id (없으면 생성됨) */
export const trashTagId = () => invoke<number>("trash_tag_id");
export const markDelete = (fileIds: number[]) =>
invoke<TagOpResult>("mark_delete", { fileIds });
export const unmarkDelete = (fileIds: number[]) =>
invoke<TagOpResult>("unmark_delete", { fileIds });
export const pendingDeleteStats = () => invoke<PendingDelete>("pending_delete_stats");
/** 비우기 — 삭제 예정 파일을 디스크에서 영구 삭제한다. 복구 불가. */
export function emptyDeleted(onProgress?: (p: OpProgress) => void): Promise<OpSummary> {
const ch = new Channel<OpProgress>();
ch.onmessage = onProgress ?? (() => {});
return invoke<OpSummary>("empty_deleted", { onProgress: ch });
}
// ── 파일 작업 ───────────────────────────────────────────
export interface OpSummary {
@@ -286,6 +411,9 @@ export interface DupeMember {
fileId: number;
name: string;
dir: string;
/** 이 파일이 있는 폴더 — 검토 중 그 위치로 바로 이동하는 데 쓴다 */
folderId: number;
sourceId: number;
size: number;
width: number | null;
height: number | null;
@@ -293,11 +421,22 @@ export interface DupeMember {
kind: number;
isKeeper: boolean;
distance: number | null;
/**
* `keeper 시각 - 이 멤버 시각`(ms). 비교 재생이 두 영상을 같은 내용에 맞추는 값이다:
* `member.currentTime = keeper.currentTime - offsetMs/1000`.
* null이면 정렬을 확인하지 못한 멤버(전이 병합) — 맞춤 없이 0초부터 나란히 돈다.
*/
offsetMs: number | null;
/** keeper 시간축에서 일치가 확인된 구간 */
segStartMs: number | null;
segEndMs: number | null;
}
export interface DupeGroup {
id: number;
kind: number; // 0 exact, 1 image, 2 video
/** 유사도 0~1 (완전 동일은 1.0). 등급(확실/의심)을 여기서 파생한다. */
score: number | null;
members: DupeMember[];
}
@@ -308,6 +447,8 @@ export function startDedupe(
recursive?: boolean;
imageThreshold?: number;
includeVideo?: boolean;
/** 회전·반전본까지 찾는다 — 후보 생성이 몇 배 비싸져 기본은 꺼둔다 */
includeRotated?: boolean;
},
onProgress: (p: DedupeProgress) => void,
): Promise<void> {
@@ -319,14 +460,28 @@ export function startDedupe(
recursive: args.recursive ?? null,
imageThreshold: args.imageThreshold ?? 5,
includeVideo: args.includeVideo ?? false,
includeRotated: args.includeRotated ?? false,
onProgress: ch,
});
}
/** 지문 생성 속도 — 백필 스레드가 배치마다 다시 읽으므로 재시작 없이 반영된다. */
export type SigSpeed = "slow" | "normal" | "fast";
export const sigSpeed = () => invoke<SigSpeed>("sig_speed");
export const setSigSpeed = (speed: SigSpeed) => invoke<void>("set_sig_speed", { speed });
export const cancelDedupe = () => invoke<void>("cancel_dedupe");
export const listDupeGroups = () => invoke<DupeGroup[]>("list_dupe_groups");
export const resolveDupeGroup = (groupId: number) =>
invoke<void>("resolve_dupe_group", { groupId });
/**
* 그룹에서 보관할 파일을 지정한다 — 삭제 대상이 바뀌므로 DB에 남는다.
*
* `offsetMs`·`segStartMs`·`segEndMs`는 보관 항목 시간축 기준 값이라 기준이 바뀌면 함께 옮겨진다.
* 그 계산은 백엔드에만 있고, 여기서는 **옮겨진 결과를 그대로 받는다**.
*/
export const setDupeKeeper = (groupId: number, fileId: number) =>
invoke<DupeMember[]>("set_dupe_keeper", { groupId, fileId });
export const dismissDupePair = (fileA: number, fileB: number) =>
invoke<void>("dismiss_dupe_pair", { fileA, fileB });
@@ -425,6 +580,19 @@ export function decidePlayback(info: PlaybackInfo): PlaybackPlan {
export const onThumbsReady = (cb: (ids: number[]) => void): Promise<UnlistenFn> =>
listen<number[]>("thumbs-ready", (e) => cb(e.payload));
/**
* 대량 삭제 안전장치가 발동했다 — 한 스캔이 소스의 절반 이상을 지우려 해서 보류했다.
* payload = 지우지 않은 건수.
*/
export interface ScanGuarded {
sourceId: number;
/** 지우지 않은 건수 */
count: number;
}
export const onScanGuarded = (cb: (g: ScanGuarded) => void): Promise<UnlistenFn> =>
listen<ScanGuarded>("scan-guarded", (e) => cb(e.payload));
export const onLibraryChanged = (cb: (sourceId: number) => void): Promise<UnlistenFn> =>
listen<number>("library-changed", (e) => cb(e.payload));
@@ -437,3 +605,44 @@ export function thumbUrl(info: AppInfo, fileId: number, sizeClass: 0 | 1 = 0): s
export function mediaUrl(info: AppInfo, fileId: number): string {
return `http://127.0.0.1:${info.mediaPort}/media/${info.mediaToken}/${fileId}`;
}
// ── 간단 편집 (회전·반전) ─────────────────────────────────
/** 원본을 직접 고친다. 사진(JPEG)·영상 회전은 무손실, 영상 반전만 재인코딩. */
export type EditOp = "rotateCw" | "rotateCcw" | "rotate180" | "flipH" | "flipV";
export interface EditSummary {
batchId: string;
done: number;
failed: number;
errors: string[];
/** 재인코딩된 파일 수 — 0이면 화질 손실이 전혀 없었다 */
reencoded: number;
}
export function editFiles(
fileIds: number[],
op: EditOp,
onProgress?: (p: OpProgress) => void,
): Promise<EditSummary> {
const ch = new Channel<OpProgress>();
if (onProgress) ch.onmessage = onProgress;
return invoke<EditSummary>("edit_files", { fileIds, op, onProgress: ch });
}
/** 되돌릴 수 없는 편집 — 원본은 백업으로 옮겨지고 Ctrl+Z로 돌아온다. */
export type Cut =
| { kind: "crop"; x: number; y: number; w: number; h: number }
| { kind: "trim"; startMs: number; endMs: number };
export const cutFile = (fileId: number, cut: Cut) =>
invoke<EditSummary>("cut_file", { fileId, cut });
/** 구간 자르기 타임라인에 깔 프레임 (데이터 URL — 서버 라우트 없음) */
export interface StripFrame {
atMs: number;
url: string;
}
export const filmstrip = (fileId: number, count: number) =>
invoke<StripFrame[]>("filmstrip", { fileId, count });
+255
View File
@@ -0,0 +1,255 @@
// 본문 카드 상단 경로 밴드 — 현재 위치를 보여주고 상위로 되돌아가는 유일한 경로다.
// 폴더 세그먼트는 드롭 타깃이라 '상위로 되돌리는 이동'이 드래그로도 된다.
// 세그먼트가 4개를 넘으면 [홈 › … › 뒤에서 2개]로 접는다 — 밴드는 절대 두 줄이 되지 않는다.
import {
IconChevronRight,
IconFolder,
IconHome,
IconSearch,
IconSmart,
IconTag,
IconX,
} from "../ui/icons";
import {
createMemo,
createResource,
createSignal,
For,
Show,
type Component,
type JSX,
} from "solid-js";
import { ko } from "../i18n/ko";
import { ancestry, sourceIdOf, sources } from "../state/folderIndex";
import {
exitSearch,
focusItemView,
selected,
setView,
sidebarVersion,
snapshot,
toast,
view,
} from "../state/store";
import { listSmartFolders, listTags } from "../ipc/commands";
import { performMove } from "../features/fileops/MoveDialog";
interface Seg {
icon?: JSX.Element;
label: string;
/** 없으면 클릭 불가 = 현재 위치 */
go?: () => void;
/** 폴더 세그먼트만 드롭 타깃이 된다 */
folderId?: number;
}
/** seg가 없는 노드 = 접힘 표시(…) */
interface Node {
seg?: Seg;
gapTitle?: string;
current: boolean;
}
/**
* 현재 위치 크럼(클릭 불가).
* `shrink`는 `.crumb.is-current`의 `flex:none`을 의도적으로 푼다 —
* Tailwind 유틸리티는 @layer utilities라 @layer components를 이긴다.
* 폴더명이나 검색어가 길면 flex:none인 현재 크럼이 `.crumb-tail`(항목수·선택 칩)을
* `overflow:hidden` 밖으로 밀어내 '선택됨' 표시가 통째로 사라진다 —
* 스펙이 툴바에서 없앤 뒤로 선택 개수를 보여주는 곳은 여기뿐이다.
* 잘린 이름은 title로 그대로 읽히니 이쪽이 손실이 작다.
*/
const Current: Component<{ label: string; children: JSX.Element }> = (p) => (
<span class="crumb is-current min-w-0 shrink" title={p.label}>
{p.children}
</span>
);
export const Breadcrumb: Component = () => {
// 태그·스마트 폴더는 뷰에 id만 들어 있어 이름을 여기서 직접 조회한다.
const [tags] = createResource(() => sidebarVersion(), listTags);
const [smarts] = createResource(() => sidebarVersion(), listSmartFolders);
const [dropId, setDropId] = createSignal<number | null>(null);
const searchView = () => {
const v = view();
return v.type === "search" ? v : undefined;
};
const segs = createMemo<Seg[]>(() => {
const v = view();
const srcName = (id: number) => sources().find((s) => s.id === id)?.name ?? "…";
const home: Seg = {
icon: <IconHome size={13} />,
label: ko.sidebar.allItems,
go: () => {
void setView({ type: "all" });
focusItemView();
},
};
switch (v.type) {
case "all":
return [{ ...home, go: undefined }];
case "source":
return [home, { icon: <IconFolder size={13} />, label: srcName(v.sourceId) }];
case "folder": {
const srcId = v.sourceId ?? sourceIdOf(v.folderId);
const out: Seg[] = [home];
if (srcId != null)
out.push({
icon: <IconFolder size={13} />,
label: srcName(srcId),
go: () => {
void setView({ type: "source", sourceId: srcId });
focusItemView();
},
});
for (const n of ancestry(v.folderId, srcId))
out.push({
label: n.name,
folderId: n.id,
go: () => {
void setView({ type: "folder", folderId: n.id, recursive: true, sourceId: srcId });
focusItemView();
},
});
return out;
}
case "tag":
return [
home,
{
icon: <IconTag size={13} />,
label: tags.latest?.find((t) => t.id === v.tagId)?.name ?? "…",
},
];
case "smart":
return [
home,
{
icon: <IconSmart size={13} />,
label: smarts.latest?.find((s) => s.id === v.smartId)?.name ?? "…",
},
];
case "search":
return []; // 검색은 아래에서 따로 그린다
}
});
const nodes = createMemo<Node[]>(() => {
const all = segs();
const last = all.length - 1;
const node = (i: number): Node => ({ seg: all[i], current: i === last });
if (all.length <= 4) return all.map((_, i) => node(i));
// 접어도 홈과 마지막 두 단계는 남긴다 — 되돌아갈 발판과 현재 위치는 항상 보인다
return [
node(0),
{ gapTitle: all.map((s) => s.label).join(" "), current: false },
node(last - 1),
node(last),
];
});
const inner = (seg: Seg) => (
<>
<Show when={seg.icon}>{(ic) => <span>{ic()}</span>}</Show>
<span class="min-w-0 truncate">{seg.label}</span>
</>
);
/** 한 세그먼트. 현재 위치는 클릭 불가, 폴더 세그먼트는 드롭 타깃이 된다. */
const CrumbSeg: Component<{ seg: Seg; current: boolean }> = (p) => (
<Show
when={!p.current && p.seg.go}
fallback={<Current label={p.seg.label}>{inner(p.seg)}</Current>}
>
<button
class="crumb"
classList={{ "is-drop": p.seg.folderId != null && dropId() === p.seg.folderId }}
tabindex={-1}
title={p.seg.label}
onClick={() => p.seg.go!()}
onDragOver={(e) => {
const fid = p.seg.folderId;
if (fid == null || !e.dataTransfer?.types.includes("application/x-archive-move")) return;
e.preventDefault();
setDropId(fid);
}}
onDragLeave={() => setDropId(null)}
onDrop={(e) => {
const fid = p.seg.folderId;
if (fid == null) return;
e.preventDefault();
setDropId(null);
void performMove(fid).catch((err) => toast(String(err)));
}}
>
{inner(p.seg)}
</button>
</Show>
);
return (
<div class="crumb-bar">
<Show
when={searchView()}
fallback={
<For each={nodes()}>
{(n, i) => (
<>
<Show when={i() > 0}>
<span class="crumb-sep"><IconChevronRight size={12} /></span>
</Show>
<Show
when={n.seg}
fallback={
<span class="crumb-sep" title={n.gapTitle}>
</span>
}
>
{(seg) => <CrumbSeg seg={seg()} current={n.current} />}
</Show>
</>
)}
</For>
}
>
{(sv) => (
<>
<Current label={sv().query}>
{/* 아이콘은 icons.tsx(lucide) 경유만 — 이모지는 폰트마다 다르게 그려지고
currentColor를 따르지 않아 두 테마에서 색이 어긋난다 */}
<IconSearch size={13} />
<span class="min-w-0 truncate">{ko.crumb.searchResult(sv().query)}</span>
</Current>
<span class="crumb-sep">·</span>
<span class="crumb-sep">{ko.crumb.searchScope}</span>
<button
class="icon-btn"
tabindex={-1}
title={ko.crumb.exitSearch}
aria-label={ko.crumb.exitSearch}
onClick={() => {
void exitSearch();
focusItemView();
}}
>
<IconX size={12} />
</button>
</>
)}
</Show>
<div class="crumb-tail">
<span>{ko.grid.items(snapshot()?.count ?? 0)}</span>
<Show when={selected().size > 0}>
<span class="chip chip--accent chip--xs">
<span>{ko.grid.selected(selected().size)}</span>
</span>
</Show>
</div>
</div>
);
};
+322 -99
View File
@@ -1,45 +1,83 @@
import { createResource, createSignal, For, onCleanup, Show, type Component } from "solid-js";
import {
createEffect,
createResource,
For,
on,
onCleanup,
onMount,
Show,
type Component,
type JSX,
} from "solid-js";
import {
IconArrowDown,
IconArrowUp,
IconGrid,
IconInfo,
IconList,
IconMoon,
IconPanelRight,
IconRefresh,
IconSearch,
IconSun,
IconZoom,
} from "../ui/icons";
import { open } from "@tauri-apps/plugin-dialog";
import { ko } from "../i18n/ko";
import { JustifiedGrid } from "../features/grid/JustifiedGrid";
import { ListView } from "../features/browse/ListView";
import { FolderChips } from "../features/browse/FolderChips";
import { Breadcrumb } from "./Breadcrumb";
import {
GRID_ROW_MAX,
GRID_ROW_MIN,
clearScanProgress,
detailsOpen,
focusItemView,
gridRowHeight,
kindFilter,
refreshSidebar,
refreshSnapshot,
runSearch,
searchQuery,
setGridRowHeight,
setKindFilter,
setMinRating,
minRating,
setSort,
setView,
setViewMode,
showInfo,
sidebarVersion,
snapshot,
sortDesc,
sortKey,
theme,
toast,
toggleDetails,
toggleShowInfo,
toggleTheme,
updateScanProgress,
view,
viewMode,
type KindFilter,
type SortKey,
type ViewMode,
} from "../state/store";
import { addLocalSource, listSources, startScan } from "../ipc/commands";
import { addLocalSource, listSources, reloadSource, rescanAll, startScan } from "../ipc/commands";
import { openRemoteDialog } from "../features/sources/RemoteDialog";
export const ContentArea: Component = () => {
const [q, setQ] = createSignal("");
// 검색어는 store가 소유한다 — 폴더로 이동하면 자동으로 비워지고,
// ✕로 검색을 끝내면 검색 직전 위치로 돌아간다.
let debounce: ReturnType<typeof setTimeout> | undefined;
const onInput = (value: string) => {
setQ(value);
clearTimeout(debounce);
debounce = setTimeout(() => {
const query = value.trim();
if (query.length > 0) void setView({ type: "search", query });
else if (view().type === "search") void setView({ type: "all" });
}, 250);
debounce = setTimeout(() => void runSearch(value), 250);
};
onCleanup(() => clearTimeout(debounce));
// 타이핑 직후(250ms 안에) 폴더를 클릭하면 대기 중이던 타이머가 뒤늦게 터져
// 방금 연 폴더를 검색 결과로 되돌린다. 뷰가 바뀌면 대기 중인 검색을 버린다.
// (자기 자신이 만든 검색 뷰 전환에서는 이미 실행된 타이머라 clearTimeout이 무해하다)
createEffect(on(view, () => clearTimeout(debounce)));
// 소스가 하나도 없으면 온보딩 표시. 소스 추가/제거(sidebarVersion) 시 재조회해
// 첫 소스를 추가하면 재시작 없이 즉시 그리드로 전환된다.
@@ -49,39 +87,93 @@ export const ContentArea: Component = () => {
);
const isEmpty = () => sources() && sources()!.length === 0;
// 수동 새로고침 — 디스크를 다시 읽는 증분 재스캔.
// 완료 갱신은 백엔드의 library-changed 이벤트가 담당한다.
const doRescan = async () => {
try {
await rescanAll();
toast(ko.refresh.started);
} catch (e) {
toast(String(e));
}
await refreshSnapshot();
};
// F5 — 입력 중이 아닐 때만
onMount(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key !== "F5") return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
e.preventDefault();
void doRescan();
};
window.addEventListener("keydown", onKey);
onCleanup(() => window.removeEventListener("keydown", onKey));
});
const addLocal = async () => {
const dir = await open({ directory: true, multiple: false, title: ko.onboarding.addLocal });
if (typeof dir !== "string") return;
const src = await addLocalSource(dir);
refreshSidebar();
await startScan(src.id, updateScanProgress);
try {
const r = await addLocalSource(dir);
refreshSidebar();
// 이미 등록된 폴더면 다시 불러오기만 — 같은 폴더가 두 번 매핑되지 않게 한다
if (r.existing) {
toast(ko.source.alreadyMapped(r.source.name));
await reloadSource(r.source.id);
} else {
await startScan(r.source.id, updateScanProgress);
}
} catch (e) {
// 사이드바 onAddSource와 같은 이유 — startScan이 던지면 done 진행이 오지 않아
// 마지막 값이 그대로 남는다. 이제 작업 존이 스크롤 밖 고정 영역이라 그 유령 행은
// 스크롤로도 사라지지 않고 영구히 떠 있는다. 온보딩이 첫 소스를 추가하는 경로다.
clearScanProgress();
toast(String(e));
}
};
// min-w-0 — 없으면 그리드의 1fr 열이 툴바 내용 최소폭 아래로 줄지 못해
// 창이 좁을 때 오른쪽 상세 패널을 화면 밖으로 밀어낸다.
// content-col — 툴바 압축(@container toolbar)의 기준 컨테이너.
return (
<main class="flex min-h-0 flex-col bg-[var(--bg-app)]">
<div class="flex h-11 shrink-0 items-center gap-3 border-b border-[var(--border)] px-3">
<input
type="search"
value={q()}
onInput={(e) => onInput(e.currentTarget.value)}
placeholder={ko.search.placeholder}
class="h-7 w-64 rounded-md border border-[var(--border)] bg-[var(--bg-panel)] px-2.5 text-xs text-[var(--text-primary)] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
/>
<span class="text-[11px] text-[var(--text-muted)]">
{snapshot() ? ko.grid.items(snapshot()!.count) : ""}
</span>
<main class="content-col flex min-h-0 min-w-0 flex-col gap-2">
{/* 툴바 — 카드가 아니라 페이지 배경 위. 어떤 폭에서도 한 줄을 유지한다:
컨트롤은 flex:none이라 글자가 세로로 접히지 않고, 폭이 모자라면 검색창이
먼저 줄고(min 120), 그래도 모자라면 컨테이너 쿼리가 라벨·줌을 접는다. */}
<div class="flex h-[var(--h-toolbar)] shrink-0 items-center gap-2 overflow-x-auto overflow-y-hidden px-1 whitespace-nowrap">
{/* 1. 검색 — 남는 폭을 전부 흡수한다 */}
<div class="field-wrap" style={{ flex: "1", "min-width": "120px" }}>
<span class="field-icon"><IconSearch size={14} /></span>
<input
type="search"
class="field field--search field--block"
value={searchQuery()}
onInput={(e) => onInput(e.currentTarget.value)}
placeholder={ko.search.placeholder}
/>
</div>
{/* 종류 필터: 전체 / 사진 / 영상 */}
<div class="flex overflow-hidden rounded border border-[var(--border)] text-[11px]">
<For each={[["all", ko.filter.all], ["image", ko.filter.image], ["video", ko.filter.video]] as [KindFilter, string][]}>
{/* 2. 종류 필터: 전체 / 사진 / 영상 */}
<div class="segmented">
<For
each={
[
["all", ko.filter.all],
["image", ko.filter.image],
["video", ko.filter.video],
] as [KindFilter, string][]
}
>
{([k, label]) => (
<button
class="px-2 py-0.5"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": kindFilter() === k,
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": kindFilter() !== k,
classList={{ "is-on": kindFilter() === k }}
tabindex={-1}
onClick={() => {
setKindFilter(k);
focusItemView();
}}
onClick={() => setKindFilter(k)}
>
{label}
</button>
@@ -89,79 +181,210 @@ export const ContentArea: Component = () => {
</For>
</div>
{/* 정렬: 키 선택 + 방향 토글 */}
<div class="flex items-center gap-1 text-[11px] text-[var(--text-secondary)]">
<select
class="rounded border border-[var(--border)] bg-[var(--bg-panel)] px-1.5 py-0.5 outline-none"
value={sortKey()}
onChange={(e) => setSort(e.currentTarget.value as SortKey, sortDesc())}
{/* 3. 보기 방식: 격자 / 리스트 (V) */}
<div class="segmented" title={ko.viewMode.title}>
<For
each={
[
["grid", <IconGrid size={14} />, ko.viewMode.grid],
["list", <IconList size={14} />, ko.viewMode.list],
] as [ViewMode, JSX.Element, string][]
}
>
<For each={[["taken", ko.sort.taken], ["name", ko.sort.name], ["size", ko.sort.size], ["mtime", ko.sort.mtime]] as [SortKey, string][]}>
{([k, label]) => <option value={k}>{label}</option>}
</For>
</select>
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 hover:bg-[var(--bg-hover)]"
onClick={() => setSort(sortKey(), !sortDesc())}
title={sortDesc() ? ko.sort.desc : ko.sort.asc}
>
{sortDesc() ? "↓" : "↑"}
</button>
{([m, icon, label]) => (
<button
classList={{ "is-on": viewMode() === m }}
tabindex={-1}
aria-label={label}
onClick={() => {
setViewMode(m);
focusItemView();
}}
>
{icon}
<span class="seg-label">{label}</span>
</button>
)}
</For>
</div>
{/* 썸네일 정보 오버레이 토글 */}
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[11px]"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": showInfo(),
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": !showInfo(),
{/* 3b. 별점 하한 — '이상'이다. 정확히 N점만 보는 화면은 쓸 일이 드물고,
"3점 넘는 것만 추려서 보기"가 실제 작업이다. */}
<select
class="field field--sort"
title={ko.rating.label}
aria-label={ko.rating.label}
value={String(minRating())}
onChange={(e) => {
setMinRating(Number(e.currentTarget.value));
focusItemView();
}}
onClick={toggleShowInfo}
title={ko.grid.infoToggle}
>
<option value="0">{`${ko.rating.filterAll}`}</option>
<For each={[1, 2, 3, 4, 5]}>
{(n) => <option value={String(n)}>{ko.rating.filterAtLeast(n)}</option>}
</For>
</select>
{/* 4. 정렬: 키 선택 + 방향 토글 */}
<select
class="field field--sort"
title={ko.sort.label}
aria-label={ko.sort.label}
value={sortKey()}
onChange={(e) => setSort(e.currentTarget.value as SortKey, sortDesc())}
>
<For
each={
[
["taken", ko.sort.taken],
["name", ko.sort.name],
["size", ko.sort.size],
["mtime", ko.sort.mtime],
["rating", ko.rating.sort],
] as [SortKey, string][]
}
>
{([k, label]) => <option value={k}>{label}</option>}
</For>
</select>
<button
class="toolbar-btn toolbar-btn--icon"
tabindex={-1}
title={sortDesc() ? ko.sort.desc : ko.sort.asc}
aria-label={sortDesc() ? ko.sort.desc : ko.sort.asc}
onClick={() => {
setSort(sortKey(), !sortDesc());
focusItemView();
}}
>
{sortDesc() ? <IconArrowDown size={13} /> : <IconArrowUp size={13} />}
</button>
{/* 그리드 줌 슬라이더 */}
<div class="ml-auto flex items-center gap-1.5 text-[var(--text-muted)]" title="썸네일 크기 (Ctrl+휠)">
<span class="text-xs">🔍</span>
<input
type="range"
class="h-1 w-28 accent-[var(--accent)]"
min={GRID_ROW_MIN}
max={GRID_ROW_MAX}
step={5}
value={gridRowHeight()}
onInput={(e) => setGridRowHeight(Number(e.currentTarget.value))}
/>
</div>
</div>
<Show
when={isEmpty()}
fallback={<JustifiedGrid />}
>
<div class="grid flex-1 place-items-center">
<div class="flex max-w-sm flex-col items-center gap-3 text-center">
<div class="text-lg font-semibold text-[var(--text-primary)]">{ko.onboarding.title}</div>
<div class="text-sm text-[var(--text-secondary)]">{ko.onboarding.subtitle}</div>
<div class="mt-2 flex gap-2">
<button
class="rounded-md bg-[var(--accent)] px-4 py-2 text-xs text-white"
onClick={() => void addLocal()}
>
{ko.onboarding.addLocal}
</button>
<button
class="rounded-md border border-[var(--border)] px-4 py-2 text-xs text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={openRemoteDialog}
>
{ko.onboarding.addRemote}
</button>
{/* 5. 수동 새로고침 — 워처가 변경 알림을 못 받는 SMB/NAS 대비 */}
<button
class="toolbar-btn toolbar-btn--icon"
tabindex={-1}
title={ko.refresh.title}
aria-label={ko.refresh.action}
onClick={() => {
void doRescan();
focusItemView();
}}
>
<IconRefresh size={14} />
</button>
{/* 6. 썸네일 정보 오버레이 토글 — 리스트 보기는 열로 이미 보여준다 */}
<Show when={viewMode() === "grid"}>
<button
class="toolbar-btn toolbar-btn--icon"
classList={{ "is-on": showInfo() }}
tabindex={-1}
title={ko.grid.infoToggle}
aria-label={ko.grid.infoToggle}
onClick={() => {
toggleShowInfo();
focusItemView();
}}
>
<IconInfo size={14} />
</button>
</Show>
{/* 7. 그리드 줌 (격자 전용) — 좁아지면 숨는다. Ctrl+휠은 그대로 살아 있다.
.toolbar-zoom 엘리먼트에 display 유틸리티를 붙이면 안 된다:
Tailwind 유틸리티는 @layer utilities라 @layer components의
`@container … { .toolbar-zoom { display: none } }`를 항상 이겨서
압축 규칙이 통째로 죽는다. flex는 안쪽 래퍼가 맡는다. */}
<Show when={viewMode() === "grid"}>
<div class="toolbar-zoom shrink-0" title={ko.grid.zoomTitle}>
<div class="flex items-center gap-1.5 text-[var(--text-muted)]">
<IconZoom size={13} />
<input
type="range"
class="h-1 w-24 accent-[var(--accent)]"
min={GRID_ROW_MIN}
max={GRID_ROW_MAX}
step={5}
value={gridRowHeight()}
aria-label={ko.grid.zoomTitle}
onInput={(e) => setGridRowHeight(Number(e.currentTarget.value))}
/>
</div>
<div class="mt-1 text-[11px] text-[var(--text-muted)]">{ko.onboarding.hint}</div>
</div>
</div>
</Show>
</Show>
{/* 8. 테마 전환 — 아이콘은 '전환될 상태'를 보여준다.
앱 내 유일한 진입점이라 어떤 폭에서도 숨기지 않는다. */}
<button
class="toolbar-btn toolbar-btn--icon"
tabindex={-1}
aria-label={ko.theme.label}
title={theme() === "dark" ? ko.theme.toLight : ko.theme.toDark}
onClick={() => {
toggleTheme();
focusItemView();
}}
>
{theme() === "dark" ? <IconSun size={14} /> : <IconMoon size={14} />}
</button>
{/* 9. 상세 패널 토글 — 자신이 여닫는 패널과 물리적으로 인접해야 하므로 최우측 */}
<button
class="toolbar-btn toolbar-btn--icon"
classList={{ "is-on": detailsOpen() }}
tabindex={-1}
title={ko.details.toggle}
aria-label={ko.details.toggle}
onClick={() => {
toggleDetails();
focusItemView();
}}
>
<IconPanelRight size={14} />
</button>
</div>
<div class="surface-card surface-card--fill flex-1">
<Show when={!isEmpty()}>
<Breadcrumb />
<FolderChips />
</Show>
<Show
when={isEmpty()}
fallback={
<Show when={viewMode() === "list"} fallback={<JustifiedGrid />}>
<ListView />
</Show>
}
>
<div class="grid min-h-0 flex-1 place-items-center">
<div class="flex max-w-sm flex-col items-center gap-3 text-center">
<div class="text-[var(--fs-xl)] leading-[var(--lh-xl)] font-semibold text-[var(--text-primary)]">
{ko.onboarding.title}
</div>
<div class="text-[var(--fs-md)] text-[var(--text-secondary)]">
{ko.onboarding.subtitle}
</div>
<div class="mt-2 flex gap-2">
<button
class="toolbar-btn toolbar-btn--primary toolbar-btn--lg"
onClick={() => void addLocal()}
>
{ko.onboarding.addLocal}
</button>
<button class="toolbar-btn toolbar-btn--lg" onClick={openRemoteDialog}>
{ko.onboarding.addRemote}
</button>
</div>
<div class="mt-1 text-[var(--fs-xs)] text-[var(--text-muted)]">
{ko.onboarding.hint}
</div>
</div>
</div>
</Show>
</div>
</main>
);
};
+622 -66
View File
@@ -1,15 +1,54 @@
import { createResource, createSignal, For, Show, type Component } from "solid-js";
// 상세 패널 (300px) — 헤더(썸네일·이름·닫기) → 밑줄 탭(정보/태그) → 스크롤 본문.
// 색은 예외 없이 var() 경유 — 다크·라이트 두 팔레트에서 동시에 성립해야 한다.
// 헤더 썸네일 슬롯은 '사진 면'이 아니라 24px 크롬 슬롯이라 --bg-panel-raised를 쓴다
// (--grid-cell-bg는 테마 무관 고정 다크라 흰 카드 안에 검은 사각형이 박힌다).
import { IconCalendar, IconClock, IconFile, IconFolder, IconImage, IconLayers, IconTag, IconX } from "../ui/icons";
import {
createMemo,
createResource,
createSignal,
For,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import type { JSX } from "solid-js";
import { ko } from "../i18n/ko";
import {
applyTagSet,
assignTags,
createTag,
createTagSet,
deleteTagSet,
fileDetails,
fileTagsOf,
listTags,
listTagSets,
setRating,
thumbUrl,
unassignTags,
updateTagSet,
type FileDetails,
type Tag,
type TagSet,
} from "../ipc/commands";
import { refreshSidebar, selected, toast, view } from "../state/store";
import { StarRating } from "../ui/StarRating";
import { performMarkDelete, performTrash } from "../features/fileops/ContextMenu";
import { openMoveDialog } from "../features/fileops/MoveDialog";
import {
bumpTagVersion,
detailsTab,
focusItemView,
info,
refreshSidebar,
refreshSnapshot,
selected,
setDetailsOpen,
setDetailsTab,
thumbVersions,
toast,
} from "../state/store";
function fmtSize(bytes: number): string {
if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(2)} GB`;
@@ -18,20 +57,268 @@ function fmtSize(bytes: number): string {
return `${bytes} B`;
}
function fmtDate(ms: number | null): string {
/** ListView와 같은 형식 — YYYY-MM-DD HH:mm */
function fmtDate(ms: number | null | undefined): string {
if (!ms) return "—";
return new Date(ms).toLocaleString("ko-KR");
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())}`;
}
const Row: Component<{ label: string; value: string }> = (props) => (
<div class="px-3 py-1">
<div class="text-[10px] text-[var(--text-muted)]">{props.label}</div>
<div class="break-all text-xs text-[var(--text-primary)]" title={props.value}>
{props.value}
/** 0:42 / 1:03:20 — 현행 "212초" 표기 폐기 */
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")}`;
}
/** 정보 타일 1칸 */
const Tile: Component<{
icon: JSX.Element;
label: string;
value: string;
clamp?: boolean;
title?: string;
}> = (props) => (
<div class="info-tile">
<span class="info-tile__icon">{props.icon}</span>
<div class="info-tile__body">
<div class="info-tile__label">{props.label}</div>
<div
class="info-tile__value"
classList={{ "info-tile__value--clamp": props.clamp }}
title={props.title}
>
{props.value}
</div>
</div>
</div>
);
/** 정보 타일 2분할 — 300px 폭에서 깨지지 않는 상한이 2칸이다 */
const SplitTile: Component<{
icon: JSX.Element;
labelA: string;
valueA: string;
labelB: string;
valueB: string;
}> = (props) => (
<div class="info-tile info-tile--split">
<span class="info-tile__icon">{props.icon}</span>
<div class="info-tile__body">
<div class="min-w-0">
<div class="info-tile__label">{props.labelA}</div>
<div class="info-tile__value">{props.valueA}</div>
</div>
<div class="min-w-0">
<div class="info-tile__label">{props.labelB}</div>
<div class="info-tile__value">{props.valueB}</div>
</div>
</div>
</div>
);
/** 선택 없음 — 두 탭이 공유한다 */
const EmptyState: Component = () => (
<div class="flex h-[180px] flex-col items-center justify-center gap-2 px-6 text-center">
<IconImage size={32} class="text-[var(--text-muted)] opacity-50" />
<span class="text-[11px] leading-4 text-[var(--text-muted)]">{ko.details.noSelection}</span>
</div>
);
/**
* 세트 만들기·고치기. 태그 편집기 위에 겹쳐 뜬다 — 모달 3종과 같은 언어(스크림 + 카드 + Esc).
*
* 단축키는 **눌러서 지정한다**. 문자열로 적게 하면 "Ctrl+1"과 "ctrl+1"과 "^1"이 섞이고,
* 실제로 그 조합이 브라우저에서 어떤 key로 오는지 사용자가 알 수 없다.
*/
const TagSetEditor: Component<{
target: TagSet | "new";
allTags: Tag[];
onClose: () => void;
onSaved: () => void;
}> = (props) => {
const existing = () => (props.target === "new" ? undefined : props.target);
const [name, setName] = createSignal(existing()?.name ?? "");
const [hotkey, setHotkey] = createSignal<string | null>(existing()?.hotkey ?? null);
const [picked, setPicked] = createSignal<Set<number>>(
new Set((existing()?.tags ?? []).map((t) => t.id)),
);
const [capturing, setCapturing] = createSignal(false);
const toggle = (id: number) =>
setPicked((p) => {
const n = new Set(p);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
const save = async () => {
const n = name().trim();
if (!n) return;
try {
const ex = existing();
if (ex) {
await updateTagSet(ex.id, { name: n, hotkey: hotkey(), tagIds: [...picked()] });
} else {
const id = await createTagSet(n, [...picked()]);
if (hotkey()) await updateTagSet(id, { hotkey: hotkey() });
}
props.onSaved();
} catch (e) {
toast(String(e));
}
};
const remove = async () => {
const ex = existing();
if (!ex) return;
try {
await deleteTagSet(ex.id);
props.onSaved();
} catch (e) {
toast(String(e));
}
};
// 키 캡처 — 수식키만 눌린 상태는 무시한다(Ctrl을 누르는 중에 확정되면 안 된다)
const onCapture = (e: KeyboardEvent) => {
if (!capturing()) return;
e.preventDefault();
e.stopPropagation();
if (e.key === "Escape") {
setCapturing(false);
return;
}
if (["Control", "Shift", "Alt", "Meta"].includes(e.key)) return;
setHotkey(`${e.ctrlKey ? "Ctrl+" : ""}${e.shiftKey ? "Shift+" : ""}${e.key}`);
setCapturing(false);
};
onMount(() => {
window.addEventListener("keydown", onCapture, { capture: true });
onCleanup(() => window.removeEventListener("keydown", onCapture, { capture: true }));
});
return (
<div class="modal-scrim" onClick={props.onClose}>
<div class="modal-card w-[420px]" onClick={(e) => e.stopPropagation()}>
<div class="modal-head">
<span class="modal-head__title">
{existing() ? ko.tags.editSet : ko.tags.newSet}
</span>
<button class="icon-btn ml-auto" onClick={props.onClose} title={ko.common.close}>
<IconX size={14} />
</button>
</div>
<div class="modal-body">
<div class="section-label">{ko.tags.setName}</div>
<input
class="field field--block mt-1.5"
value={name()}
placeholder={ko.tags.setNamePlaceholder}
onInput={(e) => setName(e.currentTarget.value)}
/>
<div class="section-label mt-3">{ko.tags.setHotkey}</div>
<div class="mt-1.5 flex items-center gap-2">
<button
class="toolbar-btn"
classList={{ "is-on": capturing() }}
onClick={() => setCapturing(true)}
>
{capturing() ? ko.tags.pressKey : (hotkey() ?? ko.tags.noHotkey)}
</button>
<Show when={hotkey()}>
<button class="link-btn" onClick={() => setHotkey(null)}>
{ko.tags.clearHotkey}
</button>
</Show>
</div>
<div class="mt-1 text-[10px] leading-4 text-[var(--text-muted)]">
{ko.tags.hotkeyNote}
</div>
<div class="section-label mt-3">{ko.tags.setMembers(picked().size)}</div>
<div class="tag-pick mt-1.5">
<For each={props.allTags}>
{(t) => (
<button
class="chip chip--button"
classList={{ "is-on": picked().has(t.id) }}
onClick={() => toggle(t.id)}
>
{t.name}
</button>
)}
</For>
</div>
</div>
<div class="modal-bar">
<Show when={existing()}>
<button class="toolbar-btn toolbar-btn--danger-fill" onClick={() => void remove()}>
{ko.common.delete}
</button>
</Show>
<button
class="toolbar-btn toolbar-btn--primary ml-auto"
disabled={!name().trim()}
onClick={() => void save()}
>
{ko.common.save}
</button>
</div>
</div>
</div>
);
};
/**
* 별점 편집기 — 선택한 전부에 같은 값을 준다. 태그 편집기와 같은 자리(태그 탭 위)에 산다.
*
* 여러 항목의 값이 서로 다르면 채우지 않고 '여러 값'이라고 말한다. 아무 값이나 골라
* 채워 보이면 사용자는 그게 이미 매겨진 점수라고 읽고, 건드리지 않은 항목까지 덮어쓴다.
*/
const RatingEditor: Component = () => {
const [bump, setBump] = createSignal(0);
const ids = () => [...selected()];
const [details] = createResource(
() => (ids().length > 0 ? `${ids().join(",")}:${bump()}` : undefined),
async () => fileDetails(ids().slice(0, 500)),
);
const values = () => (details() ?? []).map((d) => d.rating);
const mixed = () => new Set(values()).size > 1;
const value = () => (mixed() ? 0 : (values()[0] ?? 0));
const apply = async (next: number) => {
const list = ids();
if (list.length === 0) return;
try {
const n = await setRating(list, next);
toast(ko.rating.applied(n, next));
setBump((v) => v + 1);
// 그리드 오버레이의 별은 스냅샷에서 온다 — 다시 읽어야 반영된다
await refreshSnapshot();
} catch (e) {
toast(String(e));
}
};
return (
<div class="flex items-center gap-2 px-3 pt-2.5">
<span class="section-label !mb-0">{ko.rating.label}</span>
<StarRating value={value()} mixed={mixed()} onChange={(n) => void apply(n)} />
<Show when={mixed()}>
<span class="text-[10px] text-[var(--text-muted)]">{ko.rating.mixed}</span>
</Show>
</div>
);
};
/** 선택 항목 태그 편집기 (단일/다중 선택 공용) */
const TagEditor: Component = () => {
const [tagVersion, setTagVersion] = createSignal(0);
@@ -59,6 +346,7 @@ const TagEditor: Component = () => {
const r = await assignTags(ids, tagId);
toast(ko.tags.assignTo(r.count, trimmed));
setTagVersion((v) => v + 1);
bumpTagVersion();
refreshSidebar();
} catch (e) {
toast(String(e));
@@ -70,47 +358,268 @@ const TagEditor: Component = () => {
const r = await unassignTags(ids, tagId);
toast(ko.tags.removedFrom(r.count, name));
setTagVersion((v) => v + 1);
bumpTagVersion();
refreshSidebar();
};
// ── 붙이기: 검색이 아니라 클릭 ──────────────────────────────
// 예전에는 입력창에 매번 이름을 쳐야 했다. 태그는 몇 개~수십 개고 그중 같은 것을 반복해서
// 붙이는 작업이라, 목록을 보여주고 누르게 하는 편이 훨씬 빠르다.
// 입력창은 **새 태그를 만들 때와 목록이 길 때 좁히는 용도**로만 남긴다.
const [filter, setFilter] = createSignal("");
const attachedIds = () => new Set((currentTags() ?? []).map((t) => t.id));
const pickable = createMemo(() => {
const q = filter().trim().toLowerCase();
const all = allTags() ?? [];
return q ? all.filter((t) => t.name.toLowerCase().includes(q)) : all;
});
/** 입력한 이름이 기존에 없으면 '새로 만들기'를 내민다 */
const canCreate = () => {
const q = filter().trim();
return q.length > 0 && !(allTags() ?? []).some((t) => t.name === q);
};
const toggleTag = async (t: Tag) => {
if (selected().size === 0) return;
if (attachedIds().has(t.id)) await removeTag(t.id, t.name);
else await addTag(t.name);
};
// ── 세트 ────────────────────────────────────────────────────
const [sets, { refetch: refetchSets }] = createResource(() => tagVersion(), listTagSets);
const [setEditor, setSetEditor] = createSignal<TagSet | "new" | undefined>();
const runSet = async (s: TagSet) => {
const ids = [...selected()];
if (ids.length === 0 || s.tags.length === 0) return;
try {
const r = await applyTagSet(ids, s.id);
toast(ko.tags.setApplied(s.name, r.count));
setTagVersion((v) => v + 1);
bumpTagVersion();
refreshSidebar();
} catch (e) {
toast(String(e));
}
};
// 세트 단축키 — 선택이 있고 입력창에 포커스가 없을 때만 듣는다.
// (입력 중에 '1'을 눌렀다고 세트가 적용되면 안 된다)
onMount(() => {
const onKey = (e: KeyboardEvent) => {
if (e.altKey || e.metaKey) return;
const el = document.activeElement;
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) return;
if (selected().size === 0) return;
const combo = `${e.ctrlKey ? "Ctrl+" : ""}${e.shiftKey ? "Shift+" : ""}${e.key}`;
const hit = (sets() ?? []).find((s) => s.hotkey && s.hotkey === combo);
if (!hit) return;
e.preventDefault();
e.stopPropagation();
void runSet(hit);
};
window.addEventListener("keydown", onKey);
onCleanup(() => window.removeEventListener("keydown", onKey));
});
return (
<div class="px-3 py-1">
<div class="text-[10px] text-[var(--text-muted)]">{ko.details.tags}</div>
<div class="mt-1 flex flex-wrap gap-1">
<Show when={singleId() != null}>
<div class="px-3 pt-2.5 pb-3">
{/* 붙어 있는 태그 */}
<div class="section-label">{ko.details.tags}</div>
<div class="mt-1.5 flex flex-wrap gap-1.5">
<Show
when={(currentTags()?.length ?? 0) > 0}
fallback={<span class="text-[11px] text-[var(--text-muted)]">{ko.tags.noneYet}</span>}
>
<For each={currentTags()}>
{(t) => (
<span class="flex items-center gap-1 rounded-full bg-[var(--accent-soft)] px-2 py-0.5 text-[11px] text-[var(--accent)]">
{t.name}
<button
class="hover:text-[var(--danger)]"
onClick={() => void removeTag(t.id, t.name)}
>
×
<span class="chip chip--accent">
<span>{t.name}</span>
<button class="chip__x" onClick={() => void removeTag(t.id, t.name)}>
<IconX size={11} />
</button>
</span>
)}
</For>
</Show>
</div>
{/* 세트 — 한 번에 여러 개 */}
<Show when={(sets()?.length ?? 0) > 0}>
<div class="section-label mt-3">{ko.tags.sets}</div>
<div class="mt-1.5 flex flex-wrap gap-1.5">
<For each={sets()}>
{(s) => (
<button
class="chip chip--button"
disabled={selected().size === 0}
title={ko.tags.setHint(s.tags.map((t) => t.name).join(", "))}
onClick={() => void runSet(s)}
onContextMenu={(e) => {
e.preventDefault();
setSetEditor(s);
}}
>
{s.name}
<Show when={s.hotkey}>
<kbd class="chip__kbd">{s.hotkey}</kbd>
</Show>
</button>
)}
</For>
</div>
</Show>
{/* 태그 고르기 — 전체 목록을 눌러서 붙인다 */}
<div class="section-label mt-3 flex items-center justify-between">
<span>{ko.tags.pick}</span>
<button class="link-btn" onClick={() => setSetEditor("new")}>
{ko.tags.newSet}
</button>
</div>
<input
class="mt-1.5 w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1 text-[11px] outline-none placeholder:text-[var(--text-muted)] focus:border-[var(--accent)]"
placeholder={ko.tags.add}
list="all-tags-datalist"
class="field field--block mt-1.5"
placeholder={ko.tags.filterOrCreate}
value={filter()}
onInput={(e) => setFilter(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
void addTag(e.currentTarget.value);
e.currentTarget.value = "";
}
if (e.key !== "Enter") return;
void addTag(e.currentTarget.value);
setFilter("");
}}
/>
<datalist id="all-tags-datalist">
<For each={allTags()}>{(t) => <option value={t.name} />}</For>
</datalist>
<div class="tag-pick mt-1.5">
<Show when={canCreate()}>
<button class="chip chip--button chip--new" onClick={() => void addTag(filter())}>
+ {filter().trim()}
</button>
</Show>
<For each={pickable()}>
{(t) => (
<button
class="chip chip--button"
classList={{ "is-on": attachedIds().has(t.id) }}
disabled={selected().size === 0}
onClick={() => void toggleTag(t)}
title={ko.tags.countHint(t.fileCount)}
>
{t.name}
</button>
)}
</For>
<Show when={(pickable().length ?? 0) === 0 && !canCreate()}>
<span class="text-[11px] text-[var(--text-muted)]">{ko.tags.noMatch}</span>
</Show>
</div>
<Show when={setEditor()}>
{(target) => (
<TagSetEditor
target={target()}
allTags={allTags() ?? []}
onClose={() => setSetEditor(undefined)}
onSaved={() => {
setSetEditor(undefined);
void refetchSets();
setTagVersion((v) => v + 1);
}}
/>
)}
</Show>
</div>
);
};
/** 정보 탭 — 단일 선택 */
const InfoTiles: Component<{ d: FileDetails }> = (props) => (
<>
<div class="section-label mx-3 mt-2.5 mb-1.5">{ko.details.sectionMedia}</div>
<div class="grid gap-1.5 px-3">
<Show
when={props.d.width && props.d.height}
fallback={
<Tile icon={<IconImage size={14} />} label={ko.details.fileSize} value={fmtSize(props.d.size)} />
}
>
<SplitTile
icon={<IconImage size={14} />}
labelA={ko.details.dimensions}
valueA={`${props.d.width} × ${props.d.height}`}
labelB={ko.details.fileSize}
valueB={fmtSize(props.d.size)}
/>
</Show>
<Show when={props.d.durationMs}>
<Tile icon={<IconClock size={14} />} label={ko.details.duration} value={fmtDuration(props.d.durationMs)} />
</Show>
<Show
when={props.d.takenAt}
fallback={
<Tile icon={<IconCalendar size={14} />} label={ko.details.modifiedAt} value={fmtDate(props.d.mtimeMs)} />
}
>
<SplitTile
icon={<IconCalendar size={14} />}
labelA={ko.details.takenAt}
valueA={fmtDate(props.d.takenAt)}
labelB={ko.details.modifiedAt}
valueB={fmtDate(props.d.mtimeMs)}
/>
</Show>
<Tile icon={<IconFolder size={14} />} label={ko.details.filePath} value={props.d.dir} clamp title={props.d.dir} />
</div>
<div class="section-label mx-3 mt-2.5 mb-1.5">{ko.details.sectionBasic}</div>
<div class="grid gap-1.5 px-3 pb-3">
<Tile
icon={<IconTag size={14} />}
label={ko.details.kind}
value={props.d.kind === 1 ? ko.filter.video : ko.filter.image}
/>
<Tile
icon={<IconFile size={14} />}
label={ko.details.fileName}
value={props.d.name}
clamp
title={props.d.name}
/>
</div>
</>
);
/** 정보 탭 — 다중 선택. 용량 합계·종류별 집계는 만들지 않는다(file_details 500 상한) */
const BulkPanel: Component = () => (
<div class="grid gap-1.5 px-3 py-3">
<div class="info-tile">
<span class="info-tile__icon"><IconLayers size={14} /></span>
<div class="info-tile__body">
<div class="info-tile__value info-tile__value--big tabular-nums">
{selected().size.toLocaleString("ko-KR")}
</div>
<div class="info-tile__label">{ko.details.selectedUnit}</div>
</div>
</div>
<div class="grid grid-cols-2 gap-1.5">
<button class="toolbar-btn" tabindex={-1} onClick={() => setDetailsTab("tags")}>
{ko.details.bulkTag}
</button>
<button class="toolbar-btn" tabindex={-1} onClick={openMoveDialog}>
{ko.ops.moveTo}
</button>
<button class="toolbar-btn" tabindex={-1} onClick={() => void performMarkDelete()}>
{ko.trash.mark}
</button>
<button
class="toolbar-btn toolbar-btn--danger"
tabindex={-1}
onClick={() => void performTrash()}
>
{ko.ops.trash}
</button>
</div>
</div>
);
export const DetailsPanel: Component = () => {
const singleId = () => {
const s = selected();
@@ -120,46 +629,93 @@ export const DetailsPanel: Component = () => {
const list = await fileDetails([id]);
return list[0] as FileDetails | undefined;
});
// 뷰 변경 시 details 리소스가 재평가되도록 view 추적
void view;
// 썸네일이 없는 항목은 404가 난다 — 실패한 id만 기억해 두고 선택이 바뀌면 자동 해제된다.
const [thumbErrId, setThumbErrId] = createSignal<number | undefined>();
const thumbSrc = () => {
const id = singleId();
const i = info();
if (id == null || !i || thumbErrId() === id) return undefined;
return `${thumbUrl(i, id)}&v=${thumbVersions[id] ?? 0}`;
};
const headTitle = () =>
details()?.name ??
(selected().size > 1 ? ko.grid.selected(selected().size) : ko.details.title);
return (
<aside class="flex min-h-0 flex-col overflow-y-auto border-l border-[var(--border)] bg-[var(--bg-panel)]">
<div class="px-3 pt-4 pb-1.5 text-[11px] font-semibold tracking-wide text-[var(--text-muted)]">
{ko.details.title}
</div>
<Show
when={details()}
fallback={
<div class="px-3 py-2 text-xs leading-5 text-[var(--text-muted)]">
{selected().size > 1
? ko.grid.selected(selected().size)
: ko.details.noSelection}
</div>
}
>
{(d) => (
<>
<Row label={ko.details.fileName} value={d().name} />
<Row label={ko.details.filePath} value={d().dir} />
<Row label={ko.details.fileSize} value={fmtSize(d().size)} />
<Show when={d().width && d().height}>
<Row label={ko.details.dimensions} value={`${d().width} × ${d().height}`} />
</Show>
<Show when={d().durationMs}>
<Row
label={ko.details.duration}
value={`${Math.round((d().durationMs ?? 0) / 1000)}`}
<aside class="surface-card surface-card--fill">
{/* 헤더 h40 */}
<div class="flex h-10 flex-none items-center gap-2 border-b border-[var(--border)] px-3">
<div class="h-6 w-6 flex-none overflow-hidden rounded-[var(--r-sm)] bg-[var(--bg-panel-raised)]">
<Show when={thumbSrc()}>
{(src) => (
<img
src={src()}
class="h-full w-full object-cover"
decoding="async"
draggable={false}
onError={() => setThumbErrId(singleId())}
/>
)}
</Show>
</div>
<span
class="min-w-0 flex-1 truncate text-[13px] font-semibold text-[var(--text-primary)]"
title={headTitle()}
>
{headTitle()}
</span>
<button
class="icon-btn"
tabindex={-1}
title={ko.details.close}
onClick={() => {
setDetailsOpen(false);
focusItemView();
}}
>
<IconX size={13} />
</button>
</div>
<div class="tabs">
<button
classList={{ "is-on": detailsTab() === "info" }}
tabindex={-1}
onClick={() => setDetailsTab("info")}
>
{ko.details.tabInfo}
</button>
<button
classList={{ "is-on": detailsTab() === "tags" }}
tabindex={-1}
onClick={() => setDetailsTab("tags")}
>
{ko.details.tabTags}
</button>
</div>
{/* min-h-0을 빠뜨리면 body{overflow:hidden} 때문에 스크롤바 없이 조용히 잘린다 */}
<div class="min-h-0 flex-1 overflow-y-auto">
<Show
when={detailsTab() === "info"}
fallback={
<Show when={selected().size > 0} fallback={<EmptyState />}>
{/* 별점이 태그 위에 온다 — 분류 작업에서 먼저 손이 가는 쪽이고,
한 줄이라 태그 목록을 아래로 많이 밀지 않는다 */}
<RatingEditor />
<TagEditor />
</Show>
<Row label={ko.details.takenAt} value={fmtDate(d().takenAt)} />
<Row label={ko.details.modifiedAt} value={fmtDate(d().mtimeMs)} />
</>
)}
</Show>
<Show when={selected().size > 0}>
<TagEditor />
</Show>
}
>
<Show when={selected().size > 0} fallback={<EmptyState />}>
<Show when={singleId() != null} fallback={<BulkPanel />}>
<Show when={details()}>{(d) => <InfoTiles d={d()} />}</Show>
</Show>
</Show>
</Show>
</div>
</aside>
);
};
+29 -2
View File
@@ -1,9 +1,17 @@
// 키보드 단축키 도움말 오버레이 (? 키로 열기).
import { For, onCleanup, onMount, Show, type Component } from "solid-js";
import { IconX } from "../ui/icons";
import { createResource, For, onCleanup, onMount, Show, type Component } from "solid-js";
import { ko } from "../i18n/ko";
import { listTagSets } from "../ipc/commands";
import { closeShortcuts, shortcutsOpen } from "../state/store";
export const ShortcutsOverlay: Component = () => {
// 열릴 때만 읽는다 — 세트는 자주 바뀌지 않고, 닫혀 있는 오버레이가 폴링할 이유가 없다
const [setKeys] = createResource(
() => (shortcutsOpen() ? 1 : undefined),
async () => (await listTagSets()).filter((s) => s.hotkey),
);
onMount(() => {
const onKey = (e: KeyboardEvent) => {
if (!shortcutsOpen()) return;
@@ -36,7 +44,7 @@ export const ShortcutsOverlay: Component = () => {
onClick={closeShortcuts}
title={ko.shortcuts.close}
>
<IconX size={14} />
</button>
</div>
<div class="flex flex-col gap-1.5">
@@ -50,6 +58,25 @@ export const ShortcutsOverlay: Component = () => {
</div>
)}
</For>
{/* 사용자가 지정한 태그 세트 단축키 — 고정 목록에 적을 수 없으므로 여기서 합친다.
여기에 안 보이면 "내가 뭘 지정했더라"를 확인할 곳이 없다. */}
<Show when={(setKeys()?.length ?? 0) > 0}>
<div class="mt-1.5 border-t border-[var(--border)] pt-1.5 text-[10px] text-[var(--text-muted)]">
{ko.tags.sets}
</div>
<For each={setKeys()}>
{(s) => (
<div class="flex items-center justify-between gap-4 text-xs">
<kbd class="shrink-0 rounded border border-[var(--border)] bg-[var(--bg-app)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--text-secondary)]">
{s.hotkey}
</kbd>
<span class="text-right text-[var(--text-secondary)]">
{s.name} {s.tags.map((t) => t.name).join(", ")}
</span>
</div>
)}
</For>
</Show>
</div>
</div>
</div>
+846 -218
View File
File diff suppressed because it is too large Load Diff
+51 -64
View File
@@ -1,78 +1,65 @@
// 상태바 h26 — 페이지 배경 위(테두리 없음, 카드 아님).
//
// 역할 분담(사이드바 작업 존과의 계약). 요구는 '탐색·정리 중에 사이드바에서 진행을
// 볼 수 있게'였고, 그 답은 같은 정보를 두 곳에 복사하는 것이 아니라 나누는 것이다.
// · 사이드바 작업 존 = '지금 무엇이 얼마나' — 작업의 존재·수량·진행 막대·취소,
// 그리고 중복 모달로 들어가는 진입점(openDedupe는 거기 두 곳뿐이다).
// · 여기(footer) = '자세한 사정' — 토스트 한 줄, 지금 읽고 있는 폴더 경로, 데이터 위치.
//
// 그래서 여기서 사라진 것들:
// · 중복 탐지 진행/취소 — 사이드바 작업 행이 소유한다. 진입점도 두지 않는다.
// footer가 중복 탐지에 대해 말하는 것은 완료·취소 토스트 한 번뿐이다(store가 던진다).
// · 썸네일·메타·지문 대기 수 — 사이드바 백필 행이 소유한다. 지문 대기 수가 사라지는
// 것이 아니다: 그 행의 부제(ko.tasks.backfillDetail)가 '지문 N'까지 말한다.
// 항목수·선택수는 브레드크럼이, 포터블/버전은 사이드바 통계 카드가 이미 가져갔다.
import { Show, type Component } from "solid-js";
import { ko } from "../i18n/ko";
import {
dedupeProgress,
dedupeRunning,
info,
openDedupe,
pending,
requestCancelDedupe,
scanProgress,
selected,
snapshot,
statusMessage,
} from "../state/store";
import { dedupeRunning, info, pending, scanProgress, statusMessage } from "../state/store";
export const StatusBar: Component = () => {
/** 무엇이든 돌고 있는가 — 값은 말하지 않고 '준비됨'을 말할 자격만 판정한다.
이 게이트가 없으면 사이드바가 '중복 찾는 중'인데 footer가 '준비됨'이라 말한다. */
const anyTask = () =>
scanProgress() != null ||
dedupeRunning() ||
pending().thumbs > 0 ||
pending().meta > 0 ||
pending().sigs > 0;
/** 왼쪽 한 칸 — 우선순위 하나로 정한다: 토스트 > 지금 읽는 폴더 > 준비됨.
두 문장을 나란히 truncate 하면 둘 다 못 읽는다. 토스트는 4초짜리라
그동안 스캔 경로가 가려져도 진행 자체는 사이드바가 계속 보여 준다. */
const line = (): { text: string; accent: boolean } | undefined => {
const msg = statusMessage();
if (msg) return { text: msg, accent: true };
const dir = scanProgress()?.currentDir;
if (dir) return { text: ko.statusBar.scanning(dir), accent: true };
return anyTask() ? undefined : { text: ko.statusBar.ready, accent: false };
};
return (
<footer class="flex h-6 items-center gap-3 border-t border-[var(--border)] bg-[var(--bg-panel)] px-3 text-[11px] text-[var(--text-secondary)]">
<Show when={statusMessage()}>
<span class="text-[var(--accent)]">{statusMessage()}</span>
</Show>
<Show
when={scanProgress()}
fallback={
<span>
{snapshot() ? ko.grid.items(snapshot()!.count) : ko.statusBar.ready}
<Show when={selected().size > 0}>
<span class="ml-2 text-[var(--accent)]">{ko.grid.selected(selected().size)}</span>
</Show>
</span>
}
>
{(p) => (
<span class="text-[var(--accent)]">
{ko.statusBar.scanning(p().currentDir)} {p().seen.toLocaleString("ko-KR")}
<footer class="flex h-[var(--h-status)] items-center gap-3 bg-[var(--bg-app)] px-3 text-[11px] text-[var(--text-secondary)]">
<Show when={line()}>
{(l) => (
<span
class="min-w-0 truncate"
classList={{
"text-[var(--accent-text)]": l().accent,
"text-[var(--text-muted)]": !l().accent,
}}
title={l().text}
>
{l().text}
</span>
)}
</Show>
{/* 중복 탐지 진행 — 패널을 닫아도 상태바에 표시 (클릭 시 패널 열기 / 취소) */}
<Show when={dedupeRunning()}>
<span class="flex items-center gap-1.5 text-[var(--accent)]">
<span class="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-[var(--accent)] border-t-transparent" />
<button class="hover:underline" onClick={openDedupe} title={ko.dedupe.open}>
{dedupeProgress()
? ko.dedupe.scanning(dedupeProgress()!.phase, dedupeProgress()!.current, dedupeProgress()!.total)
: ko.dedupe.starting}
</button>
<button
class="rounded px-1 text-[var(--text-muted)] hover:text-[var(--danger)]"
onClick={requestCancelDedupe}
title={ko.common.cancel}
>
</button>
</span>
</Show>
{/* 백그라운드 처리 진행 — 썸네일/메타 대기 수 (있을 때만) */}
<Show when={pending().thumbs > 0 || pending().meta > 0}>
<span class="flex items-center gap-1.5 text-[var(--text-muted)]">
<span class="inline-block h-2.5 w-2.5 animate-spin rounded-full border border-[var(--text-muted)] border-t-transparent" />
<Show when={pending().thumbs > 0}>{ko.statusBar.thumbPending(pending().thumbs)}</Show>
<Show when={pending().meta > 0}>
<span class="ml-1">{ko.statusBar.metaPending(pending().meta)}</span>
</Show>
</span>
</Show>
{/* 데이터 위치 — 우측 고정 */}
<Show when={info()}>
{(i) => (
<>
<span class="ml-auto truncate text-[var(--text-muted)]" title={i().dataDir}>
{i().portable ? ko.statusBar.portable : ko.statusBar.installed}
</span>
<span class="text-[var(--text-muted)]">v{i().version}</span>
</>
<span class="ml-auto min-w-0 truncate text-[var(--text-muted)]" title={i().dataDir}>
{ko.statusBar.dataDir}: {i().dataDir}
</span>
)}
</Show>
</footer>
+140
View File
@@ -0,0 +1,140 @@
// 폴더 트리 캐시 — 사이드바 트리 / 브레드크럼 조상 체인 / 폴더 칩이 공유한다.
// folder_tree는 순수 DB 조회(commands.rs:745)라 죽은 네트워크 경로에서도 즉시 반환된다.
//
// **소스별 지연 로드**다. 예전에는 sidebarVersion이 오를 때마다 모든 소스의 folder_tree를
// 한꺼번에 불렀다. 100만 건 합성 DB 실측으로 folder_tree는 소스당 103ms(+5만 노드 IPC 직렬화)
// 이므로, 소스가 몇 개만 있어도 갱신 한 번이 수백 ms다. 스캔 중에는 그 갱신이 2초마다 온다.
// 이제는 **화면에 필요한 소스만** 부른다 — 접힌 소스는 비용이 0이고, 펼친 소스도 버전당 1회다.
import { createResource, createRoot, createSignal } from "solid-js";
import { folderTree, listSources, type FolderNode, type Source } from "../ipc/commands";
import { sidebarVersion } from "./store";
export interface FolderEntry {
node: FolderNode;
sourceId: number;
children: FolderNode[];
/** 하위 폴더까지 합산한 개수. folder_tree의 fileCount는 '직속'이라
그대로 쓰면 "12개" 칩을 눌러 3,400장이 나오는 거짓말이 된다. */
subtotal: number;
}
interface SourceTree {
map: Map<number, FolderEntry>;
roots: FolderNode[]; // 최상위 폴더들
}
function buildTree(sourceId: number, nodes: FolderNode[]): SourceTree {
const map = new Map<number, FolderEntry>();
const ids = new Set(nodes.map((n) => n.id));
for (const n of nodes)
map.set(n.id, { node: n, sourceId, children: [], subtotal: n.fileCount });
const roots: FolderNode[] = [];
for (const n of nodes) {
if (n.parentId != null && ids.has(n.parentId)) map.get(n.parentId)!.children.push(n);
else roots.push(n); // Sidebar.buildTree와 동일한 루트 판정
}
// 하위 합계 — folder_tree는 ORDER BY fo.path라 부모가 항상 자식보다 앞선다
// (경로 접두사는 사전순으로 항상 선행). 역순 1패스면 충분하다.
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i];
if (n.parentId == null) continue;
const p = map.get(n.parentId);
if (p) p.subtotal += map.get(n.id)!.subtotal;
}
return { map, roots };
}
// 소스 목록은 가볍고(몇 행) 사이드바가 어차피 필요로 한다 — 항상 로드한다.
const sourcesRes = createRoot(() => createResource(() => sidebarVersion(), listSources)[0]);
// sourceId → 마지막으로 성공한 트리 + 그때의 버전. 값은 버전이 올라도 버리지 않는다:
// 재조회 중에도 이전 트리를 계속 보여줘야 트리·칩이 깜빡이지 않는다.
const trees = new Map<number, { version: number; tree: SourceTree }>();
const inflight = new Set<string>();
// 로드 완료 알림 — 이걸 읽는 계산은 트리가 도착하면 다시 평가된다.
const [loadTick, setLoadTick] = createSignal(0);
function load(sourceId: number, version: number): void {
const key = `${sourceId}:${version}`;
if (inflight.has(key)) return;
inflight.add(key);
void folderTree(sourceId)
.then((nodes) => trees.set(sourceId, { version, tree: buildTree(sourceId, nodes) }))
.catch(() => {
// 소스가 사라졌거나 조회 실패 — 빈 트리를 이 버전으로 캐시해 재시도 폭주를 막는다
trees.set(sourceId, { version, tree: { map: new Map(), roots: [] } });
})
.finally(() => {
inflight.delete(key);
setLoadTick((n) => n + 1);
});
}
/**
* 이 소스의 폴더 트리를 확보한다. 아직 없으면 로드를 시작하고, 있으면(이전 버전이라도)
* 즉시 반환한다. sidebarVersion·loadTick을 읽으므로 호출한 계산은 도착 시 다시 돈다.
*/
export function sourceTree(sourceId: number): SourceTree | undefined {
const version = sidebarVersion();
loadTick();
const cached = trees.get(sourceId);
if (!cached || cached.version !== version) load(sourceId, version);
return cached?.tree;
}
/** 최상위 폴더들 (사이드바 소스 행이 펼쳐질 때만 호출된다) */
export function rootsOf(sourceId: number): FolderNode[] {
return sourceTree(sourceId)?.roots ?? [];
}
/**
* 폴더 항목 조회. `sourceId`를 알면 그 소스만 본다(대부분의 호출이 그렇다 —
* 폴더 뷰·사이드바 행·칩 모두 소스를 들고 있다).
*
* 힌트가 없으면 **이미 로드된** 트리들만 훑고, 거기에도 없으면 전 소스 로드를 시작한다
* (드문 경로: 소스를 모르는 폴더 뷰. 다음 렌더에서 값이 채워진다).
*/
export function entryOf(folderId: number, sourceId?: number): FolderEntry | undefined {
if (sourceId != null) return sourceTree(sourceId)?.map.get(folderId);
sidebarVersion();
loadTick();
for (const c of trees.values()) {
const hit = c.tree.map.get(folderId);
if (hit) return hit;
}
for (const s of sourcesRes.latest ?? []) sourceTree(s.id);
return undefined;
}
export function sourceIdOf(folderId: number): number | undefined {
return entryOf(folderId)?.sourceId;
}
export function subtotalOf(folderId: number, sourceId?: number): number {
return entryOf(folderId, sourceId)?.subtotal ?? 0;
}
export function childrenOf(folderId: number, sourceId?: number): FolderNode[] {
return entryOf(folderId, sourceId)?.children ?? [];
}
/** 브레드크럼용 조상 체인 (루트 → 현재) */
export function ancestry(folderId: number, sourceId?: number): FolderNode[] {
const start = entryOf(folderId, sourceId);
if (!start) return [];
const tree = trees.get(start.sourceId)?.tree;
if (!tree) return [];
const out: FolderNode[] = [];
let cur: FolderEntry | undefined = start;
const guard = new Set<number>();
while (cur && !guard.has(cur.node.id)) {
guard.add(cur.node.id);
out.unshift(cur.node);
cur = cur.node.parentId != null ? tree.map.get(cur.node.parentId) : undefined;
}
return out;
}
// `.latest`를 쓴다 — 재조회 중에도 이전 값을 유지해 소스 이름·개수가 깜빡이지 않게.
export const sources = (): Source[] => sourcesRes.latest ?? [];
/** 라이브러리 전체 항목 수 (소스 재귀 개수의 합) */
export const libraryTotal = (): number =>
(sourcesRes.latest ?? []).reduce((a, s) => a + s.fileCount, 0);
+486 -20
View File
@@ -1,23 +1,32 @@
// 전역 상태 — Solid 시그널 기반 단일 스토어 모듈.
import { createSignal } from "solid-js";
import { createStore, produce } from "solid-js/store";
import { createStore, produce, unwrap } from "solid-js/store";
import { ask } from "@tauri-apps/plugin-dialog";
import {
appInfo,
cancelDedupe,
folderSnapshot,
markDelete,
onDedupeDone,
pendingCounts as fetchPendingCounts,
purgeMissing,
searchSnapshot,
smartSnapshot,
startDedupe,
tagSnapshot,
trashTagId as fetchTrashTagId,
unmarkDelete,
Snapshot,
type AppInfo,
type DedupeProgress,
type PendingCounts,
type ScanGuarded,
type ScanProgress,
type SnapOpts,
type SortKey,
type UnlistenFn,
} from "../ipc/commands";
import { ko } from "../i18n/ko";
// ── 앱 정보 (미디어 서버 포트/토큰 포함) ─────────────────
const [info, setInfo] = createSignal<AppInfo | undefined>();
@@ -35,7 +44,9 @@ export async function loadAppInfo(): Promise<void> {
export type View =
| { type: "all" }
| { type: "source"; sourceId: number }
| { type: "folder"; folderId: number; recursive: boolean }
/** sourceId는 브레드크럼·폴더 칩이 소스 컨텍스트를 얻기 위한 선택 필드.
기존 소비자(DedupePanel 등)는 구조적 타이핑이라 영향 없다. */
| { type: "folder"; folderId: number; recursive: boolean; sourceId?: number }
| { type: "search"; query: string }
| { type: "tag"; tagId: number }
| { type: "smart"; smartId: number };
@@ -50,7 +61,9 @@ const [sortKey, setSortKeySig] = createSignal<SortKey>("taken");
const [sortDesc, setSortDescSig] = createSignal(true);
export type KindFilter = "all" | "image" | "video";
const [kindFilter, setKindFilterSig] = createSignal<KindFilter>("all");
export { sortKey, sortDesc, kindFilter };
/** 별점 하한 (0=전체, 1~5=그 점수 **이상**). 종류 필터와 함께 걸린다. */
const [minRating, setMinRatingSig] = createSignal(0);
export { sortKey, sortDesc, kindFilter, minRating };
export type { SortKey };
/** 정렬 변경 → 재조회 */
@@ -64,6 +77,28 @@ export function setKindFilter(k: KindFilter): void {
setKindFilterSig(k);
void refreshSnapshot();
}
/** 별점 하한 변경(0=전체) → 재조회 */
export function setMinRating(n: number): void {
setMinRatingSig(Math.max(0, Math.min(5, n)));
void refreshSnapshot();
}
// ── 보기 방식 (격자 / 리스트) ────────────────────────────
// 같은 스냅샷을 격자(썸네일 위주)와 리스트(속성 비교 위주) 중 하나로 그린다.
// 선택·정렬·필터는 공유하므로 전환해도 보고 있던 항목이 유지된다.
export type ViewMode = "grid" | "list";
const VIEW_MODE_KEY = "archive.viewMode";
const [viewMode, setViewModeRaw] = createSignal<ViewMode>(
localStorage.getItem(VIEW_MODE_KEY) === "list" ? "list" : "grid",
);
export { viewMode };
export function setViewMode(m: ViewMode): void {
setViewModeRaw(m);
localStorage.setItem(VIEW_MODE_KEY, m);
}
export const toggleViewMode = (): void => setViewMode(viewMode() === "grid" ? "list" : "grid");
// 그리드 썸네일에 파일명·크기 오버레이 표시 (localStorage 유지)
const INFO_KEY = "archive.grid.infoOverlay";
@@ -75,14 +110,151 @@ export function toggleShowInfo(): void {
localStorage.setItem(INFO_KEY, n ? "1" : "0");
}
// ── 상세 패널 / 폴더 칩 (전부 localStorage 유지) ─────────
const DETAILS_KEY = "archive.detailsOpen";
const [detailsOpen, setDetailsOpenRaw] = createSignal(localStorage.getItem(DETAILS_KEY) !== "0");
export { detailsOpen };
export function setDetailsOpen(b: boolean): void {
setDetailsOpenRaw(b);
localStorage.setItem(DETAILS_KEY, b ? "1" : "0");
}
export const toggleDetails = (): void => setDetailsOpen(!detailsOpen());
export type DetailsTab = "info" | "tags";
const [detailsTab, setDetailsTab] = createSignal<DetailsTab>("info");
export { detailsTab, setDetailsTab };
const CHIPS_KEY = "archive.folderChips";
const [chipsOpen, setChipsOpenRaw] = createSignal(localStorage.getItem(CHIPS_KEY) !== "0");
export { chipsOpen };
export function toggleChips(): void {
const n = !chipsOpen();
setChipsOpenRaw(n);
localStorage.setItem(CHIPS_KEY, n ? "1" : "0");
}
// ── 테마 (다크 기본 / 라이트 선택) ───────────────────────
// 앱 크롬만 뒤집는다. 사진 위에 얹히는 색(--ink-on-media 계열)은 app.css에서 고정이며
// Lightbox/VideoPlayer는 범위 밖이라 항상 검은 배경을 유지한다.
export type Theme = "dark" | "light";
const THEME_KEY = "archive.theme";
function readTheme(): Theme {
try {
return localStorage.getItem(THEME_KEY) === "light" ? "light" : "dark"; // 기본 = 다크
} catch {
return "dark"; // localStorage 차단 환경에서도 앱이 뜨는 게 우선
}
}
/** 문서 루트에 반영. CSS는 :root(다크) / :root[data-theme="light"] 두 벌로 재스코프된다. */
function applyTheme(t: Theme): void {
document.documentElement.dataset.theme = t;
}
const [theme, setThemeRaw] = createSignal<Theme>(readTheme());
export { theme };
// FOUC 방지: 모듈 평가 시점에 즉시 반영한다.
// main.tsx가 App을 import → App이 store를 import → 이 줄이 render() 이전에 실행된다.
applyTheme(theme());
export function setTheme(t: Theme): void {
if (t === theme()) return;
setThemeRaw(t);
applyTheme(t);
try {
localStorage.setItem(THEME_KEY, t);
} catch {
/* 무시 — 이번 세션에만 적용된다 */
}
}
export const toggleTheme = (): void => setTheme(theme() === "dark" ? "light" : "dark");
// ── 사이드바 트리 확장 상태 (재시작 후에도 유지) ──────────
// key 규약: 소스 = `s:{sourceId}` / 폴더 = `f:{folderId}`
// 사이드바 그룹 접힘 = `gc:{sources|collections|cleanup}`
// 그룹만 의미가 반대다 — 저장하는 값이 '접힘'이다. 기본(키 없음)이 펼침이어야 하므로
// 접힘을 true로 적는다. isExpanded("gc:sources") === true 는 '소스 그룹이 접혀 있다'는 뜻이다.
const TREE_KEY = "archive.tree";
const [treeOpen, setTreeOpen] = createStore<Record<string, boolean>>(
(() => {
try {
return JSON.parse(localStorage.getItem(TREE_KEY) ?? "{}") as Record<string, boolean>;
} catch {
return {};
}
})(),
);
export const isExpanded = (key: string): boolean => treeOpen[key] === true;
export function setExpanded(key: string, v: boolean): void {
setTreeOpen(key, v);
localStorage.setItem(TREE_KEY, JSON.stringify(unwrap(treeOpen)));
}
export const toggleExpanded = (key: string): void => setExpanded(key, !isExpanded(key));
/** 사이드바 그룹 접힘 — 위 `gc:` 규약의 얇은 래퍼. 폴라리티를 호출부가 뒤집지 않게 한다. */
export const isGroupCollapsed = (group: string): boolean => isExpanded(`gc:${group}`);
export const toggleGroupCollapsed = (group: string): void => toggleExpanded(`gc:${group}`);
// ── 아이템 뷰 포커스 복귀 계약 (단축키 보존의 핵심) ────────
// 격자/리스트 스크롤 컨테이너가 자신을 등록한다. 크롬(브레드크럼·칩·툴바·사이드바)의
// 클릭 핸들러는 끝에서 focusItemView()를 불러 방향키·Delete·Ctrl+A를 되살린다.
let itemViewFocus: (() => void) | undefined;
export function registerItemViewFocus(fn: () => void): () => void {
itemViewFocus = fn;
return () => {
if (itemViewFocus === fn) itemViewFocus = undefined;
};
}
export const focusItemView = (): void => itemViewFocus?.();
// ── 검색 (store가 소유) ──────────────────────────────────
// 폴더로 이동하면 검색창이 비워지고, 검색을 끝내면 검색 '직전 위치'로 돌아온다.
// search_snapshot에 범위 인자가 없으므로 폴더 내 검색은 만들지 않는다 — 항상 전체 라이브러리.
const [searchQuery, setSearchQueryRaw] = createSignal("");
const [prevView, setPrevView] = createSignal<View | undefined>();
export { searchQuery };
let viewSeq = 0;
export async function setView(v: View): Promise<void> {
if (v.type === "search") {
if (view().type !== "search") setPrevView(view());
} else {
setPrevView(undefined);
if (searchQuery() !== "") setSearchQueryRaw(""); // 다른 곳으로 가면 검색창도 비운다
}
setViewSignal(v);
clearSelection();
await refreshSnapshot();
}
/** 검색 실행. 빈 문자열이면 검색 종료(직전 뷰로 복귀). 디바운스는 호출자 책임. */
export async function runSearch(raw: string): Promise<void> {
setSearchQueryRaw(raw); // 화면에는 원문 그대로
const q = raw.trim();
if (q.length === 0) {
await exitSearch();
return;
}
if (view().type !== "search") setPrevView(view());
setViewSignal({ type: "search", query: q });
clearSelection();
await refreshSnapshot();
}
/** 검색 종료 — 검색 직전 위치로 복귀 (없으면 전체) */
export async function exitSearch(): Promise<void> {
const back = prevView() ?? ({ type: "all" } as View);
setSearchQueryRaw("");
setPrevView(undefined);
setViewSignal(back);
clearSelection();
await refreshSnapshot();
}
export async function refreshSnapshot(): Promise<void> {
const v = view();
const seq = ++viewSeq;
@@ -91,6 +263,7 @@ export async function refreshSnapshot(): Promise<void> {
sortKey: sortKey(),
sortDesc: sortDesc(),
kind: kindFilter() === "image" ? 0 : kindFilter() === "video" ? 1 : null,
minRating: minRating() > 0 ? minRating() : null,
};
try {
let snap: Snapshot;
@@ -114,12 +287,39 @@ export async function refreshSnapshot(): Promise<void> {
snap = await smartSnapshot(v.smartId, opts);
break;
}
if (seq === viewSeq) setSnapshot(snap);
if (seq === viewSeq) {
// 크게 보는 중이면 **같은 파일**을 계속 본다.
//
// 라이트박스는 인덱스를 들고 있는데 목록이 다시 정렬되면 그 자리에 다른 파일이 온다.
// 편집(회전·자르기)처럼 보는 중에 목록이 갱신되는 경우가 흔해서, 방금 손댄 파일이
// 화면에서 사라지고 엉뚱한 항목이 뜬다 — 다시 찾아 들어가야 한다.
const openId = lightboxFileId();
setSnapshot(snap);
if (openId != null) {
const at = indexOfId(snap, openId);
// 목록에서 빠졌으면(필터에 걸림 등) 있던 자리를 지킨다 — 닫아 버리면 더 혼란스럽다
if (at >= 0) setLightboxIndex(at);
}
}
} finally {
if (seq === viewSeq) setLoading(false);
}
}
/** 지금 크게 보고 있는 파일 id (안 열려 있으면 null) */
function lightboxFileId(): number | null {
const idx = lightboxIndex();
const snap = snapshot();
if (idx == null || !snap || idx >= snap.count) return null;
return snap.id(idx);
}
/** 스냅샷에서 id의 위치. 없으면 -1. */
function indexOfId(snap: Snapshot, id: number): number {
for (let i = 0; i < snap.count; i++) if (snap.id(i) === id) return i;
return -1;
}
// ── 선택 ────────────────────────────────────────────────
const [selected, setSelected] = createSignal<ReadonlySet<number>>(new Set());
const [anchorIndex, setAnchorIndex] = createSignal<number>(-1);
@@ -156,6 +356,70 @@ export function setSelectionFromIds(ids: number[]): void {
setSelected(new Set<number>(ids));
}
// ── 삭제 대기열 ('삭제 예정' 태그) ───────────────────────
// 삭제는 2단계다: ① 표시(태그) — 되돌릴 수 있고 파일은 그대로 ② 비우기 — 영구 삭제.
// 그리드/라이트박스가 표시 상태를 즉시 반영해야 해서 id 집합을 전역으로 캐시한다.
const [trashTag, setTrashTag] = createSignal<number | undefined>();
const [markedIds, setMarkedIds] = createSignal<ReadonlySet<number>>(new Set());
export { trashTag, markedIds };
export const isMarked = (id: number): boolean => markedIds().has(id);
/** 삭제 예정 태그 id 확보 (앱 시작 시 1회) */
export async function loadTrashTag(): Promise<void> {
try {
setTrashTag(await fetchTrashTagId());
await refreshMarked();
// 최초 실행 시 태그가 방금 만들어졌을 수 있다 — 사이드바가 이를 집어가게 한다
refreshSidebar();
} catch (e) {
console.error("trash_tag_id 실패", e);
}
}
/** 삭제 예정 표시 목록 재조회 (라이브러리 전체 기준) */
export async function refreshMarked(): Promise<void> {
const tag = trashTag();
if (tag == null) return;
try {
const snap = await tagSnapshot(tag);
const next = new Set<number>();
for (let i = 0; i < snap.count; i++) next.add(snap.id(i));
setMarkedIds(next);
} catch {
/* 무시 — 표시 배지가 잠깐 어긋날 뿐 */
}
}
/**
* 삭제 예정 표시 토글. 하나라도 표시 안 된 게 있으면 전부 표시하고,
* 모두 표시돼 있으면 전부 해제한다. (Ctrl+Z로 되돌릴 수 있다)
*/
export async function toggleMarkDelete(ids: number[]): Promise<void> {
if (ids.length === 0) return;
const cur = markedIds();
const allMarked = ids.every((id) => cur.has(id));
try {
const r = allMarked ? await unmarkDelete(ids) : await markDelete(ids);
toast(allMarked ? ko.trash.unmarked(r.count) : ko.trash.marked(r.count));
await refreshMarked();
bumpTagVersion();
refreshSidebar();
// 삭제 예정 뷰를 보고 있으면 목록 자체가 바뀐다
const v = view();
if (v.type === "tag" && v.tagId === trashTag()) await refreshSnapshot();
} catch (e) {
toast(String(e));
}
}
// ── 태그 변경 버전 ───────────────────────────────────────
// 어디서든 태그를 붙이거나 떼면 올린다. 그리드 썸네일의 태그 오버레이가
// 이 값을 구독해 캐시를 버리고 다시 읽는다.
const [tagVersion, setTagVersion] = createSignal(0);
export { tagVersion };
export const bumpTagVersion = () => setTagVersion((v) => v + 1);
// ── 썸네일 갱신 버전 (thumbs-ready 이벤트로 증가) ────────
const [thumbVersions, setThumbVersions] = createStore<Record<number, number>>({});
export { thumbVersions };
@@ -172,22 +436,90 @@ export function bumpThumbVersions(ids: number[]): void {
const [scanProgress, setScanProgress] = createSignal<ScanProgress | undefined>();
export { scanProgress };
// 열거 단계는 총량을 모른다 — 다 돌아 봐야 몇 개인지 안다. 그래서 퍼센트를 지어낼 수 없다.
// 대신 '초당 몇 건'을 재서 진행이 살아 있음과 빠르기를 함께 전한다.
// 지수 평활(0.3)로 스파이크를 눌러 숫자가 튀지 않게 한다.
const [scanRate, setScanRate] = createSignal(0);
export { scanRate };
let rateSeen = 0;
let rateAt = 0;
export function updateScanProgress(p: ScanProgress): void {
setScanProgress(p.done ? undefined : p);
if (p.done) {
setScanProgress(undefined);
setScanRate(0);
rateSeen = 0;
rateAt = 0;
return;
}
const now = Date.now();
if (rateAt > 0 && p.seen >= rateSeen) {
const dt = (now - rateAt) / 1000;
// 백엔드가 100ms 스로틀로 보내므로 0.2초 이상 간격만 표본으로 쓴다
if (dt >= 0.2) {
const inst = (p.seen - rateSeen) / dt;
setScanRate((prev) => (prev > 0 ? prev * 0.7 + inst * 0.3 : inst));
rateSeen = p.seen;
rateAt = now;
}
} else {
rateSeen = p.seen;
rateAt = now;
}
setScanProgress(p);
}
/**
* 스캔 진행 표시를 지운다.
* startScan이 던지면(없는 경로·권한·이미 진행 중) done 진행이 오지 않아 마지막 값이
* 그대로 남는다 — 존재하지 않는 작업 행이 영구히 떠 있는 '유령 스캔'이 그것이다.
* 스캔을 시작하는 쪽은 catch에서 반드시 이걸 부른다.
*/
export const clearScanProgress = (): void => {
setScanProgress(undefined);
setScanRate(0);
rateSeen = 0;
rateAt = 0;
};
// ── 백그라운드 처리 진행(썸네일/메타 대기 수) ────────────
const [pending, setPending] = createSignal<PendingCounts>({ thumbs: 0, meta: 0 });
const [pending, setPending] = createSignal<PendingCounts>({
thumbs: 0,
meta: 0,
sigs: 0,
baseline: 0,
});
export { pending };
/**
* 백필 진행률 기준선 — '가장 많이 밀려 있던 순간'의 대기 수.
*
* 이 값은 **백엔드가 DB에 들고 있다**. 예전에는 여기서 시그널로만 셌는데, 그러면 앱을 껐다
* 켤 때 남은 수가 그대로 새 기준선이 되어 **진행률이 0%로 돌아갔다**(사용자 신고).
* 작업 자체는 `thumb_state`가 큐라서 정상적으로 이어지고 있었는데도 화면만 처음처럼 보였다.
*/
const [backfillTotal, setBackfillTotal] = createSignal(0);
export { backfillTotal };
let pendingTimer: ReturnType<typeof setTimeout> | undefined;
async function doRefreshPending(): Promise<void> {
try {
const c = await fetchPendingCounts();
// 필드를 하나라도 못 받으면(구 백엔드·필드 추가 시점 불일치) 합이 NaN이 되고
// NaN > 0 은 false라 작업 행이 **통째로 사라진다**. 경계에서 한 번 정규화해 그 실패를 막는다.
const raw = await fetchPendingCounts();
const c: PendingCounts = {
thumbs: raw.thumbs ?? 0,
meta: raw.meta ?? 0,
sigs: raw.sigs ?? 0,
// 기준선은 백엔드가 관리한다(재시작을 넘겨야 하므로). 구 백엔드가 안 주면 0 → 셔틀 표시.
baseline: raw.baseline ?? 0,
};
setPending(c);
setBackfillTotal(c.baseline);
clearTimeout(pendingTimer);
// 남은 작업이 있으면 계속 폴링(썸네일은 thumbs-ready로도 갱신되지만 메타 이벤트가 없어 폴링 필요)
if (c.thumbs > 0 || c.meta > 0) pendingTimer = setTimeout(() => void doRefreshPending(), 2000);
// 남은 작업이 있으면 계속 폴링(썸네일은 thumbs-ready로도 갱신되지만 메타·지문은 이벤트가 없어 폴링 필요)
if (c.thumbs > 0 || c.meta > 0 || c.sigs > 0)
pendingTimer = setTimeout(() => void doRefreshPending(), 2000);
} catch {
/* 무시 */
}
@@ -199,11 +531,72 @@ export function refreshPendingCounts(): void {
pendingTimer = setTimeout(() => void doRefreshPending(), 400);
}
// ── 대량 삭제 보류 ───────────────────────────────────────
// 한 스캔이 소스 절반 이상을 지우려 하면 백엔드가 보류한다(드라이브 미마운트·NAS 오프라인).
// 지우지 않았으므로 목록에는 없는 파일이 남는다 — 사용자가 드라이브를 확인한 뒤
// '사라진 파일 정리'로 직접 확정해야 지워진다. 그 진입점을 이 시그널이 만든다.
const [guarded, setGuarded] = createSignal<ScanGuarded | undefined>();
export { guarded };
export const noteGuarded = (g: ScanGuarded) => setGuarded(g);
/** 보류된 삭제를 확정한다. 디스크를 다시 확인하므로 연결이 돌아왔으면 아무것도 지워지지 않는다. */
export async function purgeGuarded(): Promise<void> {
const g = guarded();
if (!g) return;
const yes = await ask(ko.refresh.purgeMissingConfirm(g.count), {
title: "Archive",
kind: "warning",
});
if (!yes) return;
try {
const n = await purgeMissing(g.sourceId);
toast(n > 0 ? ko.refresh.purgeMissingDone(n) : ko.refresh.purgeMissingNone);
// 처리됐다 — 진입점은 사라진다. 다음 스캔에서 또 보류되면 다시 나타난다.
setGuarded(undefined);
refreshSidebar();
await refreshSnapshot();
} catch (e) {
toast(String(e));
}
}
// ── 사이드바 새로고침 트리거 ─────────────────────────────
const [sidebarVersion, setSidebarVersion] = createSignal(0);
export { sidebarVersion };
/** 사용자 동작(태그 추가·소스 제거 등) — 즉시 반영한다. */
export const refreshSidebar = () => setSidebarVersion((v) => v + 1);
// ── 스캔 중 갱신 폭주 합치기 ─────────────────────────────
// 열거 중에는 library-changed가 2초마다 온다(새 파일이 그리드에 보이도록 일부러 그렇게 한다).
// 그런데 그 한 번이 사이드바 전체 재조회 + 현재 뷰 스냅샷 재조회를 부른다. 100만 건 합성
// DB 실측으로 folder_tree 103ms/소스, 소스 개수 집계 99ms/소스, 스냅샷 수백 ms —
// 2초마다 이걸 다 하면 화면이 끊긴다. 이벤트발 갱신만 창(WINDOW)으로 합친다.
// 사용자 동작발 갱신은 이 경로를 타지 않으므로 여전히 즉시 반영된다.
const SOFT_WINDOW = 1500;
let softTimer: ReturnType<typeof setTimeout> | undefined;
let softLast = 0;
function runSoft(): void {
softLast = Date.now();
setSidebarVersion((v) => v + 1);
void refreshSnapshot();
void refreshMarked();
}
/** 이벤트발(library-changed) 갱신 — 선두는 즉시, 뒤따르는 것들은 창 하나로 합친다. */
export function refreshAfterLibraryChange(): void {
if (softTimer) return; // 이미 예약됨 — 창이 닫힐 때 한 번만 돈다
const since = Date.now() - softLast;
if (since >= SOFT_WINDOW) {
runSoft();
return;
}
softTimer = setTimeout(() => {
softTimer = undefined;
runSoft();
}, SOFT_WINDOW - since);
}
// ── 그리드 썸네일 크기(줌) ───────────────────────────────
export const GRID_ROW_MIN = 110;
export const GRID_ROW_MAX = 420;
@@ -236,16 +629,35 @@ export { shortcutsOpen };
export const openShortcuts = () => setShortcutsOpen(true);
export const closeShortcuts = () => setShortcutsOpen(false);
// ── 중복 검토 패널 ───────────────────────────────────────
// ── 중복 검토 모달 ───────────────────────────────────────
// 모달은 진행을 소유하지 않는다 — 열림/닫힘만 소유한다.
const [dedupeOpen, setDedupeOpen] = createSignal(false);
export { dedupeOpen };
/** 진입점은 사이드바 두 곳(정리 행 · 작업 행)뿐이다. footer에는 진입점을 두지 않는다. */
export const openDedupe = () => setDedupeOpen(true);
export const closeDedupe = () => setDedupeOpen(false);
/**
* 모달만 닫는다 — 탐지는 계속되고 취소도 계속 가능하다.
* 닫은 직후 방향키·Delete·Ctrl+A가 그리드에서 다시 동작해야 하므로 포커스를 되돌린다.
*/
export function closeDedupe(): void {
setDedupeOpen(false);
focusItemView();
}
// ── 중복 탐지 실행(전역) — 패널을 닫아도 상태바에서 진행이 보이도록 store가 소유 ──
// ── 중복 탐지 실행(전역) ─────────────────────────────────
// 진행 상태의 단일 진실원은 이 모듈이다. 모달을 닫아도 진행이 유지되고 취소가 가능하다.
// 사이드바 작업 행과 footer는 여기 있는 시그널만 읽는다 — 어느 쪽도 자체 진행
// 시그널이나 타이머를 만들지 않는다. 두 곳이 각자 세면 같은 정보가 어긋난다.
const [dedupeProgress, setDedupeProgress] = createSignal<DedupeProgress | undefined>();
const [dedupeRunning, setDedupeRunning] = createSignal(false);
export { dedupeProgress, dedupeRunning };
/** 시작 시점에 확정된 작업. 실행 중에는 '지금 도는 조건', 끝난 뒤에는 '지금 보이는 결과의 조건'. */
const [dedupeJob, setDedupeJob] = createSignal<DedupeJob | undefined>();
/** 취소를 요청한 시각(ms). 요청은 즉시 멈추지 않는다 — 백엔드가 단계 경계에서 확인한다. */
const [dedupeCancelAt, setDedupeCancelAt] = createSignal<number | undefined>();
export { dedupeProgress, dedupeRunning, dedupeJob, dedupeCancelAt };
/** 취소를 눌렀지만 아직 멈추지 않은 구간. 이 구간의 표시는 '취소 중…' 하나로 통일한다. */
export const dedupeCancelPending = (): boolean => dedupeRunning() && dedupeCancelAt() != null;
export interface DedupeRunArgs {
sourceId?: number;
@@ -253,27 +665,79 @@ export interface DedupeRunArgs {
recursive?: boolean;
imageThreshold?: number;
includeVideo?: boolean;
/** 회전·반전본까지 찾는다 (후보 생성이 몇 배 비싸다) */
includeRotated?: boolean;
/** 시작 시점의 범위 라벨. 캡처용이며 IPC로는 가지 않는다. */
scopeLabel?: string;
}
/** 중복 탐지 실행. 진행 상태는 store에 유지되어 패널을 닫아도 상태바에서 보인다. */
/**
* 시작 순간에 고정되는 작업 기술. 탐지 중 사용자가 다른 폴더로 이동해도 바뀌지 않는다 —
* '현재 폴더'라고 말하면서 전체를 훑는 거짓말을 막는 유일한 방법이다.
*/
export interface DedupeJob {
readonly scopeLabel: string;
/** 시작할 때 준 인자 그대로. '다시 탐지'가 같은 조건을 재현할 수 있다. */
readonly args: DedupeRunArgs;
readonly startedAt: number;
}
/** 중복 탐지 실행. 진행 상태는 store에 남아 모달을 닫아도 사이드바·footer에서 보인다. */
export async function runDedupe(args: DedupeRunArgs): Promise<void> {
if (dedupeRunning()) return;
setDedupeRunning(true);
setDedupeJob({
scopeLabel: args.scopeLabel ?? ko.dedupe.scopeAll,
args,
startedAt: Date.now(),
});
setDedupeCancelAt(undefined);
setDedupeProgress(undefined);
setDedupeRunning(true);
let unlisten: UnlistenFn | undefined;
try {
// 끝을 알리는 신호가 둘이다. 둘 다 들어야 한다.
// ① phase === "done" 진행 메시지 — 정상 완료 경로.
// ② dedupe-done 앱 이벤트 — 완료·취소·실패 전부. 취소되면 백엔드는 Err로 빠져나가
// "done"을 보내지 않으므로 ①만 기다리면 영원히 끝나지 않는다(취소해도 진행
// 표시가 남아 있던 원인이 이것이다). start_dedupe는 스레드를 띄우고 즉시
// Ok를 반환하므로 invoke의 resolve는 완료를 뜻하지 않는다 — 기다릴 수 없다.
// 아주 짧은 작업은 invoke보다 이벤트가 먼저 도착할 수 있어 등록을 먼저 끝낸다.
await new Promise<void>((resolve, reject) => {
startDedupe(args, (p) => {
setDedupeProgress(p);
if (p.phase === "done") resolve();
}).catch(reject);
onDedupeDone(resolve)
.then((un) => {
unlisten = un;
return startDedupe(args, (p) => {
setDedupeProgress(p);
if (p.phase === "done") resolve();
});
})
.catch(reject);
});
toast(dedupeCancelAt() != null ? ko.dedupe.canceled : ko.dedupe.done);
} catch (e) {
// 취소 구간의 실패는 사용자가 의도한 결과다 — 오류가 아니라 '취소됨'으로 말한다
if (dedupeCancelAt() == null) throw e;
toast(ko.dedupe.canceled);
} finally {
unlisten?.();
// 진행 표시는 즉시 언마운트된다(잔상 없음). dedupeJob은 남긴다 — 지금 보이는
// 결과가 어떤 범위의 결과인지 계속 말해야 한다.
setDedupeRunning(false);
setDedupeProgress(undefined);
setDedupeCancelAt(undefined);
}
}
export const requestCancelDedupe = () => void cancelDedupe();
/**
* 취소 요청. 파괴적이지 않으므로 확인 대화상자는 없다.
* 모달 헤더와 사이드바 작업 행 모두 이 함수 하나를 부른다.
*/
export function requestCancelDedupe(): void {
if (!dedupeRunning() || dedupeCancelAt() != null) return;
setDedupeCancelAt(Date.now());
void cancelDedupe().catch((e) => toast(String(e)));
}
// ── 상태 메시지 (토스트) ─────────────────────────────────
const [statusMessage, setStatusMessage] = createSignal<string | undefined>();
@@ -294,6 +758,8 @@ export function openLightbox(index: number): void {
const snap = snapshot();
if (!snap || index < 0 || index >= snap.count) return;
setLightboxIndex(index);
// 라이트박스의 이동·태그·삭제는 선택 항목에 작용한다 — 열 때 현재 항목만 선택으로 맞춘다.
selectSingle(snap.id(index), index);
}
export function closeLightbox(): void {
+1285 -25
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
// 별점 위젯 — 상세 패널과 라이트박스가 함께 쓴다.
//
// 규칙 하나가 이 컴포넌트의 전부다: **같은 별을 다시 누르면 0으로 지운다.**
// 별점은 "3점을 줬는데 취소하고 싶다"가 자주 나오는데, 0을 누를 자리를 따로 만들면
// 별 6개가 되어 무엇이 '없음'인지 헷갈린다. 누른 별이 이미 현재 값이면 해제로 읽는다.
import { For, type Component } from "solid-js";
import { IconStar } from "./icons";
import { ko } from "../i18n/ko";
export const StarRating: Component<{
value: number;
/** 0~5. 같은 값을 다시 누르면 0이 온다. */
onChange: (next: number) => void;
size?: number;
/** 여러 항목을 함께 고쳐서 값이 섞여 있을 때 — 채우지 않고 흐리게 둔다 */
mixed?: boolean;
disabled?: boolean;
}> = (props) => (
<div class="star-rating" classList={{ "is-mixed": props.mixed, "is-disabled": props.disabled }}>
<For each={[1, 2, 3, 4, 5]}>
{(n) => (
<button
class="star-rating__btn"
classList={{ "is-on": !props.mixed && props.value >= n }}
disabled={props.disabled}
aria-label={ko.rating.set(n)}
title={props.value === n ? ko.rating.clearHint : ko.rating.set(n)}
onClick={(e) => {
e.stopPropagation();
props.onChange(props.value === n ? 0 : n);
}}
>
<IconStar size={props.size ?? 14} />
</button>
)}
</For>
</div>
);
+141
View File
@@ -0,0 +1,141 @@
// 아이콘 — lucide-solid 단일 진입점.
//
// 두 가지 이유로 여기 한 파일을 거친다.
// ① 크기·굵기 기본값을 한곳에서 잡는다. lucide 기본은 24px/2인데 이 앱의 크롬은 22~28px 행이라
// 그대로 쓰면 아이콘이 버튼을 넘친다. 호출부마다 size를 적는 대신 여기서 16/1.75를 기본으로 준다.
// ② `lucide-solid` 루트가 아니라 `lucide-solid/icons/<name>` 개별 경로로 import 한다.
// 루트 배럴을 쓰면 개발 서버가 아이콘 수천 개를 전부 변환해 HMR이 느려진다.
//
// 아이콘은 장식이므로 `aria-hidden`을 붙인다 — 의미는 버튼의 title/텍스트가 전달한다.
import type { Component } from "solid-js";
import type { LucideProps } from "lucide-solid";
import ArrowDown from "lucide-solid/icons/arrow-down";
import ArrowUp from "lucide-solid/icons/arrow-up";
import Calendar from "lucide-solid/icons/calendar";
import Check from "lucide-solid/icons/check";
import Clock from "lucide-solid/icons/clock";
import FileText from "lucide-solid/icons/file-text";
import ChevronDown from "lucide-solid/icons/chevron-down";
import ChevronLeft from "lucide-solid/icons/chevron-left";
import ChevronRight from "lucide-solid/icons/chevron-right";
import FastForward from "lucide-solid/icons/fast-forward";
import Folder from "lucide-solid/icons/folder";
import FolderSearch from "lucide-solid/icons/folder-search";
import HardDrive from "lucide-solid/icons/hard-drive";
import Hourglass from "lucide-solid/icons/hourglass";
import House from "lucide-solid/icons/house";
import Image from "lucide-solid/icons/image";
import Info from "lucide-solid/icons/info";
import LayoutGrid from "lucide-solid/icons/layout-grid";
import Layers from "lucide-solid/icons/layers";
import Library from "lucide-solid/icons/library-big";
import ListIcon from "lucide-solid/icons/list";
import Maximize from "lucide-solid/icons/maximize";
import Minimize from "lucide-solid/icons/minimize";
import Moon from "lucide-solid/icons/moon";
import PanelRight from "lucide-solid/icons/panel-right";
import Pause from "lucide-solid/icons/pause";
import Pencil from "lucide-solid/icons/pencil";
import Play from "lucide-solid/icons/play";
import Plus from "lucide-solid/icons/plus";
import RefreshCw from "lucide-solid/icons/refresh-cw";
import Repeat from "lucide-solid/icons/repeat";
import Rewind from "lucide-solid/icons/rewind";
import RotateCcw from "lucide-solid/icons/rotate-ccw";
import RotateCw from "lucide-solid/icons/rotate-cw";
import FlipHorizontal from "lucide-solid/icons/flip-horizontal";
import Crop from "lucide-solid/icons/crop";
import Scissors from "lucide-solid/icons/scissors";
import Globe from "lucide-solid/icons/globe";
import Search from "lucide-solid/icons/search";
import Star from "lucide-solid/icons/star";
import Sun from "lucide-solid/icons/sun";
import Tag from "lucide-solid/icons/tag";
import Trash2 from "lucide-solid/icons/trash-2";
import TriangleAlert from "lucide-solid/icons/triangle-alert";
import Volume1 from "lucide-solid/icons/volume-1";
import Volume2 from "lucide-solid/icons/volume-2";
import VolumeX from "lucide-solid/icons/volume-x";
import X from "lucide-solid/icons/x";
import Copy from "lucide-solid/icons/copy";
import Zap from "lucide-solid/icons/zap";
import ZoomIn from "lucide-solid/icons/zoom-in";
export interface IconProps {
/** 기본 16. 행 안 소형 액션은 14, 뷰어 컨트롤은 18~20을 쓴다. */
size?: number;
/** 기본 1.75 — 작은 크기에서 2는 뭉개진다. */
strokeWidth?: number;
class?: string;
}
/** 기본값을 입힌 래퍼를 만든다. 색은 항상 `currentColor`라 부모의 text-* 클래스를 따른다. */
const make = (C: (p: LucideProps) => ReturnType<Component>): Component<IconProps> => {
return (p) => (
<C
size={p.size ?? 16}
strokeWidth={p.strokeWidth ?? 1.75}
class={p.class}
aria-hidden="true"
/>
);
};
export const IconSearch = make(Search);
export const IconZoom = make(ZoomIn);
export const IconRefresh = make(RefreshCw);
export const IconSun = make(Sun);
export const IconMoon = make(Moon);
export const IconPanelRight = make(PanelRight);
export const IconGrid = make(LayoutGrid);
export const IconList = make(ListIcon);
export const IconInfo = make(Info);
export const IconTrash = make(Trash2);
export const IconX = make(X);
export const IconPencil = make(Pencil);
export const IconWarning = make(TriangleAlert);
export const IconSmart = make(Zap);
export const IconFolder = make(Folder);
export const IconSource = make(HardDrive);
export const IconImage = make(Image);
export const IconStar = make(Star);
export const IconTag = make(Tag);
export const IconPlus = make(Plus);
export const IconCheck = make(Check);
export const IconChevronRight = make(ChevronRight);
export const IconChevronDown = make(ChevronDown);
export const IconChevronLeft = make(ChevronLeft);
export const IconArrowUp = make(ArrowUp);
export const IconArrowDown = make(ArrowDown);
export const IconHome = make(House);
export const IconClock = make(Clock);
export const IconCalendar = make(Calendar);
export const IconFile = make(FileText);
export const IconLayers = make(Layers);
export const IconLibrary = make(Library);
export const IconGlobe = make(Globe);
export const IconDuplicate = make(Copy);
/** 스캔(폴더를 읽는 중) 작업 행 */
export const IconFolderScan = make(FolderSearch);
/** 백그라운드 백필(썸네일·메타·지문) 작업 행 — '기다리는 일'이지 '보는 일'이 아니다 */
export const IconHourglass = make(Hourglass);
// 플레이어
export const IconPlay = make(Play);
export const IconPause = make(Pause);
export const IconRewind = make(Rewind);
export const IconForward = make(FastForward);
export const IconVolumeMute = make(VolumeX);
export const IconVolumeLow = make(Volume1);
export const IconVolumeHigh = make(Volume2);
export const IconRepeat = make(Repeat);
export const IconMaximize = make(Maximize);
export const IconMinimize = make(Minimize);
export const IconResetZoom = make(RotateCcw);
/** 편집: 왼쪽·오른쪽 90도 회전, 좌우 반전 */
export const IconRotateLeft = make(RotateCcw);
export const IconRotateRight = make(RotateCw);
export const IconFlipH = make(FlipHorizontal);
export const IconCrop = make(Crop);
export const IconScissors = make(Scissors);