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
|
## 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.
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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";
|
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" }}>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user