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

55
src/preload/index.ts Normal file
View File

@@ -0,0 +1,55 @@
import { contextBridge, ipcRenderer } from 'electron';
import type { WatchedDir, UndoEntry, AppSettings, ActivityEvent } from '../core/types';
contextBridge.exposeInMainWorld('api', {
// 디렉토리 관리
dirs: {
list: (): Promise<WatchedDir[]> => ipcRenderer.invoke('dirs:list'),
add: (path: string): Promise<WatchedDir> => ipcRenderer.invoke('dirs:add', path),
update: (id: string, patch: Partial<WatchedDir>) => ipcRenderer.invoke('dirs:update', id, patch),
remove: (id: string) => ipcRenderer.invoke('dirs:remove', id),
scan: (id: string) => ipcRenderer.invoke('dirs:scan', id),
applyQueue: (id: string) => ipcRenderer.invoke('dirs:applyQueue', id),
pendingQueue: (id: string) => ipcRenderer.invoke('dirs:pendingQueue', id),
selectDirectory: (): Promise<string | null> => ipcRenderer.invoke('dialog:selectDirectory'),
},
// 감시 제어
watcher: {
status: (): Promise<{ paused: boolean }> => ipcRenderer.invoke('watcher:status'),
pauseAll: () => ipcRenderer.invoke('watcher:pauseAll'),
resumeAll: () => ipcRenderer.invoke('watcher:resumeAll'),
},
// Undo
undo: {
list: (): Promise<UndoEntry[]> => ipcRenderer.invoke('undo:list'),
revertEntry: (entry: UndoEntry) => ipcRenderer.invoke('undo:revertEntry', entry),
revertLastBatch: () => ipcRenderer.invoke('undo:revertLastBatch'),
},
// 설정
settings: {
get: (): Promise<AppSettings> => ipcRenderer.invoke('settings:get'),
update: (patch: Partial<AppSettings>) => ipcRenderer.invoke('settings:update', patch),
},
// 이벤트 (main → renderer 푸시)
events: {
onActivity: (cb: (event: ActivityEvent) => void) => {
const handler = (_: unknown, event: ActivityEvent) => cb(event);
ipcRenderer.on('watcher:activity', handler);
return () => ipcRenderer.removeListener('watcher:activity', handler);
},
// 일시정지 상태 변경 (트레이 컨텍스트 메뉴 포함 모든 경로에서 발생)
onPausedChange: (cb: (paused: boolean) => void) => {
const handler = (_: unknown, paused: boolean) => cb(paused);
ipcRenderer.on('watcher:paused', handler);
return () => ipcRenderer.removeListener('watcher:paused', handler);
},
},
app: {
version: (): Promise<string> => ipcRenderer.invoke('app:version'),
},
});

View File

@@ -0,0 +1,113 @@
import React, { useEffect, useState, useCallback } from 'react';
import type { WatchedDir, ActivityEvent } from '../../core/types';
const api = (window as any).api;
export function Popover() {
const [dirs, setDirs] = useState<WatchedDir[]>([]);
const [activity, setActivity] = useState<ActivityEvent[]>([]);
const [paused, setPaused] = useState(false);
const refresh = useCallback(async () => {
setDirs(await api.dirs.list());
}, []);
useEffect(() => {
// 초기 상태: main process에서 직접 읽음 (단일 진실의 원천)
api.watcher.status().then(({ paused: p }: { paused: boolean }) => setPaused(p));
refresh();
const unsubActivity = api.events.onActivity((ev: ActivityEvent) => {
setActivity((prev) => [ev, ...prev].slice(0, 20));
refresh();
});
// main에서 pause 상태가 바뀔 때마다 업데이트 (트레이 컨텍스트 메뉴 포함)
const unsubPaused = api.events.onPausedChange((p: boolean) => setPaused(p));
return () => {
unsubActivity();
unsubPaused();
};
}, [refresh]);
const togglePause = async () => {
if (paused) {
await api.watcher.resumeAll();
} else {
await api.watcher.pauseAll();
}
// 상태는 onPausedChange 이벤트로 자동 반영되므로 직접 setPaused 불필요
};
return (
<div className="popover">
<div className="popover-header">
<h1>NFD NFC</h1>
<div className="popover-actions">
<button className="secondary" onClick={togglePause} title={paused ? '재개' : '일시정지'}>
{paused ? '▶ 재개' : '⏸ 정지'}
</button>
</div>
</div>
<div className="dir-list">
{dirs.length === 0 && (
<div className="empty-state">
.<br />
.
</div>
)}
{dirs.map((dir) => (
<DirRow key={dir.id} dir={dir} paused={paused} onRefresh={refresh} />
))}
</div>
<div className="activity-log">
<h3> </h3>
{activity.length === 0 && <div className="activity-item"> </div>}
{activity.map((ev, i) => (
<div key={i} className={`activity-item ${ev.type === 'error' || ev.type === 'collision' ? 'error' : ''}`}>
{ev.message.split('/').pop() ?? ev.message}
</div>
))}
</div>
<div className="popover-footer">
<button className="secondary" onClick={() => api.undo.revertLastBatch()}>Undo </button>
</div>
</div>
);
}
function DirRow({ dir, paused, onRefresh }: { dir: WatchedDir; paused: boolean; onRefresh: () => void }) {
const [pending, setPending] = useState(0);
useEffect(() => {
if (dir.mode === 'manual') {
api.dirs.pendingQueue(dir.id).then((q: string[]) => setPending(q.length));
}
}, [dir]);
const applyQueue = async () => {
await api.dirs.applyQueue(dir.id);
setPending(0);
onRefresh();
};
const statusClass = paused ? 'paused' : dir.enabled ? 'active' : 'disabled';
const dirName = dir.path.split('/').pop() ?? dir.path;
return (
<div className="dir-row">
<div className={`status-dot ${statusClass}`} title={statusClass} />
<div className="dir-info">
<div className="dir-name">{dirName}</div>
<div className="dir-path">{dir.path}</div>
</div>
{dir.mode === 'manual' && pending > 0 && (
<button onClick={applyQueue}>{pending} </button>
)}
</div>
);
}

View File

@@ -0,0 +1,13 @@
<!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'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
<title>NFD to NFC</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Popover } from './Popover';
import '../styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Popover />
</React.StrictMode>
);

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>
);
}

View File

@@ -0,0 +1,13 @@
<!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'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
<title>NFD to NFC — 설정</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>

View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Settings } from './Settings';
import '../styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Settings />
</React.StrictMode>
);

166
src/renderer/styles.css Normal file
View File

@@ -0,0 +1,166 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 13px;
color: #1d1d1f;
background: #f5f5f7;
-webkit-font-smoothing: antialiased;
}
/* ── 공통 ── */
button {
cursor: pointer;
border: none;
border-radius: 6px;
padding: 5px 12px;
font-size: 12px;
font-family: inherit;
background: #0071e3;
color: #fff;
transition: opacity 0.1s;
}
button:hover { opacity: 0.85; }
button:disabled { opacity: 0.4; cursor: default; }
button.secondary { background: #e5e5ea; color: #1d1d1f; }
button.danger { background: #ff3b30; }
input[type="checkbox"] { accent-color: #0071e3; }
input[type="text"], select {
border: 1px solid #d1d1d6;
border-radius: 6px;
padding: 5px 8px;
font-size: 13px;
font-family: inherit;
background: #fff;
outline: none;
}
input[type="text"]:focus, select:focus { border-color: #0071e3; }
/* ── 팝오버 ── */
.popover {
display: flex;
flex-direction: column;
height: 100vh;
background: rgba(240,240,245,0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
}
.popover-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px 8px;
border-bottom: 1px solid rgba(0,0,0,0.08);
}
.popover-header h1 { font-size: 14px; font-weight: 600; }
.popover-actions { display: flex; gap: 6px; }
.dir-list { flex: 1; overflow-y: auto; padding: 8px; display: flex; flex-direction: column; gap: 4px; }
.dir-row {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-radius: 8px;
padding: 8px 10px;
gap: 8px;
}
.dir-row .dir-info { flex: 1; overflow: hidden; }
.dir-row .dir-path {
font-size: 11px;
color: #8e8e93;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dir-row .dir-name { font-size: 13px; font-weight: 500; }
.status-dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot.active { background: #34c759; }
.status-dot.paused { background: #ff9500; }
.status-dot.disabled { background: #c7c7cc; }
.activity-log {
padding: 8px 14px;
border-top: 1px solid rgba(0,0,0,0.08);
max-height: 110px;
overflow-y: auto;
}
.activity-log h3 { font-size: 11px; color: #8e8e93; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
.activity-item { font-size: 11px; color: #3c3c43; padding: 2px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-item.error { color: #ff3b30; }
.popover-footer {
display: flex;
justify-content: space-between;
padding: 8px 14px;
border-top: 1px solid rgba(0,0,0,0.08);
}
.empty-state { padding: 20px; text-align: center; color: #8e8e93; font-size: 12px; }
/* ── 설정 ── */
.settings { display: flex; flex-direction: column; height: 100vh; }
.settings-header {
padding: 16px 20px 12px;
border-bottom: 1px solid #e5e5ea;
background: #fff;
}
.settings-header h1 { font-size: 16px; font-weight: 600; }
.tabs { display: flex; gap: 0; border-bottom: 1px solid #e5e5ea; background: #fff; padding: 0 20px; }
.tab-btn {
background: none;
color: #8e8e93;
padding: 8px 14px;
border-radius: 0;
border-bottom: 2px solid transparent;
font-size: 13px;
margin-bottom: -1px;
}
.tab-btn.active { color: #0071e3; border-bottom-color: #0071e3; }
.tab-content { flex: 1; overflow-y: auto; padding: 16px 20px; }
.section { margin-bottom: 20px; }
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; color: #8e8e93; margin-bottom: 8px; }
.card { background: #fff; border-radius: 10px; overflow: hidden; border: 1px solid #e5e5ea; }
.card-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
border-bottom: 1px solid #f2f2f7;
gap: 12px;
}
.card-row:last-child { border-bottom: none; }
.card-row label { font-size: 13px; color: #1d1d1f; }
.card-row .row-desc { font-size: 11px; color: #8e8e93; margin-top: 2px; }
.add-dir-bar {
display: flex;
gap: 8px;
margin-bottom: 12px;
align-items: center;
}
.range-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.range-tag {
background: #e5e5ea;
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
}
.range-tag:hover { background: #ff3b30; color: #fff; }
.undo-list { display: flex; flex-direction: column; gap: 4px; }
.undo-item {
background: #fff;
border-radius: 8px;
padding: 8px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: 1px solid #e5e5ea;
}
.undo-item.reverted { opacity: 0.5; }
.undo-paths { flex: 1; overflow: hidden; }
.undo-old { font-size: 11px; color: #ff3b30; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.undo-new { font-size: 11px; color: #34c759; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.undo-ts { font-size: 10px; color: #8e8e93; flex-shrink: 0; }