darktable-inspired reskin + metadata/collections, map, easy mode, select/export

UI overhaul to a darktable tone-and-manner and a set of features adapted from
darktable's proven patterns (reimplemented in our Electron/TS stack; no GPL code).

Design reskin:
- Dark neutral-gray palette + amber accent, flat/squared corners, no card shadows,
  compact darktable-style top bar (logo + pipe-separated view tabs), denser 15px base
- Done via design tokens (Tailwind slate/brand/radius/shadow remap) — minimal churn

Metadata & collections (Phase A/B):
- exifr now captures GPS + camera; asset table ALTER-migrated (gpsLat/gpsLon/camera,
  metaVersion backfill on re-index)
- Collection facet bar (year timeline / camera / color-label) filters the grid

Map & relation finder (Phase C):
- Leaflet + online OSM map tab; geotagged photos as markers
- relationService: related photos by place (GPS<1km) + time (+/-2d) + CLIP similarity

Easy mode (Phase D):
- easyMode setting (menu / onboarding); scales the whole UI (rem) + bigger thumbnails
  + large icon nav with plain labels (4050 accessibility)

Library usability:
- Video thumbnails (representative frame capture in the inference worker)
- Media filter (All / Photos / Videos) to separate them
- Clearer culling labels ("Good shots" / "To cull") + explanation tooltip
- Multi-select tiles -> Export selected to a folder (copy, best-cut extraction) and
  Delete to Recycle Bin (shell.trashItem) behind a confirm dialog
- ONNX Runtime wasm bundled locally (offline) via copy-ort-wasm + asarUnpack

Docs: DARKTABLE_REVIEW (feasibility + roadmap A->D). All typecheck/tests/build green;
boot smoke verified each phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 19:22:19 +09:00
parent 72c41ae834
commit 3e73967c7b
33 changed files with 1670 additions and 96 deletions
+68 -31
View File
@@ -10,6 +10,8 @@ import { FileList } from './components/FileList'
import { ReportView } from './components/ReportView'
import { LibraryView } from './components/LibraryView'
import { SearchView } from './components/SearchView'
import { GroupsView } from './components/GroupsView'
import { MapView } from './components/MapView'
import type { AppView } from './store'
export default function App(): JSX.Element {
@@ -17,6 +19,7 @@ export default function App(): JSX.Element {
const phase = useStore((s) => s.phase)
const view = useStore((s) => s.view)
const setView = useStore((s) => s.setView)
const easyMode = useStore((s) => s.easyMode)
const onboarded = useStore((s) => s.onboarded)
const refreshProfiles = useStore((s) => s.refreshProfiles)
const initSettings = useStore((s) => s.initSettings)
@@ -33,41 +36,67 @@ export default function App(): JSX.Element {
if (!ready) return <div className="h-full" />
if (!onboarded) return <Onboarding />
const tabs: { id: AppView; label: string }[] = [
{ id: 'organize', label: t('nav.organize') },
{ id: 'library', label: t('nav.library') },
{ id: 'search', label: t('nav.search') }
const tabs: { id: AppView; label: string; easyLabel: string; icon: string }[] = [
{ id: 'organize', label: t('nav.organize'), easyLabel: t('easynav.organize'), icon: '📂' },
{ id: 'library', label: t('nav.library'), easyLabel: t('easynav.library'), icon: '🖼️' },
{ id: 'search', label: t('nav.search'), easyLabel: t('easynav.search'), icon: '🔍' },
{ id: 'map', label: t('nav.map'), easyLabel: t('easynav.map'), icon: '🗺️' },
{ id: 'groups', label: t('nav.groups'), easyLabel: t('easynav.groups'), icon: '🧹' }
]
return (
<div className="h-full flex flex-col">
<header className="px-6 pt-4 bg-white dark:bg-slate-800 border-b border-slate-200 dark:border-slate-700 shadow-sm shrink-0">
<div className="flex items-end justify-between">
<div>
<h1 className="text-xl font-bold text-brand-dark dark:text-brand">{t('app.title')}</h1>
<p className="text-sm text-slate-500 dark:text-slate-400">{t('app.subtitle')}</p>
{easyMode ? (
/* 쉬운 모드: 대형 아이콘+구어체 버튼 네비 */
<header className="px-5 py-3 bg-slate-100 dark:bg-slate-800 border-b border-slate-300 dark:border-slate-700 shrink-0">
<nav className="grid grid-cols-5 gap-2">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setView(tab.id)}
className={`flex flex-col items-center gap-1 rounded-md py-3 border transition-colors ${
view === tab.id
? 'border-brand bg-brand/15 text-brand'
: 'border-slate-300 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:border-brand'
}`}
>
<span className="text-2xl leading-none">{tab.icon}</span>
<span className="text-base font-semibold">{tab.easyLabel}</span>
</button>
))}
</nav>
</header>
) : (
/* darktable식 컴팩트 상단바: 좌측 로고 · 우측 파이프 구분 탭 */
<header className="h-11 px-4 bg-slate-100 dark:bg-slate-800 border-b border-slate-300 dark:border-slate-700 shrink-0 flex items-center justify-between">
<div className="flex items-center gap-2 select-none">
<span className="text-brand text-base leading-none"></span>
<span className="text-sm font-semibold tracking-widest lowercase text-slate-600 dark:text-slate-300">
photoai
</span>
</div>
</div>
{/* 탭 네비 */}
<nav className="flex gap-1 mt-3 -mb-px">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setView(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
view === tab.id
? 'border-brand text-brand'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-brand'
}`}
>
{tab.label}
</button>
))}
</nav>
</header>
<nav className="flex items-center">
{tabs.map((tab, i) => (
<span key={tab.id} className="flex items-center">
{i > 0 && <span className="text-slate-300 dark:text-slate-600 px-1.5">|</span>}
<button
onClick={() => setView(tab.id)}
className={`text-sm tracking-wide transition-colors ${
view === tab.id
? 'text-brand font-semibold'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-100'
}`}
>
{tab.label}
</button>
</span>
))}
</nav>
</header>
)}
{view === 'organize' ? (
<main className="flex-1 min-h-0 grid grid-cols-12 gap-4 p-6">
<main className="flex-1 min-h-0 grid grid-cols-12 gap-3 p-4">
{/* 좌측: 설정 패널 (자체 스크롤) */}
<section className="col-span-5 min-h-0 flex flex-col gap-4 overflow-y-auto pr-2">
<ProfileManager />
@@ -84,13 +113,21 @@ export default function App(): JSX.Element {
</section>
</main>
) : view === 'library' ? (
<main className="flex-1 min-h-0 overflow-y-auto p-6">
<main className="flex-1 min-h-0 overflow-y-auto p-4">
<LibraryView />
</main>
) : (
<main className="flex-1 min-h-0 overflow-y-auto p-6">
) : view === 'search' ? (
<main className="flex-1 min-h-0 overflow-y-auto p-4">
<SearchView />
</main>
) : view === 'map' ? (
<main className="flex-1 min-h-0 overflow-y-auto p-4">
<MapView />
</main>
) : (
<main className="flex-1 min-h-0 overflow-y-auto p-4">
<GroupsView />
</main>
)}
</div>
)