@pylonsync/create-pylon 0.3.374 → 0.3.376

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pylonsync/create-pylon",
3
- "version": "0.3.374",
3
+ "version": "0.3.376",
4
4
  "description": "Scaffold a new Pylon app — realtime backend + web/mobile/expo frontends in one command. Run via `npm create @pylonsync/pylon@latest`.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,22 +1,26 @@
1
1
  import React from "react";
2
- import { Link } from "@pylonsync/react";
2
+ import { Link, type PageAuth, type SsrResponse } from "@pylonsync/react";
3
3
 
4
- // Server-rendered split-screen shell for /login and /signup: the form on the
5
- // left, a brand/testimonial panel on the right (hidden on small screens). The
6
- // form itself (the client island) is passed in as `children`.
7
- export function AuthShell({
8
- title,
9
- switchPrompt,
10
- switchLabel,
11
- switchHref,
12
- children,
13
- }: {
14
- title: string;
15
- switchPrompt: string;
16
- switchLabel: string;
17
- switchHref: string;
4
+ // `(auth)` route group → the shared split-screen frame for /login and /signup:
5
+ // the form card on the left, a brand/testimonial panel on the right (hidden on
6
+ // small screens). Each page supplies what differs heading, switch link, and
7
+ // the form — as `children`. The group segment adds no URL prefix, and because
8
+ // these routes sit outside `(marketing)`, none of the site nav/footer renders
9
+ // here.
10
+ interface LayoutProps {
18
11
  children: React.ReactNode;
19
- }) {
12
+ auth: PageAuth;
13
+ response: SsrResponse;
14
+ }
15
+
16
+ export default function AuthLayout({ children, auth, response }: LayoutProps) {
17
+ // Already signed in? Skip the auth screens entirely. Gating here covers
18
+ // every page in the group; `response.redirect` runs in the synchronous
19
+ // shell render, so it's a real 307 before any HTML is sent.
20
+ if (auth.user_id) {
21
+ response.redirect("/dashboard");
22
+ return null;
23
+ }
20
24
  return (
21
25
  <div className="grid min-h-screen bg-white lg:grid-cols-2">
22
26
  {/* Form side */}
@@ -27,19 +31,7 @@ export function AuthShell({
27
31
  A
28
32
  </span>
29
33
  </Link>
30
- <h1 className="mt-5 text-[22px] font-semibold tracking-tight text-zinc-900">
31
- {title}
32
- </h1>
33
- <p className="mt-1 text-[13px] text-zinc-500">
34
- {switchPrompt}{" "}
35
- <Link
36
- href={switchHref}
37
- className="font-medium text-zinc-900 underline underline-offset-2"
38
- >
39
- {switchLabel}
40
- </Link>
41
- </p>
42
- <div className="mt-6">{children}</div>
34
+ {children}
43
35
  </div>
44
36
  </div>
45
37
 
@@ -0,0 +1,33 @@
1
+ import React from "react";
2
+ import { Link, type Metadata } from "@pylonsync/react";
3
+ import { AuthForm } from "../auth-form";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Sign in — Acme",
7
+ robots: "noindex",
8
+ };
9
+
10
+ // `(auth)/login/page.tsx` → `/login`. The `(auth)` layout owns the
11
+ // split-screen frame and the already-signed-in redirect; this page is just
12
+ // the heading, the switch link, and the form.
13
+ export default function LoginPage() {
14
+ return (
15
+ <>
16
+ <h1 className="mt-5 text-[22px] font-semibold tracking-tight text-zinc-900">
17
+ Welcome back
18
+ </h1>
19
+ <p className="mt-1 text-[13px] text-zinc-500">
20
+ New to Acme?{" "}
21
+ <Link
22
+ href="/signup"
23
+ className="font-medium text-zinc-900 underline underline-offset-2"
24
+ >
25
+ Create an account
26
+ </Link>
27
+ </p>
28
+ <div className="mt-6">
29
+ <AuthForm mode="login" />
30
+ </div>
31
+ </>
32
+ );
33
+ }
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+ import { Link, type Metadata } from "@pylonsync/react";
3
+ import { AuthForm } from "../auth-form";
4
+
5
+ export const metadata: Metadata = {
6
+ title: "Create your account — Acme",
7
+ robots: "noindex",
8
+ };
9
+
10
+ // `(auth)/signup/page.tsx` → `/signup`. Register mode of the same form; the
11
+ // `(auth)` layout supplies the frame and the signed-in redirect.
12
+ export default function SignupPage() {
13
+ return (
14
+ <>
15
+ <h1 className="mt-5 text-[22px] font-semibold tracking-tight text-zinc-900">
16
+ Create an account
17
+ </h1>
18
+ <p className="mt-1 text-[13px] text-zinc-500">
19
+ Already have an account?{" "}
20
+ <Link
21
+ href="/login"
22
+ className="font-medium text-zinc-900 underline underline-offset-2"
23
+ >
24
+ Sign in
25
+ </Link>
26
+ </p>
27
+ <div className="mt-6">
28
+ <AuthForm mode="signup" />
29
+ </div>
30
+ </>
31
+ );
32
+ }
@@ -7,8 +7,8 @@ import { siteConfig } from "@/lib/site.config";
7
7
  // `(marketing)` is a ROUTE GROUP: the parens segment is stripped from every
8
8
  // URL (so `(marketing)/page.tsx` still serves `/`), and this layout wraps
9
9
  // only the pages inside the group — the marketing nav up top, a fat footer
10
- // below. Routes outside the group (/login, /signup, /dashboard) render bare
11
- // in the root shell; the dashboard brings its own sidebar shell.
10
+ // below. The other sections bring their own layouts: `(auth)` the split-screen
11
+ // frame, `dashboard/` the sidebar shell.
12
12
  //
13
13
  // A layout receives the page props plus `children`. `auth.user_id` is null for
14
14
  // anonymous visitors and the signed-in user's id otherwise — resolved
@@ -1,6 +1,5 @@
1
1
  import React, { use } from "react";
2
2
  import { type Metadata, type PageProps } from "@pylonsync/react";
3
- import { DashboardShell } from "@/components/dashboard-shell";
4
3
  import { Billing, type Subscription } from "../dashboard-client";
5
4
 
6
5
  export const metadata: Metadata = {
@@ -11,20 +10,15 @@ export const metadata: Metadata = {
11
10
  // `/dashboard/billing` — the active workspace's plan + Stripe checkout/portal.
12
11
  // The StripeSubscription row is resolved server-side (the @pylonsync/stripe
13
12
  // read policy scopes it to the active tenant), so the plan paints with no flash;
14
- // upgrade/manage open Stripe and the webhook keeps the row in sync.
13
+ // upgrade/manage open Stripe and the webhook keeps the row in sync. Auth gate +
14
+ // shell chrome come from the dashboard layout.
15
15
  const ACTIVE = ["active", "trialing", "past_due"];
16
16
 
17
17
  export default function BillingPage({ auth, response, serverData }: PageProps) {
18
- if (!auth.user_id) {
19
- response.redirect("/login");
20
- return null;
21
- }
22
18
  if (!auth.tenant_id) {
23
19
  response.redirect("/dashboard");
24
20
  return null;
25
21
  }
26
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
27
- const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
28
22
  const subs = use(serverData.list<Subscription>("StripeSubscription"));
29
23
  const subscription =
30
24
  subs.find(
@@ -33,17 +27,10 @@ export default function BillingPage({ auth, response, serverData }: PageProps) {
33
27
  subs[0] ??
34
28
  null;
35
29
  return (
36
- <DashboardShell
37
- active="billing"
38
- title="Billing"
39
- userEmail={me?.email ?? ""}
40
- orgName={org?.name}
41
- >
42
- <Billing
43
- tenantId={auth.tenant_id}
44
- role={auth.roles?.[0] ?? ""}
45
- subscription={subscription}
46
- />
47
- </DashboardShell>
30
+ <Billing
31
+ tenantId={auth.tenant_id}
32
+ role={auth.roles?.[0] ?? ""}
33
+ subscription={subscription}
34
+ />
48
35
  );
49
36
  }
@@ -0,0 +1,58 @@
1
+ import React, { use } from "react";
2
+ import type { PageAuth, ServerData, SsrResponse } from "@pylonsync/react";
3
+ import { DashboardShell, NAV } from "@/components/dashboard-shell";
4
+
5
+ // `app/dashboard/layout.tsx` wraps every /dashboard page in the sidebar +
6
+ // top-bar shell, so no page repeats the chrome. This is the thin-server-
7
+ // wrapper pattern: the layout gates auth, resolves the chrome's data
8
+ // server-side (user email, org name), derives the active nav item + title
9
+ // from the request URL, and hands the interactive shell (a `"use client"`
10
+ // component) its props.
11
+ interface LayoutProps {
12
+ children: React.ReactNode;
13
+ url: string;
14
+ auth: PageAuth;
15
+ response: SsrResponse;
16
+ serverData: ServerData;
17
+ }
18
+
19
+ export default function DashboardLayout({
20
+ children,
21
+ url,
22
+ auth,
23
+ response,
24
+ serverData,
25
+ }: LayoutProps) {
26
+ // The layout renders before any page below it, so gating here protects the
27
+ // whole /dashboard section: signed-out visitors get a real 307 to /login
28
+ // and no page code runs.
29
+ if (!auth.user_id) {
30
+ response.redirect("/login");
31
+ return null;
32
+ }
33
+ // No active workspace: render the page bare — /dashboard shows the
34
+ // full-screen ProvisionWorkspace flow, and the subpages redirect back to
35
+ // /dashboard themselves. The shell's org switcher has nothing to show yet.
36
+ if (!auth.tenant_id) {
37
+ return <>{children}</>;
38
+ }
39
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
40
+ const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
41
+ // Longest matching NAV href wins so /dashboard/projects highlights
42
+ // Projects, not Overview.
43
+ const path = (url ?? "").split("?")[0];
44
+ const nav =
45
+ NAV.filter((n) => path === n.href || path.startsWith(n.href + "/")).sort(
46
+ (a, b) => b.href.length - a.href.length,
47
+ )[0] ?? NAV[0];
48
+ return (
49
+ <DashboardShell
50
+ active={nav.key}
51
+ title={nav.label}
52
+ userEmail={me?.email ?? ""}
53
+ orgName={org?.name}
54
+ >
55
+ {children}
56
+ </DashboardShell>
57
+ );
58
+ }
@@ -1,6 +1,5 @@
1
- import React, { use } from "react";
1
+ import React from "react";
2
2
  import { type Metadata, type PageProps } from "@pylonsync/react";
3
- import { DashboardShell } from "@/components/dashboard-shell";
4
3
  import { Members } from "../dashboard-client";
5
4
 
6
5
  export const metadata: Metadata = {
@@ -10,30 +9,18 @@ export const metadata: Metadata = {
10
9
 
11
10
  // `/dashboard/members` — the active org's roster + invites. The roster (with
12
11
  // real emails) is loaded client-side from the framework's org-members endpoint;
13
- // invites are gated to owners/admins both here and on the server.
14
- export default function MembersPage({ auth, response, serverData }: PageProps) {
15
- if (!auth.user_id) {
16
- response.redirect("/login");
17
- return null;
18
- }
12
+ // invites are gated to owners/admins both here and on the server. Auth gate +
13
+ // shell chrome come from the dashboard layout.
14
+ export default function MembersPage({ auth, response }: PageProps) {
19
15
  if (!auth.tenant_id) {
20
16
  response.redirect("/dashboard");
21
17
  return null;
22
18
  }
23
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
24
- const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
25
19
  return (
26
- <DashboardShell
27
- active="members"
28
- title="Members"
29
- userEmail={me?.email ?? ""}
30
- orgName={org?.name}
31
- >
32
- <Members
33
- tenantId={auth.tenant_id}
34
- currentUserId={auth.user_id}
35
- role={auth.roles?.[0] ?? ""}
36
- />
37
- </DashboardShell>
20
+ <Members
21
+ tenantId={auth.tenant_id}
22
+ currentUserId={auth.user_id!}
23
+ role={auth.roles?.[0] ?? ""}
24
+ />
38
25
  );
39
26
  }
@@ -1,6 +1,5 @@
1
1
  import React, { use } from "react";
2
2
  import { type Metadata, type PageProps } from "@pylonsync/react";
3
- import { DashboardShell } from "@/components/dashboard-shell";
4
3
  import { ProvisionWorkspace } from "./provision-workspace";
5
4
  import {
6
5
  Overview,
@@ -14,29 +13,20 @@ export const metadata: Metadata = {
14
13
  robots: "noindex",
15
14
  };
16
15
 
17
- // `app/dashboard/page.tsx` → `/dashboard`. Server-side auth gate, then the
18
- // active org (`auth.tenant_id`) + this org's rows are read during the render
19
- // via `serverData` + React 19 `use()` — resolved server-side and replayed on
20
- // hydration, so the dashboard paints with real data on the first byte (no
21
- // client fetch, no empty-state flash). /dashboard sits outside the
22
- // `(marketing)` route group, so the marketing nav/footer never render here;
23
- // the shell is the only chrome.
24
- export default function DashboardPage({
25
- auth,
26
- response,
27
- serverData,
28
- }: PageProps) {
29
- if (!auth.user_id) {
30
- response.redirect("/login");
31
- return null;
32
- }
16
+ // `app/dashboard/page.tsx` → `/dashboard`. The dashboard layout owns the auth
17
+ // gate and the shell chrome; this page reads the active org's rows during the
18
+ // render via `serverData` + React 19 `use()` — resolved server-side and
19
+ // replayed on hydration, so the dashboard paints with real data on the first
20
+ // byte (no client fetch, no empty-state flash).
21
+ export default function DashboardPage({ auth, serverData }: PageProps) {
33
22
  // No active workspace (signup's auto-provision failed, or the user left/
34
23
  // deleted their last org). Every read below is tenant-scoped, so instead of
35
24
  // an empty shell, provision one client-side and reload into a ready dashboard.
25
+ // The layout renders this bare (no shell) when there's no tenant.
36
26
  if (!auth.tenant_id) {
37
27
  return <ProvisionWorkspace />;
38
28
  }
39
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
29
+ const me = use(serverData.get<{ email?: string }>("User", auth.user_id!));
40
30
  const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
41
31
  const projects = use(serverData.list<Project>("Project"));
42
32
  const members = use(serverData.list<OrgMemberRow>("OrgMember"));
@@ -51,20 +41,13 @@ export default function DashboardPage({
51
41
  );
52
42
  const plan = active ? active.plan : "free";
53
43
  return (
54
- <DashboardShell
55
- active="overview"
56
- title="Overview"
57
- userEmail={me?.email ?? ""}
44
+ <Overview
45
+ tenantId={auth.tenant_id}
58
46
  orgName={org?.name}
59
- >
60
- <Overview
61
- tenantId={auth.tenant_id}
62
- orgName={org?.name}
63
- userEmail={me?.email}
64
- projects={projects}
65
- memberCount={memberCount}
66
- plan={plan}
67
- />
68
- </DashboardShell>
47
+ userEmail={me?.email}
48
+ projects={projects}
49
+ memberCount={memberCount}
50
+ plan={plan}
51
+ />
69
52
  );
70
53
  }
@@ -1,6 +1,5 @@
1
1
  import React, { use } from "react";
2
2
  import { type Metadata, type PageProps } from "@pylonsync/react";
3
- import { DashboardShell } from "@/components/dashboard-shell";
4
3
  import { Projects, type Project } from "../dashboard-client";
5
4
 
6
5
  export const metadata: Metadata = {
@@ -9,31 +8,13 @@ export const metadata: Metadata = {
9
8
  };
10
9
 
11
10
  // `/dashboard/projects` — this org's projects, server-resolved via `use()` then
12
- // kept live + optimistic by `db` on the client.
13
- export default function ProjectsPage({
14
- auth,
15
- response,
16
- serverData,
17
- }: PageProps) {
18
- if (!auth.user_id) {
19
- response.redirect("/login");
20
- return null;
21
- }
11
+ // kept live + optimistic by `db` on the client. Auth gate + shell chrome come
12
+ // from the dashboard layout.
13
+ export default function ProjectsPage({ auth, response, serverData }: PageProps) {
22
14
  if (!auth.tenant_id) {
23
15
  response.redirect("/dashboard");
24
16
  return null;
25
17
  }
26
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
27
- const org = use(serverData.get<{ name?: string }>("Org", auth.tenant_id));
28
18
  const projects = use(serverData.list<Project>("Project"));
29
- return (
30
- <DashboardShell
31
- active="projects"
32
- title="Projects"
33
- userEmail={me?.email ?? ""}
34
- orgName={org?.name}
35
- >
36
- <Projects tenantId={auth.tenant_id} initial={projects} />
37
- </DashboardShell>
38
- );
19
+ return <Projects tenantId={auth.tenant_id} initial={projects} />;
39
20
  }
@@ -1,6 +1,5 @@
1
1
  import React, { use } from "react";
2
2
  import { type Metadata, type PageProps } from "@pylonsync/react";
3
- import { DashboardShell } from "@/components/dashboard-shell";
4
3
  import {
5
4
  Settings,
6
5
  type OrgInfo,
@@ -14,34 +13,23 @@ export const metadata: Metadata = {
14
13
 
15
14
  // `/dashboard/settings` — workspace settings: rename (owners/admins) and delete
16
15
  // (owners). The active org + member count are resolved server-side so the page
17
- // paints with real values on the first byte.
16
+ // paints with real values on the first byte. Auth gate + shell chrome come from
17
+ // the dashboard layout.
18
18
  export default function SettingsPage({ auth, response, serverData }: PageProps) {
19
- if (!auth.user_id) {
20
- response.redirect("/login");
21
- return null;
22
- }
23
19
  if (!auth.tenant_id) {
24
20
  response.redirect("/dashboard");
25
21
  return null;
26
22
  }
27
- const me = use(serverData.get<{ email?: string }>("User", auth.user_id));
28
23
  const org = use(serverData.get<OrgInfo>("Org", auth.tenant_id));
29
24
  const members = use(serverData.list<OrgMemberRow>("OrgMember"));
30
25
  const memberCount = members.filter(
31
26
  (m) => m.orgId === auth.tenant_id,
32
27
  ).length;
33
28
  return (
34
- <DashboardShell
35
- active="settings"
36
- title="Settings"
37
- userEmail={me?.email ?? ""}
38
- orgName={org?.name}
39
- >
40
- <Settings
41
- org={org}
42
- role={auth.roles?.[0] ?? ""}
43
- memberCount={memberCount}
44
- />
45
- </DashboardShell>
29
+ <Settings
30
+ org={org}
31
+ role={auth.roles?.[0] ?? ""}
32
+ memberCount={memberCount}
33
+ />
46
34
  );
47
35
  }
@@ -6,11 +6,10 @@ interface LayoutProps {
6
6
  }
7
7
 
8
8
  // The root layout is only the document shell: <html>, <head>, <body>, and the
9
- // site-wide theme variables. Section chrome lives in route-group layouts
10
- // `(marketing)/layout.tsx` adds the nav + footer for every marketing page.
11
- // Routes outside the group (/login, /signup, /dashboard) render bare here:
12
- // the auth screens are full-screen and the dashboard brings its own sidebar
13
- // shell.
9
+ // site-wide theme variables. Each section owns its chrome in its own layout:
10
+ // `(marketing)/layout.tsx` adds the nav + footer, `(auth)/layout.tsx` the
11
+ // split-screen frame for /login + /signup, and `dashboard/layout.tsx` the
12
+ // sidebar shell.
14
13
  export default function RootLayout({ children }: LayoutProps) {
15
14
  return (
16
15
  <html
@@ -11,24 +11,36 @@ import {
11
11
  Settings as SettingsIcon,
12
12
  LogOut,
13
13
  ExternalLink,
14
+ ChevronsUpDown,
14
15
  type LucideIcon,
15
16
  } from "lucide-react";
16
17
 
17
- type NavKey = "overview" | "projects" | "members" | "billing" | "settings";
18
+ export type NavKey = "overview" | "projects" | "members" | "billing" | "settings";
18
19
 
19
- const NAV: { key: NavKey; label: string; href: string; Icon: LucideIcon }[] = [
20
+ // Exported so `app/dashboard/layout.tsx` can derive the active item + page
21
+ // title from the request URL — one table drives both the sidebar and the
22
+ // layout's routing awareness. `group` splits the sidebar into sections:
23
+ // ungrouped items render first, then each named group under an eyebrow label.
24
+ export const NAV: {
25
+ key: NavKey;
26
+ label: string;
27
+ href: string;
28
+ Icon: LucideIcon;
29
+ group?: string;
30
+ }[] = [
20
31
  { key: "overview", label: "Overview", href: "/dashboard", Icon: LayoutDashboard },
21
32
  { key: "projects", label: "Projects", href: "/dashboard/projects", Icon: FolderKanban },
22
- { key: "members", label: "Members", href: "/dashboard/members", Icon: Users },
23
- { key: "billing", label: "Billing", href: "/dashboard/billing", Icon: CreditCard },
24
- { key: "settings", label: "Settings", href: "/dashboard/settings", Icon: SettingsIcon },
33
+ { key: "members", label: "Members", href: "/dashboard/members", Icon: Users, group: "Workspace" },
34
+ { key: "billing", label: "Billing", href: "/dashboard/billing", Icon: CreditCard, group: "Workspace" },
35
+ { key: "settings", label: "Settings", href: "/dashboard/settings", Icon: SettingsIcon, group: "Workspace" },
25
36
  ];
26
37
 
27
- // Dashboard chrome: a fixed sidebar (logo, workspace switcher, nav) plus a top
28
- // bar with a user menu. /dashboard sits outside the `(marketing)` route group,
29
- // so this shell is the only chrome here. `userEmail` is
30
- // resolved on the server (serverData.get("User", …)) and passed in, so the menu
31
- // shows a real email instead of a raw id.
38
+ // Dashboard chrome: a fixed sidebar (logo, workspace switcher, grouped nav,
39
+ // account card pinned to the bottom) plus a slim top bar. Rendered by
40
+ // `app/dashboard/layout.tsx`, which wraps every /dashboard page /dashboard
41
+ // sits outside the `(marketing)` route group, so this shell is the only chrome
42
+ // here. `userEmail` is resolved on the server (serverData.get("User", …)) and
43
+ // passed in, so the menu shows a real email instead of a raw id.
32
44
  export function DashboardShell({
33
45
  active,
34
46
  title,
@@ -45,10 +57,12 @@ export function DashboardShell({
45
57
  children: React.ReactNode;
46
58
  }) {
47
59
  const router = useRouter();
60
+ const ungrouped = NAV.filter((n) => !n.group);
61
+ const groups = [...new Set(NAV.map((n) => n.group).filter(Boolean))] as string[];
48
62
  return (
49
63
  <div className="flex min-h-screen bg-white text-zinc-900">
50
- <aside className="hidden w-60 shrink-0 flex-col border-r border-zinc-200 bg-zinc-50/60 md:flex">
51
- <div className="flex h-14 items-center border-b border-zinc-200 px-4">
64
+ <aside className="hidden w-60 shrink-0 flex-col border-r border-zinc-200/70 bg-zinc-50/80 md:flex">
65
+ <div className="flex h-14 shrink-0 items-center px-4">
52
66
  <Link href="/" className="flex items-center gap-2">
53
67
  <span className="flex size-6 items-center justify-center rounded-[7px] bg-zinc-900 text-[13px] font-bold text-white">
54
68
  A
@@ -57,7 +71,7 @@ export function DashboardShell({
57
71
  </Link>
58
72
  </div>
59
73
 
60
- <div className="px-3 py-3">
74
+ <div className="px-3 pb-1">
61
75
  {/* Every view's data is resolved server-side for the active tenant,
62
76
  so switching orgs re-renders the dashboard for the new workspace.
63
77
  A soft client navigation (router.push) re-fetches the SSR page —
@@ -69,37 +83,42 @@ export function DashboardShell({
69
83
  />
70
84
  </div>
71
85
 
72
- <nav className="flex-1 space-y-0.5 px-3">
73
- {NAV.map((n) => {
74
- const isActive = active === n.key;
75
- return (
76
- <Link
77
- key={n.key}
78
- href={n.href}
79
- className={
80
- "flex items-center gap-2.5 rounded-md px-3 py-2 text-[13.5px] font-medium transition-colors " +
81
- (isActive
82
- ? "bg-zinc-900 text-white"
83
- : "text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900")
84
- }
85
- >
86
- <n.Icon
87
- className={
88
- "size-[17px] " + (isActive ? "text-white" : "text-zinc-400")
89
- }
90
- strokeWidth={2}
91
- />
92
- {n.label}
93
- </Link>
94
- );
95
- })}
86
+ <nav className="flex-1 overflow-y-auto px-3 pt-3">
87
+ <div className="space-y-0.5">
88
+ {ungrouped.map((n) => (
89
+ <NavItem key={n.key} item={n} isActive={active === n.key} />
90
+ ))}
91
+ </div>
92
+ {groups.map((g) => (
93
+ <div key={g} className="mt-6">
94
+ <div className="mb-1.5 px-2.5 text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
95
+ {g}
96
+ </div>
97
+ <div className="space-y-0.5">
98
+ {NAV.filter((n) => n.group === g).map((n) => (
99
+ <NavItem key={n.key} item={n} isActive={active === n.key} />
100
+ ))}
101
+ </div>
102
+ </div>
103
+ ))}
96
104
  </nav>
105
+
106
+ {/* Account card pinned to the bottom of the column — the sidebar's
107
+ base holds the session controls instead of trailing off into
108
+ empty space. The dropdown opens UPWARD (bottom-full). */}
109
+ <div className="border-t border-zinc-200/70 p-3">
110
+ <UserMenu email={userEmail} direction="up" />
111
+ </div>
97
112
  </aside>
98
113
 
99
114
  <div className="flex min-w-0 flex-1 flex-col">
100
- <header className="flex h-14 items-center justify-between border-b border-zinc-200 px-6">
115
+ <header className="flex h-14 shrink-0 items-center justify-between border-b border-zinc-200/70 px-6">
101
116
  <h1 className="text-[15px] font-semibold">{title}</h1>
102
- <UserMenu email={userEmail} />
117
+ {/* The sidebar (and its account card) is hidden below md, so the
118
+ top bar carries the account menu on small screens only. */}
119
+ <div className="md:hidden">
120
+ <UserMenu email={userEmail} direction="down" />
121
+ </div>
103
122
  </header>
104
123
  <main className="flex-1 p-6">
105
124
  <div className="mx-auto max-w-4xl">{children}</div>
@@ -109,30 +128,89 @@ export function DashboardShell({
109
128
  );
110
129
  }
111
130
 
131
+ // A single sidebar row. The active row reads as a raised white card — layered
132
+ // translucent shadows instead of a hard border, so it sits naturally on the
133
+ // zinc-50 column. Inactive rows tint on hover only.
134
+ function NavItem({
135
+ item,
136
+ isActive,
137
+ }: {
138
+ item: (typeof NAV)[number];
139
+ isActive: boolean;
140
+ }) {
141
+ return (
142
+ <Link
143
+ href={item.href}
144
+ className={
145
+ "flex items-center gap-2.5 rounded-lg px-2.5 py-[7px] text-[13px] font-medium transition-colors " +
146
+ (isActive
147
+ ? "bg-white text-zinc-900 shadow-[0_1px_2px_rgba(0,0,0,0.06),0_0_0_1px_rgba(0,0,0,0.05)]"
148
+ : "text-zinc-600 hover:bg-zinc-200/50 hover:text-zinc-900")
149
+ }
150
+ >
151
+ <item.Icon
152
+ className={"size-[17px] " + (isActive ? "text-zinc-700" : "text-zinc-400")}
153
+ strokeWidth={2}
154
+ />
155
+ {item.label}
156
+ </Link>
157
+ );
158
+ }
159
+
112
160
  // Avatar + dropdown (email, View site, Sign out). Native <details> so it opens
113
- // on click with no extra state; the marketing nav/footer aren't here, so this
114
- // is the only account control.
115
- function UserMenu({ email }: { email: string }) {
161
+ // on click with no extra state. In the sidebar it renders as a full-width
162
+ // account card whose menu opens upward; in the mobile top bar it's a compact
163
+ // avatar with a downward menu.
164
+ function UserMenu({
165
+ email,
166
+ direction,
167
+ }: {
168
+ email: string;
169
+ direction: "up" | "down";
170
+ }) {
116
171
  const { signOut } = useAuth();
117
172
  const initial = (email.trim()[0] || "?").toUpperCase();
118
173
  async function onSignOut() {
119
174
  await signOut();
120
175
  window.location.assign("/");
121
176
  }
177
+ const up = direction === "up";
122
178
  return (
123
179
  <details className="group relative">
124
- <summary className="flex size-8 cursor-pointer select-none list-none items-center justify-center rounded-full bg-zinc-900 text-[12px] font-semibold text-white marker:hidden [&::-webkit-details-marker]:hidden">
125
- {initial}
180
+ <summary
181
+ className={
182
+ "cursor-pointer select-none list-none marker:hidden [&::-webkit-details-marker]:hidden " +
183
+ (up
184
+ ? "flex w-full items-center gap-2.5 rounded-lg px-2 py-2 transition-colors hover:bg-zinc-200/50"
185
+ : "flex size-9 items-center justify-center")
186
+ }
187
+ >
188
+ <span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-zinc-900 text-[11px] font-semibold text-white">
189
+ {initial}
190
+ </span>
191
+ {up ? (
192
+ <>
193
+ <span className="min-w-0 flex-1 truncate text-[13px] font-medium text-zinc-700">
194
+ {email || "Signed in"}
195
+ </span>
196
+ <ChevronsUpDown className="size-3.5 shrink-0 text-zinc-400" strokeWidth={2} />
197
+ </>
198
+ ) : null}
126
199
  </summary>
127
- <div className="absolute right-0 top-full z-40 mt-2 w-56 overflow-hidden rounded-xl border border-zinc-200 bg-white py-1 shadow-[0_16px_48px_-16px_rgba(0,0,0,0.25)]">
128
- <div className="border-b border-zinc-100 px-3 py-2">
200
+ <div
201
+ className={
202
+ "absolute z-40 w-56 overflow-hidden rounded-xl bg-white p-1 shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_16px_48px_-16px_rgba(0,0,0,0.25)] " +
203
+ (up ? "bottom-full left-0 mb-2" : "right-0 top-full mt-2")
204
+ }
205
+ >
206
+ <div className="border-b border-zinc-100 px-2.5 py-2">
129
207
  <div className="truncate text-[13px] font-medium text-zinc-900">
130
208
  {email || "Signed in"}
131
209
  </div>
132
210
  </div>
133
211
  <a
134
212
  href="/"
135
- className="flex items-center gap-2 px-3 py-2 text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
213
+ className="flex items-center gap-2 rounded-lg px-2.5 py-2 text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
136
214
  >
137
215
  <ExternalLink className="size-4 text-zinc-400" strokeWidth={2} />
138
216
  View site
@@ -140,7 +218,7 @@ function UserMenu({ email }: { email: string }) {
140
218
  <button
141
219
  type="button"
142
220
  onClick={onSignOut}
143
- className="flex w-full items-center gap-2 px-3 py-2 text-left text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
221
+ className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-[13px] text-zinc-700 transition-colors hover:bg-zinc-50"
144
222
  >
145
223
  <LogOut className="size-4 text-zinc-400" strokeWidth={2} />
146
224
  Sign out
@@ -1,28 +0,0 @@
1
- import React from "react";
2
- import { type Metadata, type PageProps } from "@pylonsync/react";
3
- import { AuthShell } from "../auth-shell";
4
- import { AuthForm } from "../auth-form";
5
-
6
- export const metadata: Metadata = {
7
- title: "Sign in — Acme",
8
- robots: "noindex",
9
- };
10
-
11
- // `app/login/page.tsx` → `/login`. Split-screen auth (form left, brand right).
12
- // Auth routes sit outside the `(marketing)` route group, so they render bare —
13
- // no marketing nav or footer.
14
- export default function LoginPage({ auth, response }: PageProps) {
15
- // Already signed in? Skip the form. `response.redirect` runs in the
16
- // synchronous shell render, so it's a real 307 before any HTML is sent.
17
- if (auth.user_id) response.redirect("/dashboard");
18
- return (
19
- <AuthShell
20
- title="Welcome back"
21
- switchPrompt="New to Acme?"
22
- switchLabel="Create an account"
23
- switchHref="/signup"
24
- >
25
- <AuthForm mode="login" />
26
- </AuthShell>
27
- );
28
- }
@@ -1,24 +0,0 @@
1
- import React from "react";
2
- import { type Metadata, type PageProps } from "@pylonsync/react";
3
- import { AuthShell } from "../auth-shell";
4
- import { AuthForm } from "../auth-form";
5
-
6
- export const metadata: Metadata = {
7
- title: "Create your account — Acme",
8
- robots: "noindex",
9
- };
10
-
11
- // `app/signup/page.tsx` → `/signup`. Split-screen auth, register mode.
12
- export default function SignupPage({ auth, response }: PageProps) {
13
- if (auth.user_id) response.redirect("/dashboard");
14
- return (
15
- <AuthShell
16
- title="Create an account"
17
- switchPrompt="Already have an account?"
18
- switchLabel="Sign in"
19
- switchHref="/login"
20
- >
21
- <AuthForm mode="signup" />
22
- </AuthShell>
23
- );
24
- }