Archive v0.1.0 — 사진/영상 라이브러리 관리 프로그램

Tauri v2 + SolidJS + SQLite + ffmpeg 사이드카로 구현한 크로스플랫폼
사진·영상 관리 앱. 로컬/NAS(SFTP·WebDAV·FTP) 소스, 가상 그리드,
인앱 재생, 태그/이동/삭제/undo, 중복 탐지, 포터블 배포.

- archive-db: SQLite 스키마·마이그레이션·단일 writer 스레드 + FTS5 trigram
- archive-vfs: VFS 4백엔드(local/sftp/ftp/webdav) + 자격증명(키체인/볼트)
- archive-indexer: 스캔·해시·썸네일·중복탐지·태그·파일작업·유지보수
- archive-media: localhost HTTP 미디어 서버(Range) + ffmpeg 스트림 잡
- 프론트: 3-pane UI, justified 가상 그리드, 라이트박스, 중복 검토 패널

Rust 테스트 49개 통과. CI: win x64/arm64 포터블 zip + macOS universal dmg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-16 22:04:06 +09:00
co-authored by Claude Opus 4.8
commit 394d1b9805
132 changed files with 23468 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import { onCleanup, onMount, type Component } from "solid-js";
import { Sidebar, onLibraryChangedRefresh } from "./shell/Sidebar";
import { ContentArea } from "./shell/ContentArea";
import { DetailsPanel } from "./shell/DetailsPanel";
import { StatusBar } from "./shell/StatusBar";
import { Lightbox } from "./features/viewer/Lightbox";
import { ContextMenu } from "./features/fileops/ContextMenu";
import { MoveDialog } from "./features/fileops/MoveDialog";
import { DedupePanel } from "./features/dedupe/DedupePanel";
import { RemoteDialog } from "./features/sources/RemoteDialog";
import { onLibraryChanged, onThumbsReady, type UnlistenFn } from "./ipc/commands";
import { bumpThumbVersions, loadAppInfo, refreshSnapshot } from "./state/store";
const App: Component = () => {
onMount(() => {
void loadAppInfo();
void refreshSnapshot();
const unlisteners: Promise<UnlistenFn>[] = [
onThumbsReady((ids) => bumpThumbVersions(ids)),
onLibraryChanged(() => onLibraryChangedRefresh()),
];
onCleanup(() => {
for (const u of unlisteners) void u.then((f) => f());
});
});
return (
<div class="grid h-full grid-rows-[1fr_auto]">
<div class="grid min-h-0 grid-cols-[240px_1fr_280px]">
<Sidebar />
<ContentArea />
<DetailsPanel />
</div>
<StatusBar />
<Lightbox />
<ContextMenu />
<MoveDialog />
<DedupePanel />
<RemoteDialog />
</div>
);
};
export default App;
+252
View File
@@ -0,0 +1,252 @@
// 중복 검토 패널 — 전체화면 오버레이. 그룹별 나란히 비교 + keeper 외 휴지통.
import {
createResource,
createSignal,
For,
Show,
type Component,
} from "solid-js";
import { ask } from "@tauri-apps/plugin-dialog";
import { ko } from "../../i18n/ko";
import {
cancelDedupe,
dismissDupePair,
listDupeGroups,
resolveDupeGroup,
startDedupe,
thumbUrl,
trashFiles,
type DedupeProgress,
type DupeGroup,
} from "../../ipc/commands";
import {
closeDedupe,
dedupeOpen,
info,
refreshSidebar,
refreshSnapshot,
toast,
} 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`;
}
const KIND_LABEL: Record<number, string> = { 0: "완전 동일", 1: "유사 이미지", 2: "유사 영상" };
export const DedupePanel: Component = () => {
const [running, setRunning] = createSignal(false);
const [progress, setProgress] = createSignal<DedupeProgress | undefined>();
const [includeVideo, setIncludeVideo] = createSignal(false);
const [threshold, setThreshold] = createSignal(5);
const [groupsVersion, setGroupsVersion] = createSignal(0);
const [groups] = createResource(
() => (dedupeOpen() ? groupsVersion() : undefined),
() => listDupeGroups(),
);
const runScan = async () => {
setRunning(true);
setProgress(undefined);
try {
await new Promise<void>((resolve, reject) => {
startDedupe(
{ imageThreshold: threshold(), includeVideo: includeVideo() },
(p) => {
setProgress(p);
if (p.phase === "done") resolve();
},
).catch(reject);
});
} catch (e) {
toast(String(e));
} finally {
setRunning(false);
setProgress(undefined);
setGroupsVersion((v) => v + 1);
}
};
/** 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;
}
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);
}
}
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}
<input
type="range"
min={0}
max={12}
value={threshold()}
class="accent-[var(--accent)]"
onInput={(e) => setThreshold(Number(e.currentTarget.value))}
/>
<span class="w-4 tabular-nums">{threshold()}</span>
</label>
<label class="flex items-center gap-1.5">
<input
type="checkbox"
checked={includeVideo()}
onChange={(e) => setIncludeVideo(e.currentTarget.checked)}
/>
{ko.dedupe.includeVideo}
</label>
</div>
<div class="ml-auto flex items-center gap-2">
<Show when={running()}>
<span class="text-[11px] text-[var(--accent)]">
{progress()
? ko.dedupe.scanning(progress()!.phase, progress()!.current, progress()!.total)
: ko.dedupe.starting}
</span>
<button
class="rounded border border-[var(--border)] px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
onClick={() => void cancelDedupe()}
>
{ko.common.cancel}
</button>
</Show>
<Show when={!running()}>
<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}
>
</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)]">
{running() ? 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>
</div>
</Show>
</div>
</div>
</Show>
);
};
+173
View File
@@ -0,0 +1,173 @@
// 그리드 우클릭 컨텍스트 메뉴 — 태그 할당, 이동, 이름변경, 휴지통.
import { createResource, createSignal, For, Show, type Component } from "solid-js";
import { ask } from "@tauri-apps/plugin-dialog";
import { ko } from "../../i18n/ko";
import {
assignTags,
listTags,
renameFile,
trashFiles,
undoLast,
} from "../../ipc/commands";
import {
clearSelection,
refreshSidebar,
refreshSnapshot,
selected,
toast,
} from "../../state/store";
import { openMoveDialog } from "./MoveDialog";
interface MenuState {
x: number;
y: number;
fileId: number;
fileName?: string;
}
const [menu, setMenu] = createSignal<MenuState | undefined>();
export const openContextMenu = (s: MenuState) => setMenu(s);
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> {
try {
const ids = [...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();
refreshSidebar();
await refreshSnapshot();
} catch (e) {
toast(String(e));
console.error("performTrash", e);
}
}
export async function performUndo(): Promise<void> {
const summary = await undoLast();
if (!summary) {
toast(ko.ops.nothingToUndo);
return;
}
toast(ko.ops.undone(summary.done));
refreshSidebar();
await refreshSnapshot();
}
const MenuItem: Component<{ label: string; onClick: () => void }> = (props) => (
<div
class="cursor-pointer rounded px-3 py-1.5 text-xs hover:bg-[var(--bg-hover)]"
onClick={() => {
closeContextMenu();
props.onClick();
}}
>
{props.label}
</div>
);
export const ContextMenu: Component = () => {
const [tags] = createResource(
() => (menu() ? 1 : undefined),
() => listTags(),
);
const onAssign = async (tagId: number, tagName: string) => {
const ids = [...selected()];
const r = await assignTags(ids, tagId);
toast(ko.tags.assignTo(r.count, tagName));
refreshSidebar();
};
return (
<>
<Show when={menu()}>
{(m) => (
<>
<div class="fixed inset-0 z-40" onClick={closeContextMenu} onContextMenu={(e) => { e.preventDefault(); closeContextMenu(); }} />
<div
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}>
<div class="px-3 pt-1 text-[10px] text-[var(--text-muted)]"></div>
<div class="max-h-36 overflow-y-auto">
<For each={tags()}>
{(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={selected().size === 1 && m().fileName}>
<MenuItem
label={ko.common.rename}
onClick={() => openRenameDialog(m().fileId, m().fileName!)}
/>
</Show>
<div class="my-1 border-t border-[var(--border)]" />
<MenuItem label={`${ko.common.delete} (휴지통)`} onClick={() => void performTrash()} />
</div>
</>
)}
</Show>
{/* 이름 변경 모달 */}
<Show when={renameTarget()}>
{(t) => {
let inputRef: HTMLInputElement | undefined;
const submit = async () => {
const newName = inputRef?.value.trim();
setRenameTarget(undefined);
if (!newName || newName === t().name) return;
try {
await renameFile(t().id, newName);
await refreshSnapshot();
} catch (e) {
toast(String(e));
}
};
return (
<div class="fixed inset-0 z-50 grid place-items-center bg-black/60" onClick={() => setRenameTarget(undefined)}>
<div
class="w-96 rounded-lg border border-[var(--border-strong)] bg-[var(--bg-panel)] p-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div class="mb-2 text-sm font-semibold">{ko.common.rename}</div>
<input
ref={inputRef}
value={t().name}
class="w-full rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1.5 text-xs outline-none focus:border-[var(--accent)]"
onKeyDown={(e) => {
if (e.key === "Enter") void submit();
if (e.key === "Escape") setRenameTarget(undefined);
}}
/>
<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={() => setRenameTarget(undefined)}
>
{ko.common.cancel}
</button>
<button
class="rounded bg-[var(--accent)] px-3 py-1 text-xs text-white"
onClick={() => void submit()}
>
{ko.common.confirm}
</button>
</div>
</div>
</div>
);
}}
</Show>
</>
);
};
+147
View File
@@ -0,0 +1,147 @@
// 폴더 선택 모달 — 선택 항목을 인덱싱된 폴더로 이동한다.
import { createResource, createSignal, For, Show, type Component } from "solid-js";
import { ko } from "../../i18n/ko";
import {
folderTree,
listSources,
moveFiles,
type FolderNode,
} from "../../ipc/commands";
import {
clearSelection,
refreshSidebar,
refreshSnapshot,
selected,
toast,
} from "../../state/store";
const [open, setOpen] = createSignal(false);
export const openMoveDialog = () => setOpen(true);
interface TreeNode extends FolderNode {
children: TreeNode[];
}
function buildTree(nodes: FolderNode[]): TreeNode[] {
const map = new Map<number, TreeNode>();
const roots: TreeNode[] = [];
for (const n of nodes) map.set(n.id, { ...n, children: [] });
for (const n of map.values()) {
if (n.parentId != null && map.has(n.parentId)) map.get(n.parentId)!.children.push(n);
else roots.push(n);
}
return roots;
}
export async function performMove(destFolderId: number): Promise<void> {
const ids = [...selected()];
if (ids.length === 0) return;
const summary = await moveFiles(ids, destFolderId, (p) =>
toast(ko.ops.inProgress(p.done, p.total)),
);
toast(
summary.failed > 0
? `${ko.ops.moveDone(summary.done)}, ${ko.ops.moveFailed(summary.failed)}`
: ko.ops.moveDone(summary.done),
);
clearSelection();
refreshSidebar();
await refreshSnapshot();
}
const FolderPickRow: Component<{
node: TreeNode;
depth: number;
onPick: (id: number) => void;
}> = (props) => {
const [expanded, setExpanded] = createSignal(props.depth < 1);
return (
<>
<div
class="flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
style={{ "padding-left": `${8 + props.depth * 14}px` }}
onClick={() => props.onPick(props.node.id)}
>
<span
class="w-3 shrink-0 text-[var(--text-muted)]"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded());
}}
>
<Show when={props.node.children.length > 0}>{expanded() ? "▾" : "▸"}</Show>
</span>
<span class="truncate">{props.node.name}</span>
</div>
<Show when={expanded()}>
<For each={props.node.children}>
{(c) => <FolderPickRow node={c} depth={props.depth + 1} onPick={props.onPick} />}
</For>
</Show>
</>
);
};
export const MoveDialog: Component = () => {
const [trees] = createResource(
() => open(),
async (isOpen) => {
if (!isOpen) return [];
const sources = await listSources();
return Promise.all(
sources
.filter((s) => s.kind === "local")
.map(async (s) => ({
source: s,
tree: buildTree(await folderTree(s.id)),
})),
);
},
);
const onPick = async (folderId: number) => {
setOpen(false);
try {
await performMove(folderId);
} catch (e) {
toast(String(e));
}
};
return (
<Show when={open()}>
<div class="fixed inset-0 z-50 grid place-items-center bg-black/60" onClick={() => setOpen(false)}>
<div
class="flex max-h-[70vh] w-96 flex-col rounded-lg border border-[var(--border-strong)] bg-[var(--bg-panel)] shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div class="border-b border-[var(--border)] px-4 py-2.5 text-sm font-semibold">
{ko.ops.moveTo}
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
<For each={trees()}>
{(entry) => (
<div class="mb-2">
<div class="px-2 py-1 text-[11px] font-semibold text-[var(--text-muted)]">
{entry.source.name}
</div>
<For each={entry.tree}>
{(n) => <FolderPickRow node={n} depth={0} onPick={(id) => void onPick(id)} />}
</For>
</div>
)}
</For>
</div>
<div class="flex justify-end border-t border-[var(--border)] px-3 py-2">
<button
class="rounded border border-[var(--border)] px-3 py-1 text-xs hover:bg-[var(--bg-hover)]"
onClick={() => setOpen(false)}
>
{ko.common.cancel}
</button>
</div>
</div>
</div>
</Show>
);
};
+317
View File
@@ -0,0 +1,317 @@
// 커스텀 justified 가상 그리드 — 레이아웃은 워커에서, 렌더는 뷰포트 ±1화면만.
import {
createEffect,
createMemo,
createSignal,
For,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import type { LayoutResult } from "./layout.worker";
import LayoutWorker from "./layout.worker?worker";
import {
anchorIndex,
info,
openLightbox,
selected,
selectSingle,
toggleSelect,
selectRange,
snapshot,
thumbVersions,
} 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";
const TARGET_ROW_HEIGHT = 200;
const GAP = 6;
const OVERSCAN_PX = 800;
/** rowTops에서 y 이상이 처음 나오는 행 인덱스 (이진탐색) */
function findRow(rowTops: Float32Array, y: number): number {
let lo = 0;
let hi = rowTops.length - 2; // 마지막 요소는 전체 높이
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (rowTops[mid] <= y) lo = mid;
else hi = mid - 1;
}
return lo;
}
export const JustifiedGrid: Component = () => {
let containerRef: HTMLDivElement | undefined;
const [layout, setLayout] = createSignal<LayoutResult | undefined>();
const [scrollTop, setScrollTop] = createSignal(0);
const [viewportH, setViewportH] = createSignal(600);
const [containerW, setContainerW] = createSignal(0);
const worker = new LayoutWorker();
let layoutSeq = 0;
worker.onmessage = (e: MessageEvent<LayoutResult>) => {
if (e.data.seq === layoutSeq) setLayout(e.data);
};
onCleanup(() => worker.terminate());
// 스냅샷/폭 변경 → 레이아웃 재계산
createEffect(() => {
const snap = snapshot();
const w = containerW();
if (!snap || w <= 0) {
setLayout(undefined);
return;
}
layoutSeq++;
const ratios = snap.aspectRatios();
worker.postMessage(
{ seq: layoutSeq, ratios, containerWidth: w, targetRowHeight: TARGET_ROW_HEIGHT, gap: GAP },
[ratios.buffer],
);
});
onMount(() => {
const el = containerRef!;
const ro = new ResizeObserver(() => {
setContainerW(el.clientWidth - 16); // 패딩 보정
setViewportH(el.clientHeight);
});
ro.observe(el);
onCleanup(() => ro.disconnect());
});
// rAF 스로틀 스크롤
let rafPending = false;
const onScroll = () => {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(() => {
rafPending = false;
if (containerRef) setScrollTop(containerRef.scrollTop);
});
};
/** 가시 항목 인덱스 범위 */
const visibleRange = createMemo<[number, number]>(() => {
const l = layout();
const snap = snapshot();
if (!l || !snap || l.rowFirst.length === 0) return [0, -1];
const top = Math.max(0, scrollTop() - OVERSCAN_PX);
const bottom = scrollTop() + viewportH() + OVERSCAN_PX;
const firstRow = findRow(l.rowTops, top);
let lastRow = firstRow;
while (lastRow + 1 < l.rowFirst.length && l.rowTops[lastRow + 1] < bottom) lastRow++;
const first = l.rowFirst[firstRow];
const last =
lastRow + 1 < l.rowFirst.length ? l.rowFirst[lastRow + 1] - 1 : snap.count - 1;
return [first, last];
});
// 뷰포트 항목 중 썸네일 없는 것 우선 생성 요청 (디바운스 200ms)
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 < 512; i++) {
if (!snap.hasThumb(i)) ids.push(snap.id(i));
}
if (ids.length > 0) void setThumbPriority(ids);
}, 200);
});
onCleanup(() => clearTimeout(prioTimer));
const indices = createMemo(() => {
const [first, last] = visibleRange();
const out: number[] = [];
for (let i = first; i <= last; i++) out.push(i);
return out;
});
const onCellClick = (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);
};
/** 위/아래 화살표: 레이아웃 기하 기반 — 인접 행에서 x중심이 가장 가까운 셀 */
const verticalNeighbor = (from: number, dir: -1 | 1): number | undefined => {
const l = layout();
const snap = snapshot();
if (!l || !snap) return undefined;
const cx = l.boxes[from * 4] + l.boxes[from * 4 + 2] / 2;
const cy = l.boxes[from * 4 + 1];
let best: number | undefined;
let bestDist = Infinity;
// 최대 3행 거리까지 탐색 (마지막 행 채움 편차 대응)
const step = dir === 1 ? 1 : -1;
for (let i = from + step; i >= 0 && i < snap.count; i += step) {
const y = l.boxes[i * 4 + 1];
if (dir === 1 ? y <= cy : y >= cy) continue; // 같은 행 스킵
const nx = l.boxes[i * 4] + l.boxes[i * 4 + 2] / 2;
const d = Math.abs(nx - cx);
if (d < bestDist) {
bestDist = d;
best = i;
} else if (best !== undefined && Math.abs(y - l.boxes[best * 4 + 1]) > 1) {
break; // 다음 행으로 넘어가면 종료
}
}
return best;
};
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;
}
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;
}
}
};
const onCellContextMenu = (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 onCellDragStart = (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";
};
return (
<div
ref={containerRef}
onScroll={onScroll}
onKeyDown={onKeyDown}
tabindex={0}
class="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden px-2 py-2 outline-none"
>
<Show
when={snapshot() && snapshot()!.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: `${layout()?.totalHeight ?? 0}px` }}>
<For each={indices()}>
{(i) => {
const snap = snapshot()!;
const l = layout()!;
const id = snap.id(i);
const x = l.boxes[i * 4];
const y = l.boxes[i * 4 + 1];
const w = l.boxes[i * 4 + 2];
const h = l.boxes[i * 4 + 3];
const version = () => thumbVersions[id] ?? 0;
const showImg = () => snap.hasThumb(i) || version() > 0;
return (
<div
class="absolute cursor-pointer overflow-hidden rounded-[3px] bg-[var(--grid-cell-bg)] outline-offset-[-2px]"
classList={{
"outline outline-2 outline-[var(--accent)]": selected().has(id),
}}
style={{
transform: `translate(${x}px, ${y}px)`,
width: `${w}px`,
height: `${h}px`,
}}
onClick={(e) => onCellClick(e, snap, i)}
onDblClick={() => openLightbox(i)}
onContextMenu={(e) => onCellContextMenu(e, snap, i)}
draggable={true}
onDragStart={(e) => onCellDragStart(e, snap, i)}
>
<Show when={showImg() && info()}>
<img
src={`${thumbUrl(info()!, id)}&v=${version()}`}
class="h-full w-full object-cover"
loading="lazy"
decoding="async"
draggable={false}
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
</Show>
<Show when={snap.kind(i) === 1}>
<span class="absolute bottom-1 right-1 rounded bg-black/60 px-1 text-[10px] text-white">
</span>
</Show>
</div>
);
}}
</For>
</div>
</Show>
</div>
);
};
+89
View File
@@ -0,0 +1,89 @@
// Justified 레이아웃 계산 워커 — 그리디 행 채우기.
// 입력: 종횡비 배열 → 출력: 항목별 [x,y,w,h] + 행 오프셋 인덱스.
export interface LayoutRequest {
seq: number;
ratios: Float32Array;
containerWidth: number;
targetRowHeight: number;
gap: number;
}
export interface LayoutResult {
seq: number;
/** n*4: [x, y, w, h] */
boxes: Float32Array;
/** 행별 상단 y (마지막 요소 = 전체 높이) */
rowTops: Float32Array;
/** 행별 첫 항목 인덱스 */
rowFirst: Uint32Array;
totalHeight: number;
}
self.onmessage = (e: MessageEvent<LayoutRequest>) => {
const { seq, ratios, containerWidth, targetRowHeight, gap } = e.data;
const n = ratios.length;
const boxes = new Float32Array(n * 4);
const rowTopsArr: number[] = [];
const rowFirstArr: number[] = [];
// 극단적 종횡비는 레이아웃 파손 방지를 위해 클램프
const clamp = (r: number) => Math.min(4, Math.max(0.25, r || 1));
let y = 0;
let rowStart = 0;
let ratioSum = 0;
const flushRow = (end: number, justify: boolean) => {
const count = end - rowStart;
if (count <= 0) return;
const gaps = gap * (count - 1);
let h = targetRowHeight;
if (justify) {
h = (containerWidth - gaps) / ratioSum;
} else {
// 마지막 행: 목표 높이 유지하되 넘치면 축소
const natural = ratioSum * targetRowHeight + gaps;
if (natural > containerWidth) h = (containerWidth - gaps) / ratioSum;
}
let x = 0;
for (let i = rowStart; i < end; i++) {
const w = clamp(ratios[i]) * h;
boxes[i * 4] = x;
boxes[i * 4 + 1] = y;
boxes[i * 4 + 2] = w;
boxes[i * 4 + 3] = h;
x += w + gap;
}
rowTopsArr.push(y);
rowFirstArr.push(rowStart);
y += h + gap;
rowStart = end;
ratioSum = 0;
};
for (let i = 0; i < n; i++) {
ratioSum += clamp(ratios[i]);
const width = ratioSum * targetRowHeight + gap * (i - rowStart);
if (width >= containerWidth) {
flushRow(i + 1, true);
}
}
flushRow(n, false);
const totalHeight = y > 0 ? y - gap : 0;
rowTopsArr.push(totalHeight); // 마지막 = 전체 높이 (이진탐색 경계)
const result: LayoutResult = {
seq,
boxes,
rowTops: new Float32Array(rowTopsArr),
rowFirst: new Uint32Array(rowFirstArr),
totalHeight,
};
(self as unknown as Worker).postMessage(result, [
result.boxes.buffer,
result.rowTops.buffer,
result.rowFirst.buffer,
]);
};
+171
View File
@@ -0,0 +1,171 @@
// 원격 소스(SFTP/WebDAV/FTP) 연결 다이얼로그.
// 비밀번호는 앱을 통해 OS 키체인/포터블 볼트에 저장되고 SQLite에는 저장되지 않는다.
import { createSignal, Show, type Component } from "solid-js";
import { ko } from "../../i18n/ko";
import {
addRemoteSource,
connectRemoteSource,
scanRemoteSource,
type RemoteInput,
} from "../../ipc/commands";
import { refreshSidebar, refreshSnapshot, toast } from "../../state/store";
const [open, setOpen] = createSignal(false);
export const openRemoteDialog = () => setOpen(true);
type Kind = "sftp" | "webdav" | "ftp";
const DEFAULT_PORT: Record<Kind, number> = { sftp: 22, ftp: 21, webdav: 5006 };
export const RemoteDialog: Component = () => {
const [kind, setKind] = createSignal<Kind>("sftp");
const [name, setName] = createSignal("");
const [host, setHost] = createSignal("");
const [port, setPort] = createSignal<number>(22);
const [username, setUsername] = createSignal("");
const [password, setPassword] = createSignal("");
const [basePath, setBasePath] = createSignal("");
const [url, setUrl] = createSignal("");
const [busy, setBusy] = createSignal(false);
const reset = () => {
setName("");
setHost("");
setUsername("");
setPassword("");
setBasePath("");
setUrl("");
};
const submit = async () => {
setBusy(true);
try {
const input: RemoteInput = {
kind: kind(),
name: name() || host() || url(),
host: host(),
port: port(),
username: username(),
password: password(),
basePath: basePath(),
url: url(),
};
const sourceId = await addRemoteSource(input);
toast(ko.remote.connecting);
await connectRemoteSource(sourceId);
toast(ko.remote.indexing);
const count = await scanRemoteSource(sourceId);
toast(ko.remote.indexed(count));
refreshSidebar();
await refreshSnapshot();
setOpen(false);
reset();
} catch (e) {
toast(String(e));
} finally {
setBusy(false);
}
};
const Field: Component<{ label: string; children: any }> = (props) => (
<label class="mb-2 block">
<div class="mb-0.5 text-[11px] text-[var(--text-muted)]">{props.label}</div>
{props.children}
</label>
);
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={open()}>
<div class="fixed inset-0 z-50 grid place-items-center bg-black/60" onClick={() => !busy() && setOpen(false)}>
<div
class="w-[420px] 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.remote.title}</div>
<div class="mb-3 flex gap-1">
{(["sftp", "webdav", "ftp"] as Kind[]).map((k) => (
<button
class="flex-1 rounded border px-2 py-1 text-xs"
classList={{
"border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]": kind() === k,
"border-[var(--border)] text-[var(--text-secondary)]": kind() !== k,
}}
onClick={() => {
setKind(k);
setPort(DEFAULT_PORT[k]);
}}
>
{k.toUpperCase()}
</button>
))}
</div>
<Field label={ko.remote.name}>
<input class={inputClass} value={name()} onInput={(e) => setName(e.currentTarget.value)} placeholder={ko.remote.namePlaceholder} />
</Field>
<Show
when={kind() === "webdav"}
fallback={
<div class="flex gap-2">
<div class="flex-1">
<Field label={ko.remote.host}>
<input class={inputClass} value={host()} onInput={(e) => setHost(e.currentTarget.value)} placeholder="192.168.0.10" />
</Field>
</div>
<div class="w-20">
<Field label={ko.remote.port}>
<input class={inputClass} type="number" value={port()} onInput={(e) => setPort(Number(e.currentTarget.value))} />
</Field>
</div>
</div>
}
>
<Field label={ko.remote.url}>
<input class={inputClass} value={url()} onInput={(e) => setUrl(e.currentTarget.value)} placeholder="https://nas.example.com:5006/dav" />
</Field>
</Show>
<div class="flex gap-2">
<div class="flex-1">
<Field label={ko.remote.username}>
<input class={inputClass} value={username()} onInput={(e) => setUsername(e.currentTarget.value)} />
</Field>
</div>
<div class="flex-1">
<Field label={ko.remote.password}>
<input class={inputClass} type="password" value={password()} onInput={(e) => setPassword(e.currentTarget.value)} />
</Field>
</div>
</div>
<Field label={ko.remote.basePath}>
<input class={inputClass} value={basePath()} onInput={(e) => setBasePath(e.currentTarget.value)} placeholder="/photo" />
</Field>
<div class="mt-3 flex items-center justify-between">
<span class="text-[10px] text-[var(--text-muted)]">{ko.remote.credNote}</span>
<div class="flex gap-2">
<button
class="rounded border border-[var(--border)] px-3 py-1 text-xs hover:bg-[var(--bg-hover)]"
onClick={() => setOpen(false)}
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.remote.connecting : ko.remote.connect}
</button>
</div>
</div>
</div>
</div>
</Show>
);
};
+119
View File
@@ -0,0 +1,119 @@
// 라이트박스 — 사진 원본/영상 재생 오버레이. Esc 닫기, ←/→ 이동.
import {
createEffect,
createMemo,
createResource,
onCleanup,
onMount,
Show,
type Component,
} from "solid-js";
import {
closeLightbox,
info,
lightboxIndex,
navLightbox,
snapshot,
} from "../../state/store";
import { fileDetails, mediaUrl, thumbUrl } from "../../ipc/commands";
import { VideoPlayer } from "./VideoPlayer";
/** 웹뷰가 <img>로 직접 그릴 수 있는 확장자 (그 외는 썸네일 폴백 — HEIC/RAW 등) */
const IMG_NATIVE = new Set(["jpg", "jpeg", "jfif", "png", "gif", "webp", "bmp", "avif"]);
export const Lightbox: Component = () => {
const item = createMemo(() => {
const snap = snapshot();
const idx = lightboxIndex();
if (!snap || idx == null || idx >= snap.count) return undefined;
return { index: idx, id: snap.id(idx), kind: snap.kind(idx) };
});
const [details] = createResource(
() => item()?.id,
async (id) => (await fileDetails([id]))[0],
);
const onKey = (e: KeyboardEvent) => {
if (lightboxIndex() == null) return;
switch (e.key) {
case "Escape":
e.preventDefault();
closeLightbox();
break;
case "ArrowLeft":
e.preventDefault();
navLightbox(-1);
break;
case "ArrowRight":
e.preventDefault();
navLightbox(1);
break;
}
};
onMount(() => {
window.addEventListener("keydown", onKey, { capture: true });
onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true }));
});
// 배경 스크롤 잠금
createEffect(() => {
document.body.style.overflow = lightboxIndex() != null ? "hidden" : "";
});
const imgSrc = () => {
const it = item();
const app = info();
const d = details();
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);
};
return (
<Show when={item()}>
{(it) => (
<div class="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)]">
{details()?.name ?? ""}
</span>
<span class="text-[11px] text-[var(--text-muted)]">
{it().index + 1} / {snapshot()?.count.toLocaleString("ko-KR")}
</span>
<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>
</div>
<div class="min-h-0 flex-1 p-2">
<Show
when={it().kind === 1}
fallback={
<div class="grid h-full w-full place-items-center">
<img
src={imgSrc()}
class="max-h-full max-w-full object-contain"
decoding="async"
onError={(e) => {
// 원본 디코드 실패 → 그리드 썸네일 폴백
const app = info();
if (app) (e.currentTarget as HTMLImageElement).src = thumbUrl(app, it().id, 0);
}}
/>
</div>
}
>
<VideoPlayer fileId={it().id} />
</Show>
</div>
</div>
)}
</Show>
);
};
+155
View File
@@ -0,0 +1,155 @@
// 비디오 플레이어 — 직접 재생(Range, 네이티브 컨트롤) 또는
// ffmpeg 스트림(리먹스/트랜스코딩, 커스텀 스크러버 + seekBase 오프셋).
import {
createEffect,
createResource,
createSignal,
onCleanup,
Show,
type Component,
} from "solid-js";
import {
decidePlayback,
mediaUrl,
playbackInfo,
startStream,
stopStream,
type AudioMode,
type PlaybackPlan,
type VideoMode,
} from "../../ipc/commands";
import { info as appInfoSig } from "../../state/store";
function fmtTime(sec: number): string {
const s = Math.max(0, Math.floor(sec));
const m = Math.floor(s / 60);
const r = s % 60;
const h = Math.floor(m / 60);
return h > 0
? `${h}:${String(m % 60).padStart(2, "0")}:${String(r).padStart(2, "0")}`
: `${m}:${String(r).padStart(2, "0")}`;
}
export const VideoPlayer: Component<{ fileId: number }> = (props) => {
let videoRef: HTMLVideoElement | undefined;
const [pb] = createResource(() => props.fileId, playbackInfo);
const [plan, setPlan] = createSignal<PlaybackPlan | undefined>();
const [src, setSrc] = createSignal<string | undefined>();
const [seekBase, setSeekBase] = createSignal(0);
const [current, setCurrent] = createSignal(0);
const [playing, setPlaying] = createSignal(false);
let jobId: number | undefined;
const durationSec = () => (pb()?.durationMs ?? 0) / 1000;
const cleanupJob = () => {
if (jobId != null) {
void stopStream(jobId);
jobId = undefined;
}
};
const startStreamAt = async (t: number, video: VideoMode, audio: AudioMode) => {
cleanupJob();
const res = await startStream(props.fileId, t, video, audio);
jobId = res.jobId;
setSeekBase(t);
setSrc(res.url);
queueMicrotask(() => videoRef?.play().catch(() => {}));
};
// 파일 변경 → 재생 계획 수립
createEffect(() => {
const p = pb();
const app = appInfoSig();
if (!p || !app) return;
cleanupJob();
setSeekBase(0);
setCurrent(0);
const decided = decidePlayback(p);
setPlan(decided);
if (decided.mode === "direct") {
setSrc(mediaUrl(app, props.fileId));
} else if (decided.mode === "stream") {
void startStreamAt(0, decided.video, decided.audio);
} else {
setSrc(undefined);
}
});
onCleanup(cleanupJob);
const isStream = () => plan()?.mode === "stream";
const onSeek = (t: number) => {
const p = plan();
if (!p) return;
if (p.mode === "direct") {
if (videoRef) videoRef.currentTime = t;
} else if (p.mode === "stream") {
void startStreamAt(t, p.video, p.audio);
}
};
const togglePlay = () => {
if (!videoRef) return;
if (videoRef.paused) void videoRef.play().catch(() => {});
else videoRef.pause();
};
return (
<div class="flex h-full w-full flex-col items-center justify-center">
<Show
when={plan()?.mode !== "unsupported"}
fallback={
<div class="max-w-md text-center text-sm text-[var(--text-muted)]">
{(plan() as { reason?: string })?.reason ?? "재생할 수 없는 형식입니다"}
</div>
}
>
<video
ref={videoRef}
src={src()}
class="max-h-[calc(100%-44px)] max-w-full flex-1 bg-black outline-none"
controls={!isStream()}
autoplay
onTimeUpdate={() => setCurrent(seekBase() + (videoRef?.currentTime ?? 0))}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onClick={() => isStream() && togglePlay()}
/>
{/* 스트림 모드 커스텀 컨트롤 (fMP4 라이브 스트림은 네이티브 시킹 불가) */}
<Show when={isStream()}>
<div class="flex h-11 w-full max-w-3xl items-center gap-3 px-2">
<button
class="w-8 rounded px-1 py-0.5 text-lg text-[var(--text-primary)] hover:bg-[var(--bg-hover)]"
onClick={togglePlay}
>
{playing() ? "⏸" : "▶"}
</button>
<span class="w-14 shrink-0 text-right text-[11px] tabular-nums text-[var(--text-secondary)]">
{fmtTime(current())}
</span>
<input
type="range"
class="h-1 flex-1 accent-[var(--accent)]"
min={0}
max={durationSec() || 0}
step={0.1}
value={current()}
onChange={(e) => onSeek(Number(e.currentTarget.value))}
/>
<span class="w-14 shrink-0 text-[11px] tabular-nums text-[var(--text-secondary)]">
{fmtTime(durationSec())}
</span>
<span class="shrink-0 rounded bg-[var(--bg-panel-raised)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)]">
{plan()?.mode === "stream" && (plan() as { video: string }).video === "transcode"
? "변환 재생"
: "리먹스 재생"}
</span>
</div>
</Show>
</Show>
</div>
);
};
+131
View File
@@ -0,0 +1,131 @@
// 한국어 UI 문자열 테이블. 모든 사용자 노출 문자열은 여기서만 가져다 쓴다.
export const ko = {
app: {
title: "Archive",
loading: "불러오는 중…",
},
sidebar: {
sources: "소스",
folders: "폴더",
tags: "태그",
smartFolders: "스마트 폴더",
dedupe: "중복 항목",
addSource: "폴더",
noSources: "등록된 소스가 없습니다.\n폴더를 추가해 시작하세요.",
},
onboarding: {
title: "Archive에 오신 것을 환영합니다",
subtitle: "사진·영상을 빠르게 조회하고 정리하세요.",
addLocal: "폴더 추가",
addRemote: "네트워크 소스 연결 (NAS)",
hint: "수만 개의 파일도 인덱싱 후 즉시 스크롤·검색됩니다.",
},
grid: {
empty: "표시할 항목이 없습니다",
items: (n: number) => `${n.toLocaleString("ko-KR")}개 항목`,
selected: (n: number) => `${n.toLocaleString("ko-KR")}개 선택됨`,
},
details: {
title: "상세 정보",
noSelection: "항목을 선택하면 정보가 표시됩니다",
fileName: "파일명",
filePath: "경로",
fileSize: "크기",
dimensions: "해상도",
duration: "재생 시간",
codec: "코덱",
takenAt: "촬영 일시",
modifiedAt: "수정 일시",
tags: "태그",
},
statusBar: {
ready: "준비됨",
scanning: (dir: string) => `스캔 중: ${dir}`,
dataDir: "데이터 위치",
portable: "포터블 모드",
installed: "일반 모드",
},
search: {
placeholder: "파일명 검색…",
},
common: {
cancel: "취소",
confirm: "확인",
delete: "삭제",
rename: "이름 바꾸기",
move: "이동",
undo: "실행 취소",
create: "만들기",
close: "닫기",
},
tags: {
add: "태그 추가…",
newPlaceholder: "새 태그 이름",
assignTo: (n: number, tag: string) => `${n}개 항목에 "${tag}" 태그 추가됨`,
removedFrom: (n: number, tag: string) => `${n}개 항목에서 "${tag}" 태그 제거됨`,
deleteConfirm: (tag: string) => `태그 "${tag}"를 삭제할까요? (파일은 삭제되지 않음)`,
exported: (path: string) => `태그를 내보냈습니다: ${path}`,
exportAction: "태그 JSON 내보내기",
},
ops: {
moveTo: "폴더로 이동…",
moveDone: (n: number) => `${n}개 이동 완료`,
moveFailed: (n: number) => `${n}개 이동 실패`,
trashConfirm: (n: number) => `${n}개 항목을 휴지통으로 보낼까요?`,
trashDone: (n: number) => `${n}개 항목을 휴지통으로 보냄`,
renamePrompt: "새 이름",
undone: (n: number) => `${n}개 작업을 되돌렸습니다`,
nothingToUndo: "되돌릴 작업이 없습니다",
inProgress: (done: number, total: number) => `처리 중 ${done}/${total}`,
},
smart: {
create: "스마트 폴더 만들기",
name: "이름",
onlyImages: "사진만",
onlyVideos: "영상만",
untagged: "태그 없는 항목",
withTags: "선택한 태그 포함",
},
remote: {
add: "네트워크 소스",
title: "네트워크 소스 연결",
name: "이름",
namePlaceholder: "예: 시놀로지 사진",
host: "호스트",
port: "포트",
url: "WebDAV URL",
username: "사용자",
password: "비밀번호",
basePath: "기준 경로",
connect: "연결 및 스캔",
connecting: "연결 중…",
indexing: "인덱싱 중…",
indexed: (n: number) => `${n.toLocaleString("ko-KR")}개 파일 인덱싱됨`,
credNote: "비밀번호는 OS 키체인에 저장됩니다",
},
dedupe: {
open: "중복 찾기",
title: "중복 항목 검토",
scan: "중복 탐지 시작",
starting: "탐지 중…",
scanning: (phase: string, cur: number, total: number) => {
const labels: Record<string, string> = {
exact: "완전 동일 검사",
image: "유사 이미지 검사",
video: "유사 영상 검사",
};
return `${labels[phase] ?? phase} ${cur.toLocaleString("ko-KR")}/${total.toLocaleString("ko-KR")}`;
},
threshold: "유사도 임계값",
includeVideo: "영상 포함",
empty: "중복 항목이 없습니다. 상단의 '중복 탐지 시작'을 눌러 검사하세요.",
groupCount: (n: number) => `${n}개 그룹`,
memberCount: (n: number) => `${n}개 항목`,
keeper: "보관",
keepBest: "최적 보관 (나머지 휴지통)",
keepAll: "모두 보관 (무시)",
trashLosersConfirm: (n: number) => `보관 항목을 제외한 ${n}개를 휴지통으로 보낼까요?`,
},
} as const;
export type Ko = typeof ko;
+400
View File
@@ -0,0 +1,400 @@
// 유일한 RPC 경계 — Rust 커맨드 호출은 전부 이 모듈을 통해서만 한다.
import { invoke, Channel } from "@tauri-apps/api/core";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
export type { UnlistenFn };
export interface AppInfo {
version: string;
dataDir: string;
portable: boolean;
mediaPort: number;
mediaToken: string;
}
export interface Source {
id: number;
kind: string;
name: string;
root: string;
fileCount: number;
}
export interface FolderNode {
id: number;
parentId: number | null;
name: string;
fileCount: number;
}
export interface FileDetails {
id: number;
name: string;
dir: string;
size: number;
mtimeMs: number;
width: number | null;
height: number | null;
durationMs: number | null;
kind: number;
takenAt: number | null;
}
export interface ScanProgress {
scanId: number;
phase: "enumerate" | "metadata" | "done";
seen: number;
currentDir: string;
done: boolean;
}
export const appInfo = () => invoke<AppInfo>("app_info");
export const listSources = () => invoke<Source[]>("list_sources");
export const addLocalSource = (path: string) =>
invoke<Source>("add_local_source", { path });
export const cancelScan = () => invoke<void>("cancel_scan");
export const folderTree = (sourceId: number) =>
invoke<FolderNode[]>("folder_tree", { sourceId });
export const fileDetails = (ids: number[]) =>
invoke<FileDetails[]>("file_details", { ids });
export const setThumbPriority = (ids: number[]) =>
invoke<void>("set_thumb_priority", { ids });
export function startScan(
sourceId: number,
onProgress: (p: ScanProgress) => void,
): Promise<void> {
const ch = new Channel<ScanProgress>();
ch.onmessage = onProgress;
return invoke<void>("start_scan", { sourceId, onProgress: ch });
}
/** 스냅샷: 항목당 10바이트 packed 바이너리 (id u32, w u16, h u16, kind u8, flags u8) */
export const ITEM_BYTES = 10;
export class Snapshot {
private view: DataView;
readonly count: number;
constructor(buf: ArrayBuffer) {
this.view = new DataView(buf);
this.count = Math.floor(buf.byteLength / ITEM_BYTES);
}
id(i: number): number {
return this.view.getUint32(i * ITEM_BYTES, true);
}
width(i: number): number {
return this.view.getUint16(i * ITEM_BYTES + 4, true);
}
height(i: number): number {
return this.view.getUint16(i * ITEM_BYTES + 6, true);
}
kind(i: number): number {
return this.view.getUint8(i * ITEM_BYTES + 8);
}
hasThumb(i: number): boolean {
return (this.view.getUint8(i * ITEM_BYTES + 9) & 1) !== 0;
}
/** 레이아웃 워커 입력용 종횡비 배열 (미지 치수는 1:1) */
aspectRatios(): Float32Array {
const out = new Float32Array(this.count);
for (let i = 0; i < this.count; i++) {
const w = this.width(i);
const h = this.height(i);
out[i] = w > 0 && h > 0 ? w / h : 1;
}
return out;
}
}
export async function folderSnapshot(args: {
sourceId?: number;
folderId?: number;
recursive?: boolean;
}): Promise<Snapshot> {
const buf = await invoke<ArrayBuffer>("folder_snapshot", {
sourceId: args.sourceId ?? null,
folderId: args.folderId ?? null,
recursive: args.recursive ?? true,
});
return new Snapshot(buf);
}
export async function searchSnapshot(query: string): Promise<Snapshot> {
const buf = await invoke<ArrayBuffer>("search_snapshot", { query });
return new Snapshot(buf);
}
// ── 태그 ────────────────────────────────────────────────
export interface Tag {
id: number;
name: string;
color: string | null;
fileCount: number;
}
export interface TagOpResult {
batchId: string;
count: number;
}
export const listTags = () => invoke<Tag[]>("list_tags");
export const createTag = (name: string, color?: string) =>
invoke<number>("create_tag", { name, color: color ?? null });
export const renameTag = (tagId: number, name: string) =>
invoke<void>("rename_tag", { tagId, name });
export const deleteTag = (tagId: number) => invoke<void>("delete_tag", { tagId });
export const assignTags = (fileIds: number[], tagId: number) =>
invoke<TagOpResult>("assign_tags", { fileIds, tagId });
export const unassignTags = (fileIds: number[], tagId: number) =>
invoke<TagOpResult>("unassign_tags", { fileIds, tagId });
export const fileTagsOf = (fileId: number) => invoke<Tag[]>("file_tags_of", { fileId });
export const exportTags = (path?: string) =>
invoke<string>("export_tags", { path: path ?? null });
export async function tagSnapshot(tagId: number): Promise<Snapshot> {
return new Snapshot(await invoke<ArrayBuffer>("tag_snapshot", { tagId }));
}
// ── 파일 작업 ───────────────────────────────────────────
export interface OpSummary {
done: number;
skipped: number;
failed: number;
batchId: string;
}
export interface OpProgress {
done: number;
total: number;
}
export function moveFiles(
fileIds: number[],
destFolderId: number,
onProgress?: (p: OpProgress) => void,
): Promise<OpSummary> {
const ch = new Channel<OpProgress>();
ch.onmessage = onProgress ?? (() => {});
return invoke<OpSummary>("move_files", { fileIds, destFolderId, onProgress: ch });
}
export const renameFile = (fileId: number, newName: string) =>
invoke<void>("rename_file", { fileId, newName });
export function trashFiles(
fileIds: number[],
onProgress?: (p: OpProgress) => void,
): Promise<OpSummary> {
const ch = new Channel<OpProgress>();
ch.onmessage = onProgress ?? (() => {});
return invoke<OpSummary>("trash_files", { fileIds, onProgress: ch });
}
export const undoLast = () => invoke<OpSummary | null>("undo_last");
// ── 스마트 폴더 ─────────────────────────────────────────
export interface SmartQuery {
kind?: number | null;
tagIds?: number[];
untagged?: boolean;
}
export interface SmartFolder {
id: number;
name: string;
query: SmartQuery;
}
export const listSmartFolders = () => invoke<SmartFolder[]>("list_smart_folders");
export const createSmartFolder = (name: string, query: SmartQuery) =>
invoke<number>("create_smart_folder", { name, query });
export const deleteSmartFolder = (id: number) =>
invoke<void>("delete_smart_folder", { id });
export async function smartSnapshot(id: number): Promise<Snapshot> {
return new Snapshot(await invoke<ArrayBuffer>("smart_snapshot", { id }));
}
// ── 원격 소스 ───────────────────────────────────────────
export interface RemoteInput {
kind: "sftp" | "ftp" | "webdav";
name: string;
host: string;
port?: number;
username: string;
password: string;
basePath?: string;
url?: string;
}
export const addRemoteSource = (input: RemoteInput) =>
invoke<number>("add_remote_source", { input });
export const connectRemoteSource = (sourceId: number) =>
invoke<boolean>("connect_remote_source", { sourceId });
export const scanRemoteSource = (sourceId: number) =>
invoke<number>("scan_remote_source", { sourceId });
export const trashRemoteFiles = (sourceId: number, fileIds: number[]) =>
invoke<OpSummary>("trash_remote_files", { sourceId, fileIds });
export const unlockVault = (passphrase: string) =>
invoke<void>("unlock_vault", { passphrase });
// ── 중복 탐지 ───────────────────────────────────────────
export interface DedupeProgress {
phase: "exact" | "image" | "video" | "done";
current: number;
total: number;
}
export interface DupeMember {
fileId: number;
name: string;
dir: string;
size: number;
width: number | null;
height: number | null;
durationMs: number | null;
kind: number;
isKeeper: boolean;
distance: number | null;
}
export interface DupeGroup {
id: number;
kind: number; // 0 exact, 1 image, 2 video
members: DupeMember[];
}
export function startDedupe(
args: { sourceId?: number; imageThreshold?: number; includeVideo?: boolean },
onProgress: (p: DedupeProgress) => void,
): Promise<void> {
const ch = new Channel<DedupeProgress>();
ch.onmessage = onProgress;
return invoke<void>("start_dedupe", {
sourceId: args.sourceId ?? null,
imageThreshold: args.imageThreshold ?? 5,
includeVideo: args.includeVideo ?? false,
onProgress: ch,
});
}
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 });
export const dismissDupePair = (fileA: number, fileB: number) =>
invoke<void>("dismiss_dupe_pair", { fileA, fileB });
export const onDedupeDone = (cb: () => void): Promise<UnlistenFn> =>
listen("dedupe-done", () => cb());
// ── 재생 ────────────────────────────────────────────────
export interface PlaybackInfo {
ext: string | null;
vcodec: string | null;
acodec: string | null;
durationMs: number | null;
width: number | null;
height: number | null;
streamAvailable: boolean;
}
export type VideoMode = "copy" | "copyhvc1" | "transcode";
export type AudioMode = "copy" | "aac";
export interface StreamStart {
jobId: number;
url: string;
}
export const playbackInfo = (fileId: number) =>
invoke<PlaybackInfo>("playback_info", { fileId });
export const startStream = (
fileId: number,
startSeconds: number,
video: VideoMode,
audio: AudioMode,
) => invoke<StreamStart>("start_stream", { fileId, startSeconds, video, audio });
export const stopStream = (jobId: number) => invoke<void>("stop_stream", { jobId });
// ── 재생 결정 로직 ──────────────────────────────────────
const V_MIME: Record<string, string> = {
h264: 'video/mp4; codecs="avc1.640028"',
hevc: 'video/mp4; codecs="hvc1.1.6.L120.90"',
vp9: 'video/webm; codecs="vp09.00.40.08"',
vp8: 'video/webm; codecs="vp8"',
av1: 'video/mp4; codecs="av01.0.08M.08"',
};
const A_MIME: Record<string, string> = {
aac: 'audio/mp4; codecs="mp4a.40.2"',
mp3: 'audio/mpeg',
opus: 'audio/webm; codecs="opus"',
vorbis: 'audio/webm; codecs="vorbis"',
flac: 'audio/mp4; codecs="flac"',
};
const probe = document.createElement("video");
const canPlay = (mime: string | undefined): boolean =>
!!mime && probe.canPlayType(mime) !== "";
const DIRECT_CONTAINERS = new Set(["mp4", "m4v", "mov", "webm"]);
/** fMP4 컨테이너에 카피해도 안전한 오디오 */
const MP4_SAFE_AUDIO = new Set(["aac", "mp3"]);
export type PlaybackPlan =
| { mode: "direct" }
| { mode: "stream"; video: VideoMode; audio: AudioMode }
| { mode: "unsupported"; reason: string };
export function decidePlayback(info: PlaybackInfo): PlaybackPlan {
const v = info.vcodec ?? "";
const a = info.acodec;
const vOk = canPlay(V_MIME[v]);
const aOk = !a || canPlay(A_MIME[a]);
const containerOk = DIRECT_CONTAINERS.has(info.ext ?? "");
if (containerOk && vOk && aOk) return { mode: "direct" };
if (!info.streamAvailable) {
return {
mode: "unsupported",
reason: `이 형식(${info.ext}/${v || "?"})은 ffmpeg 사이드카 없이 재생할 수 없습니다`,
};
}
// 영상: H.264(및 재생 가능한 HEVC)만 카피, 그 외 트랜스코딩
const videoMode: VideoMode =
v === "h264" && canPlay(V_MIME.h264)
? "copy"
: v === "hevc" && canPlay(V_MIME.hevc)
? "copyhvc1"
: "transcode";
const audioMode: AudioMode = !a || MP4_SAFE_AUDIO.has(a) ? "copy" : "aac";
return { mode: "stream", video: videoMode, audio: audioMode };
}
// ── 이벤트 ──────────────────────────────────────────────
export const onThumbsReady = (cb: (ids: number[]) => void): Promise<UnlistenFn> =>
listen<number[]>("thumbs-ready", (e) => cb(e.payload));
export const onLibraryChanged = (cb: (sourceId: number) => void): Promise<UnlistenFn> =>
listen<number>("library-changed", (e) => cb(e.payload));
// ── 미디어 URL ──────────────────────────────────────────
export function thumbUrl(info: AppInfo, fileId: number, sizeClass: 0 | 1 = 0): string {
return `http://127.0.0.1:${info.mediaPort}/thumb/${info.mediaToken}/${fileId}?s=${sizeClass}`;
}
export function mediaUrl(info: AppInfo, fileId: number): string {
return `http://127.0.0.1:${info.mediaPort}/media/${info.mediaToken}/${fileId}`;
}
+6
View File
@@ -0,0 +1,6 @@
/* @refresh reload */
import { render } from "solid-js/web";
import App from "./App";
import "./styles/app.css";
render(() => <App />, document.getElementById("root")!);
+78
View File
@@ -0,0 +1,78 @@
import { createResource, createSignal, onCleanup, Show, type Component } from "solid-js";
import { open } from "@tauri-apps/plugin-dialog";
import { ko } from "../i18n/ko";
import { JustifiedGrid } from "../features/grid/JustifiedGrid";
import { refreshSidebar, setView, snapshot, updateScanProgress, view } from "../state/store";
import { addLocalSource, listSources, startScan } from "../ipc/commands";
import { openRemoteDialog } from "../features/sources/RemoteDialog";
export const ContentArea: Component = () => {
const [q, setQ] = createSignal("");
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);
};
onCleanup(() => clearTimeout(debounce));
// 소스가 하나도 없으면 온보딩 표시
const [sources] = createResource(listSources);
const isEmpty = () => sources() && sources()!.length === 0;
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);
};
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>
</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>
</div>
<div class="mt-1 text-[11px] text-[var(--text-muted)]">{ko.onboarding.hint}</div>
</div>
</div>
</Show>
</main>
);
};
+165
View File
@@ -0,0 +1,165 @@
import { createResource, createSignal, For, Show, type Component } from "solid-js";
import { ko } from "../i18n/ko";
import {
assignTags,
createTag,
fileDetails,
fileTagsOf,
listTags,
unassignTags,
type FileDetails,
} from "../ipc/commands";
import { refreshSidebar, selected, toast, view } from "../state/store";
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): string {
if (!ms) return "—";
return new Date(ms).toLocaleString("ko-KR");
}
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}
</div>
</div>
);
/** 선택 항목 태그 편집기 (단일/다중 선택 공용) */
const TagEditor: Component = () => {
const [tagVersion, setTagVersion] = createSignal(0);
const singleId = () => {
const s = selected();
return s.size === 1 ? [...s][0] : undefined;
};
const [currentTags] = createResource(
() => (singleId() != null ? `${singleId()}:${tagVersion()}` : undefined),
async () => fileTagsOf(singleId()!),
);
const [allTags] = createResource(
() => tagVersion(),
() => listTags(),
);
const addTag = async (name: string) => {
const trimmed = name.trim();
if (!trimmed) return;
const ids = [...selected()];
if (ids.length === 0) return;
try {
const existing = (allTags() ?? []).find((t) => t.name === trimmed);
const tagId = existing ? existing.id : await createTag(trimmed);
const r = await assignTags(ids, tagId);
toast(ko.tags.assignTo(r.count, trimmed));
setTagVersion((v) => v + 1);
refreshSidebar();
} catch (e) {
toast(String(e));
}
};
const removeTag = async (tagId: number, name: string) => {
const ids = [...selected()];
const r = await unassignTags(ids, tagId);
toast(ko.tags.removedFrom(r.count, name));
setTagVersion((v) => v + 1);
refreshSidebar();
};
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}>
<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)}
>
×
</button>
</span>
)}
</For>
</Show>
</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"
onKeyDown={(e) => {
if (e.key === "Enter") {
void addTag(e.currentTarget.value);
e.currentTarget.value = "";
}
}}
/>
<datalist id="all-tags-datalist">
<For each={allTags()}>{(t) => <option value={t.name} />}</For>
</datalist>
</div>
);
};
export const DetailsPanel: Component = () => {
const singleId = () => {
const s = selected();
return s.size === 1 ? [...s][0] : undefined;
};
const [details] = createResource(singleId, async (id) => {
const list = await fileDetails([id]);
return list[0] as FileDetails | undefined;
});
// 뷰 변경 시 details 리소스가 재평가되도록 view 추적
void view;
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)}`}
/>
</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>
</aside>
);
};
+378
View File
@@ -0,0 +1,378 @@
import {
createResource,
createSignal,
For,
Show,
type Component,
} from "solid-js";
import { open } from "@tauri-apps/plugin-dialog";
import { ask } from "@tauri-apps/plugin-dialog";
import { ko } from "../i18n/ko";
import {
addLocalSource,
createSmartFolder,
createTag,
deleteSmartFolder,
deleteTag,
exportTags,
folderTree,
listSmartFolders,
listSources,
listTags,
startScan,
type FolderNode,
type Source,
} from "../ipc/commands";
import {
openDedupe,
refreshSidebar,
refreshSnapshot,
setView,
sidebarVersion,
toast,
updateScanProgress,
view,
} from "../state/store";
import { performMove } from "../features/fileops/MoveDialog";
import { openRemoteDialog } from "../features/sources/RemoteDialog";
const SectionHeader: Component<{ label: string; action?: () => void; actionLabel?: string }> = (
props,
) => (
<div class="flex items-center justify-between px-3 pt-4 pb-1.5">
<span class="text-[11px] font-semibold tracking-wide text-[var(--text-muted)]">
{props.label}
</span>
<Show when={props.action}>
<button
class="rounded px-1 text-[13px] leading-none text-[var(--text-muted)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]"
onClick={props.action}
title={props.actionLabel}
>
+
</button>
</Show>
</div>
);
interface TreeNode extends FolderNode {
children: TreeNode[];
}
function buildTree(nodes: FolderNode[]): TreeNode[] {
const map = new Map<number, TreeNode>();
const roots: TreeNode[] = [];
for (const n of nodes) map.set(n.id, { ...n, children: [] });
for (const n of map.values()) {
if (n.parentId != null && map.has(n.parentId)) map.get(n.parentId)!.children.push(n);
else roots.push(n);
}
return roots;
}
const FolderRow: Component<{ node: TreeNode; depth: number }> = (props) => {
const [expanded, setExpanded] = createSignal(props.depth === 0);
const [dragOver, setDragOver] = createSignal(false);
const isActive = () => {
const v = view();
return v.type === "folder" && v.folderId === props.node.id;
};
return (
<>
<div
class="flex cursor-pointer items-center gap-1 rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
classList={{
"bg-[var(--bg-active)]": isActive(),
"outline outline-1 outline-[var(--accent)]": dragOver(),
}}
style={{ "padding-left": `${8 + props.depth * 14}px` }}
onClick={() => void setView({ type: "folder", folderId: props.node.id, recursive: true })}
onDragOver={(e) => {
if (e.dataTransfer?.types.includes("application/x-archive-move")) {
e.preventDefault();
setDragOver(true);
}
}}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
void performMove(props.node.id).catch((err) => toast(String(err)));
}}
>
<span
class="w-3 shrink-0 text-[var(--text-muted)]"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded());
}}
>
<Show when={props.node.children.length > 0}>{expanded() ? "▾" : "▸"}</Show>
</span>
<span class="truncate text-[var(--text-primary)]">{props.node.name}</span>
<span class="ml-auto shrink-0 text-[10px] text-[var(--text-muted)]">
{props.node.fileCount > 0 ? props.node.fileCount.toLocaleString("ko-KR") : ""}
</span>
</div>
<Show when={expanded()}>
<For each={props.node.children}>
{(c) => <FolderRow node={c} depth={props.depth + 1} />}
</For>
</Show>
</>
);
};
const SourceRow: Component<{ source: Source }> = (props) => {
const [expanded, setExpanded] = createSignal(false);
const [tree] = createResource(
() => (expanded() ? `${props.source.id}:${sidebarVersion()}` : undefined),
async () => buildTree(await folderTree(props.source.id)),
);
const isActive = () => {
const v = view();
return v.type === "source" && v.sourceId === props.source.id;
};
return (
<div>
<div
class="flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
classList={{ "bg-[var(--bg-active)]": isActive() }}
onClick={() => void setView({ type: "source", sourceId: props.source.id })}
>
<span
class="w-3 shrink-0 text-[var(--text-muted)]"
onClick={(e) => {
e.stopPropagation();
setExpanded(!expanded());
}}
>
{expanded() ? "▾" : "▸"}
</span>
<span class="truncate font-medium text-[var(--text-primary)]" title={props.source.root}>
{props.source.name}
</span>
<span class="ml-auto shrink-0 text-[10px] text-[var(--text-muted)]">
{props.source.fileCount.toLocaleString("ko-KR")}
</span>
</div>
<Show when={expanded() && tree()}>
<For each={tree()}>{(n) => <FolderRow node={n} depth={1} />}</For>
</Show>
</div>
);
};
export const Sidebar: Component = () => {
const [sources, { refetch }] = createResource(
() => sidebarVersion(),
() => listSources(),
);
const [tags] = createResource(
() => sidebarVersion(),
() => listTags(),
);
const [smartFolders] = createResource(
() => sidebarVersion(),
() => listSmartFolders(),
);
const [newTagMode, setNewTagMode] = createSignal(false);
const onAddSource = async () => {
const dir = await open({ directory: true, multiple: false, title: ko.sidebar.addSource });
if (typeof dir !== "string") return;
try {
const src = await addLocalSource(dir);
void refetch();
await startScan(src.id, updateScanProgress);
} catch (e) {
toast(String(e));
}
};
const onCreateTag = async (name: string) => {
setNewTagMode(false);
const trimmed = name.trim();
if (!trimmed) return;
try {
await createTag(trimmed);
refreshSidebar();
} catch (e) {
toast(String(e));
}
};
const onDeleteTag = async (id: number, name: string) => {
const yes = await ask(ko.tags.deleteConfirm(name), { title: "Archive", kind: "warning" });
if (!yes) return;
await deleteTag(id);
refreshSidebar();
if (view().type === "tag") void setView({ type: "all" });
};
const onCreateSmart = async (name: string, query: Parameters<typeof createSmartFolder>[1]) => {
await createSmartFolder(name, query);
refreshSidebar();
};
return (
<aside class="flex min-h-0 flex-col overflow-y-auto border-r border-[var(--border)] bg-[var(--bg-panel)] pb-4">
<div class="flex items-center justify-between pr-2">
<SectionHeader label={ko.sidebar.sources} />
<div class="mt-2 flex gap-1">
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[11px] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={() => void onAddSource()}
>
+ {ko.sidebar.addSource}
</button>
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[11px] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={openRemoteDialog}
>
+ {ko.remote.add}
</button>
</div>
</div>
<div
class="mx-1 cursor-pointer rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
classList={{ "bg-[var(--bg-active)]": view().type === "all" }}
onClick={() => void setView({ type: "all" })}
>
</div>
<Show
when={sources() && sources()!.length > 0}
fallback={
<div class="whitespace-pre-line px-3 py-2 text-xs leading-5 text-[var(--text-muted)]">
{ko.sidebar.noSources}
</div>
}
>
<div class="mx-1">
<For each={sources()}>{(s) => <SourceRow source={s} />}</For>
</div>
</Show>
{/* 태그 */}
<SectionHeader label={ko.sidebar.tags} action={() => setNewTagMode(true)} actionLabel={ko.tags.newPlaceholder} />
<Show when={newTagMode()}>
<input
class="mx-3 mb-1 rounded border border-[var(--border)] bg-[var(--bg-app)] px-2 py-1 text-xs outline-none focus:border-[var(--accent)]"
placeholder={ko.tags.newPlaceholder}
ref={(el) => queueMicrotask(() => el.focus())}
onKeyDown={(e) => {
if (e.key === "Enter") void onCreateTag(e.currentTarget.value);
if (e.key === "Escape") setNewTagMode(false);
}}
onBlur={() => setNewTagMode(false)}
/>
</Show>
<div class="mx-1">
<For each={tags()}>
{(t) => (
<div
class="group flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
classList={{
"bg-[var(--bg-active)]": view().type === "tag" && (view() as { tagId: number }).tagId === t.id,
}}
onClick={() => void setView({ type: "tag", tagId: t.id })}
>
<span class="text-[var(--accent)]">#</span>
<span class="truncate">{t.name}</span>
<span class="ml-auto text-[10px] text-[var(--text-muted)]">
{t.fileCount.toLocaleString("ko-KR")}
</span>
<button
class="hidden rounded px-1 text-[10px] text-[var(--text-muted)] hover:text-[var(--danger)] group-hover:block"
onClick={(e) => {
e.stopPropagation();
void onDeleteTag(t.id, t.name);
}}
>
</button>
</div>
)}
</For>
</div>
<Show when={(tags()?.length ?? 0) > 0}>
<button
class="mx-3 mt-1 self-start rounded border border-[var(--border)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] hover:bg-[var(--bg-hover)]"
onClick={() =>
void exportTags().then((p) => toast(ko.tags.exported(p))).catch((e) => toast(String(e)))
}
>
{ko.tags.exportAction}
</button>
</Show>
{/* 스마트 폴더 */}
<SectionHeader label={ko.sidebar.smartFolders} />
<div class="mx-1">
<For each={smartFolders()}>
{(sf) => (
<div
class="group flex cursor-pointer items-center gap-1.5 rounded px-2 py-1 text-xs hover:bg-[var(--bg-hover)]"
classList={{
"bg-[var(--bg-active)]":
view().type === "smart" && (view() as { smartId: number }).smartId === sf.id,
}}
onClick={() => void setView({ type: "smart", smartId: sf.id })}
>
<span class="text-[var(--text-muted)]"></span>
<span class="truncate">{sf.name}</span>
<button
class="ml-auto hidden rounded px-1 text-[10px] text-[var(--text-muted)] hover:text-[var(--danger)] group-hover:block"
onClick={(e) => {
e.stopPropagation();
void deleteSmartFolder(sf.id).then(refreshSidebar);
}}
>
</button>
</div>
)}
</For>
<Show when={(smartFolders()?.length ?? 0) === 0}>
<div class="flex flex-wrap gap-1 px-2 py-1">
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] hover:bg-[var(--bg-hover)]"
onClick={() => void onCreateSmart(ko.smart.onlyVideos, { kind: 1 })}
>
+ {ko.smart.onlyVideos}
</button>
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] hover:bg-[var(--bg-hover)]"
onClick={() => void onCreateSmart(ko.smart.onlyImages, { kind: 0 })}
>
+ {ko.smart.onlyImages}
</button>
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[10px] text-[var(--text-muted)] hover:bg-[var(--bg-hover)]"
onClick={() => void onCreateSmart(ko.smart.untagged, { untagged: true })}
>
+ {ko.smart.untagged}
</button>
</div>
</Show>
</div>
<SectionHeader label={ko.sidebar.dedupe} />
<button
class="mx-3 mt-1 self-start rounded border border-[var(--border)] px-2 py-1 text-[11px] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={openDedupe}
>
{ko.dedupe.open}
</button>
</aside>
);
};
// 스캔 완료 후 사이드바+그리드 새로고침을 위해 외부에서 호출
export function onLibraryChangedRefresh(): void {
refreshSidebar();
void refreshSnapshot();
}
+40
View File
@@ -0,0 +1,40 @@
import { Show, type Component } from "solid-js";
import { ko } from "../i18n/ko";
import { info, scanProgress, selected, snapshot, statusMessage } from "../state/store";
export const StatusBar: Component = () => {
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")}
</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>
</>
)}
</Show>
</footer>
);
};
+176
View File
@@ -0,0 +1,176 @@
// 전역 상태 — Solid 시그널 기반 단일 스토어 모듈.
import { createSignal } from "solid-js";
import { createStore, produce } from "solid-js/store";
import {
appInfo,
folderSnapshot,
searchSnapshot,
smartSnapshot,
tagSnapshot,
Snapshot,
type AppInfo,
type ScanProgress,
} from "../ipc/commands";
// ── 앱 정보 (미디어 서버 포트/토큰 포함) ─────────────────
const [info, setInfo] = createSignal<AppInfo | undefined>();
export { info };
export async function loadAppInfo(): Promise<void> {
try {
setInfo(await appInfo());
} catch (e) {
console.error("app_info 실패", e);
}
}
// ── 현재 뷰 ─────────────────────────────────────────────
export type View =
| { type: "all" }
| { type: "source"; sourceId: number }
| { type: "folder"; folderId: number; recursive: boolean }
| { type: "search"; query: string }
| { type: "tag"; tagId: number }
| { type: "smart"; smartId: number };
const [view, setViewSignal] = createSignal<View>({ type: "all" });
const [snapshot, setSnapshot] = createSignal<Snapshot | undefined>();
const [loading, setLoading] = createSignal(false);
export { view, snapshot, loading };
let viewSeq = 0;
export async function setView(v: View): Promise<void> {
setViewSignal(v);
clearSelection();
await refreshSnapshot();
}
export async function refreshSnapshot(): Promise<void> {
const v = view();
const seq = ++viewSeq;
setLoading(true);
try {
let snap: Snapshot;
switch (v.type) {
case "all":
snap = await folderSnapshot({});
break;
case "source":
snap = await folderSnapshot({ sourceId: v.sourceId });
break;
case "folder":
snap = await folderSnapshot({ folderId: v.folderId, recursive: v.recursive });
break;
case "search":
snap = await searchSnapshot(v.query);
break;
case "tag":
snap = await tagSnapshot(v.tagId);
break;
case "smart":
snap = await smartSnapshot(v.smartId);
break;
}
if (seq === viewSeq) setSnapshot(snap);
} finally {
if (seq === viewSeq) setLoading(false);
}
}
// ── 선택 ────────────────────────────────────────────────
const [selected, setSelected] = createSignal<ReadonlySet<number>>(new Set());
const [anchorIndex, setAnchorIndex] = createSignal<number>(-1);
export { selected, anchorIndex };
export function selectSingle(id: number, index: number): void {
setSelected(new Set([id]));
setAnchorIndex(index);
}
export function toggleSelect(id: number, index: number): void {
const next = new Set<number>(selected());
if (next.has(id)) next.delete(id);
else next.add(id);
setSelected(next);
setAnchorIndex(index);
}
export function selectRange(snap: Snapshot, toIndex: number): void {
const from = anchorIndex() < 0 ? toIndex : anchorIndex();
const [a, b] = from <= toIndex ? [from, toIndex] : [toIndex, from];
const next = new Set<number>();
for (let i = a; i <= b; i++) next.add(snap.id(i));
setSelected(next);
}
export function clearSelection(): void {
setSelected(new Set<number>());
setAnchorIndex(-1);
}
// ── 썸네일 갱신 버전 (thumbs-ready 이벤트로 증가) ────────
const [thumbVersions, setThumbVersions] = createStore<Record<number, number>>({});
export { thumbVersions };
export function bumpThumbVersions(ids: number[]): void {
setThumbVersions(
produce((s) => {
for (const id of ids) s[id] = (s[id] ?? 0) + 1;
}),
);
}
// ── 스캔 진행 ───────────────────────────────────────────
const [scanProgress, setScanProgress] = createSignal<ScanProgress | undefined>();
export { scanProgress };
export function updateScanProgress(p: ScanProgress): void {
setScanProgress(p.done ? undefined : p);
}
// ── 사이드바 새로고침 트리거 ─────────────────────────────
const [sidebarVersion, setSidebarVersion] = createSignal(0);
export { sidebarVersion };
export const refreshSidebar = () => setSidebarVersion((v) => v + 1);
// ── 중복 검토 패널 ───────────────────────────────────────
const [dedupeOpen, setDedupeOpen] = createSignal(false);
export { dedupeOpen };
export const openDedupe = () => setDedupeOpen(true);
export const closeDedupe = () => setDedupeOpen(false);
// ── 상태 메시지 (토스트) ─────────────────────────────────
const [statusMessage, setStatusMessage] = createSignal<string | undefined>();
export { statusMessage };
let toastTimer: ReturnType<typeof setTimeout> | undefined;
export function toast(msg: string): void {
setStatusMessage(msg);
clearTimeout(toastTimer);
toastTimer = setTimeout(() => setStatusMessage(undefined), 4000);
}
// ── 라이트박스 ──────────────────────────────────────────
const [lightboxIndex, setLightboxIndex] = createSignal<number | null>(null);
export { lightboxIndex };
export function openLightbox(index: number): void {
const snap = snapshot();
if (!snap || index < 0 || index >= snap.count) return;
setLightboxIndex(index);
}
export function closeLightbox(): void {
setLightboxIndex(null);
}
export function navLightbox(delta: number): void {
const snap = snapshot();
const cur = lightboxIndex();
if (!snap || cur == null) return;
const next = Math.min(snap.count - 1, Math.max(0, cur + delta));
setLightboxIndex(next);
// 라이트박스 이동 시 선택도 따라간다
selectSingle(snap.id(next), next);
}
+58
View File
@@ -0,0 +1,58 @@
@import "tailwindcss";
/* 다크 테마 팔레트 — 모든 색은 변수로만 사용한다 */
:root {
--bg-app: #1b1d21;
--bg-panel: #232529;
--bg-panel-raised: #2a2d33;
--bg-hover: #33363d;
--bg-active: #3b3f48;
--border: #34373e;
--border-strong: #45494f;
--text-primary: #e6e8eb;
--text-secondary: #a3a8b0;
--text-muted: #6f747d;
--accent: #4f8cff;
--accent-soft: rgba(79, 140, 255, 0.18);
--danger: #ff5f57;
--grid-cell-bg: #2a2d33;
color-scheme: dark;
}
html,
body,
#root {
height: 100%;
margin: 0;
overflow: hidden;
}
body {
background: var(--bg-app);
color: var(--text-primary);
font-family:
"Pretendard Variable", Pretendard, -apple-system, BlinkMacSystemFont,
"Segoe UI", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif;
font-size: 13px;
user-select: none;
-webkit-user-select: none;
}
/* 스크롤바 (WebView2/WKWebView 공통) */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 5px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background-color: var(--text-muted);
}
::-webkit-scrollbar-track {
background: transparent;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />