"use client"; /** * /spaces — the spaces directory (T-136/T-137 web surface; the API — list * filters, archive/restore, stats — landed 2026-10-11 and its E2E was * pending on THIS route). * * Top-level (outside the [workspace] segment): a directory lists MULTIPLE * spaces, so it has no single current workspace. It reuses the app's * WorkspaceShell (auth gate + sidebar switcher + topbar) via the (app) * layout — the same path the /w/:workspace index page uses when no * workspace param is present. * * Filters: `type` is SERVER-side (one refetch per selection — the directory * is the member-scoped list, which can be large); `search` and the archived * toggle are CLIENT-side over the already-loaded list (no extra round-trip * for a filter the user applies to what's on screen). The server still * enforces the no-leak boundary (non-members never appear) — the client * filters only narrow the caller's own list. */ import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Archive, ArchiveRestore, FilePlus2, Loader2, Search } from "lucide-react"; import { workspaceApi } from "@/lib/api"; import { toast } from "@/stores/ui-store"; import type { Workspace } from "@/lib/types"; const TYPE_OPTIONS = [ { value: "", label: "All types" }, { value: "team", label: "Team" }, { value: "project", label: "Project" }, { value: "kb", label: "Knowledge base" }, { value: "custom", label: "Custom" } ] as const; function typeLabel(t: string | undefined): string { switch (t) { case "team": return "Team"; case "project": return "Project"; case "kb": return "Knowledge base"; case "custom": return "Custom"; default: return "Space"; } } export default function SpacesDirectoryPage() { const router = useRouter(); const qc = useQueryClient(); const [type, setType] = useState(""); const [search, setSearch] = useState(""); const [showArchived, setShowArchived] = useState(false); const [actingId, setActingId] = useState(null); // Server-side member-scoped list, filtered by type + archived (refetch per // change). The DEFAULT list EXCLUDES archived spaces, so "show archived" // must ask the server for `archived=true` (a replace, not an include) — // client-side filtering can't surface rows the server didn't return. // `type` empty → no param (all types). const list = useQuery({ queryKey: ["workspaces", "directory", { type: type || null, archived: showArchived }], queryFn: () => workspaceApi.list({ ...(type ? { type } : {}), ...(showArchived ? { archived: true } : {}) }), retry: false }); const items = list.data ?? []; // Client-side search only (narrow the caller's own already-loaded list). const filtered = useMemo(() => { const q = search.trim().toLowerCase(); return q ? items.filter((w) => w.name.toLowerCase().includes(q)) : items; }, [items, search]); const open = (w: Workspace) => router.push(`/w/${w.slug || w.id}`); const onArchive = async (w: Workspace, archive: boolean) => { setActingId(w.id); try { await (archive ? workspaceApi.archive(w.id) : workspaceApi.restoreArchive(w.id)); qc.invalidateQueries({ queryKey: ["workspaces"] }); toast("success", archive ? `“${w.name}” archived` : `“${w.name}” restored`); } catch (e) { toast("error", "Could not update the space", e instanceof Error ? e.message : undefined); } finally { setActingId(null); } }; return (

Spaces

Every space you belong to. {showArchived ? "Showing archived spaces." : "Archived spaces are hidden."}

setSearch(e.target.value)} placeholder="Search spaces…" aria-label="Search spaces" className="w-full rounded-md border border-border bg-background py-1.5 pl-8 pr-2 text-sm outline-none focus:ring-1 focus:ring-ring" />
{list.isLoading && (

Loading spaces…

)} {list.isError && (

Couldn’t load your spaces.

)} {!list.isLoading && !list.isError && filtered.length === 0 && (

{showArchived ? "No archived spaces." : "No spaces yet."}

)}
); }