diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx new file mode 100644 index 0000000..deed729 --- /dev/null +++ b/src/renderer/inbox/App.tsx @@ -0,0 +1,53 @@ +import React, { useEffect, useState } from 'react'; +import { useInbox } 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 { PendingBanner } from './components/PendingBanner.js'; +import { OllamaBanner } from './components/OllamaBanner.js'; +import { RecoveryToast } from './components/RecoveryToast.js'; + +export function App(): React.ReactElement { + const { notes, loading, loadInitial, refreshMeta, upsertNote, removeNote, continuity } = useInbox(); + const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday()); + + useEffect(() => { + void loadInitial(); + const unsub = inboxApi.onNoteUpdated((note) => { + upsertNote(note); + void refreshMeta(); + }); + const onFocus = () => { void refreshMeta(); }; + window.addEventListener('focus', onFocus); + return () => { unsub(); window.removeEventListener('focus', onFocus); }; + }, [loadInitial, refreshMeta, upsertNote]); + + const showRecovery = continuity.showRecoveryToast && !recoveryDismissed; + + return ( + <> +
+

Inkling

+ +
+
+ + { markRecoveryDismissed(); setRecoveryDismissed(true); }} + /> + + {loading && notes.length === 0 ? ( +
불러오는 중…
+ ) : notes.length === 0 ? ( +
첫 기억을 구출해보세요. Ctrl+Shift+J
+ ) : ( + notes.map((n) => ( + removeNote(n.id)} onUpdated={(u) => upsertNote(u)} /> + )) + )} +
+ + ); +} diff --git a/src/renderer/inbox/api.ts b/src/renderer/inbox/api.ts new file mode 100644 index 0000000..3a78192 --- /dev/null +++ b/src/renderer/inbox/api.ts @@ -0,0 +1,2 @@ +import type { InboxApi } from '@shared/types'; +export const inboxApi: InboxApi = window.inkling.inbox; diff --git a/src/renderer/inbox/components/ContinuityBadge.tsx b/src/renderer/inbox/components/ContinuityBadge.tsx new file mode 100644 index 0000000..a02d330 --- /dev/null +++ b/src/renderer/inbox/components/ContinuityBadge.tsx @@ -0,0 +1,2 @@ +import React from 'react'; +export function ContinuityBadge() { return null; } diff --git a/src/renderer/inbox/components/EditableField.tsx b/src/renderer/inbox/components/EditableField.tsx new file mode 100644 index 0000000..4a5910a --- /dev/null +++ b/src/renderer/inbox/components/EditableField.tsx @@ -0,0 +1,9 @@ +import React, { CSSProperties } from 'react'; +export function EditableField(props: { + value: string; + onSave: (next: string) => Promise; + style?: CSSProperties; + singleLine?: boolean; +}): React.ReactElement { + return
{props.value}
; +} diff --git a/src/renderer/inbox/components/IntentBanner.tsx b/src/renderer/inbox/components/IntentBanner.tsx new file mode 100644 index 0000000..9796bd3 --- /dev/null +++ b/src/renderer/inbox/components/IntentBanner.tsx @@ -0,0 +1,2 @@ +import React from 'react'; +export function IntentBanner(_: { noteId: string; onResolved: (intentText: string | null) => void }) { return null; } diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx new file mode 100644 index 0000000..35618dc --- /dev/null +++ b/src/renderer/inbox/components/NoteCard.tsx @@ -0,0 +1,5 @@ +import React from 'react'; +import type { Note } from '@shared/types'; +export function NoteCard({ note }: { note: Note; onDeleted: () => void; onUpdated: (n: Note) => void }) { + return
{note.rawText}
; +} diff --git a/src/renderer/inbox/components/OllamaBanner.tsx b/src/renderer/inbox/components/OllamaBanner.tsx new file mode 100644 index 0000000..503bb93 --- /dev/null +++ b/src/renderer/inbox/components/OllamaBanner.tsx @@ -0,0 +1,2 @@ +import React from 'react'; +export function OllamaBanner() { return null; } diff --git a/src/renderer/inbox/components/PendingBanner.tsx b/src/renderer/inbox/components/PendingBanner.tsx new file mode 100644 index 0000000..98c988e --- /dev/null +++ b/src/renderer/inbox/components/PendingBanner.tsx @@ -0,0 +1,2 @@ +import React from 'react'; +export function PendingBanner() { return null; } diff --git a/src/renderer/inbox/components/RecoveryToast.tsx b/src/renderer/inbox/components/RecoveryToast.tsx new file mode 100644 index 0000000..9d5be96 --- /dev/null +++ b/src/renderer/inbox/components/RecoveryToast.tsx @@ -0,0 +1,2 @@ +import React from 'react'; +export function RecoveryToast(_: { show: boolean; onDismiss: () => void }) { return null; } diff --git a/src/renderer/inbox/index.html b/src/renderer/inbox/index.html index e031d2c..8398b0a 100644 --- a/src/renderer/inbox/index.html +++ b/src/renderer/inbox/index.html @@ -2,11 +2,22 @@ - + Inkling +
- + diff --git a/src/renderer/inbox/main.tsx b/src/renderer/inbox/main.tsx new file mode 100644 index 0000000..7d27e5b --- /dev/null +++ b/src/renderer/inbox/main.tsx @@ -0,0 +1,4 @@ +import React from 'react'; +import { createRoot } from 'react-dom/client'; +import { App } from './App.js'; +createRoot(document.getElementById('root')!).render(); diff --git a/src/renderer/inbox/recoveryToast.ts b/src/renderer/inbox/recoveryToast.ts new file mode 100644 index 0000000..85f926b --- /dev/null +++ b/src/renderer/inbox/recoveryToast.ts @@ -0,0 +1,14 @@ +const KEY = 'inkling.recoveryDismissedAt'; + +export function isRecoveryDismissedToday(now = new Date()): boolean { + const v = localStorage.getItem(KEY); + if (!v) return false; + const stored = new Date(v); + const kstNow = new Date(now.getTime() + 9 * 3600_000).toISOString().slice(0, 10); + const kstStored = new Date(stored.getTime() + 9 * 3600_000).toISOString().slice(0, 10); + return kstNow === kstStored; +} + +export function markRecoveryDismissed(now = new Date()): void { + localStorage.setItem(KEY, now.toISOString()); +} diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts new file mode 100644 index 0000000..2581906 --- /dev/null +++ b/src/renderer/inbox/store.ts @@ -0,0 +1,59 @@ +import { create } from 'zustand'; +import type { Note, WeeklyContinuity } from '@shared/types'; +import { inboxApi } from './api.js'; + +interface InboxState { + notes: Note[]; + continuity: WeeklyContinuity; + pendingCount: number; + ollamaStatus: { ok: boolean; reason?: string }; + loading: boolean; + loadInitial: () => Promise; + refreshMeta: () => Promise; + upsertNote: (note: Note) => void; + removeNote: (id: string) => void; +} + +const emptyContinuity: WeeklyContinuity = { + weekStart: '', weekCount: 0, weekTarget: 7, + consecutiveCompleteWeeks: 0, showRecoveryToast: false, lastNoteAt: null +}; + +export const useInbox = create((set, get) => ({ + notes: [], + continuity: emptyContinuity, + pendingCount: 0, + ollamaStatus: { ok: true }, + loading: false, + async loadInitial() { + set({ loading: true }); + const [notes, continuity, pendingCount, ollamaStatus] = await Promise.all([ + inboxApi.listNotes({ limit: 50 }), + inboxApi.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus() + ]); + set({ notes, continuity, pendingCount, ollamaStatus, loading: false }); + }, + async refreshMeta() { + const [continuity, pendingCount, ollamaStatus] = await Promise.all([ + inboxApi.getContinuity(), + inboxApi.getPendingCount(), + inboxApi.getOllamaStatus() + ]); + set({ continuity, pendingCount, ollamaStatus }); + }, + upsertNote(note) { + const i = get().notes.findIndex((n) => n.id === note.id); + if (i >= 0) { + const next = get().notes.slice(); + next[i] = note; + set({ notes: next }); + } else { + set({ notes: [note, ...get().notes] }); + } + }, + removeNote(id) { + set({ notes: get().notes.filter((n) => n.id !== id) }); + } +}));