1"use client";
2
3
21import { useMemo, useState } from "react";
22import { useRouter } from "next/navigation";
23import { useQuery, useQueryClient } from "@tanstack/react-query";
24import { Archive, ArchiveRestore, FilePlus2, Loader2, Search } from "lucide-react";
25import { workspaceApi } from "@/lib/api";
26import { toast } from "@/stores/ui-store";
27import type { Workspace } from "@/lib/types";
28
29const TYPE_OPTIONS = [
30 { value: "", label: "All types" },
31 { value: "team", label: "Team" },
32 { value: "project", label: "Project" },
33 { value: "kb", label: "Knowledge base" },
34 { value: "custom", label: "Custom" }
35] as const;
36
37function typeLabel(t: string | undefined): string {
38 switch (t) {
39 case "team":
40 return "Team";
41 case "project":
42 return "Project";
43 case "kb":
44 return "Knowledge base";
45 case "custom":
46 return "Custom";
47 default:
48 return "Space";
49 }
50}
51
52export default function SpacesDirectoryPage() {
53 const router = useRouter();
54 const qc = useQueryClient();
55
56 const [type, setType] = useState("");
57 const [search, setSearch] = useState("");
58 const [showArchived, setShowArchived] = useState(false);
59 const [actingId, setActingId] = useState<string | null>(null);
60
61
62
63
64
65
66 const list = useQuery({
67 queryKey: ["workspaces", "directory", { type: type || null, archived: showArchived }],
68 queryFn: () =>
69 workspaceApi.list({
70 ...(type ? { type } : {}),
71 ...(showArchived ? { archived: true } : {})
72 }),
73 retry: false
74 });
75 const items = list.data ?? [];
76
77
78 const filtered = useMemo(() => {
79 const q = search.trim().toLowerCase();
80 return q ? items.filter((w) => w.name.toLowerCase().includes(q)) : items;
81 }, [items, search]);
82
83 const open = (w: Workspace) => router.push(`/w/${w.slug || w.id}`);
84
85 const onArchive = async (w: Workspace, archive: boolean) => {
86 setActingId(w.id);
87 try {
88 await (archive ? workspaceApi.archive(w.id) : workspaceApi.restoreArchive(w.id));
89 qc.invalidateQueries({ queryKey: ["workspaces"] });
90 toast("success", archive ? `“${w.name}” archived` : `“${w.name}” restored`);
91 } catch (e) {
92 toast("error", "Could not update the space", e instanceof Error ? e.message : undefined);
93 } finally {
94 setActingId(null);
95 }
96 };
97
98 return (
99 <div className="mx-auto max-w-3xl px-6 py-8">
100 <h1 className="mb-1 flex items-center gap-2 text-xl font-semibold">
101 <FilePlus2 className="size-5 text-muted-foreground" aria-hidden /> Spaces
102 </h1>
103 <p className="mb-4 text-sm text-muted-foreground">
104 Every space you belong to. {showArchived ? "Showing archived spaces." : "Archived spaces are hidden."}
105 </p>
106
107 <div className="mb-4 flex flex-wrap items-center gap-2">
108 <div className="relative min-w-52 flex-1">
109 <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" aria-hidden />
110 <input
111 data-testid="spaces-search"
112 type="search"
113 value={search}
114 onChange={(e) => setSearch(e.target.value)}
115 placeholder="Search spaces…"
116 aria-label="Search spaces"
117 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"
118 />
119 </div>
120 <select
121 data-testid="spaces-type-filter"
122 value={type}
123 onChange={(e) => setType(e.target.value)}
124 aria-label="Filter by type"
125 className="rounded-md border border-border bg-background px-2 py-1.5 text-sm"
126 >
127 {TYPE_OPTIONS.map((o) => (
128 <option key={o.value} value={o.value}>
129 {o.label}
130 </option>
131 ))}
132 </select>
133 <label className="flex cursor-pointer items-center gap-1.5 text-sm text-muted-foreground">
134 <input
135 data-testid="spaces-show-archived"
136 type="checkbox"
137 checked={showArchived}
138 onChange={(e) => setShowArchived(e.target.checked)}
139 className="size-3.5"
140 />
141 Show archived
142 </label>
143 </div>
144
145 {list.isLoading && (
146 <p className="inline-flex items-center gap-2 text-sm text-muted-foreground">
147 <Loader2 className="size-4 animate-spin" aria-hidden /> Loading spaces…
148 </p>
149 )}
150 {list.isError && (
151 <p className="rounded-md border border-dashed border-border p-4 text-sm text-muted-foreground">
152 Couldn’t load your spaces.
153 </p>
154 )}
155 {!list.isLoading && !list.isError && filtered.length === 0 && (
156 <p className="rounded-md border border-dashed border-border p-6 text-center text-sm text-muted-foreground">
157 {showArchived ? "No archived spaces." : "No spaces yet."}
158 </p>
159 )}
160
161 <ul className="space-y-1.5">
162 {filtered.map((w) => {
163 const canManage = ["owner", "admin"].includes(w.role?.toLowerCase() ?? "");
164 return (
165 <li
166 key={w.id}
167 data-testid={`space-item-${w.id}`}
168 className="flex items-center gap-2.5 rounded-md border border-border px-3 py-2"
169 >
170 <span className="shrink-0 text-lg leading-none" aria-hidden>
171 {w.emoji || "📁"}
172 </span>
173 <div className="min-w-0 flex-1">
174 <p className="truncate text-sm font-medium">{w.name}</p>
175 <p className="flex items-center gap-2 text-[11px] text-muted-foreground">
176 {typeLabel(w.type)}
177 {w.archivedAt && (
178 <span className="rounded bg-muted px-1.5 py-0.5">Archived</span>
179 )}
180 </p>
181 </div>
182 {canManage && (
183 <button
184 type="button"
185 data-testid={w.archivedAt ? `space-restore-${w.id}` : `space-archive-${w.id}`}
186 onClick={() => void onArchive(w, !w.archivedAt)}
187 disabled={actingId !== null}
188 className="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-muted disabled:opacity-50"
189 title={w.archivedAt ? "Restore this space" : "Archive this space"}
190 >
191 {actingId === w.id ? (
192 <Loader2 className="size-3.5 animate-spin" aria-hidden />
193 ) : w.archivedAt ? (
194 <ArchiveRestore className="size-3.5" aria-hidden />
195 ) : (
196 <Archive className="size-3.5" aria-hidden />
197 )}
198 {w.archivedAt ? "Restore" : "Archive"}
199 </button>
200 )}
201 <button
202 type="button"
203 data-testid={`space-open-${w.id}`}
204 onClick={() => open(w)}
205 className="rounded-md border px-2 py-1 text-xs text-muted-foreground hover:bg-muted"
206 >
207 Open
208 </button>
209 </li>
210 );
211 })}
212 </ul>
213 </div>
214 );
215}
216