src/preload + src/renderer: contextBridge + React 19 popover/settings

preload/index.ts: window.api 노출 (dirs/watcher/undo/settings/events/app).
renderer/popover: 트레이 팝오버 (300×400, frameless, alwaysOnTop, blur시 자동 닫힘).
renderer/settings: 설정창 (720×560, 3탭: 디렉토리/Undo기록/일반).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-09 15:41:18 +09:00
parent 3dff470044
commit 08f1de7ea0
8 changed files with 687 additions and 0 deletions

View File

@@ -0,0 +1,307 @@
import React, { useEffect, useState, useCallback } from 'react';
import type { WatchedDir, UndoEntry, AppSettings } from '../../core/types';
const api = (window as any).api;
type Tab = 'dirs' | 'undo' | 'general';
export function Settings() {
const [tab, setTab] = useState<Tab>('dirs');
return (
<div className="settings">
<div className="settings-header">
<h1>NFD NFC </h1>
</div>
<div className="tabs">
{(['dirs', 'undo', 'general'] as Tab[]).map((t) => (
<button key={t} className={`tab-btn${tab === t ? ' active' : ''}`} onClick={() => setTab(t)}>
{{ dirs: '디렉토리', undo: 'Undo 기록', general: '일반' }[t]}
</button>
))}
</div>
<div className="tab-content">
{tab === 'dirs' && <DirsTab />}
{tab === 'undo' && <UndoTab />}
{tab === 'general' && <GeneralTab />}
</div>
</div>
);
}
// ── 디렉토리 탭 ──
function DirsTab() {
const [dirs, setDirs] = useState<WatchedDir[]>([]);
const [scanning, setScanning] = useState<string | null>(null);
const [scanResults, setScanResults] = useState<Record<string, unknown[]>>({});
const refresh = useCallback(async () => setDirs(await api.dirs.list()), []);
useEffect(() => { refresh(); }, [refresh]);
const addDir = async () => {
const p = await api.dirs.selectDirectory();
if (!p) return;
await api.dirs.add(p);
refresh();
};
const removeDir = async (id: string) => {
await api.dirs.remove(id);
refresh();
};
const toggleEnabled = async (dir: WatchedDir) => {
await api.dirs.update(dir.id, { enabled: !dir.enabled });
refresh();
};
const toggleMode = async (dir: WatchedDir) => {
await api.dirs.update(dir.id, { mode: dir.mode === 'auto' ? 'manual' : 'auto' });
refresh();
};
const toggleRecursive = async (dir: WatchedDir) => {
await api.dirs.update(dir.id, { recursive: !dir.recursive });
refresh();
};
const dryRun = async (id: string) => {
setScanning(id);
const results = await api.dirs.scan(id);
setScanResults((prev) => ({ ...prev, [id]: results }));
setScanning(null);
};
return (
<div>
<div className="add-dir-bar">
<button onClick={addDir}>+ </button>
<span style={{ fontSize: 11, color: '#8e8e93' }}> </span>
</div>
{dirs.length === 0 && <div className="empty-state" style={{ marginTop: 40 }}> .</div>}
{dirs.map((dir) => (
<div key={dir.id} className="section">
<div className="card">
<div className="card-row">
<div>
<div style={{ fontWeight: 600 }}>{dir.path.split('/').pop()}</div>
<div className="row-desc">{dir.path}</div>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="secondary" onClick={() => dryRun(dir.id)} disabled={scanning === dir.id}>
{scanning === dir.id ? '스캔 중…' : '미리보기'}
</button>
<button className="danger" onClick={() => removeDir(dir.id)}></button>
</div>
</div>
<div className="card-row">
<label> </label>
<input type="checkbox" checked={dir.enabled} onChange={() => toggleEnabled(dir)} />
</div>
<div className="card-row">
<div>
<div>: <strong>{dir.mode === 'auto' ? '자동 변환' : '수동 승인'}</strong></div>
<div className="row-desc">
{dir.mode === 'auto' ? 'NFD 감지 즉시 자동 rename' : '감지 후 사용자 확인 필요'}
</div>
</div>
<button className="secondary" onClick={() => toggleMode(dir)}></button>
</div>
<div className="card-row">
<label> ()</label>
<input type="checkbox" checked={dir.recursive} onChange={() => toggleRecursive(dir)} />
</div>
<div className="card-row">
<div>
<div> </div>
<div className="row-desc">hex (: 0300-036F). .</div>
</div>
<RangeEditor dir={dir} onRefresh={refresh} />
</div>
</div>
{scanResults[dir.id] && (
<ScanPreview results={scanResults[dir.id]} onClose={() => setScanResults((p) => { const n = {...p}; delete n[dir.id]; return n; })} />
)}
</div>
))}
</div>
);
}
function RangeEditor({ dir, onRefresh }: { dir: WatchedDir; onRefresh: () => void }) {
const [input, setInput] = useState('');
const addRange = async () => {
const parts = input.trim().split('-');
if (parts.length !== 2) return;
const lo = parseInt(parts[0], 16);
const hi = parseInt(parts[1], 16);
if (isNaN(lo) || isNaN(hi)) return;
await api.dirs.update(dir.id, { customRanges: [...dir.customRanges, [lo, hi]] });
setInput('');
onRefresh();
};
const removeRange = async (idx: number) => {
const newRanges = dir.customRanges.filter((_, i) => i !== idx);
await api.dirs.update(dir.id, { customRanges: newRanges });
onRefresh();
};
return (
<div>
<div style={{ display: 'flex', gap: 6, marginBottom: 4 }}>
<input
type="text"
placeholder="0300-036F"
value={input}
onChange={(e) => setInput(e.target.value)}
style={{ width: 120 }}
onKeyDown={(e) => e.key === 'Enter' && addRange()}
/>
<button className="secondary" onClick={addRange}></button>
</div>
<div className="range-list">
{dir.customRanges.map(([lo, hi], i) => (
<span key={i} className="range-tag" title="클릭해서 제거" onClick={() => removeRange(i)}>
{lo.toString(16).toUpperCase()}-{hi.toString(16).toUpperCase()}
</span>
))}
</div>
</div>
);
}
function ScanPreview({ results, onClose }: { results: unknown[]; onClose: () => void }) {
return (
<div style={{ background: '#fff', borderRadius: 10, border: '1px solid #e5e5ea', padding: 12, marginTop: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<strong> ({results.length})</strong>
<button className="secondary" onClick={onClose}></button>
</div>
{results.length === 0 && <div style={{ color: '#8e8e93', fontSize: 12 }}> </div>}
{results.map((r: any, i) => (
<div key={i} style={{ fontSize: 11, color: '#3c3c43', padding: '2px 0', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{r.type === 'directory' ? '📁' : '📄'} {r.path.split('/').pop()}
</div>
))}
</div>
);
}
// ── Undo 탭 ──
function UndoTab() {
const [log, setLog] = useState<UndoEntry[]>([]);
const refresh = useCallback(async () => {
const entries = await api.undo.list();
setLog([...entries].reverse().slice(0, 200));
}, []);
useEffect(() => { refresh(); }, [refresh]);
const revertEntry = async (entry: UndoEntry) => {
await api.undo.revertEntry(entry);
refresh();
};
const revertBatch = async () => {
await api.undo.revertLastBatch();
refresh();
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
<span style={{ fontSize: 12, color: '#8e8e93' }}> 1000 </span>
<button className="secondary" onClick={revertBatch}> </button>
</div>
<div className="undo-list">
{log.length === 0 && <div className="empty-state">Undo .</div>}
{log.map((entry) => (
<div key={entry.id} className={`undo-item${entry.reverted ? ' reverted' : ''}`}>
<div className="undo-paths">
<div className="undo-old"> {entry.oldPath.split('/').pop()}</div>
<div className="undo-new"> {entry.newPath.split('/').pop()}</div>
</div>
<div className="undo-ts">{new Date(entry.ts).toLocaleTimeString('ko-KR')}</div>
{!entry.reverted && (
<button className="secondary" style={{ fontSize: 11, padding: '3px 8px' }} onClick={() => revertEntry(entry)}>
</button>
)}
</div>
))}
</div>
</div>
);
}
// ── 일반 탭 ──
function GeneralTab() {
const [settings, setSettings] = useState<AppSettings | null>(null);
useEffect(() => {
api.settings.get().then(setSettings);
}, []);
const update = async (patch: Partial<AppSettings>) => {
await api.settings.update(patch);
setSettings((prev) => prev ? { ...prev, ...patch } : prev);
};
if (!settings) return <div className="empty-state"> </div>;
return (
<div>
<div className="section">
<div className="section-title"></div>
<div className="card">
<div className="card-row">
<div>
<label> </label>
<div className="row-desc"> </div>
</div>
<select
value={settings.defaultMode}
onChange={(e) => update({ defaultMode: e.target.value as 'auto' | 'manual' })}
>
<option value="auto"> </option>
<option value="manual"> </option>
</select>
</div>
<div className="card-row">
<label>macOS </label>
<input
type="checkbox"
checked={settings.notificationsEnabled}
onChange={(e) => update({ notificationsEnabled: e.target.checked })}
/>
</div>
<div className="card-row">
<div>
<label> ()</label>
<div className="row-desc"> </div>
</div>
<input
type="number"
min={5}
max={3600}
step={5}
disabled={!settings.notificationsEnabled}
value={settings.notificationIntervalSecs ?? 30}
onChange={(e) => {
const v = Math.max(5, Math.min(3600, Number(e.target.value)));
update({ notificationIntervalSecs: v });
}}
style={{ width: 70 }}
/>
</div>
</div>
</div>
</div>
);
}