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
+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>
);
};