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 />. - 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) <noreply@anthropic.com>
This commit is contained in:
53
src/renderer/inbox/App.tsx
Normal file
53
src/renderer/inbox/App.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<div className="header">
|
||||
<h1 style={{ fontSize: 18, margin: 0 }}>Inkling</h1>
|
||||
<ContinuityBadge />
|
||||
</div>
|
||||
<main className="main">
|
||||
<OllamaBanner />
|
||||
<RecoveryToast
|
||||
show={showRecovery}
|
||||
onDismiss={() => { markRecoveryDismissed(); setRecoveryDismissed(true); }}
|
||||
/>
|
||||
<PendingBanner />
|
||||
{loading && notes.length === 0 ? (
|
||||
<div className="empty">불러오는 중…</div>
|
||||
) : notes.length === 0 ? (
|
||||
<div className="empty">첫 기억을 구출해보세요. <code>Ctrl+Shift+J</code></div>
|
||||
) : (
|
||||
notes.map((n) => (
|
||||
<NoteCard key={n.id} note={n} onDeleted={() => removeNote(n.id)} onUpdated={(u) => upsertNote(u)} />
|
||||
))
|
||||
)}
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
src/renderer/inbox/api.ts
Normal file
2
src/renderer/inbox/api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import type { InboxApi } from '@shared/types';
|
||||
export const inboxApi: InboxApi = window.inkling.inbox;
|
||||
2
src/renderer/inbox/components/ContinuityBadge.tsx
Normal file
2
src/renderer/inbox/components/ContinuityBadge.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
import React from 'react';
|
||||
export function ContinuityBadge() { return null; }
|
||||
9
src/renderer/inbox/components/EditableField.tsx
Normal file
9
src/renderer/inbox/components/EditableField.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import React, { CSSProperties } from 'react';
|
||||
export function EditableField(props: {
|
||||
value: string;
|
||||
onSave: (next: string) => Promise<void>;
|
||||
style?: CSSProperties;
|
||||
singleLine?: boolean;
|
||||
}): React.ReactElement {
|
||||
return <div style={props.style}>{props.value}</div>;
|
||||
}
|
||||
2
src/renderer/inbox/components/IntentBanner.tsx
Normal file
2
src/renderer/inbox/components/IntentBanner.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
import React from 'react';
|
||||
export function IntentBanner(_: { noteId: string; onResolved: (intentText: string | null) => void }) { return null; }
|
||||
5
src/renderer/inbox/components/NoteCard.tsx
Normal file
5
src/renderer/inbox/components/NoteCard.tsx
Normal file
@@ -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 <div style={{ background: 'white', padding: 12, marginBottom: 10, borderRadius: 8 }}>{note.rawText}</div>;
|
||||
}
|
||||
2
src/renderer/inbox/components/OllamaBanner.tsx
Normal file
2
src/renderer/inbox/components/OllamaBanner.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
import React from 'react';
|
||||
export function OllamaBanner() { return null; }
|
||||
2
src/renderer/inbox/components/PendingBanner.tsx
Normal file
2
src/renderer/inbox/components/PendingBanner.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
import React from 'react';
|
||||
export function PendingBanner() { return null; }
|
||||
2
src/renderer/inbox/components/RecoveryToast.tsx
Normal file
2
src/renderer/inbox/components/RecoveryToast.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
import React from 'react';
|
||||
export function RecoveryToast(_: { show: boolean; onDismiss: () => void }) { return null; }
|
||||
@@ -2,11 +2,22 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'" />
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: file:" />
|
||||
<title>Inkling</title>
|
||||
<style>
|
||||
body { margin: 0; font-family: system-ui, sans-serif; background: #f5f5f7; color: #111; }
|
||||
.header { display: flex; justify-content: space-between; align-items: center; padding: 12px 20px; background: white; border-bottom: 1px solid #eee; position: sticky; top: 0; z-index: 10; }
|
||||
.main { max-width: 780px; margin: 0 auto; padding: 20px; }
|
||||
.banner { padding: 10px 14px; border-radius: 8px; margin-bottom: 14px; font-size: 13px; display: flex; justify-content: space-between; align-items: center; }
|
||||
.banner.warn { background: #fff4d6; color: #7a5a00; }
|
||||
.banner.info { background: #e3f2ff; color: #0a4b80; }
|
||||
.banner.recovery { background: #e9f9e4; color: #236b1a; }
|
||||
.banner button { background: none; border: none; color: inherit; cursor: pointer; font-size: 13px; }
|
||||
.empty { text-align: center; color: #888; padding: 60px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script>document.getElementById('root').textContent = 'Inkling Inbox (renderer pending)';</script>
|
||||
<script type="module" src="/src/renderer/inbox/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
4
src/renderer/inbox/main.tsx
Normal file
4
src/renderer/inbox/main.tsx
Normal file
@@ -0,0 +1,4 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.js';
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
14
src/renderer/inbox/recoveryToast.ts
Normal file
14
src/renderer/inbox/recoveryToast.ts
Normal file
@@ -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());
|
||||
}
|
||||
59
src/renderer/inbox/store.ts
Normal file
59
src/renderer/inbox/store.ts
Normal file
@@ -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<void>;
|
||||
refreshMeta: () => Promise<void>;
|
||||
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<InboxState>((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) });
|
||||
}
|
||||
}));
|
||||
Reference in New Issue
Block a user