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:
+68
-31
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user