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 ## Phase 1 — Critical Web Gaps
These block the web app from being usable by real users. These block the web app from being usable by real users.

View File

@ -23,6 +23,10 @@
"@bytelyst/platform-client": "^0.1.0", "@bytelyst/platform-client": "^0.1.0",
"@bytelyst/dashboard-components": "^0.1.0", "@bytelyst/dashboard-components": "^0.1.0",
"@bytelyst/extraction": "^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/react-auth": "^0.1.0",
"@bytelyst/telemetry-client": "^0.1.0", "@bytelyst/telemetry-client": "^0.1.0",
"lucide-react": "^0.575.0", "lucide-react": "^0.575.0",

View File

@ -6,6 +6,7 @@ import { AppShell } from "@/components/AppShell";
import { CreateNoteModal } from "@/components/CreateNoteModal"; import { CreateNoteModal } from "@/components/CreateNoteModal";
import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client"; import { listNoteSummaries, listWorkspaceSummaries } from "@/lib/notes-client";
import { listApprovalQueue } from "@/lib/review-client"; import { listApprovalQueue } from "@/lib/review-client";
import { toast } from "@/lib/toast";
import type { NoteSummary, WorkspaceSummary } from "@/lib/types"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
export default function DashboardPage() { export default function DashboardPage() {
@ -221,6 +222,7 @@ export default function DashboardPage() {
workspaces={workspaces} workspaces={workspaces}
onCreated={() => { onCreated={() => {
setShowCreateNote(false); setShowCreateNote(false);
toast.success("Note created");
window.location.reload(); window.location.reload();
}} }}
onClose={() => setShowCreateNote(false)} onClose={() => setShowCreateNote(false)}

View File

@ -1,11 +1,12 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { AuthGuard } from "@/components/AuthGuard";
import { KeyboardShortcuts } from "@/components/KeyboardShortcuts"; import { KeyboardShortcuts } from "@/components/KeyboardShortcuts";
export default function ProductLayout({ children }: { children: ReactNode }) { export default function ProductLayout({ children }: { children: ReactNode }) {
return ( return (
<> <AuthGuard>
<KeyboardShortcuts /> <KeyboardShortcuts />
<main id="main-content">{children}</main> <main id="main-content">{children}</main>
</> </AuthGuard>
); );
} }

View File

@ -12,6 +12,7 @@ import { ArtifactPanel } from "@/components/ArtifactPanel";
import { AgentTimeline } from "@/components/AgentTimeline"; import { AgentTimeline } from "@/components/AgentTimeline";
import { LinkNoteModal } from "@/components/LinkNoteModal"; import { LinkNoteModal } from "@/components/LinkNoteModal";
import { archiveNote, createNoteArtifact, createNoteTask, getNoteDetail, restoreNote, summarizeNote, updateNoteDetail } from "@/lib/notes-client"; import { archiveNote, createNoteArtifact, createNoteTask, getNoteDetail, restoreNote, summarizeNote, updateNoteDetail } from "@/lib/notes-client";
import { toast } from "@/lib/toast";
import type { NoteDetail } from "@/lib/types"; import type { NoteDetail } from "@/lib/types";
export default function NoteDetailPage() { export default function NoteDetailPage() {
@ -46,8 +47,11 @@ export default function NoteDetailPage() {
const refreshed = await getNoteDetail(note.id); const refreshed = await getNoteDetail(note.id);
setNote(refreshed); setNote(refreshed);
setError(null); setError(null);
toast.success("Note saved");
} catch (err) { } 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 { } finally {
setIsSaving(false); setIsSaving(false);
} }
@ -78,8 +82,11 @@ export default function NoteDetailPage() {
const refreshed = await getNoteDetail(note.id); const refreshed = await getNoteDetail(note.id);
setNote(refreshed); setNote(refreshed);
setError(null); setError(null);
toast.success("Artifact created");
} catch (err) { } 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 { } finally {
setIsCreatingArtifact(false); setIsCreatingArtifact(false);
} }
@ -104,8 +111,11 @@ export default function NoteDetailPage() {
const refreshed = await getNoteDetail(note.id); const refreshed = await getNoteDetail(note.id);
setNote(refreshed); setNote(refreshed);
setError(null); setError(null);
toast.success("Task created");
} catch (err) { } 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 { } finally {
setIsCreatingTask(false); setIsCreatingTask(false);
} }
@ -116,8 +126,9 @@ export default function NoteDetailPage() {
try { try {
await summarizeNote(note.id, note.workspaceId); await summarizeNote(note.id, note.workspaceId);
setNote(await getNoteDetail(note.id, note.workspaceId)); setNote(await getNoteDetail(note.id, note.workspaceId));
toast.success("Note summarized");
} catch (err) { } 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 { try {
await archiveNote(note.id, note.workspaceId); await archiveNote(note.id, note.workspaceId);
setNote(await getNoteDetail(note.id, note.workspaceId)); setNote(await getNoteDetail(note.id, note.workspaceId));
toast.success("Note archived");
} catch (err) { } 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 { try {
await restoreNote(note.id, note.workspaceId); await restoreNote(note.id, note.workspaceId);
setNote(await getNoteDetail(note.id, note.workspaceId)); setNote(await getNoteDetail(note.id, note.workspaceId));
toast.success("Note restored");
} catch (err) { } 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 { AgentTimeline } from "@/components/AgentTimeline";
import { ProposalReviewCard } from "@/components/ProposalReviewCard"; import { ProposalReviewCard } from "@/components/ProposalReviewCard";
import { approveReviewItem, batchReviewItems, listAgentTimeline, listApprovalQueue, rejectReviewItem } from "@/lib/review-client"; import { approveReviewItem, batchReviewItems, listAgentTimeline, listApprovalQueue, rejectReviewItem } from "@/lib/review-client";
import { toast } from "@/lib/toast";
import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types"; import type { AgentTimelineItem, ApprovalQueueItem } from "@/lib/types";
export default function ReviewsPage() { export default function ReviewsPage() {
@ -122,8 +123,11 @@ export default function ReviewsPage() {
...current, ...current,
]); ]);
setReviewNote(""); setReviewNote("");
toast.success(`Proposal ${decision}`);
} catch (err) { } 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 { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -157,8 +161,11 @@ export default function ReviewsPage() {
]); ]);
setSelectedBatchIds(new Set()); setSelectedBatchIds(new Set());
setReviewNote(""); setReviewNote("");
toast.success(`Batch ${decision}: ${batchItems.length} items`);
} catch (err) { } 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 { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }

View File

@ -4,7 +4,9 @@ import Link from "next/link";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useState } from "react"; import { Suspense, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell"; 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"; import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
export default function WorkspacesPage() { export default function WorkspacesPage() {
@ -21,6 +23,8 @@ function WorkspacesPageInner() {
const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]); const [workspaces, setWorkspaces] = useState<WorkspaceSummary[]>([]);
const [query, setQuery] = useState(() => searchParams?.get("q") ?? ""); const [query, setQuery] = useState(() => searchParams?.get("q") ?? "");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false);
const [loadKey, setLoadKey] = useState(0);
useEffect(() => { useEffect(() => {
setQuery(searchParams?.get("q") ?? ""); setQuery(searchParams?.get("q") ?? "");
@ -39,7 +43,22 @@ function WorkspacesPageInner() {
setError(err instanceof Error ? err.message : "Unable to load workspaces"); 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( const notesByWorkspace = useMemo(
() => () =>
@ -97,25 +116,33 @@ function WorkspacesPageInner() {
title="Workspaces" title="Workspaces"
description="Workspace-level organization, filters, and saved-view entry points for note collections." description="Workspace-level organization, filters, and saved-view entry points for note collections."
actions={ actions={
<button <div style={{ display: "flex", gap: "var(--nl-space-3)" }}>
className="btn btn-secondary" <button
onClick={async () => { style={{ padding: "8px 16px", background: "var(--nl-accent-primary)", color: "#fff", border: "none", borderRadius: "var(--nl-radius-sm)", fontWeight: 600 }}
try { onClick={() => setShowCreate(true)}
const data = await exportNotes("json"); >
const blob = new Blob([data], { type: "application/json" }); + Workspace
const url = URL.createObjectURL(blob); </button>
const a = document.createElement("a"); <button
a.href = url; className="btn btn-secondary"
a.download = "notes-export.json"; onClick={async () => {
a.click(); try {
URL.revokeObjectURL(url); const data = await exportNotes("json");
} catch (err) { const blob = new Blob([data], { type: "application/json" });
setError(err instanceof Error ? err.message : "Export failed"); const url = URL.createObjectURL(blob);
} const a = document.createElement("a");
}} a.href = url;
> a.download = "notes-export.json";
Export Notes a.click();
</button> 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)" }}> <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)" }}> <Link href={`/workspaces?q=${encodeURIComponent(workspace.owner)}`} style={{ color: "var(--nl-text-secondary)" }}>
Owner: {workspace.owner} Owner: {workspace.owner}
</Link> </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> </div>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}> <div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
@ -198,6 +231,12 @@ function WorkspacesPageInner() {
); );
})} })}
</section> </section>
{showCreate && (
<CreateWorkspaceModal
onCreated={() => { setShowCreate(false); toast.success("Workspace created"); reload(); }}
onClose={() => setShowCreate(false)}
/>
)}
</AppShell> </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"; 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({ export function NoteEditor({
note, note,
onSave, onSave,
@ -11,45 +40,75 @@ export function NoteEditor({
isSaving?: boolean; isSaving?: boolean;
}) { }) {
const [title, setTitle] = useState(note.title); 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(() => { useEffect(() => {
setTitle(note.title); setTitle(note.title);
setBody(note.body); if (editor && note.body !== editor.getHTML()) {
}, [note.body, note.title]); 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 ( return (
<section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}> <section className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
<form <form
style={{ display: "grid", gap: "var(--nl-space-4)" }} style={{ display: "grid", gap: "var(--nl-space-4)" }}
onSubmit={(event) => { onSubmit={(e) => {
event.preventDefault(); e.preventDefault();
void onSave({ title, body }); handleSave();
}} }}
> >
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}> <div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<label htmlFor="note-title" style={{ color: "var(--nl-text-secondary)" }}> <label htmlFor="note-title" style={{ color: "var(--nl-text-secondary)" }}>Title</label>
Title
</label>
<input <input
id="note-title" id="note-title"
className="input-shell" className="input-shell"
value={title} value={title}
onChange={(event) => setTitle(event.target.value)} onChange={(e) => setTitle(e.target.value)}
/> />
</div> </div>
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}> <div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
<label htmlFor="note-body" style={{ color: "var(--nl-text-secondary)" }}> <label style={{ color: "var(--nl-text-secondary)" }}>Body</label>
Body
</label> {editor && (
<textarea <div style={{ display: "flex", gap: 4, flexWrap: "wrap", padding: "4px 0" }}>
id="note-body" <ToolbarButton active={editor.isActive("bold")} onClick={() => editor.chain().focus().toggleBold().run()} label="B" />
className="input-shell" <ToolbarButton active={editor.isActive("italic")} onClick={() => editor.chain().focus().toggleItalic().run()} label="I" />
value={body} <ToolbarButton active={editor.isActive("strike")} onClick={() => editor.chain().focus().toggleStrike().run()} label="S" />
onChange={(event) => setBody(event.target.value)} <span style={{ width: 1, background: "var(--nl-border-default)", margin: "0 4px" }} />
style={{ minHeight: 360, resize: "vertical" }} <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>
<div style={{ display: "flex", justifyContent: "flex-end" }}> <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); 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: { export async function createNoteRelationship(input: {
id: string; id: string;
workspaceId: string; workspaceId: string;