learning_ai_notes/web/src/app/(app)/workspaces/page.tsx

280 lines
11 KiB
TypeScript

"use client";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useEffect, useMemo, useState } from "react";
import { AppShell } from "@/components/AppShell";
import { CreateWorkspaceModal } from "@/components/CreateWorkspaceModal";
import { Button } from "@/components/ui/Primitives";
import { exportNotes, listNoteSummaries, listWorkspaceSummaries, deleteWorkspace } from "@/lib/notes-client";
import { buildWorkspaceContextPackMarkdown } from "@/lib/context-pack";
import { toast } from "@/lib/toast";
import type { NoteSummary, WorkspaceSummary } from "@/lib/types";
export default function WorkspacesPage() {
return (
<Suspense fallback={<AppShell title="Workspaces" description="Manage workspaces"><p>Loading...</p></AppShell>}>
<WorkspacesPageInner />
</Suspense>
);
}
function WorkspacesPageInner() {
const searchParams = useSearchParams();
const [notes, setNotes] = useState<NoteSummary[]>([]);
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") ?? "");
}, [searchParams]);
useEffect(() => {
void (async () => {
try {
const [nextNotes, nextWorkspaces] = await Promise.all([
listNoteSummaries(),
listWorkspaceSummaries(),
]);
setNotes(nextNotes);
setWorkspaces(nextWorkspaces);
} catch (err) {
setError(err instanceof Error ? err.message : "Unable to load workspaces");
}
})();
}, [loadKey]);
function reload() {
setLoadKey((k) => k + 1);
}
async function downloadWorkspaceContextPack(workspaceId: string, workspaceName: string) {
try {
const raw = await exportNotes("json", workspaceId);
const data = JSON.parse(typeof raw === "string" ? raw : JSON.stringify(raw)) as {
notes?: Array<{ id: string; title: string; body: string; tags?: string[] }>;
};
const notes = (data.notes ?? []).slice(0, 50).map((n) => ({
id: n.id,
title: n.title,
body: n.body,
tags: n.tags ?? [],
}));
const md = buildWorkspaceContextPackMarkdown({ workspaceId, workspaceName, notes });
const blob = new Blob([md], { type: "text/markdown" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `notelett-context-${workspaceId.slice(0, 8)}.md`;
a.click();
URL.revokeObjectURL(url);
toast.success("Context pack downloaded");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Export failed");
}
}
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(
() =>
new Map(
workspaces.map((workspace) => [
workspace.id,
notes.filter((note) => note.workspaceId === workspace.id),
]),
),
[notes, workspaces],
);
const savedViews = [
{
id: "workspace-all",
name: "All workspaces",
description: "Current backend-backed workspace inventory.",
resultCount: workspaces.length,
},
{
id: "workspace-shared",
name: "Shared workspaces",
description: "Workspaces with more than one member.",
resultCount: workspaces.filter((workspace) => workspace.visibility === "shared").length,
},
];
const filteredWorkspaces = useMemo(() => {
const normalized = query.trim().toLowerCase();
if (!normalized) {
return workspaces;
}
return workspaces.filter((workspace) => {
const workspaceNotes = notesByWorkspace.get(workspace.id) ?? [];
return (
workspace.name.toLowerCase().includes(normalized) ||
workspace.description.toLowerCase().includes(normalized) ||
workspace.owner.toLowerCase().includes(normalized) ||
workspace.visibility.toLowerCase().includes(normalized) ||
workspace.tags.some((tag) => tag.toLowerCase().includes(normalized)) ||
workspaceNotes.some(
(note) =>
note.title.toLowerCase().includes(normalized) ||
note.excerpt.toLowerCase().includes(normalized) ||
note.tags.some((tag) => tag.toLowerCase().includes(normalized)),
)
);
});
}, [notesByWorkspace, query, workspaces]);
return (
<AppShell
title="Workspaces"
description="Workspace-level organization, filters, and saved-view entry points for note collections."
actions={
<div style={{ display: "flex", gap: "var(--nl-space-3)" }}>
<Button
onClick={() => setShowCreate(true)}
>
+ Workspace
</Button>
<Button
variant="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)" }}>
<aside className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
<div style={{ fontWeight: 700 }}>Saved views</div>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{savedViews.map((view) => (
<Link
key={view.id}
href={view.id === "workspace-shared" ? "/workspaces?q=shared" : "/workspaces"}
className="surface-muted"
style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}
>
<strong>{view.name}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>{view.description}</span>
<span className="badge">{view.resultCount} results</span>
</Link>
))}
</div>
</aside>
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-4)" }}>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
<input
aria-label="Filter workspaces"
className="input-shell"
placeholder="Filter workspaces by owner, tag, or visibility"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
<span className="badge">owner:any</span>
<span className="badge">visibility:any</span>
<span className="badge">sort:updated</span>
</div>
</div>
</section>
</section>
{error ? (
<section className="surface-card" style={{ padding: "var(--nl-space-6)", color: "var(--nl-text-secondary)" }}>
{error}
</section>
) : null}
<section style={{ display: "grid", gap: "var(--nl-space-4)" }}>
{filteredWorkspaces.map((workspace) => {
const workspaceNotes = notesByWorkspace.get(workspace.id) ?? [];
return (
<article key={workspace.id} className="surface-card" style={{ padding: "var(--nl-space-6)", display: "grid", gap: "var(--nl-space-4)" }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: "var(--nl-space-4)", flexWrap: "wrap" }}>
<div>
<div style={{ fontSize: "var(--nl-fs-xl)", fontWeight: 700 }}>{workspace.name}</div>
<div style={{ color: "var(--nl-text-secondary)", marginTop: 6 }}>{workspace.description}</div>
</div>
<div style={{ display: "grid", gap: 8, justifyItems: "end" }}>
<Link href={`/workspaces?q=${encodeURIComponent(workspace.visibility)}`} className="badge">
{workspace.visibility}
</Link>
<Link href={`/workspaces?q=${encodeURIComponent(workspace.owner)}`} style={{ color: "var(--nl-text-secondary)" }}>
Owner: {workspace.owner}
</Link>
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void downloadWorkspaceContextPack(workspace.id, workspace.name)}
>
Context pack (.md)
</Button>
<Button
onClick={() => handleDelete(workspace.id, workspace.name)}
variant="secondary"
size="sm"
className="bg-[var(--nl-danger-muted)] text-[var(--nl-danger)] border-transparent hover:bg-[var(--nl-danger-muted)]"
>
Delete
</Button>
</div>
</div>
<div style={{ display: "flex", gap: "var(--nl-space-2)", flexWrap: "wrap" }}>
{workspace.tags.map((tag) => (
<Link key={tag} href={`/workspaces?q=${encodeURIComponent(tag)}`} className="badge">
#{tag}
</Link>
))}
</div>
<div style={{ display: "grid", gap: "var(--nl-space-3)" }}>
{workspaceNotes.map((note) => (
<Link key={note.id} href={`/notes/${note.id}`} className="surface-muted" style={{ padding: "var(--nl-space-4)", display: "grid", gap: "var(--nl-space-2)" }}>
<strong>{note.title}</strong>
<span style={{ color: "var(--nl-text-secondary)" }}>{note.excerpt}</span>
</Link>
))}
</div>
</article>
);
})}
</section>
{showCreate && (
<CreateWorkspaceModal
onCreated={() => { setShowCreate(false); toast.success("Workspace created"); reload(); }}
onClose={() => setShowCreate(false)}
/>
)}
</AppShell>
);
}