@clubedge/create-clubedge-app 0.2.0 → 0.3.0

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.
Files changed (154) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +50 -35
  3. package/bin/create.mjs +2 -466
  4. package/dist/cli/args.js +49 -0
  5. package/dist/cli/ui.js +85 -0
  6. package/dist/core/customize.js +56 -0
  7. package/dist/core/execute.js +60 -0
  8. package/dist/core/manifest.js +81 -0
  9. package/dist/core/plan.js +67 -0
  10. package/dist/core/source.js +107 -0
  11. package/dist/index.js +125 -0
  12. package/dist/package-info.js +8 -0
  13. package/dist/steps/git.js +11 -0
  14. package/dist/steps/install.js +19 -0
  15. package/dist/utils/fs.js +97 -0
  16. package/dist/utils/names.js +14 -0
  17. package/dist/utils/process.js +49 -0
  18. package/package.json +21 -5
  19. package/template/.dockerignore +8 -0
  20. package/template/.env.example +25 -0
  21. package/template/.gitattributes +6 -0
  22. package/template/.github/ISSUE_TEMPLATE/bug_report.yml +35 -0
  23. package/template/.github/ISSUE_TEMPLATE/config.yml +8 -0
  24. package/template/.github/ISSUE_TEMPLATE/feature_request.yml +24 -0
  25. package/template/.github/pull_request_template.md +20 -0
  26. package/template/.github/workflows/ci.yml +49 -0
  27. package/template/.prettierignore +1 -0
  28. package/template/.prettierrc.json +1 -0
  29. package/template/AGENTS.md +31 -0
  30. package/template/CODE_OF_CONDUCT.md +22 -0
  31. package/template/CONTRIBUTING.md +53 -0
  32. package/template/Dockerfile +32 -0
  33. package/template/LICENSE +201 -0
  34. package/template/NOTICE +6 -0
  35. package/template/PUBLISHING.md +27 -0
  36. package/template/README.md +157 -0
  37. package/template/SECURITY.md +15 -0
  38. package/template/SETUP.md +174 -0
  39. package/template/SUPPORT.md +8 -0
  40. package/template/_gitignore +13 -0
  41. package/template/apps/web/AGENTS.md +9 -0
  42. package/template/apps/web/components.json +21 -0
  43. package/template/apps/web/e2e/auth.spec.ts +37 -0
  44. package/template/apps/web/e2e/home.spec.ts +15 -0
  45. package/template/apps/web/next-env.d.ts +7 -0
  46. package/template/apps/web/next.config.ts +32 -0
  47. package/template/apps/web/package.json +33 -0
  48. package/template/apps/web/postcss.config.mjs +3 -0
  49. package/template/apps/web/public/.gitkeep +0 -0
  50. package/template/apps/web/public/.well-known/logo.svg +1 -0
  51. package/template/apps/web/src/app/actions/auth.ts +85 -0
  52. package/template/apps/web/src/app/api/health/route.ts +11 -0
  53. package/template/apps/web/src/app/auth/callback/route.ts +21 -0
  54. package/template/apps/web/src/app/dashboard/_components/account-button.tsx +34 -0
  55. package/template/apps/web/src/app/dashboard/_components/app-sidebar.tsx +91 -0
  56. package/template/apps/web/src/app/dashboard/page.tsx +352 -0
  57. package/template/apps/web/src/app/favicon.ico +0 -0
  58. package/template/apps/web/src/app/layout.tsx +36 -0
  59. package/template/apps/web/src/app/login/_components/auth-form-controls.tsx +88 -0
  60. package/template/apps/web/src/app/login/page.tsx +149 -0
  61. package/template/apps/web/src/app/page.tsx +229 -0
  62. package/template/apps/web/src/app/theme-provider.tsx +43 -0
  63. package/template/apps/web/src/app/theme-toggle.tsx +23 -0
  64. package/template/apps/web/src/components/branding/clubedge-mark.tsx +25 -0
  65. package/template/apps/web/src/components/landing/copy-command.tsx +48 -0
  66. package/template/apps/web/src/config/site.json +11 -0
  67. package/template/apps/web/src/config/site.ts +20 -0
  68. package/template/apps/web/src/env/client.ts +22 -0
  69. package/template/apps/web/src/env/server.test.ts +35 -0
  70. package/template/apps/web/src/env/server.ts +45 -0
  71. package/template/apps/web/src/lib/login-url.test.ts +16 -0
  72. package/template/apps/web/src/lib/login-url.ts +16 -0
  73. package/template/apps/web/src/lib/utils.ts +1 -0
  74. package/template/apps/web/src/proxy.ts +31 -0
  75. package/template/apps/web/src/server/auth.ts +69 -0
  76. package/template/apps/web/src/server/cache.ts +38 -0
  77. package/template/apps/web/src/server/db.ts +16 -0
  78. package/template/apps/web/src/server/storage.ts +39 -0
  79. package/template/apps/web/src/server/users.ts +17 -0
  80. package/template/apps/web/tsconfig.json +36 -0
  81. package/template/clubedge.template.json +13 -0
  82. package/template/docker-compose.yml +8 -0
  83. package/template/eslint.config.mjs +63 -0
  84. package/template/package.json +47 -0
  85. package/template/packages/auth/package.json +23 -0
  86. package/template/packages/auth/src/index.ts +9 -0
  87. package/template/packages/auth/src/supabase.test.ts +126 -0
  88. package/template/packages/auth/src/supabase.ts +65 -0
  89. package/template/packages/auth/src/types.ts +59 -0
  90. package/template/packages/auth/tsconfig.json +4 -0
  91. package/template/packages/cache/package.json +20 -0
  92. package/template/packages/cache/src/index.test.ts +67 -0
  93. package/template/packages/cache/src/index.ts +19 -0
  94. package/template/packages/cache/src/rate-limit.test.ts +52 -0
  95. package/template/packages/cache/src/rate-limit.ts +74 -0
  96. package/template/packages/cache/src/redis.ts +106 -0
  97. package/template/packages/cache/tsconfig.json +4 -0
  98. package/template/packages/core/package.json +17 -0
  99. package/template/packages/core/src/client-ip.test.ts +19 -0
  100. package/template/packages/core/src/client-ip.ts +11 -0
  101. package/template/packages/core/src/errors.test.ts +20 -0
  102. package/template/packages/core/src/errors.ts +20 -0
  103. package/template/packages/core/src/index.ts +4 -0
  104. package/template/packages/core/src/redirect.test.ts +27 -0
  105. package/template/packages/core/src/redirect.ts +20 -0
  106. package/template/packages/core/src/result.ts +10 -0
  107. package/template/packages/core/tsconfig.json +4 -0
  108. package/template/packages/db/drizzle/0000_initial.sql +10 -0
  109. package/template/packages/db/drizzle/meta/_journal.json +13 -0
  110. package/template/packages/db/drizzle.config.ts +9 -0
  111. package/template/packages/db/package.json +30 -0
  112. package/template/packages/db/scripts/load-env.ts +14 -0
  113. package/template/packages/db/scripts/migrate.ts +11 -0
  114. package/template/packages/db/scripts/seed.ts +14 -0
  115. package/template/packages/db/src/client.ts +22 -0
  116. package/template/packages/db/src/index.ts +4 -0
  117. package/template/packages/db/src/schema/index.ts +1 -0
  118. package/template/packages/db/src/schema/users.ts +14 -0
  119. package/template/packages/db/src/users.ts +16 -0
  120. package/template/packages/db/tsconfig.json +4 -0
  121. package/template/packages/storage/package.json +24 -0
  122. package/template/packages/storage/src/index.ts +1 -0
  123. package/template/packages/storage/src/s3.test.ts +86 -0
  124. package/template/packages/storage/src/s3.ts +86 -0
  125. package/template/packages/storage/src/supabase.ts +69 -0
  126. package/template/packages/storage/src/types.ts +18 -0
  127. package/template/packages/storage/tsconfig.json +4 -0
  128. package/template/packages/ui/components.json +21 -0
  129. package/template/packages/ui/package.json +32 -0
  130. package/template/packages/ui/src/components/badge.tsx +48 -0
  131. package/template/packages/ui/src/components/breadcrumb.tsx +102 -0
  132. package/template/packages/ui/src/components/button.tsx +57 -0
  133. package/template/packages/ui/src/components/card.tsx +87 -0
  134. package/template/packages/ui/src/components/input.tsx +19 -0
  135. package/template/packages/ui/src/components/label.tsx +19 -0
  136. package/template/packages/ui/src/components/separator.tsx +20 -0
  137. package/template/packages/ui/src/components/sheet.tsx +125 -0
  138. package/template/packages/ui/src/components/sidebar.tsx +692 -0
  139. package/template/packages/ui/src/components/skeleton.tsx +13 -0
  140. package/template/packages/ui/src/components/switch.tsx +31 -0
  141. package/template/packages/ui/src/components/table.tsx +88 -0
  142. package/template/packages/ui/src/components/tooltip.tsx +53 -0
  143. package/template/packages/ui/src/hooks/use-mobile.ts +19 -0
  144. package/template/packages/ui/src/lib/utils.ts +1 -0
  145. package/template/packages/ui/src/styles/globals.css +126 -0
  146. package/template/packages/ui/tsconfig.json +19 -0
  147. package/template/playwright.config.ts +19 -0
  148. package/template/pnpm-lock.yaml +3624 -0
  149. package/template/pnpm-workspace.yaml +3 -0
  150. package/template/scripts/start-standalone.mjs +37 -0
  151. package/template/tsconfig.base.json +16 -0
  152. package/template/turbo.json +17 -0
  153. package/template/vitest.config.ts +8 -0
  154. package/template.lock.json +142 -0
@@ -0,0 +1,229 @@
1
+ import { ArrowRight, ArrowUpRight, Github, LayoutDashboard } from "lucide-react";
2
+ import Link from "next/link";
3
+
4
+ import { Button } from "@clubedge/ui/components/button";
5
+ import { ClubedgeMark } from "@/components/branding/clubedge-mark";
6
+ import { CopyCommand } from "@/components/landing/copy-command";
7
+ import { siteConfig } from "@/config/site";
8
+ import { ThemeToggle } from "./theme-toggle";
9
+
10
+ const REPO_URL = siteConfig.links.repository;
11
+ const SETUP_URL = siteConfig.links.setupGuide;
12
+ const CREATE_COMMAND = "pnpm dlx @clubedge/create-clubedge-app my-app";
13
+
14
+ const included = [
15
+ {
16
+ name: "Next.js",
17
+ detail: "An App Router web app in apps/web, with server components by default.",
18
+ },
19
+ {
20
+ name: "Supabase Auth",
21
+ detail:
22
+ "Email and password sign-in, with sessions verified on the server and stored in secure cookies.",
23
+ },
24
+ {
25
+ name: "Drizzle and PostgreSQL",
26
+ detail: "Typed data access that you can extend or swap for your own infrastructure.",
27
+ },
28
+ {
29
+ name: "Base UI design system",
30
+ detail: "Shared, accessible components in @clubedge/ui, used across the whole repo.",
31
+ },
32
+ {
33
+ name: "TypeScript",
34
+ detail: "Typed from the database layer through to the UI.",
35
+ },
36
+ {
37
+ name: "Dashboard",
38
+ detail: "A starting point for your product, ready to open at /dashboard.",
39
+ },
40
+ ];
41
+
42
+ const steps = [
43
+ "Create your project with the command above.",
44
+ "Add your Supabase URL and publishable key to apps/web/.env.local.",
45
+ "Start the app, create an account, and sign in.",
46
+ ];
47
+
48
+ export default function LandingPage() {
49
+ return (
50
+ <div className="flex min-h-svh flex-col">
51
+ <header className="sticky top-0 z-20 border-b bg-background/85 backdrop-blur-xl">
52
+ <nav
53
+ aria-label="Main navigation"
54
+ className="mx-auto flex h-16 max-w-6xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8"
55
+ >
56
+ <Link
57
+ aria-label={`${siteConfig.name} home`}
58
+ className="flex shrink-0 items-center gap-2.5 rounded-md outline-none focus-visible:ring-2 focus-visible:ring-ring"
59
+ href="/"
60
+ >
61
+ <ClubedgeMark alt="" className="size-9 rounded-xl" priority />
62
+ <span className="hidden text-sm font-semibold tracking-tight min-[380px]:inline">
63
+ {siteConfig.name}
64
+ </span>
65
+ </Link>
66
+
67
+ <div className="hidden items-center gap-6 text-sm text-muted-foreground md:flex">
68
+ <a className="transition-colors hover:text-foreground" href="#stack">
69
+ Stack
70
+ </a>
71
+ <Link
72
+ className="inline-flex items-center gap-1 transition-colors hover:text-foreground"
73
+ href={SETUP_URL}
74
+ rel="noreferrer"
75
+ target="_blank"
76
+ >
77
+ Documentation <ArrowUpRight aria-hidden="true" className="size-3.5" />
78
+ </Link>
79
+ <Link
80
+ className="transition-colors hover:text-foreground"
81
+ href={REPO_URL}
82
+ rel="noreferrer"
83
+ target="_blank"
84
+ >
85
+ GitHub
86
+ </Link>
87
+ </div>
88
+
89
+ <div className="flex shrink-0 items-center gap-2 sm:gap-3">
90
+ <ThemeToggle />
91
+ <Button
92
+ aria-label="Go to dashboard"
93
+ className="sm:hidden"
94
+ render={<Link href="/dashboard" />}
95
+ size="icon"
96
+ variant="ghost"
97
+ >
98
+ <LayoutDashboard aria-hidden="true" />
99
+ </Button>
100
+ <Button
101
+ className="hidden sm:inline-flex"
102
+ render={<Link href="/dashboard" />}
103
+ size="sm"
104
+ variant="ghost"
105
+ >
106
+ Dashboard
107
+ </Button>
108
+ <Button render={<Link href="/login" />} size="sm">
109
+ Sign in
110
+ </Button>
111
+ </div>
112
+ </nav>
113
+ </header>
114
+
115
+ <main className="flex-1">
116
+ <section className="mx-auto w-full max-w-6xl px-4 pb-16 pt-16 sm:px-6 sm:pb-24 sm:pt-24 lg:px-8">
117
+ <div className="max-w-3xl">
118
+ <h1 className="text-balance text-4xl font-semibold tracking-tight sm:text-5xl lg:text-6xl">
119
+ Start with the foundation. Build what matters.
120
+ </h1>
121
+ <p className="mt-6 max-w-2xl text-pretty text-base leading-7 text-muted-foreground sm:text-lg sm:leading-8">
122
+ A modular Next.js starter with a shared Base UI design system, typed data access, and
123
+ replaceable infrastructure. Spend your time on the product, not the setup.
124
+ </p>
125
+
126
+ <div className="mt-8 max-w-xl">
127
+ <CopyCommand command={CREATE_COMMAND} />
128
+ </div>
129
+
130
+ <div className="mt-6 flex flex-col gap-3 sm:flex-row">
131
+ <Button className="h-11 px-5" render={<Link href="/dashboard" />}>
132
+ Explore the dashboard <ArrowRight aria-hidden="true" />
133
+ </Button>
134
+ <Button
135
+ className="h-11 px-5"
136
+ render={<Link href={SETUP_URL} rel="noreferrer" target="_blank" />}
137
+ variant="outline"
138
+ >
139
+ Read the setup guide <ArrowUpRight aria-hidden="true" />
140
+ </Button>
141
+ </div>
142
+ </div>
143
+ </section>
144
+
145
+ <section
146
+ aria-labelledby="stack-heading"
147
+ className="scroll-mt-16 border-t bg-muted/30"
148
+ id="stack"
149
+ >
150
+ <div className="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6 sm:py-20 lg:px-8">
151
+ <h2 className="text-2xl font-semibold tracking-tight" id="stack-heading">
152
+ What you get
153
+ </h2>
154
+ <p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
155
+ Every layer is wired together and replaceable, so you can keep what fits and change
156
+ the rest.
157
+ </p>
158
+
159
+ <dl className="mt-10 grid gap-px overflow-hidden rounded-xl border bg-border sm:grid-cols-2 lg:grid-cols-3">
160
+ {included.map((item) => (
161
+ <div className="bg-background p-6" key={item.name}>
162
+ <dt className="text-sm font-semibold">{item.name}</dt>
163
+ <dd className="mt-2 text-sm leading-6 text-muted-foreground">{item.detail}</dd>
164
+ </div>
165
+ ))}
166
+ </dl>
167
+ </div>
168
+ </section>
169
+
170
+ <section
171
+ aria-labelledby="start-heading"
172
+ className="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6 sm:py-20 lg:px-8"
173
+ >
174
+ <h2 className="text-2xl font-semibold tracking-tight" id="start-heading">
175
+ Up and running in three steps
176
+ </h2>
177
+ <ol className="mt-8 grid max-w-2xl gap-5">
178
+ {steps.map((step, index) => (
179
+ <li className="flex items-start gap-4" key={step}>
180
+ <span
181
+ aria-hidden="true"
182
+ className="grid size-7 shrink-0 place-items-center rounded-full border text-xs font-medium text-muted-foreground"
183
+ >
184
+ {index + 1}
185
+ </span>
186
+ <p className="pt-0.5 text-sm leading-6">{step}</p>
187
+ </li>
188
+ ))}
189
+ </ol>
190
+ <div className="mt-8">
191
+ <Button
192
+ render={<Link href={SETUP_URL} rel="noreferrer" target="_blank" />}
193
+ variant="outline"
194
+ >
195
+ Read the full setup guide <ArrowUpRight aria-hidden="true" />
196
+ </Button>
197
+ </div>
198
+ </section>
199
+ </main>
200
+
201
+ <footer className="border-t">
202
+ <div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 px-4 py-5 text-xs text-muted-foreground sm:flex-row sm:px-6 lg:px-8">
203
+ <span>
204
+ © {new Date().getFullYear()} {siteConfig.name}. Licensed under Apache-2.0.
205
+ </span>
206
+ <div className="flex items-center gap-5">
207
+ <Link
208
+ className="transition-colors hover:text-foreground"
209
+ href={SETUP_URL}
210
+ rel="noreferrer"
211
+ target="_blank"
212
+ >
213
+ Documentation
214
+ </Link>
215
+ <Link
216
+ className="inline-flex items-center gap-1.5 transition-colors hover:text-foreground"
217
+ href={REPO_URL}
218
+ rel="noreferrer"
219
+ target="_blank"
220
+ >
221
+ <Github aria-hidden="true" className="size-3.5" />
222
+ Contribute on GitHub
223
+ </Link>
224
+ </div>
225
+ </div>
226
+ </footer>
227
+ </div>
228
+ );
229
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
4
+
5
+ type Theme = "light" | "dark";
6
+
7
+ type ThemeContextValue = {
8
+ theme: Theme;
9
+ setTheme: (theme: Theme) => void;
10
+ };
11
+
12
+ const ThemeContext = createContext<ThemeContextValue | null>(null);
13
+
14
+ export function ThemeProvider({ children }: { children: React.ReactNode }) {
15
+ const [theme, setThemeState] = useState<Theme>("light");
16
+
17
+ useEffect(() => {
18
+ setThemeState(document.documentElement.classList.contains("dark") ? "dark" : "light");
19
+ }, []);
20
+
21
+ const setTheme = useCallback((nextTheme: Theme) => {
22
+ document.documentElement.classList.toggle("dark", nextTheme === "dark");
23
+ document.documentElement.style.colorScheme = nextTheme;
24
+ try {
25
+ window.localStorage.setItem("clubedge-theme", nextTheme);
26
+ } catch {
27
+ // Keep the in-memory theme usable when browser storage is unavailable.
28
+ }
29
+ setThemeState(nextTheme);
30
+ }, []);
31
+
32
+ const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
33
+
34
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
35
+ }
36
+
37
+ export function useTheme() {
38
+ const context = useContext(ThemeContext);
39
+ if (!context) {
40
+ throw new Error("useTheme must be used within ThemeProvider.");
41
+ }
42
+ return context;
43
+ }
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import { Moon, Sun } from "lucide-react";
4
+
5
+ import { Switch } from "@clubedge/ui/components/switch";
6
+ import { useTheme } from "./theme-provider";
7
+
8
+ export function ThemeToggle() {
9
+ const { theme, setTheme } = useTheme();
10
+
11
+ return (
12
+ <div className="flex items-center gap-2">
13
+ <Sun aria-hidden="true" className="size-4 text-muted-foreground" />
14
+ <Switch
15
+ aria-label="Toggle dark theme"
16
+ checked={theme === "dark"}
17
+ onCheckedChange={(checked) => setTheme(checked ? "dark" : "light")}
18
+ />
19
+ <Moon aria-hidden="true" className="size-4 text-muted-foreground" />
20
+ <span className="sr-only">Theme: {theme}</span>
21
+ </div>
22
+ );
23
+ }
@@ -0,0 +1,25 @@
1
+ import Image from "next/image";
2
+
3
+ type ClubedgeMarkProps = {
4
+ alt?: string;
5
+ className?: string;
6
+ priority?: boolean;
7
+ };
8
+
9
+ export function ClubedgeMark({
10
+ alt = "Clubedge",
11
+ className = "size-10",
12
+ priority = false,
13
+ }: ClubedgeMarkProps) {
14
+ return (
15
+ <Image
16
+ alt={alt}
17
+ className={className}
18
+ height={512}
19
+ priority={priority}
20
+ src="/.well-known/logo.svg"
21
+ unoptimized
22
+ width={512}
23
+ />
24
+ );
25
+ }
@@ -0,0 +1,48 @@
1
+ "use client";
2
+
3
+ import { Check, Copy } from "lucide-react";
4
+ import { useEffect, useState } from "react";
5
+
6
+ export function CopyCommand({ command }: { command: string }) {
7
+ const [copied, setCopied] = useState(false);
8
+
9
+ useEffect(() => {
10
+ if (!copied) return;
11
+ const timer = setTimeout(() => setCopied(false), 2000);
12
+ return () => clearTimeout(timer);
13
+ }, [copied]);
14
+
15
+ async function handleCopy() {
16
+ try {
17
+ await navigator.clipboard.writeText(command);
18
+ setCopied(true);
19
+ } catch {
20
+ // Clipboard can be unavailable (insecure context, denied permission).
21
+ // The command stays selectable, so we fail silently.
22
+ }
23
+ }
24
+
25
+ return (
26
+ <div className="flex items-center gap-2 rounded-xl border bg-card py-2 pl-4 pr-2 shadow-sm">
27
+ <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap py-1.5 font-mono text-sm text-foreground">
28
+ <span aria-hidden="true" className="select-none text-muted-foreground">
29
+ ${" "}
30
+ </span>
31
+ {command}
32
+ </code>
33
+ <button
34
+ aria-label={copied ? "Command copied" : "Copy command"}
35
+ className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-lg px-3 text-xs font-medium text-muted-foreground outline-none transition-colors hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
36
+ onClick={handleCopy}
37
+ type="button"
38
+ >
39
+ {copied ? (
40
+ <Check aria-hidden="true" className="size-4 text-primary" />
41
+ ) : (
42
+ <Copy aria-hidden="true" className="size-4" />
43
+ )}
44
+ <span aria-live="polite">{copied ? "Copied" : "Copy"}</span>
45
+ </button>
46
+ </div>
47
+ );
48
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "Clubedge Starter",
3
+ "shortName": "Clubedge",
4
+ "description": "A production-minded foundation for Clubedge applications.",
5
+ "serviceId": "clubedge-starter",
6
+ "workspaceLabel": "Starter workspace",
7
+ "links": {
8
+ "repository": "https://github.com/Clubedge/clubedge-starter",
9
+ "setupGuide": "https://github.com/Clubedge/clubedge-starter/blob/main/SETUP.md"
10
+ }
11
+ }
@@ -0,0 +1,20 @@
1
+ import site from "./site.json";
2
+
3
+ /**
4
+ * Project identity. Values live in site.json so tooling such as create-clubedge-app
5
+ * can rename a project by editing data instead of rewriting source files.
6
+ */
7
+ export interface SiteConfig {
8
+ name: string;
9
+ shortName: string;
10
+ description: string;
11
+ /** Machine-readable service name reported by health checks and logs. */
12
+ serviceId: string;
13
+ workspaceLabel: string;
14
+ links: {
15
+ repository: string;
16
+ setupGuide: string;
17
+ };
18
+ }
19
+
20
+ export const siteConfig: SiteConfig = site;
@@ -0,0 +1,22 @@
1
+ import { z } from "zod";
2
+
3
+ const clientSchema = z.object({
4
+ NEXT_PUBLIC_APP_URL: z.url().default("http://localhost:3000"),
5
+ NEXT_PUBLIC_SUPABASE_URL: z.url().optional().or(z.literal("")),
6
+ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: z.string().optional().or(z.literal("")),
7
+ });
8
+
9
+ const parsed = clientSchema.safeParse({
10
+ NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
11
+ NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
12
+ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
13
+ });
14
+
15
+ if (!parsed.success) {
16
+ throw new Error("Client environment validation failed. Check your NEXT_PUBLIC_* variables.");
17
+ }
18
+
19
+ export const clientEnv = parsed.data;
20
+ export const hasSupabaseAuthConfig = Boolean(
21
+ clientEnv.NEXT_PUBLIC_SUPABASE_URL && clientEnv.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY,
22
+ );
@@ -0,0 +1,35 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ async function loadFreshModule() {
4
+ vi.resetModules();
5
+ return import("./server");
6
+ }
7
+
8
+ describe("getServerEnv", () => {
9
+ afterEach(() => {
10
+ vi.unstubAllEnvs();
11
+ });
12
+
13
+ it("does not validate at import time", async () => {
14
+ vi.stubEnv("DATABASE_URL", "");
15
+ await expect(loadFreshModule()).resolves.toBeDefined();
16
+ });
17
+
18
+ it("throws on first use when required variables are missing", async () => {
19
+ vi.stubEnv("DATABASE_URL", "");
20
+ const { getServerEnv } = await loadFreshModule();
21
+ vi.spyOn(console, "error").mockImplementation(() => {});
22
+ expect(() => getServerEnv()).toThrow("Server environment validation failed");
23
+ });
24
+
25
+ it("parses and applies defaults once variables are present", async () => {
26
+ vi.stubEnv("DATABASE_URL", "postgresql://localhost/app");
27
+ vi.stubEnv("STORAGE_PROVIDER", undefined);
28
+ const { getServerEnv } = await loadFreshModule();
29
+ expect(getServerEnv()).toMatchObject({
30
+ DATABASE_URL: "postgresql://localhost/app",
31
+ STORAGE_PROVIDER: "s3",
32
+ STORAGE_REGION: "auto",
33
+ });
34
+ });
35
+ });
@@ -0,0 +1,45 @@
1
+ import { z } from "zod";
2
+
3
+ const serverSchema = z.object({
4
+ NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
5
+ DATABASE_URL: z.string().min(1, "DATABASE_URL is required"),
6
+ REDIS_URL: z
7
+ .url({ protocol: /^rediss?$/ })
8
+ .optional()
9
+ .or(z.literal("")),
10
+ STORAGE_PROVIDER: z.enum(["s3", "supabase"]).default("s3"),
11
+ STORAGE_BUCKET: z.string().optional().or(z.literal("")),
12
+ STORAGE_REGION: z.string().default("auto"),
13
+ STORAGE_ENDPOINT: z.url().optional().or(z.literal("")),
14
+ STORAGE_ACCESS_KEY_ID: z.string().optional().or(z.literal("")),
15
+ STORAGE_SECRET_ACCESS_KEY: z.string().optional().or(z.literal("")),
16
+ STORAGE_PUBLIC_URL: z.url().optional().or(z.literal("")),
17
+ SUPABASE_STORAGE_BUCKET: z.string().optional().or(z.literal("")),
18
+ });
19
+
20
+ export type ServerEnv = z.infer<typeof serverSchema>;
21
+
22
+ let cached: ServerEnv | undefined;
23
+
24
+ /**
25
+ * Validates server variables on first use rather than at import, so pages that never touch
26
+ * the database or providers can build and render without them.
27
+ */
28
+ export function getServerEnv(): ServerEnv {
29
+ if (cached) return cached;
30
+
31
+ const parsed = serverSchema.safeParse(process.env);
32
+ if (!parsed.success) {
33
+ console.error("Invalid server environment:", z.treeifyError(parsed.error));
34
+ throw new Error("Server environment validation failed. Check your .env.local file.");
35
+ }
36
+ cached = parsed.data;
37
+ return cached;
38
+ }
39
+
40
+ /** Validates only REDIS_URL, so cache and rate limiting work without the rest of the env. */
41
+ export function getRedisUrl(): string | undefined {
42
+ const parsed = serverSchema.shape.REDIS_URL.safeParse(process.env.REDIS_URL);
43
+ if (!parsed.success) throw new Error("REDIS_URL must be a redis:// or rediss:// URL.");
44
+ return parsed.data || undefined;
45
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { loginUrl } from "./login-url";
3
+
4
+ describe("loginUrl", () => {
5
+ it("builds the plain login path", () => {
6
+ expect(loginUrl()).toBe("/login");
7
+ });
8
+
9
+ it("omits the default destination and encodes everything else", () => {
10
+ expect(loginUrl({ next: "/dashboard" })).toBe("/login");
11
+ expect(loginUrl({ mode: "signup", error: "invalid-input", next: "/a?b=c" })).toBe(
12
+ "/login?mode=signup&error=invalid-input&next=%2Fa%3Fb%3Dc",
13
+ );
14
+ expect(loginUrl({ mode: "signup", checkEmail: true })).toBe("/login?mode=signup&check-email=1");
15
+ });
16
+ });
@@ -0,0 +1,16 @@
1
+ type LoginUrlOptions = {
2
+ mode?: "signin" | "signup";
3
+ error?: string;
4
+ next?: string;
5
+ checkEmail?: boolean;
6
+ };
7
+
8
+ export function loginUrl({ mode = "signin", error, next, checkEmail }: LoginUrlOptions = {}) {
9
+ const params = new URLSearchParams();
10
+ if (mode === "signup") params.set("mode", "signup");
11
+ if (error) params.set("error", error);
12
+ if (checkEmail) params.set("check-email", "1");
13
+ if (next && next !== "/dashboard") params.set("next", next);
14
+ const query = params.toString();
15
+ return query ? `/login?${query}` : "/login";
16
+ }
@@ -0,0 +1 @@
1
+ export { cn } from "@clubedge/ui/lib/utils";
@@ -0,0 +1,31 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+ import type { CookieStore } from "@clubedge/auth";
3
+ import { createSupabaseAuth } from "@clubedge/auth/supabase";
4
+ import { clientEnv, hasSupabaseAuthConfig } from "@/env/client";
5
+
6
+ export async function proxy(request: NextRequest) {
7
+ let response = NextResponse.next({ request });
8
+ if (!hasSupabaseAuthConfig) return response;
9
+
10
+ // Refreshed cookies must reach both the downstream request and the browser response.
11
+ const cookies: CookieStore = {
12
+ getAll: () => request.cookies.getAll(),
13
+ setAll(values) {
14
+ values.forEach(({ name, value }) => request.cookies.set(name, value));
15
+ response = NextResponse.next({ request });
16
+ values.forEach(({ name, value, options }) => response.cookies.set(name, value, options));
17
+ },
18
+ };
19
+
20
+ const auth = createSupabaseAuth({
21
+ url: clientEnv.NEXT_PUBLIC_SUPABASE_URL!,
22
+ publishableKey: clientEnv.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
23
+ cookies,
24
+ });
25
+ await auth.refreshSession();
26
+ return response;
27
+ }
28
+
29
+ export const config = {
30
+ matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)"],
31
+ };
@@ -0,0 +1,69 @@
1
+ import "server-only";
2
+ import { cookies } from "next/headers";
3
+ import { redirect } from "next/navigation";
4
+ import type { AuthProvider, AuthUser, CookieStore } from "@clubedge/auth";
5
+ import { createSupabaseAuth, createSupabaseServerClient } from "@clubedge/auth/supabase";
6
+ import { AppError } from "@clubedge/core";
7
+ import { clientEnv, hasSupabaseAuthConfig } from "@/env/client";
8
+ import { loginUrl } from "@/lib/login-url";
9
+
10
+ export type { AuthUser } from "@clubedge/auth";
11
+
12
+ export const isAuthConfigured = hasSupabaseAuthConfig;
13
+
14
+ /** Bridges Next.js request cookies to the framework-agnostic CookieStore. */
15
+ async function nextCookieStore(): Promise<CookieStore> {
16
+ const cookieStore = await cookies();
17
+ return {
18
+ getAll: () => cookieStore.getAll(),
19
+ setAll(values) {
20
+ try {
21
+ values.forEach(({ name, value, options }) => cookieStore.set(name, value, options));
22
+ } catch {
23
+ // Server Components cannot write cookies. The proxy refreshes sessions instead.
24
+ }
25
+ },
26
+ };
27
+ }
28
+
29
+ function supabaseConfig(cookieStore: CookieStore) {
30
+ if (!hasSupabaseAuthConfig) throw new Error("Supabase Auth is not configured.");
31
+ return {
32
+ url: clientEnv.NEXT_PUBLIC_SUPABASE_URL!,
33
+ publishableKey: clientEnv.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
34
+ cookies: cookieStore,
35
+ };
36
+ }
37
+
38
+ /** Request-scoped auth provider. Throws when authentication is not configured. */
39
+ export async function getAuth(): Promise<AuthProvider> {
40
+ return createSupabaseAuth(supabaseConfig(await nextCookieStore()));
41
+ }
42
+
43
+ /** Request-scoped Supabase client for provider features such as Storage. */
44
+ export async function getSupabaseClient() {
45
+ return createSupabaseServerClient(supabaseConfig(await nextCookieStore()));
46
+ }
47
+
48
+ export async function getCurrentUser(): Promise<AuthUser | null> {
49
+ if (!isAuthConfigured) return null;
50
+ return (await getAuth()).getUser();
51
+ }
52
+
53
+ /** For route handlers and server actions: throws a 401 AppError when signed out. */
54
+ export async function requireUser(): Promise<AuthUser> {
55
+ const user = await getCurrentUser();
56
+ if (!user) throw new AppError("Authentication required.", "UNAUTHENTICATED", 401);
57
+ return user;
58
+ }
59
+
60
+ /**
61
+ * For pages: redirects signed-out visitors to the login page and back afterwards.
62
+ * Returns null while authentication is not configured so the starter stays explorable.
63
+ */
64
+ export async function getPageUser(pathname: string): Promise<AuthUser | null> {
65
+ if (!isAuthConfigured) return null;
66
+ const user = await getCurrentUser();
67
+ if (!user) redirect(loginUrl({ next: pathname }));
68
+ return user;
69
+ }