전수 audit 후 핵심 root fix 3 + edge cases 5: ROOT - inbox:set-status IPC 가 pushNoteUpdated emit (이전엔 stale → 호출처별 refreshMeta 필요) - upsertNote 가 current view status 인식 (이전엔 잘못된 status 노트 잔류) - store async 함수 try/catch (이전엔 IPC fail 시 무한 loading) EDGE - restoreNote 가 status='active' 도 갱신 - upsertNote trash 판정 deletedAt → status='trashed' - Modal Escape dismiss 통일 (5개 modal) - OnboardingWizard IPC fail fallback (try/catch + skip) - MoveStatusModal overlay 클릭 close Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
136 lines
5.2 KiB
TypeScript
136 lines
5.2 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
||
import type { SyncConflict } from '@shared/types';
|
||
import { inboxApi } from '../api.js';
|
||
import type { SyncHelpAnchor } from './SyncHelpModal.js';
|
||
|
||
interface Props {
|
||
onClose: () => void;
|
||
onResolved: () => void;
|
||
onOpenHelp?: (anchor: SyncHelpAnchor) => void;
|
||
}
|
||
|
||
const overlayStyle: React.CSSProperties = {
|
||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||
background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center',
|
||
justifyContent: 'center', zIndex: 100
|
||
};
|
||
|
||
const modalStyle: React.CSSProperties = {
|
||
background: '#fff', borderRadius: 8, padding: 20, width: 600,
|
||
maxHeight: '70vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
|
||
};
|
||
|
||
const rowStyle: React.CSSProperties = {
|
||
border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8
|
||
};
|
||
|
||
export function ConflictModal({ onClose, onResolved, onOpenHelp }: Props): React.ReactElement {
|
||
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
|
||
const [busy, setBusy] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
void (async () => {
|
||
const c = await inboxApi.listConflicts();
|
||
if (!cancelled) setConflicts(c);
|
||
})();
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
// Escape key 로 닫기.
|
||
useEffect(() => {
|
||
function onKey(e: KeyboardEvent) {
|
||
if (e.key === 'Escape') onClose();
|
||
}
|
||
document.addEventListener('keydown', onKey);
|
||
return () => document.removeEventListener('keydown', onKey);
|
||
}, [onClose]);
|
||
|
||
async function onChoose(path: string, choice: 'local' | 'remote') {
|
||
setBusy(path);
|
||
setError(null);
|
||
const r = await inboxApi.resolveConflict(path, choice);
|
||
setBusy(null);
|
||
if (!r.ok) {
|
||
setError(`해결 실패: ${r.reason}`);
|
||
return;
|
||
}
|
||
const next = conflicts.filter((c) => c.path !== path);
|
||
setConflicts(next);
|
||
if (next.length === 0) {
|
||
onResolved();
|
||
onClose();
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div style={overlayStyle} onClick={onClose}>
|
||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||
<h3 style={{ margin: 0, fontSize: 16 }}>충돌 ({conflicts.length}건)</h3>
|
||
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
|
||
</div>
|
||
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
|
||
{conflicts.map((c) => (
|
||
<div key={c.path} style={rowStyle}>
|
||
<div style={{ fontSize: 12, color: '#888', marginBottom: 6 }}>{c.path}</div>
|
||
<div style={{ display: 'flex', gap: 12 }}>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>내 기기</div>
|
||
<pre style={preStyle()}>{c.localText || '(미리보기 없음)'}</pre>
|
||
</div>
|
||
<div style={{ flex: 1 }}>
|
||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>다른 기기</div>
|
||
<pre style={preStyle()}>{c.remoteText || '(미리보기 없음)'}</pre>
|
||
</div>
|
||
</div>
|
||
<div style={{ marginTop: 8, fontSize: 11, color: '#666', lineHeight: 1.5 }}>
|
||
<div><b>내 것 사용</b>: 이 기기의 변경을 보존하고 원격의 같은 노트 변경을 폐기.</div>
|
||
<div><b>원격 사용</b>: 원격의 변경을 가져오고 내 변경을 폐기.</div>
|
||
{onOpenHelp && (
|
||
<button
|
||
onClick={() => onOpenHelp('main-conflict')}
|
||
style={{ background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: 0, marginTop: 2, textDecoration: 'underline' }}
|
||
>
|
||
자세히 보기 →
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||
<button
|
||
onClick={() => { void onChoose(c.path, 'local'); }}
|
||
disabled={busy === c.path}
|
||
style={chooseBtnStyle('#0a4b80')}
|
||
>
|
||
{busy === c.path ? '처리 중…' : '내 것 사용'}
|
||
</button>
|
||
<button
|
||
onClick={() => { void onChoose(c.path, 'remote'); }}
|
||
disabled={busy === c.path}
|
||
style={chooseBtnStyle('#236b1a')}
|
||
>
|
||
{busy === c.path ? '처리 중…' : '원격 사용'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function preStyle(): React.CSSProperties {
|
||
return {
|
||
margin: 0, whiteSpace: 'pre-wrap', fontSize: 11, color: '#444',
|
||
background: '#fafafa', padding: 6, borderRadius: 3, maxHeight: 120, overflow: 'auto'
|
||
};
|
||
}
|
||
|
||
function chooseBtnStyle(color: string): React.CSSProperties {
|
||
return {
|
||
background: 'none', border: `1px solid ${color}`, color, cursor: 'pointer',
|
||
fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||
};
|
||
}
|