feat(v0210): RevisionHistoryModal — 이력 목록 + 회수 confirm + chain 보존

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
altair823
2026-05-09 20:51:13 +09:00
parent ff1a015226
commit 81fbacb21e
3 changed files with 171 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
import React, { useEffect, useState } from 'react';
import type { NoteRevision } from '@shared/types';
import { inboxApi } from '../api.js';
interface Props {
noteId: string;
onClose: () => void;
/** 회수 성공 후 부모 (NoteCard) 가 local rawText 를 갱신하도록 통지. */
onRestored: (newRawText: string) => 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: 520,
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
};
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('ko-KR');
}
function editedByLabel(by: 'user' | 'capture'): string {
return by === 'capture' ? '캡처' : '사용자';
}
export function RevisionHistoryModal({ noteId, onClose, onRestored }: Props): React.ReactElement {
const [revs, setRevs] = useState<NoteRevision[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const r = await inboxApi.listRevisions(noteId);
if (!cancelled) setRevs(r);
} catch (e) {
if (!cancelled) setError((e as Error).message);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [noteId]);
async function onRestore(rev: NoteRevision) {
if (!window.confirm('이 버전으로 되돌릴까요? 현재 본문도 이력에 보존됩니다.')) return;
const r = await inboxApi.restoreRevision(noteId, rev.revId);
if (!r.ok) {
setError(r.reason ?? '복원 실패');
return;
}
onRestored(rev.rawText);
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 }}> ({revs.length})</h3>
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
</div>
{loading && <div style={{ marginTop: 10, fontSize: 12, color: '#888' }}> </div>}
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
{!loading && revs.map((rev) => (
<div key={rev.revId} style={rowStyle}>
<div style={{ fontSize: 11, color: '#888', display: 'flex', justifyContent: 'space-between' }}>
<span>{formatDate(rev.editedAt)} · {editedByLabel(rev.editedBy)}</span>
<button
onClick={() => { void onRestore(rev); }}
aria-label="회수"
style={{ background: 'none', border: '1px solid #0a4b80', color: '#0a4b80', cursor: 'pointer', fontSize: 11, padding: '2px 8px', borderRadius: 3 }}
>
</button>
</div>
<pre style={{ margin: '6px 0 0 0', whiteSpace: 'pre-wrap', fontSize: 12, color: '#444', background: '#fafafa', padding: 6, borderRadius: 3 }}>
{rev.rawText}
</pre>
</div>
))}
</div>
</div>
);
}