9b044449a0
- profiles get an optional birthday (MM-DD); photos of that person taken on the date are also copied into Birthdays/<person>/<year>/ - app-wide anniversaries (label + MM-DD); any photo taken on the date is copied into Anniversaries/<label>/<year>/ (including faceless photos and videos) - copy (not move) so normal person/date sorting is preserved - CaptureDate gains day; new collection path builder; scanner skips the new folders - UI: birthday input in profile create/edit + new Anniversaries manager Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
149 lines
6.1 KiB
TypeScript
149 lines
6.1 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { useStore, wireEvents } from './store'
|
|
import { useT } from './i18n'
|
|
import { Onboarding } from './components/Onboarding'
|
|
import { ProfileManager } from './components/ProfileManager'
|
|
import { AnniversaryManager } from './components/AnniversaryManager'
|
|
import { FolderPicker } from './components/FolderPicker'
|
|
import { RunControl } from './components/RunControl'
|
|
import { ProgressView } from './components/ProgressView'
|
|
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 { FileExplorer } from './components/FileExplorer'
|
|
import { Overlays } from './overlays'
|
|
import type { AppView } from './store'
|
|
|
|
export default function App(): JSX.Element {
|
|
const t = useT()
|
|
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)
|
|
const [ready, setReady] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const unwire = wireEvents()
|
|
// 설정 로드(테마 적용 포함) 후 화면 표시
|
|
void initSettings().then(() => setReady(true))
|
|
void refreshProfiles()
|
|
return unwire
|
|
}, [refreshProfiles, initSettings])
|
|
|
|
if (!ready) return <div className="h-full" />
|
|
if (!onboarded) return <Onboarding />
|
|
|
|
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">
|
|
{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>
|
|
<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 flex">
|
|
{/* 좌측: 파일 탐색기 사이드바 */}
|
|
<aside className="w-56 shrink-0 bg-slate-100 dark:bg-slate-800 border-r border-slate-200 dark:border-slate-700">
|
|
<FileExplorer />
|
|
</aside>
|
|
|
|
<div className="flex-1 min-w-0 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 />
|
|
<AnniversaryManager />
|
|
<FolderPicker />
|
|
<RunControl />
|
|
</section>
|
|
|
|
{/* 진행/결과 — FileList만 내부 스크롤 */}
|
|
<section className="col-span-7 min-h-0 flex flex-col gap-4">
|
|
<div className="shrink-0">
|
|
{phase === 'done' ? <ReportView /> : <ProgressView />}
|
|
</div>
|
|
<FileList />
|
|
</section>
|
|
</div>
|
|
</main>
|
|
) : view === 'library' ? (
|
|
<main className="flex-1 min-h-0">
|
|
<LibraryView />
|
|
</main>
|
|
) : 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>
|
|
)}
|
|
|
|
{/* 토스트 / 확인 모달 호스트 (전역) */}
|
|
<Overlays />
|
|
</div>
|
|
)
|
|
}
|