Files
photoai/src/renderer/components/RunControl.tsx
T
koriweb 8a8c10248c 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>
2026-06-01 13:36:40 +09:00

108 lines
3.5 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}