Skip to content

Commit 31d051d

Browse files
feat: app shell — sidebar, layout, workspace context (#25) (#44)
* feat: app shell — sidebar, layout, workspace context (#25) - Add (app)/ layout with collapsible sidebar + main content area - Sidebar: workspace switcher (placeholder), page tree (placeholder), user menu with sign-out - Mobile (<768px): sidebar renders as Sheet, hamburger toggle in header - Keyboard shortcut ⌘+\ toggles sidebar visibility - Root / redirects authenticated users to their personal workspace - Install shadcn/ui sheet, dropdown-menu, separator components Co-authored-by: Ona <no-reply@ona.com> * fix: move SheetTitle inside SheetContent portal for accessibility SheetTitle was a sibling of SheetContent, but SheetContent renders through a portal. The title ended up in the main DOM while the dialog was in the portal — screen readers couldn't associate them. Co-authored-by: Ona <no-reply@ona.com> --------- Co-authored-by: Ona <no-reply@ona.com>
1 parent d67fe1f commit 31d051d

12 files changed

Lines changed: 816 additions & 16 deletions

File tree

.agents/architecture.md

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ Auto-save: debounce 500ms on editor change → write to Supabase
144144
src/
145145
├── app/ # Next.js App Router
146146
│ ├── layout.tsx # Root layout (JetBrains Mono font, TooltipProvider)
147-
│ ├── page.tsx # Landing page
147+
│ ├── page.tsx # Landing page (redirects authenticated users to workspace)
148148
│ ├── manifest.ts # PWA manifest (name, icons, display mode)
149149
│ ├── global-error.tsx # Sentry error boundary
150150
│ ├── globals.css # Tailwind v4 theme — dark-only oklch tokens, --radius: 0
@@ -153,7 +153,7 @@ src/
153153
│ │ ├── sign-in/page.tsx # /sign-in — email/password form
154154
│ │ └── sign-up/page.tsx # /sign-up — display name + email/password form
155155
│ ├── (app)/ # Authenticated route group
156-
│ │ ├── layout.tsx # Auth guard (redirects to /sign-in if no session), sign-out button
156+
│ │ ├── layout.tsx # Auth guard, fetches profile, renders AppShell
157157
│ │ └── [workspaceSlug]/
158158
│ │ └── page.tsx # /[workspaceSlug] — workspace home (placeholder)
159159
│ └── api/
@@ -162,11 +162,21 @@ src/
162162
│ ├── auth/
163163
│ │ ├── oauth-buttons.tsx # GitHub + Google buttons (disabled, "coming soon" tooltip)
164164
│ │ └── sign-out-button.tsx # Sign-out button (clears session, redirects to /sign-in)
165+
│ ├── sidebar/ # App shell sidebar components
166+
│ │ ├── app-shell.tsx # Client wrapper: SidebarProvider + sidebar + main layout
167+
│ │ ├── app-sidebar.tsx # Sidebar (desktop: collapsible aside, mobile: Sheet)
168+
│ │ ├── sidebar-context.tsx # React context for sidebar open/close state + ⌘+\ shortcut
169+
│ │ ├── workspace-switcher.tsx # Workspace name display (placeholder — functional in #27)
170+
│ │ ├── page-tree.tsx # Page list placeholder (functional in #28)
171+
│ │ └── user-menu.tsx # User dropdown with sign-out
165172
│ └── ui/ # shadcn/ui components (base-nova style, base-ui primitives)
166173
│ ├── button.tsx
167174
│ ├── card.tsx
175+
│ ├── dropdown-menu.tsx
168176
│ ├── input.tsx
169177
│ ├── label.tsx
178+
│ ├── separator.tsx
179+
│ ├── sheet.tsx
170180
│ └── tooltip.tsx
171181
├── lib/
172182
│ ├── utils.ts # cn() utility (clsx + tailwind-merge)

src/app/(app)/layout.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { redirect } from "next/navigation";
22
import { createClient } from "@/lib/supabase/server";
3-
import { SignOutButton } from "@/components/auth/sign-out-button";
3+
import { AppShell } from "@/components/sidebar/app-shell";
44

55
export default async function AppLayout({
66
children,
@@ -16,12 +16,19 @@ export default async function AppLayout({
1616
redirect("/sign-in");
1717
}
1818

19+
const { data: profile } = await supabase
20+
.from("profiles")
21+
.select("display_name, email")
22+
.eq("id", user.id)
23+
.maybeSingle();
24+
25+
const displayName =
26+
profile?.display_name || user.user_metadata?.display_name || "User";
27+
const email = profile?.email || user.email || "";
28+
1929
return (
20-
<div className="flex min-h-screen flex-col">
21-
<header className="flex items-center justify-end border-b border-white/[0.06] px-4 py-2">
22-
<SignOutButton />
23-
</header>
24-
<main className="flex-1">{children}</main>
25-
</div>
30+
<AppShell displayName={displayName} email={email}>
31+
{children}
32+
</AppShell>
2633
);
2734
}

src/app/page.tsx

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,60 @@
1-
export default function Home() {
1+
import Link from "next/link";
2+
import { redirect } from "next/navigation";
3+
import { createClient } from "@/lib/supabase/server";
4+
5+
export default async function Home() {
6+
const supabase = await createClient();
7+
const {
8+
data: { user },
9+
} = await supabase.auth.getUser();
10+
11+
if (user) {
12+
// Redirect authenticated users to their first workspace
13+
const { data: membership } = await supabase
14+
.from("members")
15+
.select("workspace_id, workspaces(slug)")
16+
.eq("user_id", user.id)
17+
.limit(1)
18+
.maybeSingle();
19+
20+
// Supabase types the joined relation based on schema — extract slug safely
21+
const workspaces = membership?.workspaces as
22+
| { slug: string }
23+
| { slug: string }[]
24+
| null
25+
| undefined;
26+
const slug = Array.isArray(workspaces)
27+
? workspaces[0]?.slug
28+
: workspaces?.slug;
29+
if (slug) {
30+
redirect(`/${slug}`);
31+
}
32+
}
33+
234
return (
335
<main className="flex min-h-screen flex-col items-center justify-center p-8">
436
<div className="max-w-2xl text-center space-y-6">
5-
<h1 className="text-5xl font-bold tracking-tight text-zinc-900 dark:text-zinc-50">
6-
Memo
7-
</h1>
8-
<p className="text-xl text-zinc-500 dark:text-zinc-400">
37+
<h1 className="text-5xl font-bold tracking-tight">Memo</h1>
38+
<p className="text-sm text-muted-foreground">
939
A Notion-style workspace, built with zero human code.
1040
</p>
1141
<div className="flex gap-4 justify-center">
1242
<a
1343
href="https://github.com/gitpod-io/memo"
14-
className="inline-flex items-center px-4 py-2 rounded-md bg-zinc-900 text-white hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200 transition-colors"
44+
className="inline-flex items-center px-4 py-2 bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/80"
1545
target="_blank"
1646
rel="noopener noreferrer"
1747
>
1848
View Source
1949
</a>
50+
<Link
51+
href="/sign-in"
52+
className="inline-flex items-center px-4 py-2 border border-white/[0.06] text-sm font-medium hover:bg-muted"
53+
>
54+
Sign In
55+
</Link>
2056
</div>
21-
<p className="text-sm text-zinc-400 dark:text-zinc-500 pt-8">
57+
<p className="text-xs text-muted-foreground pt-8">
2258
Every line of code in this repository is written by AI agents.
2359
</p>
2460
</div>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"use client";
2+
3+
import { useParams } from "next/navigation";
4+
import { useEffect, useState } from "react";
5+
import { createClient } from "@/lib/supabase/client";
6+
import { SidebarProvider } from "@/components/sidebar/sidebar-context";
7+
import { AppSidebar, SidebarToggle } from "@/components/sidebar/app-sidebar";
8+
import type { ReactNode } from "react";
9+
10+
interface AppShellProps {
11+
displayName: string;
12+
email: string;
13+
children: ReactNode;
14+
}
15+
16+
export function AppShell({ displayName, email, children }: AppShellProps) {
17+
const params = useParams<{ workspaceSlug?: string }>();
18+
const [workspaceName, setWorkspaceName] = useState("");
19+
20+
useEffect(() => {
21+
if (!params.workspaceSlug) return;
22+
23+
const supabase = createClient();
24+
supabase
25+
.from("workspaces")
26+
.select("name")
27+
.eq("slug", params.workspaceSlug)
28+
.maybeSingle()
29+
.then(({ data }) => {
30+
if (data) setWorkspaceName(data.name);
31+
});
32+
}, [params.workspaceSlug]);
33+
34+
return (
35+
<SidebarProvider>
36+
<div className="flex h-screen overflow-hidden">
37+
<AppSidebar
38+
workspaceName={workspaceName || "Workspace"}
39+
displayName={displayName}
40+
email={email}
41+
/>
42+
<div className="flex flex-1 flex-col overflow-hidden">
43+
<header className="flex h-10 shrink-0 items-center gap-2 border-b border-white/[0.06] px-4 md:hidden">
44+
<SidebarToggle />
45+
</header>
46+
<main className="flex-1 overflow-y-auto">{children}</main>
47+
</div>
48+
</div>
49+
</SidebarProvider>
50+
);
51+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"use client";
2+
3+
import { Menu } from "lucide-react";
4+
import { Button } from "@/components/ui/button";
5+
import { Separator } from "@/components/ui/separator";
6+
import {
7+
Sheet,
8+
SheetContent,
9+
SheetTitle,
10+
} from "@/components/ui/sheet";
11+
import { useSidebar } from "@/components/sidebar/sidebar-context";
12+
import { WorkspaceSwitcher } from "@/components/sidebar/workspace-switcher";
13+
import { PageTree } from "@/components/sidebar/page-tree";
14+
import { UserMenu } from "@/components/sidebar/user-menu";
15+
16+
interface AppSidebarProps {
17+
workspaceName: string;
18+
displayName: string;
19+
email: string;
20+
}
21+
22+
function SidebarContent({
23+
workspaceName,
24+
displayName,
25+
email,
26+
}: AppSidebarProps) {
27+
return (
28+
<div className="flex h-full flex-col gap-2 p-2">
29+
<WorkspaceSwitcher workspaceName={workspaceName} />
30+
<Separator className="bg-white/[0.06]" />
31+
<PageTree />
32+
<Separator className="bg-white/[0.06]" />
33+
<UserMenu displayName={displayName} email={email} />
34+
</div>
35+
);
36+
}
37+
38+
export function AppSidebar(props: AppSidebarProps) {
39+
const { open, setOpen, isMobile } = useSidebar();
40+
41+
if (isMobile) {
42+
return (
43+
<Sheet open={open} onOpenChange={setOpen}>
44+
<SheetContent
45+
side="left"
46+
className="w-60 bg-muted p-0"
47+
showCloseButton={false}
48+
>
49+
<SheetTitle className="sr-only">Navigation</SheetTitle>
50+
<SidebarContent {...props} />
51+
</SheetContent>
52+
</Sheet>
53+
);
54+
}
55+
56+
return (
57+
<aside
58+
className="h-full w-60 shrink-0 border-r border-white/[0.06] bg-muted transition-[width,opacity] duration-200 ease-out"
59+
style={{
60+
width: open ? 240 : 0,
61+
opacity: open ? 1 : 0,
62+
overflow: "hidden",
63+
}}
64+
>
65+
<SidebarContent {...props} />
66+
</aside>
67+
);
68+
}
69+
70+
export function SidebarToggle() {
71+
const { toggle, isMobile } = useSidebar();
72+
73+
if (!isMobile) return null;
74+
75+
return (
76+
<Button
77+
variant="ghost"
78+
size="icon-sm"
79+
className="shrink-0"
80+
onClick={toggle}
81+
aria-label="Toggle sidebar"
82+
>
83+
<Menu className="h-4 w-4" />
84+
</Button>
85+
);
86+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"use client";
2+
3+
import { FileText, Plus } from "lucide-react";
4+
import { Button } from "@/components/ui/button";
5+
6+
export function PageTree() {
7+
return (
8+
<div className="flex flex-1 flex-col gap-1">
9+
<p className="px-2 text-xs tracking-widest uppercase text-white/30">
10+
Pages
11+
</p>
12+
<div className="flex flex-col gap-0.5">
13+
<div className="flex items-center gap-2 px-2 py-1 text-sm text-muted-foreground">
14+
<FileText className="h-4 w-4" />
15+
<span>No pages yet</span>
16+
</div>
17+
</div>
18+
<Button
19+
variant="ghost"
20+
className="mt-1 w-full justify-start gap-2 px-2 text-muted-foreground"
21+
size="sm"
22+
>
23+
<Plus className="h-4 w-4" />
24+
New Page
25+
</Button>
26+
</div>
27+
);
28+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"use client";
2+
3+
import {
4+
createContext,
5+
useCallback,
6+
useContext,
7+
useEffect,
8+
useState,
9+
type ReactNode,
10+
} from "react";
11+
12+
interface SidebarContextValue {
13+
open: boolean;
14+
setOpen: (open: boolean) => void;
15+
toggle: () => void;
16+
isMobile: boolean;
17+
}
18+
19+
const SidebarContext = createContext<SidebarContextValue | null>(null);
20+
21+
const MOBILE_BREAKPOINT = 768;
22+
23+
export function SidebarProvider({ children }: { children: ReactNode }) {
24+
const [open, setOpen] = useState(true);
25+
const [isMobile, setIsMobile] = useState(false);
26+
27+
useEffect(() => {
28+
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
29+
const onChange = (e: MediaQueryListEvent | MediaQueryList) => {
30+
const mobile = e.matches;
31+
setIsMobile(mobile);
32+
if (mobile) {
33+
setOpen(false);
34+
}
35+
};
36+
onChange(mql);
37+
mql.addEventListener("change", onChange);
38+
return () => mql.removeEventListener("change", onChange);
39+
}, []);
40+
41+
const toggle = useCallback(() => setOpen((prev) => !prev), []);
42+
43+
// ⌘+\ keyboard shortcut to toggle sidebar
44+
useEffect(() => {
45+
function handleKeyDown(e: KeyboardEvent) {
46+
if (e.key === "\\" && (e.metaKey || e.ctrlKey)) {
47+
e.preventDefault();
48+
toggle();
49+
}
50+
}
51+
window.addEventListener("keydown", handleKeyDown);
52+
return () => window.removeEventListener("keydown", handleKeyDown);
53+
}, [toggle]);
54+
55+
return (
56+
<SidebarContext.Provider value={{ open, setOpen, toggle, isMobile }}>
57+
{children}
58+
</SidebarContext.Provider>
59+
);
60+
}
61+
62+
export function useSidebar() {
63+
const context = useContext(SidebarContext);
64+
if (!context) {
65+
throw new Error("useSidebar must be used within a SidebarProvider");
66+
}
67+
return context;
68+
}

0 commit comments

Comments
 (0)