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:
parent
7babee791d
commit
839218a19c
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
62
web/src/app/(auth)/forgot-password/page.tsx
Normal file
62
web/src/app/(auth)/forgot-password/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
15
web/src/app/(auth)/layout.tsx
Normal file
15
web/src/app/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
web/src/app/(auth)/login/page.tsx
Normal file
70
web/src/app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
web/src/app/(auth)/register/page.tsx
Normal file
84
web/src/app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
49
web/src/components/AuthGuard.tsx
Normal file
49
web/src/components/AuthGuard.tsx
Normal 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}</>;
|
||||
}
|
||||
71
web/src/components/CreateWorkspaceModal.tsx
Normal file
71
web/src/components/CreateWorkspaceModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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" }}>
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user