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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user