편의 기능(정렬·종류필터·전체선택/단축키·정보 오버레이) + 스냅샷 범위 안전화

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 <noreply@anthropic.com>
This commit is contained in:
강 한
2026-07-20 00:34:52 +09:00
co-authored by Claude Opus 4.8
parent ac3b527e8e
commit 67d2ddc59e
8 changed files with 367 additions and 36 deletions
+62 -12
View File
@@ -373,7 +373,32 @@ fn pack_snapshot(rows: Vec<(i64, Option<u32>, Option<u32>, Option<u32>, i64, i64
type SnapRow = (i64, Option<u32>, Option<u32>, Option<u32>, i64, i64); type SnapRow = (i64, Option<u32>, Option<u32>, Option<u32>, i64, i64);
const SNAP_COLS: &str = "f.id, f.width, f.height, f.orientation, f.kind, f.thumb_state"; 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<String>, // taken(기본)|name|size|mtime
pub sort_desc: Option<bool>, // 기본 내림차순
pub kind: Option<i64>, // 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<SnapRow> { fn map_snap_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<SnapRow> {
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) 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<i64>, source_id: Option<i64>,
folder_id: Option<i64>, folder_id: Option<i64>,
recursive: bool, recursive: bool,
sort_key: Option<String>,
sort_desc: Option<bool>,
kind: Option<i64>,
) -> CmdResult<Response> { ) -> CmdResult<Response> {
let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind });
let rows: Vec<SnapRow> = state let rows: Vec<SnapRow> = state
.db .db
.with_read(|conn| { .with_read(|conn| {
@@ -393,7 +422,7 @@ pub fn folder_snapshot(
(Some(fid), false) => { (Some(fid), false) => {
let sql = format!( let sql = format!(
"SELECT {SNAP_COLS} FROM files f "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 mut stmt = conn.prepare_cached(&sql)?;
let rows = stmt.query_map([fid], |r| map_snap_row(r))?; 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 "SELECT {SNAP_COLS} FROM files f
JOIN folders fo ON fo.id = f.folder_id JOIN folders fo ON fo.id = f.folder_id
WHERE fo.source_id = ?1 AND (fo.path = ?2 OR fo.path LIKE ?3) 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 mut stmt = conn.prepare_cached(&sql)?;
let rows = let rows =
@@ -422,7 +451,7 @@ pub fn folder_snapshot(
Some(sid) => { Some(sid) => {
let sql = format!( let sql = format!(
"SELECT {SNAP_COLS} FROM files f "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 mut stmt = conn.prepare_cached(&sql)?;
let rows = stmt.query_map([sid], |r| map_snap_row(r))?; let rows = stmt.query_map([sid], |r| map_snap_row(r))?;
@@ -431,7 +460,7 @@ pub fn folder_snapshot(
None => { None => {
let sql = format!( let sql = format!(
"SELECT {SNAP_COLS} FROM files f "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 mut stmt = conn.prepare_cached(&sql)?;
let rows = stmt.query_map([], |r| map_snap_row(r))?; let rows = stmt.query_map([], |r| map_snap_row(r))?;
@@ -445,11 +474,18 @@ pub fn folder_snapshot(
} }
#[tauri::command] #[tauri::command]
pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdResult<Response> { pub fn search_snapshot(
state: tauri::State<'_, AppState>,
query: String,
sort_key: Option<String>,
sort_desc: Option<bool>,
kind: Option<i64>,
) -> CmdResult<Response> {
let q = query.trim().to_string(); let q = query.trim().to_string();
if q.is_empty() { if q.is_empty() {
return Ok(Response::new(Vec::new())); 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 use_fts = state.db.fts_enabled && q.chars().count() >= 3;
let rows: Vec<SnapRow> = state let rows: Vec<SnapRow> = state
.db .db
@@ -458,7 +494,7 @@ pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdR
let sql = format!( let sql = format!(
"SELECT {SNAP_COLS} FROM files_fts ft "SELECT {SNAP_COLS} FROM files_fts ft
JOIN files f ON f.id = ft.rowid 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)?; let mut stmt = conn.prepare_cached(&sql)?;
// MATCH 구문 특수문자 무력화를 위해 따옴표로 감싼다 // MATCH 구문 특수문자 무력화를 위해 따옴표로 감싼다
@@ -468,7 +504,7 @@ pub fn search_snapshot(state: tauri::State<'_, AppState>, query: String) -> CmdR
} else { } else {
let sql = format!( let sql = format!(
"SELECT {SNAP_COLS} FROM files f "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 mut stmt = conn.prepare_cached(&sql)?;
let escaped = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_"); let escaped = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
@@ -619,14 +655,21 @@ pub fn export_tags(state: tauri::State<'_, AppState>, path: Option<String>) -> C
} }
#[tauri::command] #[tauri::command]
pub fn tag_snapshot(state: tauri::State<'_, AppState>, tag_id: i64) -> CmdResult<Response> { pub fn tag_snapshot(
state: tauri::State<'_, AppState>,
tag_id: i64,
sort_key: Option<String>,
sort_desc: Option<bool>,
kind: Option<i64>,
) -> CmdResult<Response> {
let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind });
let rows: Vec<SnapRow> = state let rows: Vec<SnapRow> = state
.db .db
.with_read(|conn| { .with_read(|conn| {
let sql = format!( let sql = format!(
"SELECT {SNAP_COLS} FROM files f "SELECT {SNAP_COLS} FROM files f
JOIN file_tags ft ON ft.file_id = f.id 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 mut stmt = conn.prepare_cached(&sql)?;
let rows = stmt.query_map([tag_id], |r| map_snap_row(r))?; 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] #[tauri::command]
pub fn smart_snapshot(state: tauri::State<'_, AppState>, id: i64) -> CmdResult<Response> { pub fn smart_snapshot(
state: tauri::State<'_, AppState>,
id: i64,
sort_key: Option<String>,
sort_desc: Option<bool>,
kind: Option<i64>,
) -> CmdResult<Response> {
let tail = snap_tail(&SnapOpts { sort_key, sort_desc, kind });
let query: SmartQuery = state let query: SmartQuery = state
.db .db
.with_read(|conn| { .with_read(|conn| {
@@ -834,7 +884,7 @@ pub fn smart_snapshot(state: tauri::State<'_, AppState>, id: i64) -> CmdResult<R
let rows: Vec<SnapRow> = state let rows: Vec<SnapRow> = state
.db .db
.with_read(|conn| { .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 mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map([], |r| map_snap_row(r))?; let rows = stmt.query_map([], |r| map_snap_row(r))?;
rows.collect() rows.collect()
+2
View File
@@ -8,6 +8,7 @@ import { ContextMenu } from "./features/fileops/ContextMenu";
import { MoveDialog } from "./features/fileops/MoveDialog"; import { MoveDialog } from "./features/fileops/MoveDialog";
import { DedupePanel } from "./features/dedupe/DedupePanel"; import { DedupePanel } from "./features/dedupe/DedupePanel";
import { RemoteDialog } from "./features/sources/RemoteDialog"; import { RemoteDialog } from "./features/sources/RemoteDialog";
import { ShortcutsOverlay } from "./shell/ShortcutsOverlay";
import { onLibraryChanged, onThumbsReady, type UnlistenFn } from "./ipc/commands"; import { onLibraryChanged, onThumbsReady, type UnlistenFn } from "./ipc/commands";
import { bumpThumbVersions, loadAppInfo, refreshPendingCounts, refreshSnapshot } from "./state/store"; import { bumpThumbVersions, loadAppInfo, refreshPendingCounts, refreshSnapshot } from "./state/store";
@@ -45,6 +46,7 @@ const App: Component = () => {
<MoveDialog /> <MoveDialog />
<DedupePanel /> <DedupePanel />
<RemoteDialog /> <RemoteDialog />
<ShortcutsOverlay />
</div> </div>
); );
}; };
+66
View File
@@ -10,6 +10,7 @@ import {
Show, Show,
type Component, type Component,
} from "solid-js"; } from "solid-js";
import { createStore, produce } from "solid-js/store";
import { computeLayout, type LayoutResult } from "./layout"; import { computeLayout, type LayoutResult } from "./layout";
import { import {
anchorIndex, anchorIndex,
@@ -17,9 +18,11 @@ import {
gridRowHeight, gridRowHeight,
info, info,
openLightbox, openLightbox,
openShortcuts,
selected, selected,
selectSingle, selectSingle,
setSelectionFromIds, setSelectionFromIds,
showInfo,
toggleSelect, toggleSelect,
selectRange, selectRange,
snapshot, snapshot,
@@ -38,6 +41,12 @@ import {
const GAP = 6; const GAP = 6;
const OVERSCAN_PX = 800; 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 이상이 처음 나오는 행 인덱스 (이진탐색) */ /** rowTops에서 y 이상이 처음 나오는 행 인덱스 (이진탐색) */
function findRow(rowTops: Float32Array, y: number): number { function findRow(rowTops: Float32Array, y: number): number {
let lo = 0; let lo = 0;
@@ -156,6 +165,35 @@ export const JustifiedGrid: Component = () => {
}); });
onCleanup(() => clearTimeout(prioTimer)); onCleanup(() => clearTimeout(prioTimer));
// 정보 오버레이용 파일명/크기 지연 로드 (showInfo 켜졌을 때 가시 범위만)
const [detailsCache, setDetailsCache] = createStore<Record<number, { name: string; size: number }>>({});
let detTimer: ReturnType<typeof setTimeout> | 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 indices = createMemo(() => {
const [first, last] = visibleRange(); const [first, last] = visibleRange();
const out: number[] = []; const out: number[] = [];
@@ -206,6 +244,25 @@ export const JustifiedGrid: Component = () => {
void performUndo(); void performUndo();
return; 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) { switch (e.key) {
case "Enter": case "Enter":
@@ -456,6 +513,15 @@ export const JustifiedGrid: Component = () => {
</span> </span>
</Show> </Show>
{/* 파일명·크기 오버레이 (툴바 ⓘ 토글) */}
<Show when={showInfo()}>
<div class="pointer-events-none absolute inset-x-0 bottom-0 bg-black/55 px-1 py-0.5 leading-tight">
<div class="truncate text-[10px] text-white/90">{detailsCache[id()]?.name ?? ""}</div>
<Show when={detailsCache[id()]}>
<div class="text-[9px] text-white/60">{fmtSize(detailsCache[id()]!.size)}</div>
</Show>
</div>
</Show>
</div> </div>
); );
}} }}
+32
View File
@@ -28,6 +28,38 @@ export const ko = {
empty: "표시할 항목이 없습니다", empty: "표시할 항목이 없습니다",
items: (n: number) => `${n.toLocaleString("ko-KR")}개 항목`, items: (n: number) => `${n.toLocaleString("ko-KR")}개 항목`,
selected: (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: { details: {
title: "상세 정보", title: "상세 정보",
+36 -17
View File
@@ -88,24 +88,30 @@ export class Snapshot {
this.view = new DataView(buf); this.view = new DataView(buf);
this.count = Math.floor(buf.byteLength / ITEM_BYTES); 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 { 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 { 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 { 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 { 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 { 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) — 대기와 구분해 스켈레톤을 멈추기 위함 */ /** 썸네일 생성을 시도했으나 실패함(state 2) — 대기와 구분해 스켈레톤을 멈추기 위함 */
thumbFailed(i: number): boolean { 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) */ /** 레이아웃 워커 입력용 종횡비 배열 (미지 치수는 1:1) */
aspectRatios(): Float32Array { aspectRatios(): Float32Array {
@@ -119,21 +125,34 @@ export class Snapshot {
} }
} }
export async function folderSnapshot(args: { /** 정렬 키: taken(촬영/기본) | name | size | mtime */
sourceId?: number; export type SortKey = "taken" | "name" | "size" | "mtime";
folderId?: number; /** 스냅샷 공통 옵션 — 정렬/종류 필터 */
recursive?: boolean; export interface SnapOpts {
}): Promise<Snapshot> { 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<Snapshot> {
const buf = await invoke<ArrayBuffer>("folder_snapshot", { const buf = await invoke<ArrayBuffer>("folder_snapshot", {
sourceId: args.sourceId ?? null, sourceId: args.sourceId ?? null,
folderId: args.folderId ?? null, folderId: args.folderId ?? null,
recursive: args.recursive ?? true, recursive: args.recursive ?? true,
...snapArgs(args),
}); });
return new Snapshot(buf); return new Snapshot(buf);
} }
export async function searchSnapshot(query: string): Promise<Snapshot> { export async function searchSnapshot(query: string, opts?: SnapOpts): Promise<Snapshot> {
const buf = await invoke<ArrayBuffer>("search_snapshot", { query }); const buf = await invoke<ArrayBuffer>("search_snapshot", { query, ...snapArgs(opts) });
return new Snapshot(buf); return new Snapshot(buf);
} }
@@ -165,8 +184,8 @@ export const fileTagsOf = (fileId: number) => invoke<Tag[]>("file_tags_of", { fi
export const exportTags = (path?: string) => export const exportTags = (path?: string) =>
invoke<string>("export_tags", { path: path ?? null }); invoke<string>("export_tags", { path: path ?? null });
export async function tagSnapshot(tagId: number): Promise<Snapshot> { export async function tagSnapshot(tagId: number, opts?: SnapOpts): Promise<Snapshot> {
return new Snapshot(await invoke<ArrayBuffer>("tag_snapshot", { tagId })); return new Snapshot(await invoke<ArrayBuffer>("tag_snapshot", { tagId, ...snapArgs(opts) }));
} }
// ── 파일 작업 ─────────────────────────────────────────── // ── 파일 작업 ───────────────────────────────────────────
@@ -227,8 +246,8 @@ export const createSmartFolder = (name: string, query: SmartQuery) =>
export const deleteSmartFolder = (id: number) => export const deleteSmartFolder = (id: number) =>
invoke<void>("delete_smart_folder", { id }); invoke<void>("delete_smart_folder", { id });
export async function smartSnapshot(id: number): Promise<Snapshot> { export async function smartSnapshot(id: number, opts?: SnapOpts): Promise<Snapshot> {
return new Snapshot(await invoke<ArrayBuffer>("smart_snapshot", { id })); return new Snapshot(await invoke<ArrayBuffer>("smart_snapshot", { id, ...snapArgs(opts) }));
} }
// ── 원격 소스 ─────────────────────────────────────────── // ── 원격 소스 ───────────────────────────────────────────
+62 -1
View File
@@ -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 { open } from "@tauri-apps/plugin-dialog";
import { ko } from "../i18n/ko"; import { ko } from "../i18n/ko";
import { JustifiedGrid } from "../features/grid/JustifiedGrid"; import { JustifiedGrid } from "../features/grid/JustifiedGrid";
@@ -6,13 +6,22 @@ import {
GRID_ROW_MAX, GRID_ROW_MAX,
GRID_ROW_MIN, GRID_ROW_MIN,
gridRowHeight, gridRowHeight,
kindFilter,
refreshSidebar, refreshSidebar,
setGridRowHeight, setGridRowHeight,
setKindFilter,
setSort,
setView, setView,
showInfo,
sidebarVersion, sidebarVersion,
snapshot, snapshot,
sortDesc,
sortKey,
toggleShowInfo,
updateScanProgress, updateScanProgress,
view, view,
type KindFilter,
type SortKey,
} from "../state/store"; } from "../state/store";
import { addLocalSource, listSources, startScan } from "../ipc/commands"; import { addLocalSource, listSources, startScan } from "../ipc/commands";
import { openRemoteDialog } from "../features/sources/RemoteDialog"; import { openRemoteDialog } from "../features/sources/RemoteDialog";
@@ -61,6 +70,58 @@ export const ContentArea: Component = () => {
<span class="text-[11px] text-[var(--text-muted)]"> <span class="text-[11px] text-[var(--text-muted)]">
{snapshot() ? ko.grid.items(snapshot()!.count) : ""} {snapshot() ? ko.grid.items(snapshot()!.count) : ""}
</span> </span>
{/* 종류 필터: 전체 / 사진 / 영상 */}
<div class="flex overflow-hidden rounded border border-[var(--border)] text-[11px]">
<For each={[["all", ko.filter.all], ["image", ko.filter.image], ["video", ko.filter.video]] as [KindFilter, string][]}>
{([k, label]) => (
<button
class="px-2 py-0.5"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": kindFilter() === k,
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": kindFilter() !== k,
}}
onClick={() => setKindFilter(k)}
>
{label}
</button>
)}
</For>
</div>
{/* 정렬: 키 선택 + 방향 토글 */}
<div class="flex items-center gap-1 text-[11px] text-[var(--text-secondary)]">
<select
class="rounded border border-[var(--border)] bg-[var(--bg-panel)] px-1.5 py-0.5 outline-none"
value={sortKey()}
onChange={(e) => setSort(e.currentTarget.value as SortKey, sortDesc())}
>
<For each={[["taken", ko.sort.taken], ["name", ko.sort.name], ["size", ko.sort.size], ["mtime", ko.sort.mtime]] as [SortKey, string][]}>
{([k, label]) => <option value={k}>{label}</option>}
</For>
</select>
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 hover:bg-[var(--bg-hover)]"
onClick={() => setSort(sortKey(), !sortDesc())}
title={sortDesc() ? ko.sort.desc : ko.sort.asc}
>
{sortDesc() ? "↓" : "↑"}
</button>
</div>
{/* 썸네일 정보 오버레이 토글 */}
<button
class="rounded border border-[var(--border)] px-1.5 py-0.5 text-[11px]"
classList={{
"bg-[var(--bg-active)] text-[var(--text-primary)]": showInfo(),
"text-[var(--text-muted)] hover:bg-[var(--bg-hover)]": !showInfo(),
}}
onClick={toggleShowInfo}
title={ko.grid.infoToggle}
>
</button>
{/* 그리드 줌 슬라이더 */} {/* 그리드 줌 슬라이더 */}
<div class="ml-auto flex items-center gap-1.5 text-[var(--text-muted)]" title="썸네일 크기 (Ctrl+휠)"> <div class="ml-auto flex items-center gap-1.5 text-[var(--text-muted)]" title="썸네일 크기 (Ctrl+휠)">
<span class="text-xs">🔍</span> <span class="text-xs">🔍</span>
+58
View File
@@ -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 (
<Show when={shortcutsOpen()}>
<div
class="fixed inset-0 z-50 grid place-items-center bg-black/50"
onClick={closeShortcuts}
>
<div
class="max-h-[80vh] w-[min(30rem,92vw)] overflow-y-auto rounded-lg border border-[var(--border)] bg-[var(--bg-panel)] p-5 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div class="mb-3 flex items-center justify-between">
<span class="text-sm font-semibold text-[var(--text-primary)]">
{ko.shortcuts.title}
</span>
<button
class="rounded px-2 text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]"
onClick={closeShortcuts}
title={ko.shortcuts.close}
>
</button>
</div>
<div class="flex flex-col gap-1.5">
<For each={ko.shortcuts.items}>
{([keys, desc]) => (
<div class="flex items-center justify-between gap-4 text-xs">
<kbd class="shrink-0 rounded border border-[var(--border)] bg-[var(--bg-app)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--text-secondary)]">
{keys}
</kbd>
<span class="text-right text-[var(--text-secondary)]">{desc}</span>
</div>
)}
</For>
</div>
</div>
</div>
</Show>
);
};
+49 -6
View File
@@ -15,6 +15,8 @@ import {
type DedupeProgress, type DedupeProgress,
type PendingCounts, type PendingCounts,
type ScanProgress, type ScanProgress,
type SnapOpts,
type SortKey,
} from "../ipc/commands"; } from "../ipc/commands";
// ── 앱 정보 (미디어 서버 포트/토큰 포함) ───────────────── // ── 앱 정보 (미디어 서버 포트/토큰 포함) ─────────────────
@@ -43,6 +45,36 @@ const [snapshot, setSnapshot] = createSignal<Snapshot | undefined>();
const [loading, setLoading] = createSignal(false); const [loading, setLoading] = createSignal(false);
export { view, snapshot, loading }; export { view, snapshot, loading };
// ── 정렬 / 종류 필터 (툴바) ──────────────────────────────
const [sortKey, setSortKeySig] = createSignal<SortKey>("taken");
const [sortDesc, setSortDescSig] = createSignal(true);
export type KindFilter = "all" | "image" | "video";
const [kindFilter, setKindFilterSig] = createSignal<KindFilter>("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; let viewSeq = 0;
export async function setView(v: View): Promise<void> { export async function setView(v: View): Promise<void> {
@@ -55,26 +87,31 @@ export async function refreshSnapshot(): Promise<void> {
const v = view(); const v = view();
const seq = ++viewSeq; const seq = ++viewSeq;
setLoading(true); setLoading(true);
const opts: SnapOpts = {
sortKey: sortKey(),
sortDesc: sortDesc(),
kind: kindFilter() === "image" ? 0 : kindFilter() === "video" ? 1 : null,
};
try { try {
let snap: Snapshot; let snap: Snapshot;
switch (v.type) { switch (v.type) {
case "all": case "all":
snap = await folderSnapshot({}); snap = await folderSnapshot({ ...opts });
break; break;
case "source": case "source":
snap = await folderSnapshot({ sourceId: v.sourceId }); snap = await folderSnapshot({ sourceId: v.sourceId, ...opts });
break; break;
case "folder": case "folder":
snap = await folderSnapshot({ folderId: v.folderId, recursive: v.recursive }); snap = await folderSnapshot({ folderId: v.folderId, recursive: v.recursive, ...opts });
break; break;
case "search": case "search":
snap = await searchSnapshot(v.query); snap = await searchSnapshot(v.query, opts);
break; break;
case "tag": case "tag":
snap = await tagSnapshot(v.tagId); snap = await tagSnapshot(v.tagId, opts);
break; break;
case "smart": case "smart":
snap = await smartSnapshot(v.smartId); snap = await smartSnapshot(v.smartId, opts);
break; break;
} }
if (seq === viewSeq) setSnapshot(snap); if (seq === viewSeq) setSnapshot(snap);
@@ -193,6 +230,12 @@ const [repeatMode, setRepeatMode] = createSignal(false);
export { repeatMode }; export { repeatMode };
export const toggleRepeat = () => setRepeatMode((r) => !r); 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); const [dedupeOpen, setDedupeOpen] = createSignal(false);
export { dedupeOpen }; export { dedupeOpen };