Skip to content

Commit c5987ef

Browse files
feat: workspace CRUD and workspace switcher (#26)
- Workspace switcher dropdown in sidebar lists all workspaces (personal first, then alphabetical) - Create workspace dialog with name input and auto-generated slug - Client-side creation limit check (disabled button at 3 workspaces) - Server-side limit enforced by existing DB trigger - Workspace settings page at /[workspaceSlug]/settings for editing name and slug - Delete workspace with AlertDialog confirmation (cascades to pages and members) - Personal workspace cannot be deleted (explanation shown instead) - Invited workspaces do not count toward creation limit - Unit tests for slug generation and validation Co-authored-by: Ona <no-reply@ona.com>
1 parent 31d051d commit c5987ef

12 files changed

Lines changed: 1001 additions & 31 deletions

File tree

.agents/architecture.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,9 @@ src/
155155
│ ├── (app)/ # Authenticated route group
156156
│ │ ├── layout.tsx # Auth guard, fetches profile, renders AppShell
157157
│ │ └── [workspaceSlug]/
158-
│ │ └── page.tsx # /[workspaceSlug] — workspace home (placeholder)
158+
│ │ ├── page.tsx # /[workspaceSlug] — workspace home (placeholder)
159+
│ │ └── settings/
160+
│ │ └── page.tsx # /[workspaceSlug]/settings — edit name/slug, delete workspace
159161
│ └── api/
160162
│ └── health/route.ts # Health check endpoint (DB connectivity)
161163
├── components/
@@ -166,12 +168,17 @@ src/
166168
│ │ ├── app-shell.tsx # Client wrapper: SidebarProvider + sidebar + main layout
167169
│ │ ├── app-sidebar.tsx # Sidebar (desktop: collapsible aside, mobile: Sheet)
168170
│ │ ├── sidebar-context.tsx # React context for sidebar open/close state + ⌘+\ shortcut
169-
│ │ ├── workspace-switcher.tsx # Workspace name display (placeholder — functional in #27)
171+
│ │ ├── workspace-switcher.tsx # Workspace dropdown: list, switch, create
172+
│ │ ├── create-workspace-dialog.tsx # Dialog for creating a new workspace
170173
│ │ ├── page-tree.tsx # Page list placeholder (functional in #28)
171174
│ │ └── user-menu.tsx # User dropdown with sign-out
175+
│ ├── workspace/ # Workspace feature components
176+
│ │ └── workspace-settings-form.tsx # Edit name/slug, delete workspace (client)
172177
│ └── ui/ # shadcn/ui components (base-nova style, base-ui primitives)
178+
│ ├── alert-dialog.tsx
173179
│ ├── button.tsx
174180
│ ├── card.tsx
181+
│ ├── dialog.tsx
175182
│ ├── dropdown-menu.tsx
176183
│ ├── input.tsx
177184
│ ├── label.tsx
@@ -181,6 +188,7 @@ src/
181188
├── lib/
182189
│ ├── utils.ts # cn() utility (clsx + tailwind-merge)
183190
│ ├── types.ts # Database entity types
191+
│ ├── workspace-utils.ts # Slug generation, validation, workspace constants
184192
│ └── supabase/
185193
│ ├── client.ts # Browser client (createBrowserClient)
186194
│ ├── server.ts # Server component client (createServerClient + cookies)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { notFound } from "next/navigation";
2+
import { createClient } from "@/lib/supabase/server";
3+
import { WorkspaceSettingsForm } from "@/components/workspace/workspace-settings-form";
4+
import type { Workspace } from "@/lib/types";
5+
6+
export default async function WorkspaceSettingsPage({
7+
params,
8+
}: {
9+
params: Promise<{ workspaceSlug: string }>;
10+
}) {
11+
const { workspaceSlug } = await params;
12+
const supabase = await createClient();
13+
14+
const { data: workspace } = await supabase
15+
.from("workspaces")
16+
.select("*")
17+
.eq("slug", workspaceSlug)
18+
.maybeSingle();
19+
20+
if (!workspace) {
21+
notFound();
22+
}
23+
24+
return (
25+
<div className="mx-auto max-w-xl p-6">
26+
<h1 className="text-2xl font-semibold">Workspace settings</h1>
27+
<p className="mt-1 text-sm text-muted-foreground">
28+
Manage your workspace name, URL, and other settings.
29+
</p>
30+
<div className="mt-6">
31+
<WorkspaceSettingsForm workspace={workspace as Workspace} />
32+
</div>
33+
</div>
34+
);
35+
}

src/app/(app)/layout.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export default async function AppLayout({
2727
const email = profile?.email || user.email || "";
2828

2929
return (
30-
<AppShell displayName={displayName} email={email}>
30+
<AppShell displayName={displayName} email={email} userId={user.id}>
3131
{children}
3232
</AppShell>
3333
);

src/components/sidebar/app-shell.tsx

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,56 @@
11
"use client";
22

33
import { useParams } from "next/navigation";
4-
import { useEffect, useState } from "react";
4+
import { useCallback, useEffect, useState } from "react";
55
import { createClient } from "@/lib/supabase/client";
66
import { SidebarProvider } from "@/components/sidebar/sidebar-context";
77
import { AppSidebar, SidebarToggle } from "@/components/sidebar/app-sidebar";
8+
import type { Workspace } from "@/lib/types";
89
import type { ReactNode } from "react";
910

1011
interface AppShellProps {
1112
displayName: string;
1213
email: string;
14+
userId: string;
1315
children: ReactNode;
1416
}
1517

16-
export function AppShell({ displayName, email, children }: AppShellProps) {
18+
export function AppShell({
19+
displayName,
20+
email,
21+
userId,
22+
children,
23+
}: AppShellProps) {
1724
const params = useParams<{ workspaceSlug?: string }>();
18-
const [workspaceName, setWorkspaceName] = useState("");
19-
20-
useEffect(() => {
21-
if (!params.workspaceSlug) return;
25+
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
2226

27+
const loadWorkspaces = useCallback(() => {
2328
const supabase = createClient();
2429
supabase
25-
.from("workspaces")
26-
.select("name")
27-
.eq("slug", params.workspaceSlug)
28-
.maybeSingle()
30+
.from("members")
31+
.select("workspace_id, workspaces(*)")
32+
.eq("user_id", userId)
2933
.then(({ data }) => {
30-
if (data) setWorkspaceName(data.name);
34+
if (data) {
35+
const ws = data
36+
.map((m) => m.workspaces as unknown as Workspace)
37+
.filter(Boolean);
38+
setWorkspaces(ws);
39+
}
3140
});
32-
}, [params.workspaceSlug]);
41+
}, [userId]);
42+
43+
useEffect(() => {
44+
loadWorkspaces();
45+
}, [loadWorkspaces, params.workspaceSlug]);
3346

3447
return (
3548
<SidebarProvider>
3649
<div className="flex h-screen overflow-hidden">
3750
<AppSidebar
38-
workspaceName={workspaceName || "Workspace"}
51+
workspaces={workspaces}
52+
currentSlug={params.workspaceSlug}
53+
userId={userId}
3954
displayName={displayName}
4055
email={email}
4156
/>

src/components/sidebar/app-sidebar.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,21 +12,30 @@ import { useSidebar } from "@/components/sidebar/sidebar-context";
1212
import { WorkspaceSwitcher } from "@/components/sidebar/workspace-switcher";
1313
import { PageTree } from "@/components/sidebar/page-tree";
1414
import { UserMenu } from "@/components/sidebar/user-menu";
15+
import type { Workspace } from "@/lib/types";
1516

1617
interface AppSidebarProps {
17-
workspaceName: string;
18+
workspaces: Workspace[];
19+
currentSlug: string | undefined;
20+
userId: string;
1821
displayName: string;
1922
email: string;
2023
}
2124

2225
function SidebarContent({
23-
workspaceName,
26+
workspaces,
27+
currentSlug,
28+
userId,
2429
displayName,
2530
email,
2631
}: AppSidebarProps) {
2732
return (
2833
<div className="flex h-full flex-col gap-2 p-2">
29-
<WorkspaceSwitcher workspaceName={workspaceName} />
34+
<WorkspaceSwitcher
35+
workspaces={workspaces}
36+
currentSlug={currentSlug}
37+
userId={userId}
38+
/>
3039
<Separator className="bg-white/[0.06]" />
3140
<PageTree />
3241
<Separator className="bg-white/[0.06]" />
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
import { useRouter } from "next/navigation";
5+
import { createClient } from "@/lib/supabase/client";
6+
import { Button } from "@/components/ui/button";
7+
import { Input } from "@/components/ui/input";
8+
import { Label } from "@/components/ui/label";
9+
import {
10+
Dialog,
11+
DialogContent,
12+
DialogDescription,
13+
DialogFooter,
14+
DialogHeader,
15+
DialogTitle,
16+
} from "@/components/ui/dialog";
17+
import { generateSlug } from "@/lib/workspace-utils";
18+
19+
interface CreateWorkspaceDialogProps {
20+
open: boolean;
21+
onOpenChange: (open: boolean) => void;
22+
userId: string;
23+
}
24+
25+
export function CreateWorkspaceDialog({
26+
open,
27+
onOpenChange,
28+
userId,
29+
}: CreateWorkspaceDialogProps) {
30+
const router = useRouter();
31+
const [name, setName] = useState("");
32+
const [error, setError] = useState<string | null>(null);
33+
const [loading, setLoading] = useState(false);
34+
35+
function handleClose() {
36+
setName("");
37+
setError(null);
38+
onOpenChange(false);
39+
}
40+
41+
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
42+
e.preventDefault();
43+
setError(null);
44+
45+
const trimmed = name.trim();
46+
if (!trimmed) {
47+
setError("Name is required.");
48+
return;
49+
}
50+
51+
const slug = generateSlug(trimmed);
52+
if (!slug) {
53+
setError("Name must contain at least one alphanumeric character.");
54+
return;
55+
}
56+
57+
setLoading(true);
58+
const supabase = createClient();
59+
60+
// Append a random suffix to ensure slug uniqueness
61+
const uniqueSlug = `${slug}-${crypto.randomUUID().slice(0, 6)}`;
62+
63+
const { data, error: insertError } = await supabase
64+
.from("workspaces")
65+
.insert({
66+
name: trimmed,
67+
slug: uniqueSlug,
68+
is_personal: false,
69+
created_by: userId,
70+
})
71+
.select("id, slug")
72+
.single();
73+
74+
if (insertError) {
75+
// DB trigger returns P0001 when limit is reached
76+
if (insertError.message.includes("Workspace limit reached")) {
77+
setError("You can create at most 3 workspaces.");
78+
} else if (insertError.message.includes("duplicate key")) {
79+
setError("A workspace with this slug already exists. Try a different name.");
80+
} else {
81+
setError(insertError.message);
82+
}
83+
setLoading(false);
84+
return;
85+
}
86+
87+
// Create owner membership for the new workspace
88+
await supabase.from("members").insert({
89+
workspace_id: data.id,
90+
user_id: userId,
91+
role: "owner",
92+
joined_at: new Date().toISOString(),
93+
});
94+
95+
setLoading(false);
96+
handleClose();
97+
router.push(`/${data.slug}`);
98+
router.refresh();
99+
}
100+
101+
return (
102+
<Dialog open={open} onOpenChange={onOpenChange}>
103+
<DialogContent>
104+
<DialogHeader>
105+
<DialogTitle>Create workspace</DialogTitle>
106+
<DialogDescription>
107+
Add a new workspace to organize your pages.
108+
</DialogDescription>
109+
</DialogHeader>
110+
<form onSubmit={handleSubmit} className="flex flex-col gap-3">
111+
<div className="flex flex-col gap-1.5">
112+
<Label htmlFor="workspace-name">Name</Label>
113+
<Input
114+
id="workspace-name"
115+
placeholder="My Workspace"
116+
value={name}
117+
onChange={(e) => setName(e.target.value)}
118+
required
119+
autoFocus
120+
autoComplete="off"
121+
/>
122+
{name.trim() && (
123+
<p className="text-xs text-muted-foreground">
124+
Slug: {generateSlug(name.trim()) || "—"}
125+
</p>
126+
)}
127+
</div>
128+
{error && <p className="text-xs text-destructive">{error}</p>}
129+
<DialogFooter>
130+
<Button
131+
type="button"
132+
variant="outline"
133+
onClick={handleClose}
134+
disabled={loading}
135+
>
136+
Cancel
137+
</Button>
138+
<Button type="submit" disabled={loading || !name.trim()}>
139+
{loading ? "Creating…" : "Create"}
140+
</Button>
141+
</DialogFooter>
142+
</form>
143+
</DialogContent>
144+
</Dialog>
145+
);
146+
}

0 commit comments

Comments
 (0)