리뷰 minor 반영. busy (저장/테스트/sync 진행) 시 정확히 사용자가 도움말이 가장 필요한 시점이라 disable 회수. read-only 컴포넌트라 race risk 0. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
164 lines
5.5 KiB
TypeScript
164 lines
5.5 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { inboxApi } from '../../api.js';
|
|
import type { SyncStatusSnapshot } from '@shared/types';
|
|
import { ConflictModal } from '../ConflictModal.js';
|
|
import { SyncHelpModal, type SyncHelpAnchor } from '../SyncHelpModal.js';
|
|
|
|
export function SyncSection(): React.ReactElement {
|
|
const [url, setUrl] = useState('');
|
|
const [draftUrl, setDraftUrl] = useState('');
|
|
const [autoEnabled, setAutoEnabled] = useState(true);
|
|
const [intervalMin, setIntervalMin] = useState(30);
|
|
const [status, setStatus] = useState<SyncStatusSnapshot | null>(null);
|
|
const [busy, setBusy] = useState<'save' | 'test' | 'sync' | null>(null);
|
|
const [feedback, setFeedback] = useState<string | null>(null);
|
|
const [showConflict, setShowConflict] = useState(false);
|
|
const [showHelp, setShowHelp] = useState<{ open: boolean; anchor?: SyncHelpAnchor }>({ open: false });
|
|
|
|
useEffect(() => {
|
|
void (async () => {
|
|
const s = await inboxApi.getSettings();
|
|
const u = s.sync_repo_url ?? '';
|
|
setUrl(u);
|
|
setDraftUrl(u);
|
|
setAutoEnabled(s.sync_auto_enabled ?? true);
|
|
setIntervalMin(s.sync_interval_min ?? 30);
|
|
setStatus(await inboxApi.getSyncStatus());
|
|
})();
|
|
}, []);
|
|
|
|
async function onSaveUrl() {
|
|
setBusy('save');
|
|
setFeedback(null);
|
|
const r = await inboxApi.configureSync(draftUrl.trim() === '' ? null : draftUrl.trim());
|
|
setBusy(null);
|
|
if (r.ok) {
|
|
setUrl(draftUrl.trim());
|
|
setFeedback('저장되었습니다');
|
|
} else {
|
|
setFeedback(`저장 실패: ${r.reason}`);
|
|
}
|
|
}
|
|
|
|
async function onTestConnection() {
|
|
setBusy('test');
|
|
setFeedback(null);
|
|
const r = await inboxApi.testSyncConnection();
|
|
setBusy(null);
|
|
setFeedback(r.ok ? '연결 성공' : `연결 실패: ${r.reason}`);
|
|
}
|
|
|
|
async function onToggleAuto(next: boolean) {
|
|
await inboxApi.setSyncAutoEnabled(next);
|
|
setAutoEnabled(next);
|
|
}
|
|
|
|
async function onChangeInterval(value: number) {
|
|
if (!Number.isInteger(value) || value < 5) return;
|
|
const r = await inboxApi.setSyncIntervalMin(value);
|
|
if (r.ok) setIntervalMin(value);
|
|
}
|
|
|
|
const conflictCount = status?.lastResult?.conflicts?.length ?? 0;
|
|
|
|
return (
|
|
<section style={{ marginTop: 24 }}>
|
|
<h3 style={{ fontSize: 14, marginBottom: 8 }}>동기화 저장소</h3>
|
|
|
|
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
|
<input
|
|
type="text"
|
|
aria-label="저장소 URL"
|
|
placeholder="git@host:user/inkling-notes.git"
|
|
value={draftUrl}
|
|
onChange={(e) => setDraftUrl(e.target.value)}
|
|
style={{ flex: 1, fontSize: 12, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4 }}
|
|
/>
|
|
<button onClick={() => { void onSaveUrl(); }} disabled={busy !== null} style={btnStyle()}>
|
|
{busy === 'save' ? '저장 중…' : '저장'}
|
|
</button>
|
|
<button onClick={() => { void onTestConnection(); }} disabled={busy !== null || url.trim() === ''} style={btnStyle()}>
|
|
{busy === 'test' ? '확인 중…' : '연결 테스트'}
|
|
</button>
|
|
<button onClick={() => setShowHelp({ open: true })} style={btnStyle()}>
|
|
도움말
|
|
</button>
|
|
</div>
|
|
|
|
{feedback !== null && (
|
|
<div style={{ fontSize: 12, color: '#444', marginBottom: 8 }}>{feedback}</div>
|
|
)}
|
|
|
|
{url.trim() !== '' && (
|
|
<>
|
|
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
|
마지막 sync: {status?.lastAt ?? '없음'} {status?.lastResult?.ok === false && status?.lastResult?.reason !== 'conflict' && (
|
|
<span style={{ color: '#a55' }}> ({status.lastResult.reason})</span>
|
|
)}
|
|
</div>
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 6 }}>
|
|
<input
|
|
type="checkbox"
|
|
checked={autoEnabled}
|
|
onChange={(e) => { void onToggleAuto(e.target.checked); }}
|
|
/>
|
|
자동 sync 사용
|
|
</label>
|
|
|
|
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 8 }}>
|
|
interval:
|
|
<input
|
|
type="number"
|
|
aria-label="sync interval (분)"
|
|
min={5}
|
|
value={intervalMin}
|
|
onChange={(e) => { void onChangeInterval(Number.parseInt(e.target.value, 10)); }}
|
|
disabled={!autoEnabled}
|
|
style={{ width: 60, fontSize: 12, padding: '2px 4px' }}
|
|
/>
|
|
분
|
|
</label>
|
|
|
|
{conflictCount > 0 && (
|
|
<div style={{ marginTop: 8 }}>
|
|
<button onClick={() => setShowConflict(true)} style={btnStyle()}>
|
|
충돌 해결… ({conflictCount}건)
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{showConflict && (
|
|
<ConflictModal
|
|
onClose={() => setShowConflict(false)}
|
|
onResolved={async () => {
|
|
setStatus(await inboxApi.getSyncStatus());
|
|
}}
|
|
onOpenHelp={(anchor) => setShowHelp({ open: true, anchor })}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{showHelp.open && (
|
|
<SyncHelpModal
|
|
onClose={() => setShowHelp({ open: false })}
|
|
initialAnchor={showHelp.anchor}
|
|
/>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function btnStyle(): React.CSSProperties {
|
|
return {
|
|
background: '#0a4b80',
|
|
color: '#fff',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
fontSize: 12,
|
|
padding: '4px 10px',
|
|
borderRadius: 4
|
|
};
|
|
}
|