편의 기능(정렬·종류필터·전체선택/단축키·정보 오버레이) + 스냅샷 범위 안전화
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:
+62
-12
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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
@@ -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) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 원격 소스 ───────────────────────────────────────────
|
// ── 원격 소스 ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user