diff --git a/docs/AGENT_TASK_ROADMAP.md b/docs/AGENT_TASK_ROADMAP.md index 89847f8..f0664b6 100644 --- a/docs/AGENT_TASK_ROADMAP.md +++ b/docs/AGENT_TASK_ROADMAP.md @@ -54,6 +54,14 @@ cd ../learning_ai_notes/backend && pnpm install && pnpm run typecheck --- +## Phase 0.5 — Fix Broken Imports + Adopt Dashboard Components + +- [x] **0.5.1** Replace broken `@bytelyst/ui` ToastProvider with `sonner` — [`7babee7`](https://github.com/saravanakumardb1/learning_ai_notes/commit/7babee7) +- [x] **0.5.2** Adopt `@bytelyst/dashboard-components` (ErrorPage, NotFoundPage, LoadingSpinner) — [`7babee7`](https://github.com/saravanakumardb1/learning_ai_notes/commit/7babee7) +- [x] **0.5.3** Replace raw `extraction-client.ts` with `@bytelyst/extraction` — [`7babee7`](https://github.com/saravanakumardb1/learning_ai_notes/commit/7babee7) + +--- + ## Phase 1 — Critical Web Gaps These block the web app from being usable by real users. diff --git a/web/package.json b/web/package.json index e0c6033..318e4de 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,10 @@ "@bytelyst/platform-client": "^0.1.0", "@bytelyst/dashboard-components": "^0.1.0", "@bytelyst/extraction": "^0.1.0", + "@tiptap/extension-placeholder": "^2.11.0", + "@tiptap/pm": "^2.11.0", + "@tiptap/react": "^2.11.0", + "@tiptap/starter-kit": "^2.11.0", "@bytelyst/react-auth": "^0.1.0", "@bytelyst/telemetry-client": "^0.1.0", "lucide-react": "^0.575.0", diff --git a/web/src/app/(app)/dashboard/page.tsx b/web/src/app/(app)/dashboard/page.tsx index 62af04d..0af8ac5 100644 --- a/web/src/app/(app)/dashboard/page.tsx +++ b/web/src/app/(app)/dashboard/page.tsx @@ -6,6 +6,7 @@ import { AppShell } from "@/components/AppShell"; import { CreateNoteModal } from "@/components/CreateNoteModal"; import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client"; import { listApprovalQueue } from "@/lib/review-client"; +import { toast } from "@/lib/toast"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types"; export default function DashboardPage() { @@ -221,6 +222,7 @@ export default function DashboardPage() { workspaces={workspaces} onCreated={() => { setShowCreateNote(false); + toast.success("Note created"); window.location.reload(); }} onClose={() => setShowCreateNote(false)} diff --git a/web/src/app/(app)/layout.tsx b/web/src/app/(app)/layout.tsx index b5b3fd7..9106211 100644 --- a/web/src/app/(app)/layout.tsx +++ b/web/src/app/(app)/layout.tsx @@ -1,11 +1,12 @@ import type { ReactNode } from "react"; +import { AuthGuard } from "@/components/AuthGuard"; import { KeyboardShortcuts } from "@/components/KeyboardShortcuts"; export default function ProductLayout({ children }: { children: ReactNode }) { return ( - <> + {children} - > + ); } diff --git a/web/src/app/(app)/notes/[noteId]/page.tsx b/web/src/app/(app)/notes/[noteId]/page.tsx index a4df35e..9626d50 100644 --- a/web/src/app/(app)/notes/[noteId]/page.tsx +++ b/web/src/app/(app)/notes/[noteId]/page.tsx @@ -12,6 +12,7 @@ import { ArtifactPanel } from "@/components/ArtifactPanel"; import { AgentTimeline } from "@/components/AgentTimeline"; import { LinkNoteModal } from "@/components/LinkNoteModal"; import { archiveNote, createNoteArtifact, createNoteTask, getNoteDetail, restoreNote, summarizeNote, updateNoteDetail } from "@/lib/notes-client"; +import { toast } from "@/lib/toast"; import type { NoteDetail } from "@/lib/types"; export default function NoteDetailPage() { @@ -46,8 +47,11 @@ export default function NoteDetailPage() { const refreshed = await getNoteDetail(note.id); setNote(refreshed); setError(null); + toast.success("Note saved"); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to save note"); + const msg = err instanceof Error ? err.message : "Unable to save note"; + setError(msg); + toast.error(msg); } finally { setIsSaving(false); } @@ -78,8 +82,11 @@ export default function NoteDetailPage() { const refreshed = await getNoteDetail(note.id); setNote(refreshed); setError(null); + toast.success("Artifact created"); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to create artifact"); + const msg = err instanceof Error ? err.message : "Unable to create artifact"; + setError(msg); + toast.error(msg); } finally { setIsCreatingArtifact(false); } @@ -104,8 +111,11 @@ export default function NoteDetailPage() { const refreshed = await getNoteDetail(note.id); setNote(refreshed); setError(null); + toast.success("Task created"); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to create task"); + const msg = err instanceof Error ? err.message : "Unable to create task"; + setError(msg); + toast.error(msg); } finally { setIsCreatingTask(false); } @@ -116,8 +126,9 @@ export default function NoteDetailPage() { try { await summarizeNote(note.id, note.workspaceId); setNote(await getNoteDetail(note.id, note.workspaceId)); + toast.success("Note summarized"); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to summarize note"); + toast.error(err instanceof Error ? err.message : "Unable to summarize note"); } } @@ -126,8 +137,9 @@ export default function NoteDetailPage() { try { await archiveNote(note.id, note.workspaceId); setNote(await getNoteDetail(note.id, note.workspaceId)); + toast.success("Note archived"); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to archive note"); + toast.error(err instanceof Error ? err.message : "Unable to archive note"); } } @@ -136,8 +148,9 @@ export default function NoteDetailPage() { try { await restoreNote(note.id, note.workspaceId); setNote(await getNoteDetail(note.id, note.workspaceId)); + toast.success("Note restored"); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to restore note"); + toast.error(err instanceof Error ? err.message : "Unable to restore note"); } } diff --git a/web/src/app/(app)/reviews/page.tsx b/web/src/app/(app)/reviews/page.tsx index fa9578a..36303bb 100644 --- a/web/src/app/(app)/reviews/page.tsx +++ b/web/src/app/(app)/reviews/page.tsx @@ -6,6 +6,7 @@ import { AppShell } from "@/components/AppShell"; import { AgentTimeline } from "@/components/AgentTimeline"; import { ProposalReviewCard } from "@/components/ProposalReviewCard"; import { approveReviewItem, batchReviewItems, listAgentTimeline, listApprovalQueue, rejectReviewItem } from "@/lib/review-client"; +import { toast } from "@/lib/toast"; import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types"; export default function ReviewsPage() { @@ -122,8 +123,11 @@ export default function ReviewsPage() { ...current, ]); setReviewNote(""); + toast.success(`Proposal ${decision}`); } catch (err) { - setError(err instanceof Error ? err.message : "Unable to update review state"); + const msg = err instanceof Error ? err.message : "Unable to update review state"; + setError(msg); + toast.error(msg); } finally { setIsSubmitting(false); } @@ -157,8 +161,11 @@ export default function ReviewsPage() { ]); setSelectedBatchIds(new Set()); setReviewNote(""); + toast.success(`Batch ${decision}: ${batchItems.length} items`); } catch (err) { - setError(err instanceof Error ? err.message : "Batch review failed"); + const msg = err instanceof Error ? err.message : "Batch review failed"; + setError(msg); + toast.error(msg); } finally { setIsSubmitting(false); } diff --git a/web/src/app/(app)/workspaces/page.tsx b/web/src/app/(app)/workspaces/page.tsx index 922fe74..7687a82 100644 --- a/web/src/app/(app)/workspaces/page.tsx +++ b/web/src/app/(app)/workspaces/page.tsx @@ -4,7 +4,9 @@ import Link from "next/link"; import { useSearchParams } from "next/navigation"; import { Suspense, useEffect, useMemo, useState } from "react"; import { AppShell } from "@/components/AppShell"; -import { exportNotes, listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client"; +import { CreateWorkspaceModal } from "@/components/CreateWorkspaceModal"; +import { exportNotes, listNoteSummaries, listWorkspaceSummaries, deleteWorkspace } from "@/lib/notes-client"; +import { toast } from "@/lib/toast"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types"; export default function WorkspacesPage() { @@ -21,6 +23,8 @@ function WorkspacesPageInner() { const [workspaces, setWorkspaces] = useState([]); const [query, setQuery] = useState(() => searchParams?.get("q") ?? ""); const [error, setError] = useState(null); + const [showCreate, setShowCreate] = useState(false); + const [loadKey, setLoadKey] = useState(0); useEffect(() => { setQuery(searchParams?.get("q") ?? ""); @@ -39,7 +43,22 @@ function WorkspacesPageInner() { setError(err instanceof Error ? err.message : "Unable to load workspaces"); } })(); - }, []); + }, [loadKey]); + + function reload() { + setLoadKey((k) => k + 1); + } + + async function handleDelete(id: string, name: string) { + if (!confirm(`Delete workspace "${name}"? This cannot be undone.`)) return; + try { + await deleteWorkspace(id); + toast.success(`Workspace "${name}" deleted`); + reload(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Delete failed"); + } + } const notesByWorkspace = useMemo( () => @@ -97,25 +116,33 @@ function WorkspacesPageInner() { title="Workspaces" description="Workspace-level organization, filters, and saved-view entry points for note collections." actions={ - { - try { - const data = await exportNotes("json"); - const blob = new Blob([data], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "notes-export.json"; - a.click(); - URL.revokeObjectURL(url); - } catch (err) { - setError(err instanceof Error ? err.message : "Export failed"); - } - }} - > - Export Notes - + + setShowCreate(true)} + > + + Workspace + + { + try { + const data = await exportNotes("json"); + const blob = new Blob([data], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "notes-export.json"; + a.click(); + URL.revokeObjectURL(url); + } catch (err) { + setError(err instanceof Error ? err.message : "Export failed"); + } + }} + > + Export Notes + + } > @@ -177,6 +204,12 @@ function WorkspacesPageInner() { Owner: {workspace.owner} + handleDelete(workspace.id, workspace.name)} + style={{ padding: "4px 10px", fontSize: "var(--nl-fs-sm)", background: "rgba(255,110,110,0.12)", color: "var(--nl-danger, #FF6E6E)", border: "none", borderRadius: "var(--nl-radius-sm)" }} + > + Delete + @@ -198,6 +231,12 @@ function WorkspacesPageInner() { ); })} + {showCreate && ( + { setShowCreate(false); toast.success("Workspace created"); reload(); }} + onClose={() => setShowCreate(false)} + /> + )} ); } diff --git a/web/src/app/(auth)/forgot-password/page.tsx b/web/src/app/(auth)/forgot-password/page.tsx new file mode 100644 index 0000000..00054c4 --- /dev/null +++ b/web/src/app/(auth)/forgot-password/page.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import Link from "next/link"; +import { useAuth } from "@/lib/auth"; + +export default function ForgotPasswordPage() { + const { forgotPassword, isLoading, error, success, clearMessages } = useAuth(); + const [email, setEmail] = useState(""); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + clearMessages(); + await forgotPassword(email); + } + + return ( + + Reset password + + Enter your email and we'll send a reset link if the account exists. + + + {error && ( + + {error} + + )} + + {success && ( + + {success} + + )} + + + Email + setEmail(e.target.value)} + /> + + + + {isLoading ? "Sending…" : "Send reset link"} + + + + Back to sign in + + + ); +} diff --git a/web/src/app/(auth)/layout.tsx b/web/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..9beca8b --- /dev/null +++ b/web/src/app/(auth)/layout.tsx @@ -0,0 +1,15 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; + +export default function AuthLayout({ children }: { children: ReactNode }) { + return ( + + + + NoteLett + + {children} + + + ); +} diff --git a/web/src/app/(auth)/login/page.tsx b/web/src/app/(auth)/login/page.tsx new file mode 100644 index 0000000..94df825 --- /dev/null +++ b/web/src/app/(auth)/login/page.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth"; + +export default function LoginPage() { + const { login, isLoading, error, clearMessages } = useAuth(); + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + clearMessages(); + const ok = await login(email, password); + if (ok) router.push("/dashboard"); + } + + return ( + + Sign in + + {error && ( + + {error} + + )} + + + Email + setEmail(e.target.value)} + /> + + + + Password + setPassword(e.target.value)} + /> + + + + {isLoading ? "Signing in…" : "Sign in"} + + + + Forgot password? + Create account + + + ); +} diff --git a/web/src/app/(auth)/register/page.tsx b/web/src/app/(auth)/register/page.tsx new file mode 100644 index 0000000..acf58de --- /dev/null +++ b/web/src/app/(auth)/register/page.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth"; + +export default function RegisterPage() { + const { register, isLoading, error, clearMessages } = useAuth(); + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [displayName, setDisplayName] = useState(""); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + clearMessages(); + const ok = await register(email, password, displayName); + if (ok) router.push("/dashboard"); + } + + return ( + + Create account + + {error && ( + + {error} + + )} + + + Display name + setDisplayName(e.target.value)} + /> + + + + Email + setEmail(e.target.value)} + /> + + + + Password + setPassword(e.target.value)} + /> + + + + {isLoading ? "Creating account…" : "Create account"} + + + + Already have an account?{" "} + Sign in + + + ); +} diff --git a/web/src/components/AuthGuard.tsx b/web/src/components/AuthGuard.tsx new file mode 100644 index 0000000..c831c16 --- /dev/null +++ b/web/src/components/AuthGuard.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useEffect, useState, type ReactNode } from "react"; +import { useRouter } from "next/navigation"; +import { useAuth } from "@/lib/auth"; +import { checkKillSwitch } from "@/lib/kill-switch"; + +export function AuthGuard({ children }: { children: ReactNode }) { + const { isAuthenticated, isLoading: authLoading } = useAuth(); + const router = useRouter(); + const [killSwitchMsg, setKillSwitchMsg] = useState(null); + const [ready, setReady] = useState(false); + + useEffect(() => { + if (authLoading) return; + + if (!isAuthenticated) { + router.replace("/login"); + return; + } + + checkKillSwitch() + .then(({ disabled, message }) => { + if (disabled) { + setKillSwitchMsg(message || "This application is currently unavailable."); + } else { + setReady(true); + } + }) + .catch(() => { + setReady(true); + }); + }, [isAuthenticated, authLoading, router]); + + if (killSwitchMsg) { + return ( + + + Service Unavailable + {killSwitchMsg} + + + ); + } + + if (!ready) return null; + + return <>{children}>; +} diff --git a/web/src/components/CreateWorkspaceModal.tsx b/web/src/components/CreateWorkspaceModal.tsx new file mode 100644 index 0000000..ec5b65c --- /dev/null +++ b/web/src/components/CreateWorkspaceModal.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { createWorkspace } from "@/lib/notes-client"; + +interface Props { + onCreated: () => void; + onClose: () => void; +} + +export function CreateWorkspaceModal({ onCreated, onClose }: Props) { + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!name.trim() || saving) return; + + setSaving(true); + setError(null); + + try { + await createWorkspace({ + id: crypto.randomUUID(), + name: name.trim(), + description: description.trim() || undefined, + }); + onCreated(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create workspace"); + } finally { + setSaving(false); + } + } + + return ( + { if (e.target === e.currentTarget) onClose(); }} + > + + Create Workspace + + {error && {error}} + + + Name + setName(e.target.value)} autoFocus /> + + + + Description + setDescription(e.target.value)} rows={3} style={{ resize: "vertical" }} /> + + + + Cancel + + {saving ? "Creating…" : "Create"} + + + + + ); +} diff --git a/web/src/components/NoteEditor.tsx b/web/src/components/NoteEditor.tsx index a04681e..6d2acbd 100644 --- a/web/src/components/NoteEditor.tsx +++ b/web/src/components/NoteEditor.tsx @@ -1,6 +1,35 @@ -import { useEffect, useState } from "react"; +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useEditor, EditorContent } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import Placeholder from "@tiptap/extension-placeholder"; import type { NoteDetail } from "@/lib/types"; +const TOOLBAR_BTN: React.CSSProperties = { + border: "none", + borderRadius: "var(--nl-radius-sm)", + padding: "4px 8px", + background: "transparent", + color: "var(--nl-text-secondary)", + fontSize: "var(--nl-fs-sm)", + cursor: "pointer", +}; + +const TOOLBAR_BTN_ACTIVE: React.CSSProperties = { + ...TOOLBAR_BTN, + background: "rgba(90,140,255,0.18)", + color: "var(--nl-text-primary)", +}; + +function ToolbarButton({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) { + return ( + + {label} + + ); +} + export function NoteEditor({ note, onSave, @@ -11,45 +40,75 @@ export function NoteEditor({ isSaving?: boolean; }) { const [title, setTitle] = useState(note.title); - const [body, setBody] = useState(note.body); + + const editor = useEditor({ + extensions: [ + StarterKit.configure({ + heading: { levels: [1, 2, 3] }, + }), + Placeholder.configure({ placeholder: "Start writing…" }), + ], + content: note.body, + editorProps: { + attributes: { + class: "input-shell", + style: "min-height:360px;outline:none;line-height:1.7;", + }, + }, + }); useEffect(() => { setTitle(note.title); - setBody(note.body); - }, [note.body, note.title]); + if (editor && note.body !== editor.getHTML()) { + editor.commands.setContent(note.body); + } + }, [note.title, note.body, editor]); + + const handleSave = useCallback(() => { + if (!editor) return; + void onSave({ title, body: editor.getHTML() }); + }, [editor, title, onSave]); return ( { - event.preventDefault(); - void onSave({ title, body }); + onSubmit={(e) => { + e.preventDefault(); + handleSave(); }} > - - Title - + Title setTitle(event.target.value)} + onChange={(e) => setTitle(e.target.value)} /> - - Body - - setBody(event.target.value)} - style={{ minHeight: 360, resize: "vertical" }} - /> + Body + + {editor && ( + + editor.chain().focus().toggleBold().run()} label="B" /> + editor.chain().focus().toggleItalic().run()} label="I" /> + editor.chain().focus().toggleStrike().run()} label="S" /> + + editor.chain().focus().toggleHeading({ level: 1 }).run()} label="H1" /> + editor.chain().focus().toggleHeading({ level: 2 }).run()} label="H2" /> + editor.chain().focus().toggleHeading({ level: 3 }).run()} label="H3" /> + + editor.chain().focus().toggleBulletList().run()} label="•" /> + editor.chain().focus().toggleOrderedList().run()} label="1." /> + editor.chain().focus().toggleCodeBlock().run()} label="<>" /> + editor.chain().focus().toggleBlockquote().run()} label="❝" /> + + )} + + diff --git a/web/src/lib/notes-client.ts b/web/src/lib/notes-client.ts index 28ae3e9..956248e 100644 --- a/web/src/lib/notes-client.ts +++ b/web/src/lib/notes-client.ts @@ -358,6 +358,36 @@ export async function exportNotes(format: "json" | "markdown", workspaceId?: str return typeof res === "string" ? res : JSON.stringify(res, null, 2); } +export async function createWorkspace(input: { + id: string; + name: string; + description?: string; +}): Promise { + const api = createNotesApiClient(); + return api.fetch("/workspaces", { + method: "POST", + body: JSON.stringify(input), + }); +} + +export async function updateWorkspace( + workspaceId: string, + updates: { name?: string; description?: string }, +): Promise { + const api = createNotesApiClient(); + await api.fetch(`/workspaces/${encodeURIComponent(workspaceId)}`, { + method: "PATCH", + body: JSON.stringify(updates), + }); +} + +export async function deleteWorkspace(workspaceId: string): Promise { + const api = createNotesApiClient(); + await api.fetch(`/workspaces/${encodeURIComponent(workspaceId)}`, { + method: "DELETE", + }); +} + export async function createNoteRelationship(input: { id: string; workspaceId: string;
+ Enter your email and we'll send a reset link if the account exists. +
+ Back to sign in +
+ Already have an account?{" "} + Sign in +
{killSwitchMsg}