feat: add auth pages, AuthGuard, Tiptap editor, workspace CRUD, toast notifications

Phase 1 of the execution roadmap:
- Build login, register, forgot-password pages using useAuth() from @bytelyst/react-auth
- Add AuthGuard with kill-switch check wrapping (app) routes
- Replace plain textarea NoteEditor with Tiptap rich text editor
- Add workspace create/delete with CreateWorkspaceModal
- Wire sonner toast notifications on all mutation handlers

Made-with: Cursor
This commit is contained in:
Saravana Achu Mac 2026-03-29 20:38:33 -07:00
parent 7babee791d
commit 839218a19c
15 changed files with 566 additions and 52 deletions

View File

@ -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.

View File

@ -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",

View File

@ -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)}

View File

@ -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 (
<>
<AuthGuard>
<KeyboardShortcuts />
<main id="main-content">{children}</main>
</>
</AuthGuard>
);
}

View File

@ -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");
}
}

View File

@ -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);
}

View File

@ -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<WorkspaceSummary[]>([]);
const [query, setQuery] = useState(() => searchParams?.get("q") ?? "");
const [error, setError] = useState<string | null>(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={
<button
className="btn btn-secondary"
onClick={async () => {
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
</button>
<div style={{ display: "flex", gap: "var(--nl-space-3)" }}>
<button
style={{ padding: "8px 16px", background: "var(--nl-accent-primary)", color: "#fff", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600 }}
onClick={() => setShowCreate(true)}
>
+ Workspace
</button>
<button
className="btn btn-secondary"
onClick={async () => {
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
</button>
</div>
}
>
<section style={{ display: "grid", gridTemplateColumns: "minmax(260px, 320px) minmax(0, 1fr)", gap: "var(--nl-space-4)" }}>
@ -177,6 +204,12 @@ function WorkspacesPageInner() {
<Link href={`/workspaces?q=${encodeURIComponent(workspace.owner)}`} style={{ color: "var(--nl-text-secondary)" }}>
Owner: {workspace.owner}
</Link>
<button
onClick={() => 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
</button>
</div>
</div>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
@ -198,6 +231,12 @@ function WorkspacesPageInner() {
);
})}
</section>
{showCreate && (
<CreateWorkspaceModal
onCreated={() => { setShowCreate(false); toast.success("Workspace created"); reload(); }}
onClose={() => setShowCreate(false)}
/>
)}
</AppShell>
);
}

View File

@ -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 (
<form onSubmit={handleSubmit} className="surface-card" style={{ padding: "var(--nl-space-8)", display: "grid", gap: "var(--nl-space-5)" }}>
<h1 style={{ margin: 0, fontSize: "var(--nl-fs-xl)" }}>Reset password</h1>
<p style={{ margin: 0, color: "var(--nl-text-secondary)", fontSize: "var(--nl-fs-sm)" }}>
Enter your email and we&apos;ll send a reset link if the account exists.
</p>
{error && (
<div role="alert" style={{ padding: "10px 14px", borderRadius: "var(--nl-radius-sm)", background: "rgba(255,110,110,0.12)", color: "var(--nl-danger, #FF6E6E)", fontSize: "var(--nl-fs-sm)" }}>
{error}
</div>
)}
{success && (
<div role="status" style={{ padding: "10px 14px", borderRadius: "var(--nl-radius-sm)", background: "rgba(74,222,128,0.12)", color: "#4ADE80", fontSize: "var(--nl-fs-sm)" }}>
{success}
</div>
)}
<label style={{ display: "grid", gap: 6 }}>
<span style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>Email</span>
<input
type="email"
required
autoComplete="email"
className="input-shell"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<button
type="submit"
disabled={isLoading}
className="input-shell"
style={{ background: "var(--nl-accent-primary, #5A8CFF)", color: "#fff", border: "none", fontWeight: 600, cursor: isLoading ? "wait" : "pointer" }}
>
{isLoading ? "Sending…" : "Send reset link"}
</button>
<p style={{ textAlign: "center", fontSize: "var(--nl-fs-sm)" }}>
<Link href="/login" style={{ color: "var(--nl-accent-primary)" }}>Back to sign in</Link>
</p>
</form>
);
}

View File

@ -0,0 +1,15 @@
import type { ReactNode } from "react";
import Link from "next/link";
export default function AuthLayout({ children }: { children: ReactNode }) {
return (
<main style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: "var(--nl-space-8)" }}>
<div style={{ width: "100%", maxWidth: 440, display: "grid", gap: "var(--nl-space-6)" }}>
<Link href="/" style={{ textAlign: "center", fontFamily: "var(--nl-font-display)", fontSize: "var(--nl-fs-2xl)" }}>
NoteLett
</Link>
{children}
</div>
</main>
);
}

View File

@ -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 (
<form onSubmit={handleSubmit} className="surface-card" style={{ padding: "var(--nl-space-8)", display: "grid", gap: "var(--nl-space-5)" }}>
<h1 style={{ margin: 0, fontSize: "var(--nl-fs-xl)" }}>Sign in</h1>
{error && (
<div role="alert" style={{ padding: "10px 14px", borderRadius: "var(--nl-radius-sm)", background: "rgba(255,110,110,0.12)", color: "var(--nl-danger, #FF6E6E)", fontSize: "var(--nl-fs-sm)" }}>
{error}
</div>
)}
<label style={{ display: "grid", gap: 6 }}>
<span style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>Email</span>
<input
type="email"
required
autoComplete="email"
className="input-shell"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<label style={{ display: "grid", gap: 6 }}>
<span style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>Password</span>
<input
type="password"
required
autoComplete="current-password"
className="input-shell"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button
type="submit"
disabled={isLoading}
className="input-shell"
style={{ background: "var(--nl-accent-primary, #5A8CFF)", color: "#fff", border: "none", fontWeight: 600, cursor: isLoading ? "wait" : "pointer" }}
>
{isLoading ? "Signing in…" : "Sign in"}
</button>
<div style={{ display: "flex", justifyContent: "space-between", fontSize: "var(--nl-fs-sm)" }}>
<Link href="/forgot-password" style={{ color: "var(--nl-accent-primary)" }}>Forgot password?</Link>
<Link href="/register" style={{ color: "var(--nl-accent-primary)" }}>Create account</Link>
</div>
</form>
);
}

View File

@ -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 (
<form onSubmit={handleSubmit} className="surface-card" style={{ padding: "var(--nl-space-8)", display: "grid", gap: "var(--nl-space-5)" }}>
<h1 style={{ margin: 0, fontSize: "var(--nl-fs-xl)" }}>Create account</h1>
{error && (
<div role="alert" style={{ padding: "10px 14px", borderRadius: "var(--nl-radius-sm)", background: "rgba(255,110,110,0.12)", color: "var(--nl-danger, #FF6E6E)", fontSize: "var(--nl-fs-sm)" }}>
{error}
</div>
)}
<label style={{ display: "grid", gap: 6 }}>
<span style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>Display name</span>
<input
type="text"
required
autoComplete="name"
className="input-shell"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
</label>
<label style={{ display: "grid", gap: 6 }}>
<span style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>Email</span>
<input
type="email"
required
autoComplete="email"
className="input-shell"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<label style={{ display: "grid", gap: 6 }}>
<span style={{ fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>Password</span>
<input
type="password"
required
minLength={8}
autoComplete="new-password"
className="input-shell"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<button
type="submit"
disabled={isLoading}
className="input-shell"
style={{ background: "var(--nl-accent-primary, #5A8CFF)", color: "#fff", border: "none", fontWeight: 600, cursor: isLoading ? "wait" : "pointer" }}
>
{isLoading ? "Creating account…" : "Create account"}
</button>
<p style={{ textAlign: "center", fontSize: "var(--nl-fs-sm)", color: "var(--nl-text-secondary)" }}>
Already have an account?{" "}
<Link href="/login" style={{ color: "var(--nl-accent-primary)" }}>Sign in</Link>
</p>
</form>
);
}

View File

@ -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<string | null>(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 (
<div style={{ minHeight: "100vh", display: "grid", placeItems: "center", padding: "var(--nl-space-8)" }}>
<div className="surface-card" style={{ padding: "var(--nl-space-8)", maxWidth: 480, textAlign: "center" }}>
<h1 style={{ margin: 0, fontSize: "var(--nl-fs-xl)", color: "var(--nl-danger, #FF6E6E)" }}>Service Unavailable</h1>
<p style={{ marginTop: "var(--nl-space-4)", color: "var(--nl-text-secondary)" }}>{killSwitchMsg}</p>
</div>
</div>
);
}
if (!ready) return null;
return <>{children}</>;
}

View File

@ -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<string | null>(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 (
<div
style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.5)", display: "flex", alignItems: "center", justifyContent: "center", zIndex: 1000 }}
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<form
onSubmit={handleSubmit}
className="surface-card"
style={{ padding: "var(--nl-space-6)", width: "100%", maxWidth: 480, display: "grid", gap: "var(--nl-space-4)" }}
>
<div style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700 }}>Create Workspace</div>
{error && <div style={{ color: "var(--nl-danger, #FF6E6E)", fontSize: "var(--nl-fs-sm)" }}>{error}</div>}
<label style={{ display: "grid", gap: 6 }}>
<span style={{ fontWeight: 600, fontSize: "var(--nl-fs-sm)" }}>Name</span>
<input className="input-shell" type="text" required value={name} onChange={(e) => setName(e.target.value)} autoFocus />
</label>
<label style={{ display: "grid", gap: 6 }}>
<span style={{ fontWeight: 600, fontSize: "var(--nl-fs-sm)" }}>Description</span>
<textarea className="input-shell" value={description} onChange={(e) => setDescription(e.target.value)} rows={3} style={{ resize: "vertical" }} />
</label>
<div style={{ display: "flex", justifyContent: "flex-end", gap: "var(--nl-space-3)" }}>
<button type="button" className="surface-muted" style={{ padding: "8px 16px", border: "none" }} onClick={onClose}>Cancel</button>
<button type="submit" disabled={!name.trim() || saving} style={{ padding: "8px 16px", background: "var(--nl-accent-primary)", color: "#fff", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600 }}>
{saving ? "Creating…" : "Create"}
</button>
</div>
</form>
</div>
);
}

View File

@ -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 (
<button type="button" style={active ? TOOLBAR_BTN_ACTIVE : TOOLBAR_BTN} onClick={onClick} title={label}>
{label}
</button>
);
}
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 (
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
<form
style={{ display: "grid", gap: "var(--nl-space-4)" }}
onSubmit={(event) => {
event.preventDefault();
void onSave({ title, body });
onSubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<label htmlFor="note-title" style={{ color: "var(--nl-text-secondary)" }}>
Title
</label>
<label htmlFor="note-title" style={{ color: "var(--nl-text-secondary)" }}>Title</label>
<input
id="note-title"
className="input-shell"
value={title}
onChange={(event) => setTitle(event.target.value)}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<label htmlFor="note-body" style={{ color: "var(--nl-text-secondary)" }}>
Body
</label>
<textarea
id="note-body"
className="input-shell"
value={body}
onChange={(event) => setBody(event.target.value)}
style={{ minHeight: 360, resize: "vertical" }}
/>
<label style={{ color: "var(--nl-text-secondary)" }}>Body</label>
{editor && (
<div style={{ display: "flex", gap: 4, flexWrap: "wrap", padding: "4px 0" }}>
<ToolbarButton active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()} label="B" />
<ToolbarButton active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()} label="I" />
<ToolbarButton active={editor.isActive("strike")} onClick={() => editor.chain().focus().toggleStrike().run()} label="S" />
<span style={{ width: 1, background: "var(--nl-border-default)", margin: "0 4px" }} />
<ToolbarButton active={editor.isActive("heading", { level: 1 })} onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()} label="H1" />
<ToolbarButton active={editor.isActive("heading", { level: 2 })} onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()} label="H2" />
<ToolbarButton active={editor.isActive("heading", { level: 3 })} onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()} label="H3" />
<span style={{ width: 1, background: "var(--nl-border-default)", margin: "0 4px" }} />
<ToolbarButton active={editor.isActive("bulletList")} onClick={() => editor.chain().focus().toggleBulletList().run()} label="•" />
<ToolbarButton active={editor.isActive("orderedList")} onClick={() => editor.chain().focus().toggleOrderedList().run()} label="1." />
<ToolbarButton active={editor.isActive("codeBlock")} onClick={() => editor.chain().focus().toggleCodeBlock().run()} label="<>" />
<ToolbarButton active={editor.isActive("blockquote")} onClick={() => editor.chain().focus().toggleBlockquote().run()} label="❝" />
</div>
)}
<EditorContent editor={editor} />
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>

View File

@ -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<WorkspaceDoc> {
const api = createNotesApiClient();
return api.fetch<WorkspaceDoc>("/workspaces", {
method: "POST",
body: JSON.stringify(input),
});
}
export async function updateWorkspace(
workspaceId: string,
updates: { name?: string; description?: string },
): Promise<void> {
const api = createNotesApiClient();
await api.fetch(`/workspaces/${encodeURIComponent(workspaceId)}`, {
method: "PATCH",
body: JSON.stringify(updates),
});
}
export async function deleteWorkspace(workspaceId: string): Promise<void> {
const api = createNotesApiClient();
await api.fetch(`/workspaces/${encodeURIComponent(workspaceId)}`, {
method: "DELETE",
});
}
export async function createNoteRelationship(input: {
id: string;
workspaceId: string;