Initial commit: AI Photo Organizer (Electron + face-api)
Local-first photo organizer that auto-sorts images by face recognition and EXIF capture date. - Electron app with 3-process split: Main (Node) / UI Renderer (React) / hidden Inference Renderer (face-api + WebGL) - Core pipeline: scan -> face match + EXIF -> path build -> atomic move/copy - Move = copy -> verify -> delete; auto-rename on filename collision - 1st-registered profile = move, others = copy; unmatched -> [미정]/YYYY/MM - EXIF date with mtime fallback - Vitest unit tests (pathBuilder / fileOps / concurrency) all green - electron-builder config for Windows (nsis) + macOS (dmg) - Docs: PRD / DECISIONS / ARCHITECTURE Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useStore, wireEvents } from './store'
|
||||
import { ProfileManager } from './components/ProfileManager'
|
||||
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'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const phase = useStore((s) => s.phase)
|
||||
const refreshProfiles = useStore((s) => s.refreshProfiles)
|
||||
|
||||
useEffect(() => {
|
||||
const unwire = wireEvents()
|
||||
void refreshProfiles()
|
||||
return unwire
|
||||
}, [refreshProfiles])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<header className="px-6 py-4 bg-white border-b border-slate-200 shadow-sm">
|
||||
<h1 className="text-xl font-bold text-brand-dark">AI Photo Organizer</h1>
|
||||
<p className="text-sm text-slate-500">
|
||||
얼굴 인식 + 촬영일 기준 자동 사진 정리 · 로컬 전용
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 grid grid-cols-12 gap-4 p-6 overflow-hidden">
|
||||
{/* 좌측: 설정 패널 */}
|
||||
<section className="col-span-5 flex flex-col gap-4 overflow-y-auto pr-2">
|
||||
<ProfileManager />
|
||||
<FolderPicker />
|
||||
<RunControl />
|
||||
</section>
|
||||
|
||||
{/* 우측: 진행/결과 */}
|
||||
<section className="col-span-7 flex flex-col gap-4 overflow-hidden">
|
||||
{phase === 'done' ? <ReportView /> : <ProgressView />}
|
||||
<FileList />
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useStore } from '../store'
|
||||
import type { FileDecisionKind } from '@shared/types'
|
||||
|
||||
const KIND_STYLE: Record<FileDecisionKind, { label: string; cls: string }> = {
|
||||
moved: { label: '이동', cls: 'bg-emerald-100 text-emerald-700' },
|
||||
copied: { label: '복사', cls: 'bg-sky-100 text-sky-700' },
|
||||
unmatched: { label: '미정', cls: 'bg-slate-200 text-slate-600' },
|
||||
failed: { label: '실패', cls: 'bg-red-100 text-red-700' }
|
||||
}
|
||||
|
||||
function baseName(p: string): string {
|
||||
const idx = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'))
|
||||
return idx >= 0 ? p.slice(idx + 1) : p
|
||||
}
|
||||
|
||||
/** 처리 결과 스트림 (최근 건 상단) */
|
||||
export function FileList(): JSX.Element {
|
||||
const processed = useStore((s) => s.processed)
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4 flex-1 overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold">처리 내역</h2>
|
||||
<span className="text-xs text-slate-400">최근 {processed.length}건</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{processed.length === 0 ? (
|
||||
<p className="text-sm text-slate-400 py-4">아직 처리된 파일이 없습니다.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-slate-100">
|
||||
{processed.map((f, i) => {
|
||||
const style = KIND_STYLE[f.kind]
|
||||
return (
|
||||
<li key={`${f.file}-${i}`} className="py-2 flex items-center gap-3">
|
||||
<span
|
||||
className={`text-[11px] font-semibold rounded px-2 py-0.5 ${style.cls}`}
|
||||
>
|
||||
{style.label}
|
||||
</span>
|
||||
<span className="mono text-xs truncate flex-1" title={f.file}>
|
||||
{baseName(f.file)}
|
||||
</span>
|
||||
<span className="text-xs text-slate-400">
|
||||
{f.matchedNames.length > 0
|
||||
? f.matchedNames.join(', ')
|
||||
: f.error
|
||||
? f.error.slice(0, 40)
|
||||
: '—'}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useStore } from '../store'
|
||||
|
||||
/** 소스 폴더 + 출력 루트 선택 */
|
||||
export function FolderPicker(): JSX.Element {
|
||||
const source = useStore((s) => s.source)
|
||||
const outputRoot = useStore((s) => s.outputRoot)
|
||||
const setSource = useStore((s) => s.setSource)
|
||||
const setOutput = useStore((s) => s.setOutput)
|
||||
|
||||
const pickSource = async () => {
|
||||
const p = await window.api.dialog.pickSource()
|
||||
if (p) setSource(p)
|
||||
}
|
||||
const pickOutput = async () => {
|
||||
const p = await window.api.dialog.pickOutput()
|
||||
if (p) setOutput(p)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h2 className="font-semibold mb-3">2. 폴더 선택</h2>
|
||||
|
||||
<Row label="정리할 폴더 (소스)" value={source} onPick={pickSource} />
|
||||
<Row label="결과 저장 폴더 (출력)" value={outputRoot} onPick={pickOutput} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Row(props: {
|
||||
label: string
|
||||
value: string | null
|
||||
onPick: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="mb-3 last:mb-0">
|
||||
<div className="text-xs text-slate-500 mb-1">{props.label}</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm mono truncate bg-slate-50">
|
||||
{props.value ?? '미선택'}
|
||||
</div>
|
||||
<button
|
||||
className="border border-brand text-brand rounded-lg px-3 text-sm font-medium"
|
||||
onClick={props.onPick}
|
||||
>
|
||||
찾기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import { MAX_PROFILES } from '@shared/constants'
|
||||
|
||||
/** 최대 3인 프로필 등록/수정 + 참조 이미지 추가 */
|
||||
export function ProfileManager(): JSX.Element {
|
||||
const profiles = useStore((s) => s.profiles)
|
||||
const refreshProfiles = useStore((s) => s.refreshProfiles)
|
||||
const [name, setName] = useState('')
|
||||
const [busy, setBusy] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const addProfile = async () => {
|
||||
const trimmed = name.trim()
|
||||
if (!trimmed) return
|
||||
setError(null)
|
||||
try {
|
||||
// 등록 순서 = 현재 인원 수 (뒤에 추가)
|
||||
await window.api.profiles.upsert({ name: trimmed, order: profiles.length })
|
||||
setName('')
|
||||
await refreshProfiles()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const addReference = async (id: string) => {
|
||||
const paths = await window.api.dialog.pickImages()
|
||||
if (paths.length === 0) return
|
||||
setBusy(id)
|
||||
setError(null)
|
||||
try {
|
||||
await window.api.profiles.addReference(id, paths)
|
||||
await refreshProfiles()
|
||||
} catch (e) {
|
||||
setError((e as Error).message)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (id: string) => {
|
||||
await window.api.profiles.remove(id)
|
||||
await refreshProfiles()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-semibold">1. 인물 프로필</h2>
|
||||
<span className="text-xs text-slate-400">
|
||||
{profiles.length}/{MAX_PROFILES}명 · 순서 = 이동 우선순위
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3">
|
||||
<input
|
||||
className="flex-1 border border-slate-300 rounded-lg px-3 py-2 text-sm"
|
||||
placeholder="인물 이름 (예: seunghyun)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && addProfile()}
|
||||
disabled={profiles.length >= MAX_PROFILES}
|
||||
/>
|
||||
<button
|
||||
className="bg-brand text-white rounded-lg px-4 text-sm font-medium disabled:opacity-40"
|
||||
onClick={addProfile}
|
||||
disabled={profiles.length >= MAX_PROFILES || !name.trim()}
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <p className="text-sm text-red-600 mb-2">{error}</p>}
|
||||
|
||||
<ul className="flex flex-col gap-2">
|
||||
{profiles.map((p, i) => (
|
||||
<li
|
||||
key={p.id}
|
||||
className="flex items-center justify-between bg-slate-50 rounded-lg px-3 py-2"
|
||||
>
|
||||
<div>
|
||||
<span className="text-xs font-bold text-brand mr-2">#{i + 1}</span>
|
||||
<span className="font-medium">{p.name}</span>
|
||||
<span className="text-xs text-slate-400 ml-2">
|
||||
참조 {p.descriptors.length}장
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="text-xs border border-brand text-brand rounded px-2 py-1 disabled:opacity-40"
|
||||
onClick={() => addReference(p.id)}
|
||||
disabled={busy === p.id}
|
||||
>
|
||||
{busy === p.id ? '분석 중…' : '얼굴 추가'}
|
||||
</button>
|
||||
<button
|
||||
className="text-xs border border-red-300 text-red-500 rounded px-2 py-1"
|
||||
onClick={() => remove(p.id)}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{profiles.length === 0 && (
|
||||
<li className="text-sm text-slate-400 py-2">
|
||||
등록된 프로필이 없습니다. 이름을 추가하고 참조 얼굴 사진을 등록하세요.
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useStore } from '../store'
|
||||
|
||||
/** 실시간 진행률 바 + 현재 처리 파일 */
|
||||
export function ProgressView(): JSX.Element {
|
||||
const phase = useStore((s) => s.phase)
|
||||
const progress = useStore((s) => s.progress)
|
||||
|
||||
const total = progress?.total ?? 0
|
||||
const done = progress?.done ?? 0
|
||||
const pct = total > 0 ? Math.round((done / total) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="font-semibold">진행 상황</h2>
|
||||
<span className="text-sm text-slate-500">
|
||||
{phase === 'running' ? `${done} / ${total}` : '대기 중'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand transition-[width] duration-200"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-2">
|
||||
<span className="text-xs text-slate-400 mono truncate max-w-[80%]">
|
||||
{progress?.current ?? (phase === 'running' ? '스캔 중…' : '실행 대기')}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-brand">{pct}%</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useStore } from '../store'
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
const s = Math.round(ms / 1000)
|
||||
const m = Math.floor(s / 60)
|
||||
const rem = s % 60
|
||||
return m > 0 ? `${m}분 ${rem}초` : `${rem}초`
|
||||
}
|
||||
|
||||
/** 잡 완료 후 결과 리포트 */
|
||||
export function ReportView(): JSX.Element {
|
||||
const report = useStore((s) => s.report)
|
||||
const errors = useStore((s) => s.errors)
|
||||
if (!report) return <></>
|
||||
|
||||
const stats = [
|
||||
{ label: '총 처리', value: report.total, cls: 'text-slate-700' },
|
||||
{ label: '이동', value: report.moved, cls: 'text-emerald-600' },
|
||||
{ label: '복사', value: report.copied, cls: 'text-sky-600' },
|
||||
{ label: '미정', value: report.unmatched, cls: 'text-slate-500' },
|
||||
{ label: '실패', value: report.failed, cls: 'text-red-600' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-semibold">✅ 작업 완료</h2>
|
||||
<span className="text-sm text-slate-500">소요 {fmtDuration(report.elapsedMs)}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-2 mb-3">
|
||||
{stats.map((s) => (
|
||||
<div key={s.label} className="bg-slate-50 rounded-lg p-2 text-center">
|
||||
<div className={`text-lg font-bold ${s.cls}`}>{s.value}</div>
|
||||
<div className="text-[11px] text-slate-400">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-slate-400 mono truncate" title={report.logPath}>
|
||||
로그: {report.logPath}
|
||||
</div>
|
||||
|
||||
{errors.length > 0 && (
|
||||
<details className="mt-3">
|
||||
<summary className="text-xs text-red-600 cursor-pointer">
|
||||
오류 {errors.length}건 보기
|
||||
</summary>
|
||||
<ul className="mt-1 max-h-32 overflow-y-auto text-[11px] text-red-500 mono">
|
||||
{errors.map((e, i) => (
|
||||
<li key={i} className="truncate">
|
||||
{e.file}: {e.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useStore } from '../store'
|
||||
|
||||
/** 실행/취소 + 옵션(임계값, 동시성, 검출기) */
|
||||
export function RunControl(): JSX.Element {
|
||||
const { source, outputRoot, profiles, options, phase } = useStore((s) => ({
|
||||
source: s.source,
|
||||
outputRoot: s.outputRoot,
|
||||
profiles: s.profiles,
|
||||
options: s.options,
|
||||
phase: s.phase
|
||||
}))
|
||||
const setOptions = useStore((s) => s.setOptions)
|
||||
const startJob = useStore((s) => s.startJob)
|
||||
const cancelJob = useStore((s) => s.cancelJob)
|
||||
const resetJob = useStore((s) => s.resetJob)
|
||||
|
||||
const hasDescriptors = profiles.some((p) => p.descriptors.length > 0)
|
||||
const canRun = !!source && !!outputRoot && phase !== 'running'
|
||||
const running = phase === 'running'
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-slate-200 p-4">
|
||||
<h2 className="font-semibold mb-3">3. 실행 옵션</h2>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
<label className="text-sm">
|
||||
<span className="block text-xs text-slate-500 mb-1">
|
||||
매칭 임계값 ({options.matchThreshold.toFixed(2)})
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={0.3}
|
||||
max={0.7}
|
||||
step={0.01}
|
||||
value={options.matchThreshold}
|
||||
onChange={(e) => setOptions({ matchThreshold: Number(e.target.value) })}
|
||||
disabled={running}
|
||||
className="w-full"
|
||||
/>
|
||||
<span className="text-[11px] text-slate-400">낮을수록 엄격</span>
|
||||
</label>
|
||||
|
||||
<label className="text-sm">
|
||||
<span className="block text-xs text-slate-500 mb-1">
|
||||
동시 처리 ({options.concurrency})
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={8}
|
||||
step={1}
|
||||
value={options.concurrency}
|
||||
onChange={(e) => setOptions({ concurrency: Number(e.target.value) })}
|
||||
disabled={running}
|
||||
className="w-full"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="text-sm col-span-2">
|
||||
<span className="block text-xs text-slate-500 mb-1">검출 엔진</span>
|
||||
<select
|
||||
className="w-full border border-slate-300 rounded-lg px-2 py-1.5 text-sm"
|
||||
value={options.detector}
|
||||
onChange={(e) => setOptions({ detector: e.target.value as 'ssd' | 'tiny' })}
|
||||
disabled={running}
|
||||
>
|
||||
<option value="ssd">정확도 우선 (SSD MobileNet)</option>
|
||||
<option value="tiny">속도 우선 (Tiny Face)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!hasDescriptors && (
|
||||
<p className="text-xs text-amber-600 mb-2">
|
||||
⚠️ 등록된 얼굴이 없습니다. 매칭 인물 없이 모두 [미정]으로 분류됩니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
{!running ? (
|
||||
<button
|
||||
className="flex-1 bg-brand text-white rounded-lg py-2.5 font-semibold disabled:opacity-40"
|
||||
onClick={startJob}
|
||||
disabled={!canRun}
|
||||
>
|
||||
{phase === 'done' ? '다시 실행' : '정리 시작'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex-1 bg-red-500 text-white rounded-lg py-2.5 font-semibold"
|
||||
onClick={cancelJob}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
)}
|
||||
{phase === 'done' && (
|
||||
<button
|
||||
className="border border-slate-300 rounded-lg px-4 text-sm"
|
||||
onClick={resetJob}
|
||||
>
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
import type { ExposedApi } from '@shared/types'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
api: ExposedApi
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; img-src 'self' data: file:; style-src 'self' 'unsafe-inline';"
|
||||
/>
|
||||
<title>AI Photo Organizer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/index.css'
|
||||
|
||||
const container = document.getElementById('root')
|
||||
if (!container) throw new Error('#root 요소를 찾을 수 없음')
|
||||
|
||||
createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
import { create } from 'zustand'
|
||||
import type {
|
||||
Profile,
|
||||
JobOptions,
|
||||
FileProcessed,
|
||||
ProgressEvent,
|
||||
Report
|
||||
} from '@shared/types'
|
||||
import { DEFAULT_JOB_OPTIONS } from '@shared/constants'
|
||||
|
||||
export type JobPhase = 'idle' | 'running' | 'done'
|
||||
|
||||
interface AppState {
|
||||
// 프로필
|
||||
profiles: Profile[]
|
||||
setProfiles: (p: Profile[]) => void
|
||||
refreshProfiles: () => Promise<void>
|
||||
|
||||
// 폴더/옵션
|
||||
source: string | null
|
||||
outputRoot: string | null
|
||||
options: JobOptions
|
||||
setSource: (s: string | null) => void
|
||||
setOutput: (s: string | null) => void
|
||||
setOptions: (o: Partial<JobOptions>) => void
|
||||
|
||||
// 잡 상태
|
||||
phase: JobPhase
|
||||
progress: ProgressEvent | null
|
||||
processed: FileProcessed[]
|
||||
report: Report | null
|
||||
errors: { file: string; message: string }[]
|
||||
|
||||
startJob: () => Promise<void>
|
||||
cancelJob: () => Promise<void>
|
||||
resetJob: () => void
|
||||
|
||||
// 이벤트 핸들러(내부)
|
||||
_onProgress: (p: ProgressEvent) => void
|
||||
_onFile: (f: FileProcessed) => void
|
||||
_onDone: (r: Report) => void
|
||||
_onError: (e: { file: string; message: string }) => void
|
||||
}
|
||||
|
||||
export const useStore = create<AppState>((set, get) => ({
|
||||
profiles: [],
|
||||
setProfiles: (profiles) => set({ profiles }),
|
||||
refreshProfiles: async () => {
|
||||
const profiles = await window.api.profiles.list()
|
||||
set({ profiles })
|
||||
},
|
||||
|
||||
source: null,
|
||||
outputRoot: null,
|
||||
options: { ...DEFAULT_JOB_OPTIONS },
|
||||
setSource: (source) => set({ source }),
|
||||
setOutput: (outputRoot) => set({ outputRoot }),
|
||||
setOptions: (o) => set({ options: { ...get().options, ...o } }),
|
||||
|
||||
phase: 'idle',
|
||||
progress: null,
|
||||
processed: [],
|
||||
report: null,
|
||||
errors: [],
|
||||
|
||||
startJob: async () => {
|
||||
const { source, outputRoot, options } = get()
|
||||
if (!source || !outputRoot) return
|
||||
set({ phase: 'running', progress: null, processed: [], report: null, errors: [] })
|
||||
await window.api.job.run({ source, outputRoot, options })
|
||||
},
|
||||
cancelJob: async () => {
|
||||
await window.api.job.cancel()
|
||||
},
|
||||
resetJob: () => set({ phase: 'idle', progress: null, processed: [], report: null, errors: [] }),
|
||||
|
||||
_onProgress: (progress) => set({ progress }),
|
||||
_onFile: (f) =>
|
||||
set((s) => ({
|
||||
// 메모리 보호: 최근 500건만 UI에 유지 (리포트는 Main이 집계)
|
||||
processed: [f, ...s.processed].slice(0, 500)
|
||||
})),
|
||||
_onDone: (report) => set({ report, phase: 'done' }),
|
||||
_onError: (e) => set((s) => ({ errors: [e, ...s.errors].slice(0, 200) }))
|
||||
}))
|
||||
|
||||
/** 앱 시작 시 1회: Main→UI 이벤트 구독 */
|
||||
export function wireEvents(): () => void {
|
||||
const s = useStore.getState()
|
||||
const offs = [
|
||||
window.api.on('job:progress', s._onProgress),
|
||||
window.api.on('job:fileProcessed', s._onFile),
|
||||
window.api.on('job:done', s._onDone),
|
||||
window.api.on('job:error', s._onError)
|
||||
]
|
||||
return () => offs.forEach((off) => off())
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
background: #f5f6fa;
|
||||
color: #1f2330;
|
||||
}
|
||||
|
||||
/* 파일 목록 가독성용 모노 폰트 */
|
||||
.mono {
|
||||
font-family: 'Cascadia Code', 'Consolas', monospace;
|
||||
}
|
||||
Reference in New Issue
Block a user