@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 +1 -1
- package/templates/saas/app/{auth-shell.tsx → (auth)/layout.tsx} +21 -29
- package/templates/saas/app/(auth)/login/page.tsx +33 -0
- package/templates/saas/app/(auth)/signup/page.tsx +32 -0
- package/templates/saas/app/(marketing)/layout.tsx +2 -2
- package/templates/saas/app/dashboard/billing/page.tsx +7 -20
- package/templates/saas/app/dashboard/layout.tsx +58 -0
- package/templates/saas/app/dashboard/members/page.tsx +9 -22
- package/templates/saas/app/dashboard/page.tsx +15 -32
- package/templates/saas/app/dashboard/projects/page.tsx +4 -23
- package/templates/saas/app/dashboard/settings/page.tsx +7 -19
- package/templates/saas/app/layout.tsx +4 -5
- package/templates/saas/components/dashboard-shell.tsx +126 -48
- package/templates/saas/app/login/page.tsx +0 -28
- package/templates/saas/app/signup/page.tsx +0 -24
- /package/templates/saas/app/{auth-form.tsx → (auth)/auth-form.tsx} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pylonsync/create-pylon",
|
|
3
|
-
"version": "0.3.
|
|
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
|
-
//
|
|
5
|
-
// left, a brand/testimonial panel on the right (hidden on
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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.
|
|
11
|
-
//
|
|
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
|
-
<
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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`.
|
|
18
|
-
//
|
|
19
|
-
// via `serverData` + React 19 `use()` — resolved server-side and
|
|
20
|
-
// hydration, so the dashboard paints with real data on the first
|
|
21
|
-
// client fetch, no empty-state flash).
|
|
22
|
-
|
|
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
|
-
<
|
|
55
|
-
|
|
56
|
-
title="Overview"
|
|
57
|
-
userEmail={me?.email ?? ""}
|
|
44
|
+
<Overview
|
|
45
|
+
tenantId={auth.tenant_id}
|
|
58
46
|
orgName={org?.name}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
<
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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.
|
|
10
|
-
// `(marketing)/layout.tsx` adds the nav + footer
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
|
|
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
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
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/
|
|
51
|
-
<div className="flex h-14 items-center
|
|
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
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
|
114
|
-
//
|
|
115
|
-
|
|
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
|
|
125
|
-
{
|
|
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
|
|
128
|
-
|
|
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-
|
|
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-
|
|
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
|
-
}
|
|
File without changes
|