From 08f1de7ea0bf10bfb1f2bacb81a491c111c6c210 Mon Sep 17 00:00:00 2001 From: jung-geun Date: Sat, 9 May 2026 15:41:18 +0900 Subject: [PATCH] src/preload + src/renderer: contextBridge + React 19 popover/settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/preload/index.ts | 55 ++++++ src/renderer/popover/Popover.tsx | 113 +++++++++++ src/renderer/popover/index.html | 13 ++ src/renderer/popover/main.tsx | 10 + src/renderer/settings/Settings.tsx | 307 +++++++++++++++++++++++++++++ src/renderer/settings/index.html | 13 ++ src/renderer/settings/main.tsx | 10 + src/renderer/styles.css | 166 ++++++++++++++++ 8 files changed, 687 insertions(+) create mode 100644 src/preload/index.ts create mode 100644 src/renderer/popover/Popover.tsx create mode 100644 src/renderer/popover/index.html create mode 100644 src/renderer/popover/main.tsx create mode 100644 src/renderer/settings/Settings.tsx create mode 100644 src/renderer/settings/index.html create mode 100644 src/renderer/settings/main.tsx create mode 100644 src/renderer/styles.css diff --git a/src/preload/index.ts b/src/preload/index.ts new file mode 100644 index 0000000..97048d8 --- /dev/null +++ b/src/preload/index.ts @@ -0,0 +1,55 @@ +import { contextBridge, ipcRenderer } from 'electron'; +import type { WatchedDir, UndoEntry, AppSettings, ActivityEvent } from '../core/types'; + +contextBridge.exposeInMainWorld('api', { + // 디렉토리 관리 + dirs: { + list: (): Promise => ipcRenderer.invoke('dirs:list'), + add: (path: string): Promise => ipcRenderer.invoke('dirs:add', path), + update: (id: string, patch: Partial) => 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 => 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 => ipcRenderer.invoke('undo:list'), + revertEntry: (entry: UndoEntry) => ipcRenderer.invoke('undo:revertEntry', entry), + revertLastBatch: () => ipcRenderer.invoke('undo:revertLastBatch'), + }, + + // 설정 + settings: { + get: (): Promise => ipcRenderer.invoke('settings:get'), + update: (patch: Partial) => 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 => ipcRenderer.invoke('app:version'), + }, +}); diff --git a/src/renderer/popover/Popover.tsx b/src/renderer/popover/Popover.tsx new file mode 100644 index 0000000..d86b6bf --- /dev/null +++ b/src/renderer/popover/Popover.tsx @@ -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([]); + const [activity, setActivity] = useState([]); + 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 ( +
+
+

NFD → NFC

+
+ +
+
+ +
+ {dirs.length === 0 && ( +
+ 감시 중인 디렉토리가 없습니다.
+ 설정에서 추가하세요. +
+ )} + {dirs.map((dir) => ( + + ))} +
+ +
+

최근 활동

+ {activity.length === 0 &&
활동 없음
} + {activity.map((ev, i) => ( +
+ {ev.message.split('/').pop() ?? ev.message} +
+ ))} +
+ +
+ +
+
+ ); +} + +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 ( +
+
+
+
{dirName}
+
{dir.path}
+
+ {dir.mode === 'manual' && pending > 0 && ( + + )} +
+ ); +} diff --git a/src/renderer/popover/index.html b/src/renderer/popover/index.html new file mode 100644 index 0000000..845022e --- /dev/null +++ b/src/renderer/popover/index.html @@ -0,0 +1,13 @@ + + + + + + + NFD to NFC + + +
+ + + diff --git a/src/renderer/popover/main.tsx b/src/renderer/popover/main.tsx new file mode 100644 index 0000000..391aac2 --- /dev/null +++ b/src/renderer/popover/main.tsx @@ -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( + + + +); diff --git a/src/renderer/settings/Settings.tsx b/src/renderer/settings/Settings.tsx new file mode 100644 index 0000000..4610a9c --- /dev/null +++ b/src/renderer/settings/Settings.tsx @@ -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('dirs'); + + return ( +
+
+

NFD → NFC 설정

+
+
+ {(['dirs', 'undo', 'general'] as Tab[]).map((t) => ( + + ))} +
+
+ {tab === 'dirs' && } + {tab === 'undo' && } + {tab === 'general' && } +
+
+ ); +} + +// ── 디렉토리 탭 ── +function DirsTab() { + const [dirs, setDirs] = useState([]); + const [scanning, setScanning] = useState(null); + const [scanResults, setScanResults] = useState>({}); + + 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 ( +
+
+ + 감시할 디렉토리를 추가하세요 +
+ + {dirs.length === 0 &&
추가된 디렉토리가 없습니다.
} + + {dirs.map((dir) => ( +
+
+
+
+
{dir.path.split('/').pop()}
+
{dir.path}
+
+
+ + +
+
+
+ + toggleEnabled(dir)} /> +
+
+
+
모드: {dir.mode === 'auto' ? '자동 변환' : '수동 승인'}
+
+ {dir.mode === 'auto' ? 'NFD 감지 즉시 자동 rename' : '감지 후 사용자 확인 필요'} +
+
+ +
+
+ + toggleRecursive(dir)} /> +
+
+
+
추가 필터 범위
+
hex 범위 (예: 0300-036F). 한글은 기본 포함.
+
+ +
+
+ + {scanResults[dir.id] && ( + setScanResults((p) => { const n = {...p}; delete n[dir.id]; return n; })} /> + )} +
+ ))} +
+ ); +} + +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 ( +
+
+ setInput(e.target.value)} + style={{ width: 120 }} + onKeyDown={(e) => e.key === 'Enter' && addRange()} + /> + +
+
+ {dir.customRanges.map(([lo, hi], i) => ( + removeRange(i)}> + {lo.toString(16).toUpperCase()}-{hi.toString(16).toUpperCase()} + + ))} +
+
+ ); +} + +function ScanPreview({ results, onClose }: { results: unknown[]; onClose: () => void }) { + return ( +
+
+ 미리보기 ({results.length}개) + +
+ {results.length === 0 &&
변환 대상 없음
} + {results.map((r: any, i) => ( +
+ {r.type === 'directory' ? '📁' : '📄'} {r.path.split('/').pop()} +
+ ))} +
+ ); +} + +// ── Undo 탭 ── +function UndoTab() { + const [log, setLog] = useState([]); + + 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 ( +
+
+ 최대 1000개 보관 + +
+
+ {log.length === 0 &&
Undo 기록이 없습니다.
} + {log.map((entry) => ( +
+
+
↩ {entry.oldPath.split('/').pop()}
+
→ {entry.newPath.split('/').pop()}
+
+
{new Date(entry.ts).toLocaleTimeString('ko-KR')}
+ {!entry.reverted && ( + + )} +
+ ))} +
+
+ ); +} + +// ── 일반 탭 ── +function GeneralTab() { + const [settings, setSettings] = useState(null); + + useEffect(() => { + api.settings.get().then(setSettings); + }, []); + + const update = async (patch: Partial) => { + await api.settings.update(patch); + setSettings((prev) => prev ? { ...prev, ...patch } : prev); + }; + + if (!settings) return
로딩 중…
; + + return ( +
+
+
동작
+
+
+
+ +
새 디렉토리 추가 시 기본으로 적용
+
+ +
+
+ + update({ notificationsEnabled: e.target.checked })} + /> +
+
+
+ +
지정 시간마다 변환 건수를 한 번에 알림
+
+ { + const v = Math.max(5, Math.min(3600, Number(e.target.value))); + update({ notificationIntervalSecs: v }); + }} + style={{ width: 70 }} + /> +
+
+
+
+ ); +} diff --git a/src/renderer/settings/index.html b/src/renderer/settings/index.html new file mode 100644 index 0000000..2cb1d04 --- /dev/null +++ b/src/renderer/settings/index.html @@ -0,0 +1,13 @@ + + + + + + + NFD to NFC — 설정 + + +
+ + + diff --git a/src/renderer/settings/main.tsx b/src/renderer/settings/main.tsx new file mode 100644 index 0000000..daa4507 --- /dev/null +++ b/src/renderer/settings/main.tsx @@ -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( + + + +); diff --git a/src/renderer/styles.css b/src/renderer/styles.css new file mode 100644 index 0000000..d811afd --- /dev/null +++ b/src/renderer/styles.css @@ -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; }