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