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