Skip to content

Commit 42cb0c4

Browse files
feat: pages CRUD and sidebar page tree (#27) (#48)
* feat: pages CRUD and sidebar page tree (#27) Co-authored-by: Ona <no-reply@ona.com> * fix: [ci-fix] resolve review nits — inline style to Tailwind, saveTitle error handling - Replace inline style={{ minHeight: "60vh" }} with Tailwind min-h-[60vh] class - Move lastSavedRef update after successful Supabase write so failed saves retry Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
1 parent a2007bd commit 42cb0c4

7 files changed

Lines changed: 1018 additions & 17 deletions

File tree

.agents/architecture.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ 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 (page list or empty state)
159+
│ │ └── [pageId]/page.tsx # /[workspaceSlug]/[pageId] — page with title + editor placeholder
159160
│ └── api/
160161
│ └── health/route.ts # Health check endpoint (DB connectivity)
161162
├── components/
@@ -168,8 +169,10 @@ src/
168169
│ │ ├── sidebar-context.tsx # React context for sidebar open/close state + ⌘+\ shortcut
169170
│ │ ├── workspace-switcher.tsx # Dropdown listing all workspaces, create workspace trigger
170171
│ │ ├── create-workspace-dialog.tsx # Dialog for creating a new workspace
171-
│ │ ├── page-tree.tsx # Page list placeholder (functional in #28)
172+
│ │ ├── page-tree.tsx # Hierarchical page tree with CRUD, drag-and-drop, nest/unnest
172173
│ │ └── user-menu.tsx # User dropdown with settings link + sign-out
174+
│ ├── page-title.tsx # Inline-editable page title (saves on blur/Enter)
175+
│ ├── workspace-home.tsx # Workspace home: page list or empty state with create CTA
173176
│ ├── workspace-settings-form.tsx # Edit workspace name/slug, delete workspace
174177
│ └── ui/ # shadcn/ui components (base-nova style, base-ui primitives)
175178
│ ├── alert-dialog.tsx
@@ -214,7 +217,7 @@ src/
214217
│ │ ├── layout.tsx # App shell (sidebar + main content), passes userId
215218
│ │ └── [workspaceSlug]/
216219
│ │ ├── page.tsx # /[workspaceSlug] (workspace home)
217-
│ │ ├── [pageId]/page.tsx # /[workspaceSlug]/[pageId] (editor) — planned
220+
│ │ ├── [pageId]/page.tsx # /[workspaceSlug]/[pageId] (page view + editor placeholder)
218221
│ │ └── settings/
219222
│ │ ├── page.tsx # /[workspaceSlug]/settings (name, slug, delete)
220223
│ │ └── members/page.tsx # /[workspaceSlug]/settings/members — planned
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { notFound } from "next/navigation";
2+
import { createClient } from "@/lib/supabase/server";
3+
import { PageTitle } from "@/components/page-title";
4+
5+
export default async function PageView({
6+
params,
7+
}: {
8+
params: Promise<{ workspaceSlug: string; pageId: string }>;
9+
}) {
10+
const { workspaceSlug, pageId } = await params;
11+
const supabase = await createClient();
12+
13+
const { data: workspace } = await supabase
14+
.from("workspaces")
15+
.select("id")
16+
.eq("slug", workspaceSlug)
17+
.maybeSingle();
18+
19+
if (!workspace) {
20+
notFound();
21+
}
22+
23+
const { data: page } = await supabase
24+
.from("pages")
25+
.select("*")
26+
.eq("id", pageId)
27+
.eq("workspace_id", workspace.id)
28+
.maybeSingle();
29+
30+
if (!page) {
31+
notFound();
32+
}
33+
34+
return (
35+
<div className="mx-auto max-w-3xl p-6">
36+
<PageTitle key={page.id} pageId={page.id} initialTitle={page.title} />
37+
<div className="mt-4">
38+
<p className="text-sm text-muted-foreground">
39+
Type &apos;/&apos; for commands
40+
</p>
41+
</div>
42+
</div>
43+
);
44+
}
Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { notFound } from "next/navigation";
22
import { createClient } from "@/lib/supabase/server";
3+
import { WorkspaceHome } from "@/components/workspace-home";
34

45
export default async function WorkspacePage({
56
params,
@@ -9,6 +10,10 @@ export default async function WorkspacePage({
910
const { workspaceSlug } = await params;
1011
const supabase = await createClient();
1112

13+
const {
14+
data: { user },
15+
} = await supabase.auth.getUser();
16+
1217
const { data: workspace } = await supabase
1318
.from("workspaces")
1419
.select("id, name, slug")
@@ -19,14 +24,18 @@ export default async function WorkspacePage({
1924
notFound();
2025
}
2126

27+
const { data: pages } = await supabase
28+
.from("pages")
29+
.select("id, title, parent_id, position, icon, updated_at")
30+
.eq("workspace_id", workspace.id)
31+
.is("parent_id", null)
32+
.order("position", { ascending: true });
33+
2234
return (
23-
<div className="flex flex-col items-center justify-center p-6">
24-
<div className="max-w-2xl space-y-4 text-center">
25-
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
26-
<p className="text-sm text-muted-foreground">
27-
Your workspace is ready. Pages and editor coming soon.
28-
</p>
29-
</div>
30-
</div>
35+
<WorkspaceHome
36+
workspace={workspace}
37+
pages={pages ?? []}
38+
userId={user?.id ?? ""}
39+
/>
3140
);
3241
}

src/components/page-title.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
"use client";
2+
3+
import { useCallback, useEffect, useRef, useState } from "react";
4+
import { createClient } from "@/lib/supabase/client";
5+
6+
interface PageTitleProps {
7+
pageId: string;
8+
initialTitle: string;
9+
}
10+
11+
/**
12+
* Inline-editable page title. Parent should use `key={pageId}` to reset
13+
* state when navigating between pages.
14+
*/
15+
export function PageTitle({ pageId, initialTitle }: PageTitleProps) {
16+
const [title, setTitle] = useState(initialTitle);
17+
const inputRef = useRef<HTMLInputElement>(null);
18+
const lastSavedRef = useRef(initialTitle);
19+
20+
// Focus the title field when the page is new (empty title)
21+
useEffect(() => {
22+
if (!initialTitle && inputRef.current) {
23+
inputRef.current.focus();
24+
}
25+
}, [initialTitle]);
26+
27+
const saveTitle = useCallback(
28+
async (value: string) => {
29+
const trimmed = value.trim();
30+
if (trimmed === lastSavedRef.current) return;
31+
32+
const supabase = createClient();
33+
const { error } = await supabase
34+
.from("pages")
35+
.update({ title: trimmed })
36+
.eq("id", pageId);
37+
38+
if (!error) {
39+
lastSavedRef.current = trimmed;
40+
}
41+
},
42+
[pageId]
43+
);
44+
45+
function handleBlur() {
46+
saveTitle(title);
47+
}
48+
49+
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
50+
if (e.key === "Enter") {
51+
e.preventDefault();
52+
saveTitle(title);
53+
inputRef.current?.blur();
54+
}
55+
}
56+
57+
return (
58+
<input
59+
ref={inputRef}
60+
type="text"
61+
value={title}
62+
onChange={(e) => setTitle(e.target.value)}
63+
onBlur={handleBlur}
64+
onKeyDown={handleKeyDown}
65+
placeholder="Untitled"
66+
className="w-full bg-transparent text-3xl font-bold text-foreground placeholder:text-muted-foreground outline-none"
67+
aria-label="Page title"
68+
/>
69+
);
70+
}

src/components/sidebar/app-sidebar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ function SidebarContent({
2828
<div className="flex h-full flex-col gap-2 p-2">
2929
<WorkspaceSwitcher userId={userId} />
3030
<Separator className="bg-white/[0.06]" />
31-
<PageTree />
31+
<PageTree userId={userId} />
3232
<Separator className="bg-white/[0.06]" />
3333
<UserMenu displayName={displayName} email={email} />
3434
</div>

0 commit comments

Comments
 (0)