- 그리드 썸네일 줌: Ctrl+휠 + 툴바 슬라이더(110~420px), localStorage 유지 - 라이트박스 사진 줌/팬: 휠(커서 기준)·드래그·더블클릭·+/-/0 키, 파일 이동 시 리셋, 확대율 배지 - 영상 재생목록: 종료 시 다음 항목 자동재생, 🔁 반복 토글 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
208 lines
7.2 KiB
TypeScript
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);
|
|
}
|