Files
inkling/src/renderer/inbox/App.tsx

300 lines
12 KiB
TypeScript

import React, { useEffect, useState } from 'react';
import { useInbox, selectFilteredNotes } from './store.js';
import { inboxApi } from './api.js';
import { isRecoveryDismissedToday, markRecoveryDismissed } from './recoveryToast.js';
import { NoteCard } from './components/NoteCard.js';
import { ContinuityBadge } from './components/ContinuityBadge.js';
import { IdentityCounter } from './components/IdentityCounter.js';
import { PendingBanner } from './components/PendingBanner.js';
import { OllamaBanner } from './components/OllamaBanner.js';
import { RecoveryToast } from './components/RecoveryToast.js';
import { TagUndoToast } from './components/TagUndoToast.js';
import { ExpiryBanner } from './components/ExpiryBanner.js';
import { FailedBanner } from './components/FailedBanner.js';
import { RecallBanner } from './components/RecallBanner.js';
import { SettingsPage } from './components/SettingsPage.js';
import { OnboardingWizard } from './components/OnboardingWizard.js';
import { SearchBox } from './components/SearchBox.js';
import { ReviewView } from './components/ReviewView.js';
import { Sidebar } from './components/Sidebar.js';
import { PromotionBanner } from './components/PromotionBanner.js';
import { BatchMoveModal } from './components/BatchMoveModal.js';
import type { InboxView } from './store.js';
// QuickCapture 단축키 modifier — macOS 는 Cmd, 그 외는 Ctrl.
const MOD_KEY = /Mac/i.test(navigator.platform) ? 'Cmd' : 'Ctrl';
export function App(): React.ReactElement {
const {
notes, trashNotes, trashCount, showTrash,
loading, loadInitial, refreshMeta, upsertNote, removeNote,
continuity, tagFilter, setTagFilter,
toggleShowTrash, restoreNote, permanentDeleteNote, emptyTrash
} = useInbox();
const showSettings = useInbox((s) => s.showSettings);
const setShowSettings = useInbox((s) => s.setShowSettings);
// v0.2.9 Cut B Task 5 — 4탭 (Inbox/완료/보관/휴지통).
const view = useInbox((s) => s.view);
const counts = useInbox((s) => s.counts);
const setView = useInbox((s) => s.setView);
const searchResults = useInbox((s) => s.searchResults);
const selectedNotebookId = useInbox((s) => s.selectedNotebookId);
const notebooks = useInbox((s) => s.notebooks);
const runBatchClassify = useInbox((s) => s.runBatchClassify);
const batchClassifying = useInbox((s) => s.batchClassifying);
const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday());
// v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시.
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
useEffect(() => {
void (async () => {
try {
const settings = await inboxApi.getSettings();
setShowOnboarding(!settings.onboarding_completed);
} catch {
// 안전한 fallback — settings 읽기 실패 시 wizard 미표시 (기존 사용자 무영향).
setShowOnboarding(false);
}
})();
}, []);
useEffect(() => {
void useInbox.getState().loadNotebooks();
void useInbox.getState().loadPromotionCandidates();
}, []);
useEffect(() => {
const isMac = /Mac/i.test(navigator.platform);
const handler = (e: KeyboardEvent) => {
if (e.key === 'b' && (isMac ? e.metaKey : e.ctrlKey) && !e.shiftKey && !e.altKey) {
e.preventDefault();
useInbox.getState().toggleSidebar();
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
useEffect(() => {
void loadInitial();
const unsubNote = inboxApi.onNoteUpdated((note) => {
upsertNote(note);
void refreshMeta();
});
const unsubOllama = inboxApi.onOllamaStatus((status) => {
useInbox.setState({ ollamaStatus: status });
});
const unsubNav = inboxApi.onNavigate((view) => {
// v0.2.9 Cut B Task 4 — setView 가 mirror state (showTrash/showSettings) 동기 갱신.
useInbox.getState().setView(view);
});
const onFocus = () => { void refreshMeta(); };
window.addEventListener('focus', onFocus);
return () => { unsubNote(); unsubOllama(); unsubNav(); window.removeEventListener('focus', onFocus); };
// onOllamaStatus 콜백은 useInbox.setState 직접 호출 — store reference 가 안정적이라
// deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제.
}, [loadInitial, refreshMeta, upsertNote]);
// v0.4 T5 — default notebook(첫 번째) 선택 시 "AI 정리하기" 버튼 노출 조건.
const isDefaultNotebook = notebooks.length > 0 && selectedNotebookId === notebooks[0]?.id;
if (showOnboarding === null) return <></>;
if (showOnboarding) return <OnboardingWizard onClose={() => setShowOnboarding(false)} />;
if (view === 'review-daily') return <ReviewView period="daily" />;
if (view === 'review-weekly') return <ReviewView period="weekly" />;
if (view === 'review-monthly') return <ReviewView period="monthly" />;
if (showSettings) return <SettingsPage />;
const showRecovery = continuity.showRecoveryToast && !recoveryDismissed;
const filtered = selectFilteredNotes({ notes, tagFilter });
const displayed = searchResults !== null ? searchResults : filtered;
const tabBtnStyle = (active: boolean): React.CSSProperties => ({
background: active ? '#0a4b80' : 'transparent',
color: active ? '#fff' : '#0a4b80',
border: '1px solid #0a4b80',
borderRadius: 4,
padding: '4px 10px',
fontSize: 12,
cursor: 'pointer'
});
return (
<div style={{ display: 'flex', height: '100vh' }}>
<Sidebar />
<main style={{ flex: 1, overflowY: 'auto' }}>
<div className="header">
<button
onClick={() => useInbox.getState().toggleSidebar()}
aria-label="사이드바 토글"
title="사이드바 토글 (Cmd/Ctrl+B)"
style={{
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 18, padding: '0 8px 0 0', color: '#444', lineHeight: 1
}}
>
</button>
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
<div style={{ display: 'flex', gap: 6, marginLeft: 12 }}>
{(
[
{ key: 'inbox', label: 'Inbox', count: counts.active },
{ key: 'completed', label: '완료', count: counts.completed },
{ key: 'trash', label: '휴지통', count: counts.trashed }
] as const
).map((t) => (
<button
key={t.key}
type="button"
role="tab"
aria-selected={view === t.key}
onClick={() => setView(t.key)}
style={tabBtnStyle(view === t.key)}
>
{t.label}({t.count})
</button>
))}
</div>
{view === 'inbox' && isDefaultNotebook && notebooks.length > 1 && (
<button
onClick={() => { void runBatchClassify(); }}
disabled={batchClassifying}
style={{
background: '#5a3a8c', color: '#fff', border: 'none', borderRadius: 4,
padding: '4px 10px', fontSize: 11, cursor: batchClassifying ? 'not-allowed' : 'pointer',
marginLeft: 8
}}
>
{batchClassifying ? '🪄 분석 중…' : '🪄 AI 정리하기'}
</button>
)}
<select
aria-label="회고 기간"
value={view.startsWith('review-') ? view.replace('review-', '') : ''}
onChange={(e) => {
const v = e.target.value;
if (v === 'daily' || v === 'weekly' || v === 'monthly') setView(`review-${v}` as InboxView);
}}
style={{ marginLeft: 8, fontSize: 12, padding: '4px 6px', border: '1px solid #0a4b80', borderRadius: 4, color: '#0a4b80', background: 'transparent' }}
>
<option value="">📅 </option>
<option value="daily"></option>
<option value="weekly"></option>
<option value="monthly"></option>
</select>
<SearchBox />
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 2, marginLeft: 'auto' }}>
<ContinuityBadge />
<IdentityCounter />
</div>
<button
aria-label="설정 열기"
onClick={() => setShowSettings(true)}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: 4,
fontSize: 16,
marginLeft: 8
}}
>
</button>
</div>
<div className="main">
{!showTrash && (
<>
{/* AI/만료/회상 배너는 active 노트 컨텍스트 — inbox 탭에서만 노출.
completed/archived 에서는 무관 컨텐츠라 숨김. */}
{view === 'inbox' && (
<>
<OllamaBanner onOpenSettings={() => setShowSettings(true)} />
<RecoveryToast
show={showRecovery}
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
/>
<PendingBanner />
<FailedBanner />
<ExpiryBanner />
<RecallBanner />
<PromotionBanner />
</>
)}
{tagFilter !== null && (
<div style={{
background: '#eaf3ff', color: '#0a4b80', padding: '6px 12px',
borderRadius: 6, margin: '8px 0', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8
}}>
<span>🔎 : <strong>#{tagFilter}</strong></span>
<span style={{ color: '#666' }}>({filtered.length})</span>
<button
onClick={() => setTagFilter(null)}
style={{ marginLeft: 'auto', background: 'none', border: 'none', color: '#0a4b80', cursor: 'pointer', fontSize: 12 }}
title="필터 해제"
>
</button>
</div>
)}
{loading && notes.length === 0 ? (
<div className="empty"> </div>
) : searchResults !== null && displayed.length === 0 ? (
<div className="empty"> .</div>
) : notes.length === 0 ? (
<div className="empty">릿 . <code>{MOD_KEY}+Shift+J</code></div>
) : displayed.length === 0 ? (
<div className="empty"> .</div>
) : (
displayed.map((n) => (
<NoteCard
key={n.id} note={n} mode="inbox"
onDeleted={() => removeNote(n.id)}
onUpdated={(u) => upsertNote(u)}
/>
))
)}
</>
)}
{showTrash && (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', margin: '8px 0' }}>
<div style={{ fontSize: 13, color: '#666' }}>
{trashCount === 0 ? '휴지통이 비어있습니다.' : `${trashCount}개 보관 중`}
</div>
<button
onClick={() => void emptyTrash()}
disabled={trashCount === 0}
style={{
background: trashCount === 0 ? '#666' : '#a33', color: '#fff',
border: 'none', borderRadius: 4, padding: '4px 10px',
fontSize: 12, cursor: trashCount === 0 ? 'not-allowed' : 'pointer'
}}
>
({trashCount})
</button>
</div>
{trashNotes.length === 0 ? null : (
trashNotes.map((n) => (
<NoteCard
key={n.id} note={n} mode="trash"
onUpdated={(u) => upsertNote(u)}
onRestore={() => void restoreNote(n.id)}
onPermanentDelete={() => void permanentDeleteNote(n.id)}
/>
))
)}
</>
)}
</div>
<TagUndoToast />
</main>
<BatchMoveModal />
</div>
);
}