Files
inkling/src/renderer/inbox/components/settings/SyncSection.tsx
altair823 8cd6382902 fix(sync-help): SyncSection 도움말 버튼 busy 중 disable 해제
리뷰 minor 반영. busy (저장/테스트/sync 진행) 시 정확히 사용자가
도움말이 가장 필요한 시점이라 disable 회수. read-only 컴포넌트라
race risk 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 23:40:16 +09:00

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
};
}