From 6b522b31d02f20e8b4da1aeb088e3c01e0b7af06 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 25 Apr 2026 12:16:08 +0900 Subject: [PATCH] feat(inbox): React shell + store + component stubs (v0.2) Task 22 of the slice plan. Wires the Inbox window's renderer: - index.html (replaces the placeholder shipped in Task 2) with the full layout styles + module script. - api.ts re-exports window.inkling.inbox as a typed InboxApi. - recoveryToast.ts persists per-day toast dismissal in localStorage (KST date key) so the banner never re-fires after the user closes it. - store.ts: zustand store with notes, continuity, pendingCount, ollamaStatus, loadInitial(), refreshMeta(), upsert/remove. - main.tsx mounts . - App.tsx orchestrates loadInitial + onNoteUpdated subscription + window-focus refresh, renders header / banners / note list. - 7 component stubs (NoteCard / EditableField / IntentBanner / RecoveryToast / ContinuityBadge / PendingBanner / OllamaBanner) so the shell typechecks today; Tasks 23-28 swap each in. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/renderer/inbox/App.tsx | 53 +++++++++++++++++ src/renderer/inbox/api.ts | 2 + .../inbox/components/ContinuityBadge.tsx | 2 + .../inbox/components/EditableField.tsx | 9 +++ .../inbox/components/IntentBanner.tsx | 2 + src/renderer/inbox/components/NoteCard.tsx | 5 ++ .../inbox/components/OllamaBanner.tsx | 2 + .../inbox/components/PendingBanner.tsx | 2 + .../inbox/components/RecoveryToast.tsx | 2 + src/renderer/inbox/index.html | 15 ++++- src/renderer/inbox/main.tsx | 4 ++ src/renderer/inbox/recoveryToast.ts | 14 +++++ src/renderer/inbox/store.ts | 59 +++++++++++++++++++ 13 files changed, 169 insertions(+), 2 deletions(-) create mode 100644 src/renderer/inbox/App.tsx create mode 100644 src/renderer/inbox/api.ts create mode 100644 src/renderer/inbox/components/ContinuityBadge.tsx create mode 100644 src/renderer/inbox/components/EditableField.tsx create mode 100644 src/renderer/inbox/components/IntentBanner.tsx create mode 100644 src/renderer/inbox/components/NoteCard.tsx create mode 100644 src/renderer/inbox/components/OllamaBanner.tsx create mode 100644 src/renderer/inbox/components/PendingBanner.tsx create mode 100644 src/renderer/inbox/components/RecoveryToast.tsx create mode 100644 src/renderer/inbox/main.tsx create mode 100644 src/renderer/inbox/recoveryToast.ts create mode 100644 src/renderer/inbox/store.ts 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) }); + } +}));