Files
Archive/src/state/store.ts
T
강 한andClaude Opus 4.8 dd9d6bfa0c 그리드 줌 · 사진 줌/팬 · 영상 재생목록 추가
- 그리드 썸네일 줌: Ctrl+휠 + 툴바 슬라이더(110~420px), localStorage 유지
- 라이트박스 사진 줌/팬: 휠(커서 기준)·드래그·더블클릭·+/-/0 키,
  파일 이동 시 리셋, 확대율 배지
- 영상 재생목록: 종료 시 다음 항목 자동재생, 🔁 반복 토글

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 05:46:48 +09:00

208 lines
7.2 KiB
TypeScript

// 전역 상태 — 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);
}
/** 마퀴(드래그 사각형) 선택 — id 집합으로 직접 설정 */
export function setSelectionFromIds(ids: number[]): void {
setSelected(new Set<number>(ids));
}
// ── 썸네일 갱신 버전 (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);
// ── 그리드 썸네일 크기(줌) ───────────────────────────────
export const GRID_ROW_MIN = 110;
export const GRID_ROW_MAX = 420;
const GRID_ROW_KEY = "archive.grid.rowHeight";
const initialRow = Number(localStorage.getItem(GRID_ROW_KEY) ?? "200");
const [gridRowHeight, setGridRowHeightRaw] = createSignal(
Number.isFinite(initialRow) ? Math.min(GRID_ROW_MAX, Math.max(GRID_ROW_MIN, initialRow)) : 200,
);
export { gridRowHeight };
export function setGridRowHeight(px: number): void {
const clamped = Math.min(GRID_ROW_MAX, Math.max(GRID_ROW_MIN, Math.round(px)));
setGridRowHeightRaw(clamped);
localStorage.setItem(GRID_ROW_KEY, String(clamped));
}
/** 상대 배율로 줌 (Ctrl+휠) */
export function zoomGrid(factor: number): void {
setGridRowHeight(gridRowHeight() * factor);
}
// ── 영상 재생목록 옵션 ───────────────────────────────────
const [repeatMode, setRepeatMode] = createSignal(false);
export { repeatMode };
export const toggleRepeat = () => setRepeatMode((r) => !r);
// ── 중복 검토 패널 ───────────────────────────────────────
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);
}