280 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|