From d73e11f0fd3393c0398b4a43d625381f9ff966f0 Mon Sep 17 00:00:00 2001 From: g1nation Date: Tue, 2 Jun 2026 18:56:27 +0900 Subject: [PATCH] v0.3.0: keyboard culling, toasts+undo, smart chips, selection gestures, guide - keyboard-first culling in the library grid: cursor nav (arrows), 1-5 rating, P/X pick/reject labels, [ ] cycle color, Space preview, Delete trash - toast + in-app confirm/prompt overlays replace all native alert/confirm/prompt; delete is now Gmail-style deferred trash with Undo - smart quick-filter chips (today / this week / this year / best 4+ / videos), backed by a new mtime date-range filter in AssetQuery - selection gestures: shift-click range, Ctrl/Cmd+A select-all, Esc clear - mosaic: grid lines, lower default density/blend, click tile to view source - user guide updated: library, culling, shortcuts, search/map/groups, mosaic Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- src/main/guide.ts | 94 +++++++- src/main/indexDb.ts | 8 + src/renderer/App.tsx | 4 + src/renderer/components/FileExplorer.tsx | 13 +- src/renderer/components/GroupsView.tsx | 5 +- src/renderer/components/LibraryView.tsx | 289 ++++++++++++++++++++--- src/renderer/overlays.tsx | 255 ++++++++++++++++++++ src/renderer/styles/index.css | 12 + src/shared/i18n.ts | 13 + src/shared/types.ts | 3 + 11 files changed, 649 insertions(+), 49 deletions(-) create mode 100644 src/renderer/overlays.tsx diff --git a/package.json b/package.json index 0a1a9a3..964487e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ai-photo-organizer", - "version": "0.2.0", + "version": "0.3.0", "description": "Local-first AI photo organizer — face recognition + EXIF based auto archiving", "author": "PhotoAI", "license": "MIT", diff --git a/src/main/guide.ts b/src/main/guide.ts index 32fd94e..574b196 100644 --- a/src/main/guide.ts +++ b/src/main/guide.ts @@ -11,7 +11,7 @@ const GUIDE: Record정리(자동 분류) 외에도 라이브러리에서 사진을 빠르게 둘러보고 평가·정리할 수 있습니다. 아래 순서대로 따라 하면 됩니다.', sections: [ { h: '0. 개념 한눈에 보기', @@ -79,10 +79,53 @@ const GUIDE: Record라이브러리 탭은 정리(자동 분류)와 별개로, 폴더를 그 자리에서 색인(인덱싱)해 빠르게 둘러보고 평가·정리하는 공간입니다.', + '좌측 라이브러리에서 [+ 폴더 추가] 후 [색인 시작]을 누르면 썸네일과 메타데이터가 만들어집니다. (원본은 그대로, 썸네일은 앱 캐시에 저장 — 비파괴)', + '좌측 패널: 파일 탐색기 · 컬렉션(연도/카메라/색라벨) · 필터(종류·품질·별점) · 이미지 정보. 사진에 마우스만 올리면 그 사진의 촬영정보(EXIF)가 정보 패널에 바로 표시됩니다.', + '중앙 상단 크기 슬라이더로 썸네일 밀도를 조절합니다(작게 = 한눈에 컨택트시트, 크게 = 상세).', + '사진을 더블클릭하면 전체화면 뷰어로 열립니다(좌측 정보 + 하단 필름스트립 + 상단 별점·색라벨). ←/→로 이동, Esc로 닫으면 보던 스크롤 위치가 유지됩니다.', + '중앙 상단 퀵필터 칩(오늘 · 이번 주 · 올해 · 베스트 ★4+ · 영상만)으로 원탭 필터링.' + ] + }, + { + h: '9. 사진 평가 · 선택 · 정리 (컬링)', + body: [ + '선택: 사진을 누른 채 쓸면 지나간 범위가 선택됩니다(이미 선택된 사진에서 시작하면 해제). Shift+클릭 범위 선택, Ctrl/⌘+A 전체 선택, Esc 해제.', + '별점 · 색라벨: 우측 패널 또는 사진 위 호버 버튼으로 부여합니다. 여러 장을 선택하면 일괄 적용됩니다.', + '내보내기: 고른 사진을 [폴더로 내보내기]로 한 폴더에 복사합니다(원본 보존).', + '삭제: 삭제하면 즉시 화면에서 빠지고 하단에 "실행취소" 토스트가 뜹니다. 5초 안에 누르면 복원, 안 누르면 휴지통으로 이동합니다(복구 가능).' + ] + }, + { + h: '10. 키보드 단축키 (라이브러리)', + body: [ + '
← → ↑ ↓    커서 이동\n1–5         별점 주기 / 0 해제\nP / X       좋음(초록) / 제외(빨강) 라벨\n[  ]        색라벨 순환\nSpace/Enter 미리보기(전체화면) 열기\nDelete      삭제 (실행취소 가능)\nShift+클릭  범위 선택\nCtrl/⌘ + A  전체 선택   ·   Esc  선택 해제
', + '선택이 있으면 별점/라벨/삭제가 선택한 전체에, 없으면 커서가 가리키는 한 장에 적용됩니다. (입력칸에 글자를 칠 때는 단축키가 동작하지 않습니다.)' + ] + }, + { + h: '11. 검색 · 지도 · 그룹·정화', + body: [ + '검색: 자연어로 사진을 찾습니다(예: "바다", "생일"). 처음 한 번 검색 색인을 만든 뒤 사용하며, 한국어는 자동 번역되어 매칭됩니다.', + '지도: GPS가 있는 사진이 지도에 표시됩니다. 아래 스트립의 위치 없는 사진을 지도 위로 끌어다 놓으면 위치가 지정됩니다(색인에만 저장 — 원본 파일 EXIF는 변경하지 않음).', + '그룹·정화: 비슷한 사진을 자동으로 묶어 보여줘 중복을 골라 정리하도록 돕습니다.' + ] + }, + { + h: '12. 포토모자이크', + body: [ + '라이브러리에서 대표 사진을 한 장 고른 뒤 우측 🧩 이 사진으로 모자이크를 누르면, 라이브러리 사진들이 작은 타일이 되어 그 사진을 재현합니다.', + '해상도(타일 수) · 색 보정 · 격자선으로 느낌을 조절하고, 모자이크의 타일을 클릭하면 그 자리에 실제로 쓰인 원본 사진을 볼 수 있습니다. [PNG 저장]으로 내보냅니다. (사진이 많을수록 색 매칭이 정교해집니다.)' + ] + }, + { + h: '13. 문제 해결', body: [ '썸네일/사진이 안 보임: 참조 사진 원본 파일이 이동/삭제되지 않았는지 확인하세요.', '모두 Unsorted로 감: 프로필에 얼굴(참조 사진)이 등록되었는지 확인하세요.', + '라이브러리가 비어 있음: 폴더를 추가하고 [색인 시작]을 실행했는지 확인하세요.', '설정(언어/테마)은 메뉴 보기에서 언제든 바꿀 수 있습니다.' ] } @@ -91,7 +134,7 @@ const GUIDE: RecordOrganize (auto-sorting), the Library lets you browse, rate and cull photos fast. Follow the steps below.', sections: [ { h: '0. Key concepts', @@ -159,10 +202,53 @@ const GUIDE: RecordLibrary tab is separate from Organize: it indexes a folder in place so you can browse, rate and cull quickly.', + 'In the left Library panel, click [+ Add folder] then [Start indexing] to build thumbnails and metadata. (Originals untouched; thumbnails are cached by the app — non-destructive.)', + 'Left panels: File explorer · Collections (year/camera/color label) · Filters (type·quality·rating) · Image information. Just hover a photo to see its EXIF in the info panel.', + 'Use the Size slider (top center) to change thumbnail density (small = whole-folder contact sheet, large = detail).', + 'Double-click a photo to open the fullscreen viewer (left info + bottom filmstrip + top rating/labels). Move with ←/→; Esc returns with your scroll position preserved.', + 'One-tap quick-filter chips (Today · This week · This year · Best ★4+ · Videos) at the top center.' + ] + }, + { + h: '9. Rate · select · cull', + body: [ + 'Select: press and sweep across photos to select a range (start on a selected one to deselect). Shift+click for range, Ctrl/⌘+A select all, Esc to clear.', + 'Rating · color labels: apply from the right panel or the hover buttons on a photo. Select many to apply in bulk.', + 'Export: copy the chosen photos into one folder with [Export to folder] (originals preserved).', + 'Delete: removed from view immediately with an "Undo" toast. Click it within 5 seconds to restore; otherwise it moves to the Recycle Bin (recoverable).' + ] + }, + { + h: '10. Keyboard shortcuts (Library)', + body: [ + '
← → ↑ ↓    move cursor\n1–5         set rating / 0 clears\nP / X       pick (green) / reject (red) label\n[  ]        cycle color label\nSpace/Enter open fullscreen preview\nDelete      trash (undoable)\nShift+click range select\nCtrl/⌘ + A  select all   ·   Esc  clear
', + 'Rating/label/delete apply to the whole selection if any, otherwise to the single cursor tile. (Shortcuts are off while typing in a text field.)' + ] + }, + { + h: '11. Search · Map · Groups', + body: [ + 'Search: find photos by natural language (e.g. "beach", "birthday"). Build the search index once, then use it; Korean is auto-translated for matching.', + 'Map: geotagged photos appear on the map. Drag a photo without GPS from the strip onto the map to set its location (saved to the index only — original EXIF is not changed).', + 'Groups: automatically clusters similar photos so you can pick duplicates and clean up.' + ] + }, + { + h: '12. Photo mosaic', + body: [ + 'Pick one target photo in the Library, then click 🧩 Make mosaic on the right — your library photos become tiles that recreate that picture.', + 'Tune it with Resolution (tile count) · Color blend · Grid lines, and click a tile to see the source photo placed there. Export with [Save PNG]. (More photos = finer color matching.)' + ] + }, + { + h: '13. Troubleshooting', body: [ 'Thumbnails/photos not showing: make sure the original reference files were not moved or deleted.', 'Everything goes to Unsorted: check that the profile actually has reference faces registered.', + 'Library is empty: make sure you added a folder and ran [Start indexing].', 'Language/theme can be changed anytime from the View menu.' ] } diff --git a/src/main/indexDb.ts b/src/main/indexDb.ts index fc0c83e..bfb832c 100644 --- a/src/main/indexDb.ts +++ b/src/main/indexDb.ts @@ -233,6 +233,14 @@ class IndexDb { ) params.push(query.tag) } + if (query.dateFrom != null) { + conds.push('mtime >= ?') + params.push(query.dateFrom) + } + if (query.dateTo != null) { + conds.push('mtime <= ?') + params.push(query.dateTo) + } return { where: conds.length ? `WHERE ${conds.join(' AND ')}` : '', params } } diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 0c805cc..18b35e6 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -13,6 +13,7 @@ import { SearchView } from './components/SearchView' import { GroupsView } from './components/GroupsView' import { MapView } from './components/MapView' import { FileExplorer } from './components/FileExplorer' +import { Overlays } from './overlays' import type { AppView } from './store' export default function App(): JSX.Element { @@ -137,6 +138,9 @@ export default function App(): JSX.Element { )} + + {/* 토스트 / 확인 모달 호스트 (전역) */} + ) } diff --git a/src/renderer/components/FileExplorer.tsx b/src/renderer/components/FileExplorer.tsx index a32af50..99cf084 100644 --- a/src/renderer/components/FileExplorer.tsx +++ b/src/renderer/components/FileExplorer.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useState } from 'react' import { useStore } from '../store' import { useT } from '../i18n' +import { toast, confirm, promptText } from '../overlays' import type { FsEntry } from '@shared/types' /** 경로의 부모 디렉터리 (Windows/POSIX 겸용) */ @@ -56,30 +57,30 @@ export function FileExplorer(): JSX.Element { const copyPath = (p: string) => void navigator.clipboard?.writeText(p) const newFolder = async (parent: string) => { - const name = window.prompt(t('explorer.newFolderPrompt')) + const name = await promptText(t('explorer.newFolderPrompt')) if (!name) return try { await window.api.fs.mkdir(parent, name) await reload(parent) setExpanded((prev) => new Set(prev).add(parent)) } catch (e) { - window.alert(t('explorer.opFailed', { msg: (e as Error).message })) + toast(t('explorer.opFailed', { msg: (e as Error).message }), { tone: 'error' }) } } const deleteFolder = async (path: string) => { - if (!window.confirm(t('explorer.confirmDelete', { name: baseOf(path) }))) return + if (!(await confirm(t('explorer.confirmDelete', { name: baseOf(path) }), { danger: true, confirmLabel: t('explorer.delete') }))) return try { await window.api.fs.trash(path) if (selected === path) setSelected(null) await reload(parentOf(path)) } catch (e) { - window.alert(t('explorer.opFailed', { msg: (e as Error).message })) + toast(t('explorer.opFailed', { msg: (e as Error).message }), { tone: 'error' }) } } const moveFolder = async (src: string, destDir: string) => { - if (!window.confirm(t('explorer.confirmMove', { src: baseOf(src), dest: baseOf(destDir) }))) return + if (!(await confirm(t('explorer.confirmMove', { src: baseOf(src), dest: baseOf(destDir) })))) return try { await window.api.fs.move(src, destDir) if (selected === src) setSelected(null) @@ -87,7 +88,7 @@ export function FileExplorer(): JSX.Element { await reload(destDir) setExpanded((prev) => new Set(prev).add(destDir)) } catch (e) { - window.alert(t('explorer.opFailed', { msg: (e as Error).message })) + toast(t('explorer.opFailed', { msg: (e as Error).message }), { tone: 'error' }) } } diff --git a/src/renderer/components/GroupsView.tsx b/src/renderer/components/GroupsView.tsx index 8c81303..705229a 100644 --- a/src/renderer/components/GroupsView.tsx +++ b/src/renderer/components/GroupsView.tsx @@ -1,5 +1,6 @@ import { useState } from 'react' import { useT } from '../i18n' +import { toast, confirm } from '../overlays' import { thumbUrl, baseName } from '../media' import type { AssetGroup, IndexedAsset } from '@shared/types' @@ -45,7 +46,7 @@ export function GroupsView(): JSX.Element { const trash = async () => { if (selected.size === 0) return - if (!window.confirm(t('groups.confirmTrash', { n: selected.size }))) return + if (!(await confirm(t('groups.confirmTrash', { n: selected.size }), { danger: true }))) return setTrashing(true) try { const ids = [...selected] @@ -61,7 +62,7 @@ export function GroupsView(): JSX.Element { .filter((g) => g.members.length > 1) ) setSelected(new Set()) - window.alert(t('groups.trashed', { n })) + toast(t('groups.trashed', { n }), { tone: 'success' }) } finally { setTrashing(false) } diff --git a/src/renderer/components/LibraryView.tsx b/src/renderer/components/LibraryView.tsx index 47ffda6..69a03d4 100644 --- a/src/renderer/components/LibraryView.tsx +++ b/src/renderer/components/LibraryView.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { ReactNode } from 'react' +import type { ReactNode, MouseEvent as ReactMouseEvent } from 'react' import { useStore } from '../store' import { useT } from '../i18n' import { thumbUrl, mediaUrl, baseName } from '../media' import { MosaicView } from './MosaicView' +import { toast } from '../overlays' import type { IndexedAsset, QualityFilter, @@ -75,6 +76,13 @@ export function LibraryView(): JSX.Element { const [tags, setTags] = useState([]) const [selected, setSelected] = useState>(new Set()) const [busy, setBusy] = useState(false) + // 스마트 퀵필터: 시간 칩(오늘/이번주/올해)이 설정하는 mtime 범위 + 활성 칩 표시 + const [dateRange, setDateRange] = useState<{ from: number; to: number } | null>(null) + const [timeChip, setTimeChip] = useState<'today' | 'week' | 'year' | null>(null) + // 키보드 컬링 커서(그리드 내 인덱스) + 선택 앵커(Shift 범위선택용) + const [cursor, setCursor] = useState(-1) + const anchorRef = useRef(-1) + const gridScrollRef = useRef(null) // 그리드 밀도(열 수). 작을수록 큰 썸네일, 클수록 고밀도 컨택트시트 const [columns, setColumns] = useState(6) // 전체화면 뷰어(라이트박스) 대상. null이면 닫힘 @@ -97,8 +105,19 @@ export function LibraryView(): JSX.Element { const [tagInput, setTagInput] = useState('') const query = useMemo( - () => ({ filter, kind, ratingMin, year, camera, label: labelFilter, folder, tag: tagFilter }), - [filter, kind, ratingMin, year, camera, labelFilter, folder, tagFilter] + () => ({ + filter, + kind, + ratingMin, + year, + camera, + label: labelFilter, + folder, + tag: tagFilter, + dateFrom: dateRange?.from ?? null, + dateTo: dateRange?.to ?? null + }), + [filter, kind, ratingMin, year, camera, labelFilter, folder, tagFilter, dateRange] ) const loadAssets = useCallback(async (offset: number, q: AssetQuery) => { @@ -225,16 +244,36 @@ export function LibraryView(): JSX.Element { return () => window.removeEventListener('mouseup', onUp) }, []) - const onTileClick = (a: IndexedAsset) => { + // 범위 선택(Shift+클릭): 앵커~현재 사이를 모두 선택 + const selectRange = (from: number, to: number) => { + const lo = Math.min(from, to) + const hi = Math.max(from, to) + setSelected((prev) => { + const next = new Set(prev) + for (let i = lo; i <= hi; i++) { + const id = assets[i]?.id + if (id != null) next.add(id) + } + return next + }) + } + const onTileClick = (a: IndexedAsset, idx: number, e: ReactMouseEvent) => { if (suppressClickRef.current) { // 방금 드래그 선택을 마침 → 뒤따르는 click 토글 무시 suppressClickRef.current = false return } + setFocused(a) + setCursor(idx) + // Shift+클릭 → 앵커부터 범위 선택 (토글 아님) + if (e.shiftKey && anchorRef.current >= 0) { + selectRange(anchorRef.current, idx) + return + } + anchorRef.current = idx if (clickTimer.current != null) return // 더블클릭 진행 중 — 두 번째 클릭 무시 clickTimer.current = window.setTimeout(() => { clickTimer.current = null - setFocused(a) toggleSelect(a) }, 200) } @@ -279,6 +318,163 @@ export function LibraryView(): JSX.Element { const targetIds = (): number[] => selected.size > 0 ? [...selected] : focused?.id != null ? [focused.id] : [] + // 스마트 시간 칩(오늘/이번 주/올해) — mtime 범위로 필터 + const applyTimeChip = (which: 'today' | 'week' | 'year') => { + if (timeChip === which) { + setTimeChip(null) + setDateRange(null) + return + } + const now = new Date() + const to = now.getTime() + const start = new Date(now) + start.setHours(0, 0, 0, 0) + if (which === 'week') start.setDate(start.getDate() - 6) + else if (which === 'year') { + start.setMonth(0, 1) + } + setTimeChip(which) + setDateRange({ from: start.getTime(), to }) + } + + // 키보드 컬링 대상: 선택이 있으면 선택 전체, 없으면 커서(또는 포커스) 1장 + const cullTargets = useCallback((): IndexedAsset[] => { + if (selected.size > 0) return assets.filter((a) => a.id != null && selected.has(a.id)) + const a = cursor >= 0 ? assets[cursor] : focused + return a ? [a] : [] + }, [selected, assets, cursor, focused]) + const applyRating = useCallback( + async (rating: number) => { + for (const a of cullTargets()) { + if (a.id == null) continue + await window.api.index.setRating(a.id, rating) + patchAsset(a.id, { rating }) + } + }, + [cullTargets] + ) + const applyLabelSet = useCallback( + async (label: ColorLabel) => { + for (const a of cullTargets()) { + if (a.id == null) continue + await window.api.index.setLabel(a.id, label) + patchAsset(a.id, { label }) + } + void refreshFacets() + }, + [cullTargets, refreshFacets] + ) + const cycleLabel = useCallback( + async (dir: 1 | -1) => { + const order: Exclude[] = LABEL_COLORS.map((c) => c.id) + for (const a of cullTargets()) { + if (a.id == null) continue + const i = a.label ? order.indexOf(a.label) : -1 + const ni = a.label == null ? (dir > 0 ? 0 : order.length - 1) : i + dir + const next: ColorLabel = ni < 0 || ni >= order.length ? null : order[ni] + await window.api.index.setLabel(a.id, next) + patchAsset(a.id, { label: next }) + } + void refreshFacets() + }, + [cullTargets, refreshFacets] + ) + + // 삭제: 즉시 UI에서 빼고 5초 뒤 실제 휴지통 이동 — 그 사이 토스트의 '실행취소'로 되돌림 (Gmail식) + const trashIds = useCallback( + (ids: number[]) => { + if (ids.length === 0) return + const removed = new Set(ids) + setAssets((prev) => prev.filter((x) => x.id == null || !removed.has(x.id))) + setSelected((prev) => { + const next = new Set(prev) + ids.forEach((id) => next.delete(id)) + return next + }) + let undone = false + const timer = window.setTimeout(() => { + if (undone) return + void window.api.groups.trash(ids).then(() => refreshFacets()) + }, 5000) + toast(t('sel.trashedSoft', { n: ids.length }), { + action: { + label: t('action.undo'), + onClick: () => { + undone = true + clearTimeout(timer) + void loadAssets(0, query) // 아직 DB엔 남아있으므로 다시 불러오면 복원 + } + } + }) + }, + [refreshFacets, loadAssets, query, t] + ) + + // 그리드 전역 키보드 컬링 (뷰어/모자이크 열림 시·입력창 포커스 시 비활성) + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (viewer || mosaicTarget) return + const tag = (e.target as HTMLElement | null)?.tagName + if (tag === 'INPUT' || tag === 'TEXTAREA') return + if (e.metaKey || e.ctrlKey) { + if (e.key === 'a' || e.key === 'A') { + e.preventDefault() + void selectAll() + } + return + } + const n = assets.length + if (n === 0) return + const cur = cursor < 0 ? 0 : cursor + const moveTo = (idx: number) => { + const c = Math.max(0, Math.min(n - 1, idx)) + setCursor(c) + setFocused(assets[c]) + anchorRef.current = c + } + switch (e.key) { + case 'ArrowRight': e.preventDefault(); moveTo(cur + 1); break + case 'ArrowLeft': e.preventDefault(); moveTo(cur - 1); break + case 'ArrowDown': e.preventDefault(); moveTo(cur + columns); break + case 'ArrowUp': e.preventDefault(); moveTo(cur - columns); break + case 'Escape': clearSelection(); break + case ' ': + case 'Enter': { + e.preventDefault() + const a = assets[cur] + if (a) { setFocused(a); setViewer(a) } + break + } + case 'Delete': + case 'Backspace': { + e.preventDefault() + const ids = selected.size > 0 ? [...selected] : assets[cur]?.id != null ? [assets[cur].id!] : [] + trashIds(ids) + break + } + case 'p': case 'P': e.preventDefault(); void applyLabelSet('green'); break + case 'x': case 'X': e.preventDefault(); void applyLabelSet('red'); break + case '[': e.preventDefault(); void cycleLabel(-1); break + case ']': e.preventDefault(); void cycleLabel(1); break + default: + if (e.key >= '0' && e.key <= '5') { + e.preventDefault() + void applyRating(Number(e.key)) + } + } + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewer, mosaicTarget, assets, cursor, columns, selected, applyRating, applyLabelSet, cycleLabel]) + + // 커서 이동 시 해당 타일을 보이도록 스크롤 + useEffect(() => { + if (cursor < 0) return + const el = gridScrollRef.current?.querySelector(`[data-idx="${cursor}"]`) + el?.scrollIntoView({ block: 'nearest' }) + }, [cursor]) + // 일괄 평가 (선택 대상) const bulkRate = async (rating: number) => { const ids = targetIds() @@ -292,34 +488,20 @@ export function LibraryView(): JSX.Element { void refreshFacets() } - // 내보내기 / 삭제 + // 내보내기 const exportSelected = async () => { if (selected.size === 0) return setBusy(true) try { const r = await window.api.index.export([...selected]) - if (r) window.alert(t('sel.exported', { n: r.count, dest: r.dest })) - } finally { - setBusy(false) - } - } - const deleteSelected = async () => { - if (selected.size === 0) return - if (!window.confirm(t('sel.confirmDelete', { n: selected.size }))) return - setBusy(true) - try { - const ids = [...selected] - const n = await window.api.groups.trash(ids) - const removed = new Set(ids) - setAssets((prev) => prev.filter((x) => x.id == null || !removed.has(x.id))) - clearSelection() - void refreshFacets() - window.alert(t('sel.deleted', { n })) + if (r) toast(t('sel.exportedShort', { n: r.count, dest: baseName(r.dest) }), { tone: 'success' }) } finally { setBusy(false) } } + const deleteSelected = () => trashIds([...selected]) + // 태깅 / 메타데이터 const attachTag = async () => { const name = tagInput.trim() @@ -339,7 +521,7 @@ export function LibraryView(): JSX.Element { const saveMeta = async () => { if (focused?.id == null) return await window.api.meta.set(focused.id, meta) - window.alert(t('meta.saved')) + toast(t('meta.saved'), { tone: 'success' }) } const running = indexPhase === 'running' @@ -531,10 +713,18 @@ export function LibraryView(): JSX.Element { {/* ===== 중앙: 그리드 ===== */}
-
- {t('lib.grid')} - · {assets.length} - {selected.size > 0 && · {t('sel.count', { n: selected.size })}} +
+ {t('lib.grid')} + · {assets.length} + {selected.size > 0 && · {t('sel.count', { n: selected.size })}} + {/* 스마트 퀵필터 칩 */} +
+ applyTimeChip('today')}>{t('chip.today')} + applyTimeChip('week')}>{t('chip.week')} + applyTimeChip('year')}>{t('chip.year')} + = 4} onClick={() => setRatingMin(ratingMin >= 4 ? 0 : 4)}>{t('chip.best')} + setKind(kind === 'video' ? 'all' : 'video')}>{t('chip.video')} +
{/* 밀도(썸네일 크기) 슬라이더 — 작게=컨택트시트, 크게=상세 */}
-
+
{assets.length === 0 ? (

{t('lib.gridEmpty')}

) : ( @@ -570,12 +760,14 @@ export function LibraryView(): JSX.Element { {assets.map((a, i) => ( = 9} - onClickTile={() => onTileClick(a)} + onClickTile={(e) => onTileClick(a, i, e)} onDoubleTile={() => onTileDouble(a)} onHover={() => { setHovered(a) @@ -587,6 +779,9 @@ export function LibraryView(): JSX.Element { /> ))}
+

+ {t('kbd.hint')} +

{hasMore && (
+ ) +} + function FilterLabel(props: { children: ReactNode; className?: string }): JSX.Element { return (
void + onClickTile: (e: ReactMouseEvent) => void onDoubleTile: () => void onHover: () => void onMouseDownTile: () => void onRate: (rating: number) => void onLabel: (label: Exclude) => void }): JSX.Element { - const { asset: a, selected, focused, compact } = props + const { asset: a, selected, focused, cursor, compact } = props const isVideo = VIDEO_EXTS.includes(a.ext) const labelColor = a.label ? LABEL_COLORS.find((c) => c.id === a.label)?.cls : null const stop = (e: { stopPropagation: () => void }) => e.stopPropagation() @@ -1080,15 +1293,19 @@ function AssetTile(props: { return (
void } +export type ToastItem = { id: number; message: string; tone: ToastTone; action?: ToastAction } + +type ToastState = { + toasts: ToastItem[] + push: ( + message: string, + opts?: { tone?: ToastTone; action?: ToastAction; duration?: number } + ) => number + dismiss: (id: number) => void +} + +let seq = 1 +const useToastStore = create((set, get) => ({ + toasts: [], + push: (message, opts) => { + const id = seq++ + set((s) => ({ + toasts: [...s.toasts, { id, message, tone: opts?.tone ?? 'info', action: opts?.action }] + })) + const duration = opts?.duration ?? (opts?.action ? 6000 : 3200) + if (duration > 0) window.setTimeout(() => get().dismiss(id), duration) + return id + }, + dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((x) => x.id !== id) })) +})) + +/** 어디서든 호출 가능한 토스트 (훅 아님). action을 주면 Undo 등 버튼이 붙는다. */ +export function toast( + message: string, + opts?: { tone?: ToastTone; action?: ToastAction; duration?: number } +): number { + return useToastStore.getState().push(message, opts) +} + +/* ===================== Confirm (promise-based, replaces window.confirm) ===================== */ + +type ConfirmReq = { + id: number + message: string + confirmLabel: string + danger: boolean + resolve: (ok: boolean) => void +} +type ConfirmState = { + current: ConfirmReq | null + ask: (message: string, opts?: { confirmLabel?: string; danger?: boolean }) => Promise + answer: (ok: boolean) => void +} + +const useConfirmStore = create((set, get) => ({ + current: null, + ask: (message, opts) => + new Promise((resolve) => { + set({ + current: { + id: seq++, + message, + confirmLabel: opts?.confirmLabel ?? '확인', + danger: opts?.danger ?? false, + resolve + } + }) + }), + answer: (ok) => { + const cur = get().current + if (cur) cur.resolve(ok) + set({ current: null }) + } +})) + +/** await confirm('정말 삭제할까요?', { danger:true }) → boolean */ +export function confirm( + message: string, + opts?: { confirmLabel?: string; danger?: boolean } +): Promise { + return useConfirmStore.getState().ask(message, opts) +} + +/* ===================== Prompt (text input, replaces window.prompt) ===================== */ + +type PromptReq = { + id: number + message: string + value: string + placeholder: string + resolve: (value: string | null) => void +} +type PromptState = { + current: PromptReq | null + ask: (message: string, opts?: { initial?: string; placeholder?: string }) => Promise + setValue: (v: string) => void + answer: (value: string | null) => void +} + +const usePromptStore = create((set, get) => ({ + current: null, + ask: (message, opts) => + new Promise((resolve) => { + set({ + current: { + id: seq++, + message, + value: opts?.initial ?? '', + placeholder: opts?.placeholder ?? '', + resolve + } + }) + }), + setValue: (v) => set((s) => (s.current ? { current: { ...s.current, value: v } } : s)), + answer: (value) => { + const cur = get().current + if (cur) cur.resolve(value) + set({ current: null }) + } +})) + +/** await promptText('새 폴더 이름:') → string | null */ +export function promptText( + message: string, + opts?: { initial?: string; placeholder?: string } +): Promise { + return usePromptStore.getState().ask(message, opts) +} + +/* ===================== Host component (mount once in App) ===================== */ + +const TONE_CLS: Record = { + info: 'border-slate-600', + success: 'border-emerald-500/60', + error: 'border-red-500/60' +} + +export function Overlays(): JSX.Element { + const toasts = useToastStore((s) => s.toasts) + const dismiss = useToastStore((s) => s.dismiss) + const confirmReq = useConfirmStore((s) => s.current) + const answer = useConfirmStore((s) => s.answer) + const promptReq = usePromptStore((s) => s.current) + const setPromptValue = usePromptStore((s) => s.setValue) + const promptAnswer = usePromptStore((s) => s.answer) + + return ( + <> + {/* 토스트 스택 (하단 중앙) */} +
+ {toasts.map((tt) => ( +
+ {tt.message} + {tt.action && ( + + )} + +
+ ))} +
+ + {/* 확인 모달 */} + {confirmReq && ( +
answer(false)} + > +
e.stopPropagation()} + > +

+ {confirmReq.message} +

+
+ + +
+
+
+ )} + + {/* 입력 모달 */} + {promptReq && ( +
promptAnswer(null)} + > +
e.stopPropagation()} + > +

{promptReq.message}

+ setPromptValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') promptAnswer(promptReq.value) + else if (e.key === 'Escape') promptAnswer(null) + }} + className="w-full border border-slate-300 dark:border-neutral-600 dark:bg-neutral-700 dark:text-slate-100 rounded px-2 py-1.5 text-sm mb-4" + /> +
+ + +
+
+
+ )} + + ) +} diff --git a/src/renderer/styles/index.css b/src/renderer/styles/index.css index 7588ee1..baea8aa 100644 --- a/src/renderer/styles/index.css +++ b/src/renderer/styles/index.css @@ -34,6 +34,18 @@ html.dark body { font-family: 'Cascadia Code', 'Consolas', monospace; } +/* 토스트 등장 */ +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + /* darktable식 얇은 스크롤바 */ ::-webkit-scrollbar { width: 10px; diff --git a/src/shared/i18n.ts b/src/shared/i18n.ts index 1ac6f74..d9581d7 100644 --- a/src/shared/i18n.ts +++ b/src/shared/i18n.ts @@ -323,10 +323,23 @@ export const MESSAGES: Table = { en: 'Move {n} selected items to the Recycle Bin?\n(Originals are moved to the trash and can be restored.)\nContinue?' }, 'sel.deleted': { ko: '{n}개를 휴지통으로 이동했습니다.', en: 'Moved {n} to the trash.' }, + 'sel.trashedSoft': { ko: '{n}장 삭제됨', en: 'Deleted {n}' }, + 'sel.exportedShort': { ko: '{n}장 내보냄 → {dest}', en: 'Exported {n} → {dest}' }, 'sel.exported': { ko: '{n}개를 내보냈습니다.\n{dest}', en: 'Exported {n} items.\n{dest}' }, + 'action.undo': { ko: '실행취소', en: 'Undo' }, + // 스마트 퀵필터 칩 + 'chip.today': { ko: '오늘', en: 'Today' }, + 'chip.week': { ko: '이번 주', en: 'This week' }, + 'chip.year': { ko: '올해', en: 'This year' }, + 'chip.best': { ko: '베스트 ★4+', en: 'Best ★4+' }, + 'chip.video': { ko: '영상만', en: 'Videos' }, + 'kbd.hint': { + ko: '키보드: ←→↑↓ 이동 · 1-5 별점 · P/X 좋음·제외 · [ ] 색라벨 · Space 미리보기 · Del 삭제', + en: 'Keys: ←→↑↓ move · 1-5 rate · P/X pick·reject · [ ] color · Space preview · Del trash' + }, // 미디어 종류 (사진/영상 분리) 'media.all': { ko: '전체', en: 'All' }, diff --git a/src/shared/types.ts b/src/shared/types.ts index 657ed94..a0f9e4e 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -220,6 +220,9 @@ export interface AssetQuery { folder?: string | null /** 태그 필터 */ tag?: string | null + /** 파일시각(mtime, epoch ms) 범위 — 스마트 칩(오늘/이번 주/올해)용 */ + dateFrom?: number | null + dateTo?: number | null } /** 컬렉션 패싯 한 항목 */