From 67d2ddc59e03a1968e85c9b3fc64f27fd29cd9eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B0=95=20=ED=95=9C?= Date: Mon, 20 Jul 2026 00:31:44 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8E=B8=EC=9D=98=20=EA=B8=B0=EB=8A=A5(?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=C2=B7=EC=A2=85=EB=A5=98=ED=95=84=ED=84=B0?= =?UTF-8?q?=C2=B7=EC=A0=84=EC=B2=B4=EC=84=A0=ED=83=9D/=EB=8B=A8=EC=B6=95?= =?UTF-8?q?=ED=82=A4=C2=B7=EC=A0=95=EB=B3=B4=20=EC=98=A4=EB=B2=84=EB=A0=88?= =?UTF-8?q?=EC=9D=B4)=20+=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EC=95=88=EC=A0=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Snapshot 접근자 범위 안전화(핵심 버그): - id/width/height/kind/hasThumb/thumbFailed가 count 밖 i에서 DataView RangeError를 던져 Solid 반응성 그래프 전체가 무너지던 문제 수정. 스냅샷이 줄어들 때(필터/검색/ 폴더 전환) For가 초과 셀을 제거하기 전에 셀 접근자가 옛 인덱스로 재실행되며 발생. 범위 밖이면 0/false 반환. (증상: 필터/검색이 한 번은 되고 이후 먹통) 기능: - 정렬: 촬영일/이름/크기/수정일 × 오름·내림 (툴바 드롭다운+방향 토글). backend snap_tail로 ORDER BY 구성, 4개 스냅샷 커맨드에 sort/kind 파라미터 추가. - 종류 필터: 전체/사진/영상 툴바 토글 (backend kind 필터). - 전체 선택(Ctrl+A)/선택 반전(Ctrl+Shift+A), ? 키 단축키 도움말 오버레이. - 썸네일 정보 오버레이(ⓘ 토글): 가시 셀에 파일명·크기 지연 로드 표시. 검증(사진120+영상25): 종류필터 120/25/145/120 반복 정상, 정렬 순서 변경, Ctrl+A 145개 선택, ? 오버레이, ⓘ로 파일명 표시 확인. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/commands.rs | 74 ++++++++++++++++++++++++----- src/App.tsx | 2 + src/features/grid/JustifiedGrid.tsx | 66 +++++++++++++++++++++++++ src/i18n/ko.ts | 32 +++++++++++++ src/ipc/commands.ts | 53 ++++++++++++++------- src/shell/ContentArea.tsx | 63 +++++++++++++++++++++++- src/shell/ShortcutsOverlay.tsx | 58 ++++++++++++++++++++++ src/state/store.ts | 55 ++++++++++++++++++--- 8 files changed, 367 insertions(+), 36 deletions(-) create mode 100644 src/shell/ShortcutsOverlay.tsx diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index cd17cd9..9ec020a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -373,7 +373,32 @@ fn pack_snapshot(rows: Vec<(i64, Option, Option, Option, i64, i64 type SnapRow = (i64, Option, Option, Option, i64, i64); const SNAP_COLS: &str = "f.id, f.width, f.height, f.orientation, f.kind, f.thumb_state"; -const SNAP_ORDER: &str = "ORDER BY COALESCE(f.taken_at, f.mtime_ms) DESC, f.id DESC"; + +/// 스냅샷 공통 옵션 — 종류 필터 + 정렬. 프론트 툴바에서 전달. +#[derive(Default)] +pub struct SnapOpts { + pub sort_key: Option, // taken(기본)|name|size|mtime + pub sort_desc: Option, // 기본 내림차순 + pub kind: Option, // 0=사진, 1=영상, 그 외/None=전체 +} + +/// WHERE 꼬리(종류 필터) + ORDER BY 절을 만든다. +/// sort_key/kind는 고정 집합에서만 해석하므로 SQL 주입 안전(kind는 i64 리터럴). +fn snap_tail(o: &SnapOpts) -> String { + let kind_clause = match o.kind { + Some(0) => " AND f.kind = 0", + Some(1) => " AND f.kind = 1", + _ => "", + }; + let dir = if o.sort_desc.unwrap_or(true) { "DESC" } else { "ASC" }; + let order = match o.sort_key.as_deref() { + Some("name") => format!("ORDER BY f.name COLLATE NOCASE {dir}, f.id {dir}"), + Some("size") => format!("ORDER BY f.size {dir}, f.id {dir}"), + Some("mtime") => format!("ORDER BY f.mtime_ms {dir}, f.id {dir}"), + _ => format!("ORDER BY COALESCE(f.taken_at, f.mtime_ms) {dir}, f.id {dir}"), + }; + format!("{kind_clause} {order}") +} fn map_snap_row(r: &rusqlite::Row<'_>) -> rusqlite::Result { Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) @@ -385,7 +410,11 @@ pub fn folder_snapshot( source_id: Option, folder_id: Option, recursive: bool, + sort_key: Option, + sort_desc: Option, + kind: Option, ) -> CmdResult { + let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind }); let rows: Vec = state .db .with_read(|conn| { @@ -393,7 +422,7 @@ pub fn folder_snapshot( (Some(fid), false) => { let sql = format!( "SELECT {SNAP_COLS} FROM files f - WHERE f.folder_id = ?1 AND f.deleted_at IS NULL {SNAP_ORDER}" + WHERE f.folder_id = ?1 AND f.deleted_at IS NULL {tail}" ); let mut stmt = conn.prepare_cached(&sql)?; let rows = stmt.query_map([fid], |r| map_snap_row(r))?; @@ -411,7 +440,7 @@ pub fn folder_snapshot( "SELECT {SNAP_COLS} FROM files f JOIN folders fo ON fo.id = f.folder_id WHERE fo.source_id = ?1 AND (fo.path = ?2 OR fo.path LIKE ?3) - AND f.deleted_at IS NULL {SNAP_ORDER}" + AND f.deleted_at IS NULL {tail}" ); let mut stmt = conn.prepare_cached(&sql)?; let rows = @@ -422,7 +451,7 @@ pub fn folder_snapshot( Some(sid) => { let sql = format!( "SELECT {SNAP_COLS} FROM files f - WHERE f.source_id = ?1 AND f.deleted_at IS NULL {SNAP_ORDER}" + WHERE f.source_id = ?1 AND f.deleted_at IS NULL {tail}" ); let mut stmt = conn.prepare_cached(&sql)?; let rows = stmt.query_map([sid], |r| map_snap_row(r))?; @@ -431,7 +460,7 @@ pub fn folder_snapshot( None => { let sql = format!( "SELECT {SNAP_COLS} FROM files f - WHERE f.deleted_at IS NULL {SNAP_ORDER}" + WHERE f.deleted_at IS NULL {tail}" ); let mut stmt = conn.prepare_cached(&sql)?; let rows = stmt.query_map([], |r| map_snap_row(r))?; @@ -445,11 +474,18 @@ pub fn folder_snapshot( } #[tauri::command] -pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdResult { +pub fn search_snapshot( + state: tauri::State<'_, AppState>, + query: String, + sort_key: Option, + sort_desc: Option, + kind: Option, +) -> CmdResult { let q = query.trim().to_string(); if q.is_empty() { return Ok(Response::new(Vec::new())); } + let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind }); let use_fts = state.db.fts_enabled && q.chars().count() >= 3; let rows: Vec = state .db @@ -458,7 +494,7 @@ pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdR let sql = format!( "SELECT {SNAP_COLS} FROM files_fts ft JOIN files f ON f.id = ft.rowid - WHERE files_fts MATCH ?1 AND f.deleted_at IS NULL {SNAP_ORDER}" + WHERE files_fts MATCH ?1 AND f.deleted_at IS NULL {tail}" ); let mut stmt = conn.prepare_cached(&sql)?; // MATCH 구문 특수문자 무력화를 위해 따옴표로 감싼다 @@ -468,7 +504,7 @@ pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdR } else { let sql = format!( "SELECT {SNAP_COLS} FROM files f - WHERE f.name LIKE '%' || ?1 || '%' ESCAPE '\\' AND f.deleted_at IS NULL {SNAP_ORDER}" + WHERE f.name LIKE '%' || ?1 || '%' ESCAPE '\\' AND f.deleted_at IS NULL {tail}" ); let mut stmt = conn.prepare_cached(&sql)?; let escaped = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); @@ -619,14 +655,21 @@ pub fn export_tags(state: tauri::State<'_, AppState>, path: Option) -> C } #[tauri::command] -pub fn tag_snapshot(state: tauri::State<'_, AppState>, tag_id: i64) -> CmdResult { +pub fn tag_snapshot( + state: tauri::State<'_, AppState>, + tag_id: i64, + sort_key: Option, + sort_desc: Option, + kind: Option, +) -> CmdResult { + let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind }); let rows: Vec = state .db .with_read(|conn| { let sql = format!( "SELECT {SNAP_COLS} FROM files f JOIN file_tags ft ON ft.file_id = f.id - WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL {SNAP_ORDER}" + WHERE ft.tag_id = ?1 AND f.deleted_at IS NULL {tail}" ); let mut stmt = conn.prepare_cached(&sql)?; let rows = stmt.query_map([tag_id], |r| map_snap_row(r))?; @@ -797,7 +840,14 @@ pub fn delete_smart_folder(state: tauri::State<'_, AppState>, id: i64) -> CmdRes } #[tauri::command] -pub fn smart_snapshot(state: tauri::State<'_, AppState>, id: i64) -> CmdResult { +pub fn smart_snapshot( + state: tauri::State<'_, AppState>, + id: i64, + sort_key: Option, + sort_desc: Option, + kind: Option, +) -> CmdResult { + let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind }); let query: SmartQuery = state .db .with_read(|conn| { @@ -834,7 +884,7 @@ pub fn smart_snapshot(state: tauri::State<'_, AppState>, id: i64) -> CmdResult = state .db .with_read(|conn| { - let sql = format!("SELECT {SNAP_COLS} FROM files f WHERE {where_sql} {SNAP_ORDER}"); + let sql = format!("SELECT {SNAP_COLS} FROM files f WHERE {where_sql} {tail}"); let mut stmt = conn.prepare(&sql)?; let rows = stmt.query_map([], |r| map_snap_row(r))?; rows.collect() diff --git a/src/App.tsx b/src/App.tsx index d2cdb5e..eb21950 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { ContextMenu } from "./features/fileops/ContextMenu"; import { MoveDialog } from "./features/fileops/MoveDialog"; import { DedupePanel } from "./features/dedupe/DedupePanel"; import { RemoteDialog } from "./features/sources/RemoteDialog"; +import { ShortcutsOverlay } from "./shell/ShortcutsOverlay"; import { onLibraryChanged, onThumbsReady, type UnlistenFn } from "./ipc/commands"; import { bumpThumbVersions, loadAppInfo, refreshPendingCounts, refreshSnapshot } from "./state/store"; @@ -45,6 +46,7 @@ const App: Component = () => { + ); }; diff --git a/src/features/grid/JustifiedGrid.tsx b/src/features/grid/JustifiedGrid.tsx index 18a4b38..1b6b322 100644 --- a/src/features/grid/JustifiedGrid.tsx +++ b/src/features/grid/JustifiedGrid.tsx @@ -10,6 +10,7 @@ import { Show, type Component, } from "solid-js"; +import { createStore, produce } from "solid-js/store"; import { computeLayout, type LayoutResult } from "./layout"; import { anchorIndex, @@ -17,9 +18,11 @@ import { gridRowHeight, info, openLightbox, + openShortcuts, selected, selectSingle, setSelectionFromIds, + showInfo, toggleSelect, selectRange, snapshot, @@ -38,6 +41,12 @@ import { const GAP = 6; const OVERSCAN_PX = 800; +function fmtSize(bytes: number): string { + if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)}MB`; + if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(0)}KB`; + return `${bytes}B`; +} + /** rowTops에서 y 이상이 처음 나오는 행 인덱스 (이진탐색) */ function findRow(rowTops: Float32Array, y: number): number { let lo = 0; @@ -156,6 +165,35 @@ export const JustifiedGrid: Component = () => { }); onCleanup(() => clearTimeout(prioTimer)); + // 정보 오버레이용 파일명/크기 지연 로드 (showInfo 켜졌을 때 가시 범위만) + const [detailsCache, setDetailsCache] = createStore>({}); + let detTimer: ReturnType | undefined; + createEffect(() => { + if (!showInfo()) return; + const [first, last] = visibleRange(); + const snap = snapshot(); + if (!snap || last < first) return; + clearTimeout(detTimer); + detTimer = setTimeout(() => { + const need: number[] = []; + for (let i = first; i <= last && need.length < 512; i++) { + const id = snap.id(i); + if (detailsCache[id] === undefined) need.push(id); + } + if (need.length === 0) return; + void fileDetails(need) + .then((ds) => + setDetailsCache( + produce((s) => { + for (const d of ds) s[d.id] = { name: d.name, size: d.size }; + }), + ), + ) + .catch(() => {}); + }, 150); + }); + onCleanup(() => clearTimeout(detTimer)); + const indices = createMemo(() => { const [first, last] = visibleRange(); const out: number[] = []; @@ -206,6 +244,25 @@ export const JustifiedGrid: Component = () => { void performUndo(); return; } + // Ctrl+A 전체 선택 / Ctrl+Shift+A 선택 반전 + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") { + e.preventDefault(); + const all: number[] = []; + for (let i = 0; i < snap.count; i++) all.push(snap.id(i)); + if (e.shiftKey) { + const cur = selected(); + setSelectionFromIds(all.filter((id) => !cur.has(id))); + } else { + setSelectionFromIds(all); + } + return; + } + // ? 단축키 도움말 + if (e.key === "?") { + e.preventDefault(); + openShortcuts(); + return; + } switch (e.key) { case "Enter": @@ -456,6 +513,15 @@ export const JustifiedGrid: Component = () => { 영상 + {/* 파일명·크기 오버레이 (툴바 ⓘ 토글) */} + +
+
{detailsCache[id()]?.name ?? ""}
+ +
{fmtSize(detailsCache[id()]!.size)}
+
+
+
); }} diff --git a/src/i18n/ko.ts b/src/i18n/ko.ts index ed823e8..73d7126 100644 --- a/src/i18n/ko.ts +++ b/src/i18n/ko.ts @@ -28,6 +28,38 @@ export const ko = { empty: "표시할 항목이 없습니다", items: (n: number) => `${n.toLocaleString("ko-KR")}개 항목`, selected: (n: number) => `${n.toLocaleString("ko-KR")}개 선택됨`, + infoToggle: "썸네일에 파일명·크기 표시", + }, + filter: { + all: "전체", + image: "사진", + video: "영상", + }, + sort: { + taken: "촬영일", + name: "이름", + size: "크기", + mtime: "수정일", + asc: "오름차순", + desc: "내림차순", + }, + shortcuts: { + title: "키보드 단축키", + close: "닫기 (Esc / ?)", + items: [ + ["Space / K", "재생·일시정지 (영상)"], + ["← →", "이전·다음 항목 (그리드) / 5초 이동 (영상)"], + ["↑ ↓", "위·아래 줄 이동 (그리드) / 볼륨 (영상)"], + ["Enter / Space", "선택 항목 크게 보기"], + ["Ctrl+A", "전체 선택"], + ["Ctrl+Shift+A", "선택 반전"], + ["Esc", "선택 해제 / 닫기"], + ["Delete", "휴지통으로"], + ["Ctrl+Z", "실행 취소"], + ["F2", "이름 바꾸기"], + ["Ctrl+휠", "썸네일 크기"], + ["?", "이 도움말"], + ] as [string, string][], }, details: { title: "상세 정보", diff --git a/src/ipc/commands.ts b/src/ipc/commands.ts index c671979..3ba29d4 100644 --- a/src/ipc/commands.ts +++ b/src/ipc/commands.ts @@ -88,24 +88,30 @@ export class Snapshot { this.view = new DataView(buf); this.count = Math.floor(buf.byteLength / ITEM_BYTES); } + // 모든 접근자는 범위 밖 i에 안전해야 한다. 스냅샷이 줄어들 때(필터/검색/폴더 전환) + // For가 초과 셀을 제거하기 전에 셀의 반응형 접근자가 옛 인덱스로 재실행될 수 있는데, + // DataView가 범위를 벗어나면 RangeError를 던져 Solid 반응성 그래프 전체가 무너진다. + private ok(i: number): boolean { + return i >= 0 && i < this.count; + } id(i: number): number { - return this.view.getUint32(i * ITEM_BYTES, true); + return this.ok(i) ? this.view.getUint32(i * ITEM_BYTES, true) : 0; } width(i: number): number { - return this.view.getUint16(i * ITEM_BYTES + 4, true); + return this.ok(i) ? this.view.getUint16(i * ITEM_BYTES + 4, true) : 0; } height(i: number): number { - return this.view.getUint16(i * ITEM_BYTES + 6, true); + return this.ok(i) ? this.view.getUint16(i * ITEM_BYTES + 6, true) : 0; } kind(i: number): number { - return this.view.getUint8(i * ITEM_BYTES + 8); + return this.ok(i) ? this.view.getUint8(i * ITEM_BYTES + 8) : 0; } hasThumb(i: number): boolean { - return (this.view.getUint8(i * ITEM_BYTES + 9) & 1) !== 0; + return this.ok(i) && (this.view.getUint8(i * ITEM_BYTES + 9) & 1) !== 0; } /** 썸네일 생성을 시도했으나 실패함(state 2) — 대기와 구분해 스켈레톤을 멈추기 위함 */ thumbFailed(i: number): boolean { - return (this.view.getUint8(i * ITEM_BYTES + 9) & 2) !== 0; + return this.ok(i) && (this.view.getUint8(i * ITEM_BYTES + 9) & 2) !== 0; } /** 레이아웃 워커 입력용 종횡비 배열 (미지 치수는 1:1) */ aspectRatios(): Float32Array { @@ -119,21 +125,34 @@ export class Snapshot { } } -export async function folderSnapshot(args: { - sourceId?: number; - folderId?: number; - recursive?: boolean; -}): Promise { +/** 정렬 키: taken(촬영/기본) | name | size | mtime */ +export type SortKey = "taken" | "name" | "size" | "mtime"; +/** 스냅샷 공통 옵션 — 정렬/종류 필터 */ +export interface SnapOpts { + sortKey?: SortKey; + sortDesc?: boolean; + kind?: number | null; // 0=사진, 1=영상, null=전체 +} +const snapArgs = (o?: SnapOpts) => ({ + sortKey: o?.sortKey ?? null, + sortDesc: o?.sortDesc ?? null, + kind: o?.kind ?? null, +}); + +export async function folderSnapshot( + args: { sourceId?: number; folderId?: number; recursive?: boolean } & SnapOpts, +): Promise { const buf = await invoke("folder_snapshot", { sourceId: args.sourceId ?? null, folderId: args.folderId ?? null, recursive: args.recursive ?? true, + ...snapArgs(args), }); return new Snapshot(buf); } -export async function searchSnapshot(query: string): Promise { - const buf = await invoke("search_snapshot", { query }); +export async function searchSnapshot(query: string, opts?: SnapOpts): Promise { + const buf = await invoke("search_snapshot", { query, ...snapArgs(opts) }); return new Snapshot(buf); } @@ -165,8 +184,8 @@ export const fileTagsOf = (fileId: number) => invoke("file_tags_of", { fi export const exportTags = (path?: string) => invoke("export_tags", { path: path ?? null }); -export async function tagSnapshot(tagId: number): Promise { - return new Snapshot(await invoke("tag_snapshot", { tagId })); +export async function tagSnapshot(tagId: number, opts?: SnapOpts): Promise { + return new Snapshot(await invoke("tag_snapshot", { tagId, ...snapArgs(opts) })); } // ── 파일 작업 ─────────────────────────────────────────── @@ -227,8 +246,8 @@ export const createSmartFolder = (name: string, query: SmartQuery) => export const deleteSmartFolder = (id: number) => invoke("delete_smart_folder", { id }); -export async function smartSnapshot(id: number): Promise { - return new Snapshot(await invoke("smart_snapshot", { id })); +export async function smartSnapshot(id: number, opts?: SnapOpts): Promise { + return new Snapshot(await invoke("smart_snapshot", { id, ...snapArgs(opts) })); } // ── 원격 소스 ─────────────────────────────────────────── diff --git a/src/shell/ContentArea.tsx b/src/shell/ContentArea.tsx index b41f5ae..1328f19 100644 --- a/src/shell/ContentArea.tsx +++ b/src/shell/ContentArea.tsx @@ -1,4 +1,4 @@ -import { createResource, createSignal, onCleanup, Show, type Component } from "solid-js"; +import { createResource, createSignal, For, onCleanup, Show, type Component } from "solid-js"; import { open } from "@tauri-apps/plugin-dialog"; import { ko } from "../i18n/ko"; import { JustifiedGrid } from "../features/grid/JustifiedGrid"; @@ -6,13 +6,22 @@ import { GRID_ROW_MAX, GRID_ROW_MIN, gridRowHeight, + kindFilter, refreshSidebar, setGridRowHeight, + setKindFilter, + setSort, setView, + showInfo, sidebarVersion, snapshot, + sortDesc, + sortKey, + toggleShowInfo, updateScanProgress, view, + type KindFilter, + type SortKey, } from "../state/store"; import { addLocalSource, listSources, startScan } from "../ipc/commands"; import { openRemoteDialog } from "../features/sources/RemoteDialog"; @@ -61,6 +70,58 @@ export const ContentArea: Component = () => { {snapshot() ? ko.grid.items(snapshot()!.count) : ""} + + {/* 종류 필터: 전체 / 사진 / 영상 */} +
+ + {([k, label]) => ( + + )} + +
+ + {/* 정렬: 키 선택 + 방향 토글 */} +
+ + +
+ + {/* 썸네일 정보 오버레이 토글 */} + + {/* 그리드 줌 슬라이더 */}
🔍 diff --git a/src/shell/ShortcutsOverlay.tsx b/src/shell/ShortcutsOverlay.tsx new file mode 100644 index 0000000..6fe990c --- /dev/null +++ b/src/shell/ShortcutsOverlay.tsx @@ -0,0 +1,58 @@ +// 키보드 단축키 도움말 오버레이 (? 키로 열기). +import { For, onCleanup, onMount, Show, type Component } from "solid-js"; +import { ko } from "../i18n/ko"; +import { closeShortcuts, shortcutsOpen } from "../state/store"; + +export const ShortcutsOverlay: Component = () => { + onMount(() => { + const onKey = (e: KeyboardEvent) => { + if (!shortcutsOpen()) return; + if (e.key === "Escape" || e.key === "?") { + e.preventDefault(); + e.stopPropagation(); + closeShortcuts(); + } + }; + window.addEventListener("keydown", onKey, { capture: true }); + onCleanup(() => window.removeEventListener("keydown", onKey, { capture: true })); + }); + + return ( + +
+
e.stopPropagation()} + > +
+ + {ko.shortcuts.title} + + +
+
+ + {([keys, desc]) => ( +
+ + {keys} + + {desc} +
+ )} +
+
+
+
+
+ ); +}; diff --git a/src/state/store.ts b/src/state/store.ts index f90001b..f2b6eaf 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -15,6 +15,8 @@ import { type DedupeProgress, type PendingCounts, type ScanProgress, + type SnapOpts, + type SortKey, } from "../ipc/commands"; // ── 앱 정보 (미디어 서버 포트/토큰 포함) ───────────────── @@ -43,6 +45,36 @@ const [snapshot, setSnapshot] = createSignal(); const [loading, setLoading] = createSignal(false); export { view, snapshot, loading }; +// ── 정렬 / 종류 필터 (툴바) ────────────────────────────── +const [sortKey, setSortKeySig] = createSignal("taken"); +const [sortDesc, setSortDescSig] = createSignal(true); +export type KindFilter = "all" | "image" | "video"; +const [kindFilter, setKindFilterSig] = createSignal("all"); +export { sortKey, sortDesc, kindFilter }; +export type { SortKey }; + +/** 정렬 변경 → 재조회 */ +export function setSort(key: SortKey, desc: boolean): void { + setSortKeySig(key); + setSortDescSig(desc); + void refreshSnapshot(); +} +/** 종류 필터 변경(전체/사진/영상) → 재조회 */ +export function setKindFilter(k: KindFilter): void { + setKindFilterSig(k); + void refreshSnapshot(); +} + +// 그리드 썸네일에 파일명·크기 오버레이 표시 (localStorage 유지) +const INFO_KEY = "archive.grid.infoOverlay"; +const [showInfo, setShowInfoRaw] = createSignal(localStorage.getItem(INFO_KEY) === "1"); +export { showInfo }; +export function toggleShowInfo(): void { + const n = !showInfo(); + setShowInfoRaw(n); + localStorage.setItem(INFO_KEY, n ? "1" : "0"); +} + let viewSeq = 0; export async function setView(v: View): Promise { @@ -55,26 +87,31 @@ export async function refreshSnapshot(): Promise { const v = view(); const seq = ++viewSeq; setLoading(true); + const opts: SnapOpts = { + sortKey: sortKey(), + sortDesc: sortDesc(), + kind: kindFilter() === "image" ? 0 : kindFilter() === "video" ? 1 : null, + }; try { let snap: Snapshot; switch (v.type) { case "all": - snap = await folderSnapshot({}); + snap = await folderSnapshot({ ...opts }); break; case "source": - snap = await folderSnapshot({ sourceId: v.sourceId }); + snap = await folderSnapshot({ sourceId: v.sourceId, ...opts }); break; case "folder": - snap = await folderSnapshot({ folderId: v.folderId, recursive: v.recursive }); + snap = await folderSnapshot({ folderId: v.folderId, recursive: v.recursive, ...opts }); break; case "search": - snap = await searchSnapshot(v.query); + snap = await searchSnapshot(v.query, opts); break; case "tag": - snap = await tagSnapshot(v.tagId); + snap = await tagSnapshot(v.tagId, opts); break; case "smart": - snap = await smartSnapshot(v.smartId); + snap = await smartSnapshot(v.smartId, opts); break; } if (seq === viewSeq) setSnapshot(snap); @@ -193,6 +230,12 @@ const [repeatMode, setRepeatMode] = createSignal(false); export { repeatMode }; export const toggleRepeat = () => setRepeatMode((r) => !r); +// ── 단축키 도움말 오버레이 ─────────────────────────────── +const [shortcutsOpen, setShortcutsOpen] = createSignal(false); +export { shortcutsOpen }; +export const openShortcuts = () => setShortcutsOpen(true); +export const closeShortcuts = () => setShortcutsOpen(false); + // ── 중복 검토 패널 ─────────────────────────────────────── const [dedupeOpen, setDedupeOpen] = createSignal(false); export { dedupeOpen };