mirror of
https://github.com/jung-geun/NFD2NFC.git
synced 2026-06-21 04:15:14 +09:00
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:
55
src/preload/index.ts
Normal file
55
src/preload/index.ts
Normal 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'),
|
||||
},
|
||||
});
|
||||
113
src/renderer/popover/Popover.tsx
Normal file
113
src/renderer/popover/Popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/renderer/popover/index.html
Normal file
13
src/renderer/popover/index.html
Normal 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>
|
||||
10
src/renderer/popover/main.tsx
Normal file
10
src/renderer/popover/main.tsx
Normal 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>
|
||||
);
|
||||
307
src/renderer/settings/Settings.tsx
Normal file
307
src/renderer/settings/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
src/renderer/settings/index.html
Normal file
13
src/renderer/settings/index.html
Normal 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>
|
||||
10
src/renderer/settings/main.tsx
Normal file
10
src/renderer/settings/main.tsx
Normal 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
166
src/renderer/styles.css
Normal 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; }
|
||||
Reference in New Issue
Block a user