@clubedge/create-clubedge-app 0.3.0 → 0.4.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 (81) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +29 -13
  3. package/dist/cli/args.js +5 -0
  4. package/dist/cli/ui.js +2 -0
  5. package/dist/core/customize.js +40 -1
  6. package/dist/core/manifest.js +41 -3
  7. package/dist/core/plan.js +68 -3
  8. package/dist/index.js +42 -7
  9. package/dist/utils/fs.js +3 -0
  10. package/package.json +5 -4
  11. package/template/.dockerignore +3 -0
  12. package/template/.github/workflows/ci.yml +51 -4
  13. package/template/.prettierignore +1 -0
  14. package/template/AGENTS.md +3 -2
  15. package/template/CONTRIBUTING.md +2 -2
  16. package/template/README.md +18 -5
  17. package/template/SETUP.md +23 -1
  18. package/template/_gitignore +4 -0
  19. package/template/apps/start/.env.example +27 -0
  20. package/template/apps/start/AGENTS.md +11 -0
  21. package/template/apps/start/Dockerfile +38 -0
  22. package/template/apps/start/package.json +37 -0
  23. package/template/apps/start/public/.well-known/logo.svg +1 -0
  24. package/template/apps/start/public/favicon.ico +0 -0
  25. package/template/apps/start/src/components/auth/auth-form-controls.tsx +108 -0
  26. package/template/apps/start/src/components/branding/clubedge-mark.tsx +24 -0
  27. package/template/apps/start/src/components/dashboard/account-button.tsx +33 -0
  28. package/template/apps/start/src/components/dashboard/app-sidebar.tsx +90 -0
  29. package/template/apps/start/src/components/landing/copy-command.tsx +46 -0
  30. package/template/apps/start/src/components/theme/theme-provider.tsx +41 -0
  31. package/template/apps/start/src/components/theme/theme-toggle.tsx +21 -0
  32. package/template/apps/start/src/config/site.json +11 -0
  33. package/template/apps/start/src/config/site.ts +20 -0
  34. package/template/apps/start/src/env/server.test.ts +48 -0
  35. package/template/apps/start/src/env/server.ts +67 -0
  36. package/template/apps/start/src/lib/login-url.test.ts +16 -0
  37. package/template/apps/start/src/lib/login-url.ts +16 -0
  38. package/template/apps/start/src/routeTree.gen.ts +220 -0
  39. package/template/apps/start/src/router.tsx +27 -0
  40. package/template/apps/start/src/routes/__root.tsx +59 -0
  41. package/template/apps/start/src/routes/api/health.ts +15 -0
  42. package/template/apps/start/src/routes/auth/callback.ts +10 -0
  43. package/template/apps/start/src/routes/auth/sign-in.ts +11 -0
  44. package/template/apps/start/src/routes/auth/sign-out.ts +11 -0
  45. package/template/apps/start/src/routes/auth/sign-up.ts +11 -0
  46. package/template/apps/start/src/routes/dashboard.tsx +351 -0
  47. package/template/apps/start/src/routes/index.tsx +233 -0
  48. package/template/apps/start/src/routes/login.tsx +164 -0
  49. package/template/apps/start/src/server/auth-forms.test.ts +169 -0
  50. package/template/apps/start/src/server/auth-forms.ts +118 -0
  51. package/template/apps/start/src/server/auth.functions.ts +27 -0
  52. package/template/apps/start/src/server/auth.ts +51 -0
  53. package/template/apps/start/src/server/cache.ts +38 -0
  54. package/template/apps/start/src/server/cookies.test.ts +41 -0
  55. package/template/apps/start/src/server/cookies.ts +31 -0
  56. package/template/apps/start/src/server/db.ts +16 -0
  57. package/template/apps/start/src/server/storage.ts +39 -0
  58. package/template/apps/start/src/server/users.ts +17 -0
  59. package/template/apps/start/src/start.ts +40 -0
  60. package/template/apps/start/src/styles.css +2 -0
  61. package/template/apps/start/tsconfig.json +19 -0
  62. package/template/apps/start/vite.config.ts +27 -0
  63. package/template/apps/web/package.json +2 -2
  64. package/template/{scripts → apps/web/scripts}/start-standalone.mjs +1 -2
  65. package/template/clubedge.template.json +17 -1
  66. package/template/e2e/app.ts +12 -0
  67. package/template/{apps/web/e2e → e2e}/auth.spec.ts +2 -2
  68. package/template/eslint.config.mjs +4 -0
  69. package/template/package.json +3 -3
  70. package/template/packages/auth/package.json +1 -1
  71. package/template/packages/cache/package.json +1 -1
  72. package/template/packages/core/package.json +1 -1
  73. package/template/packages/db/package.json +1 -1
  74. package/template/packages/storage/package.json +1 -1
  75. package/template/packages/ui/package.json +1 -1
  76. package/template/playwright.config.ts +6 -2
  77. package/template/pnpm-lock.yaml +1382 -19
  78. package/template/turbo.json +1 -1
  79. package/template/vitest.config.ts +2 -0
  80. package/template.lock.json +50 -5
  81. /package/template/{apps/web/e2e → e2e}/home.spec.ts +0 -0
@@ -3,13 +3,13 @@
3
3
  [![CI](https://github.com/Clubedge/clubedge-starter/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Clubedge/clubedge-starter/actions/workflows/ci.yml)
4
4
  [![License: Apache-2.0](https://img.shields.io/badge/License-Apache--2.0-blue.svg)](LICENSE)
5
5
 
6
- Clubedge Starter is a production-oriented, modular Next.js application foundation. It brings together a pnpm monorepo, shared shadcn/ui components, PostgreSQL access through Drizzle, Supabase Auth, optional Redis and storage adapters, and a working example dashboard. It provides engineering conventions and a reference implementation; review its security and deployment choices for your application before production use.
6
+ Clubedge Starter is a production-oriented, modular React application foundation, available for Next.js and TanStack Start. It brings together a pnpm monorepo, shared shadcn/ui components, PostgreSQL access through Drizzle, Supabase Auth, optional Redis and storage adapters, and a working example dashboard. It provides engineering conventions and a reference implementation; review its security and deployment choices for your application before production use.
7
7
 
8
8
  This is a starter, not a hosted service or a one-command app generator. Fork it or use it as a reference, then adapt the app and provider configuration to your project. Contributions and bug reports are welcome; see [CONTRIBUTING.md](CONTRIBUTING.md). Repository maintainers can use [PUBLISHING.md](PUBLISHING.md) for the GitHub launch checklist.
9
9
 
10
10
  ## Features
11
11
 
12
- - Next.js App Router, React, TypeScript, Tailwind CSS 4, and shadcn/ui using Base UI primitives.
12
+ - Next.js App Router or TanStack Start, with React, TypeScript, Tailwind CSS 4, and shadcn/ui using Base UI primitives.
13
13
  - pnpm workspaces and Turborepo, with the web app in `apps/web` and reusable UI source in `packages/ui`.
14
14
  - Drizzle ORM and PostgreSQL schema, migrations, and seed commands.
15
15
  - Supabase Auth using cookie based server clients from `@supabase/ssr`.
@@ -22,13 +22,13 @@ This is a starter, not a hosted service or a one-command app generator. Fork it
22
22
 
23
23
  | Capability | Included choice | Other supported options |
24
24
  | ----------------------- | ---------------------------------------------------- | --------------------------------------------------------------------- |
25
- | Web app | Next.js App Router, React, TypeScript | — |
25
+ | Web app | Next.js App Router, React, TypeScript | TanStack Start (`apps/start`), sharing every package. |
26
26
  | UI | Tailwind CSS 4, shadcn/ui, and Base UI | Add or replace components in your app or shared UI package. |
27
27
  | Database | PostgreSQL through Drizzle ORM | Any reachable PostgreSQL provider; Supabase PostgreSQL is documented. |
28
28
  | Authentication | Supabase Auth with `@supabase/ssr` | No alternate auth adapter is currently included. |
29
29
  | Cache and rate limiting | Optional Redis protocol client | Upstash, self-hosted Redis, or a compatible Redis service. |
30
30
  | Object storage | S3-compatible adapter or Supabase Storage | AWS S3, Cloudflare R2, and other S3-compatible services. |
31
- | Local runtime | Docker and Next.js standalone output | Run directly with Node.js during development. |
31
+ | Local runtime | Docker and Next.js standalone or Nitro output | Run directly with Node.js during development. |
32
32
  | Verification | Vitest, Playwright, ESLint, Prettier, GitHub Actions | — |
33
33
 
34
34
  ## Architecture
@@ -53,7 +53,7 @@ Drizzle manages application data in PostgreSQL. Supabase Auth manages identity a
53
53
  - **Infrastructure dependencies stay optional where practical.** Redis is only needed for distributed cache and rate limiting. Without Redis, the rate limiter falls back to a per-process memory window, which is suitable for a single server instance only.
54
54
  - **Storage has a provider boundary.** Application code can use the storage interface with S3-compatible services or Supabase Storage; authorization and upload validation remain the caller's responsibility.
55
55
  - **Shared packages are framework-agnostic.** Packages under `packages/` receive configuration and request cookies as arguments and never import Next.js, so another app (for example a TanStack Start app) can reuse them unchanged. ESLint enforces these boundaries.
56
- - **One composition root per app.** `apps/web/src/server/` is the only place that reads environment variables and chooses providers. Routes, actions, and components call it rather than a provider SDK.
56
+ - **One composition root per app.** Each app's `src/server/` is the only place that reads environment variables and chooses providers. Routes, actions, and components call it rather than a provider SDK.
57
57
  - **Keep the baseline focused.** Additional providers, queues, and generator tooling should be added when a real use case calls for them.
58
58
 
59
59
  ## Quick start
@@ -117,11 +117,24 @@ Run these from the repository root:
117
117
 
118
118
  Playwright's first run may require installing Chromium with `pnpm exec playwright install chromium`. CI runs the browser checks automatically.
119
119
 
120
+ ## Frameworks
121
+
122
+ This repository contains the same application twice, so both stay tested against the shared packages:
123
+
124
+ | App | Framework | Run it |
125
+ | ------------ | ------------------ | ----------------------------------- |
126
+ | `apps/web` | Next.js App Router | `pnpm dev` |
127
+ | `apps/start` | TanStack Start | `pnpm --filter @clubedge/start dev` |
128
+
129
+ Projects generated by `create-clubedge-app` contain only the framework you choose, always in `apps/web`. The TanStack Start app reads `apps/start/.env.local`; copy `apps/start/.env.example` there. Its variables have no browser prefix (`APP_URL`, `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY`) because authentication runs on the server. Run the browser suite against it with `E2E_APP=start pnpm test:e2e`, and build its image with `docker build -f apps/start/Dockerfile --build-arg APP_DIR=apps/start --build-arg APP_PACKAGE=@clubedge/start .`.
130
+
120
131
  ## Repository structure
121
132
 
122
133
  ```text
123
134
  apps/web/ Next.js app: routes, actions, UI, and the composition root
124
135
  src/server/ Wires environment, cookies, and providers into the packages
136
+ apps/start/ TanStack Start app with the same pages, built on the same packages
137
+ e2e/ Playwright suite shared by both apps (E2E_APP selects one)
125
138
  packages/core/ Errors, Result type, and HTTP helpers (no dependencies)
126
139
  packages/auth/ AuthProvider and CookieStore interfaces, Supabase adapter
127
140
  packages/db/ Drizzle schema, client factory, migrations, and seed script
package/template/SETUP.md CHANGED
@@ -150,7 +150,29 @@ pnpm exec playwright install chromium
150
150
  pnpm test:e2e
151
151
  ```
152
152
 
153
- The Playwright configuration starts the local app with a placeholder database URL; browser scenarios should not require real provider credentials.
153
+ The Playwright configuration starts the local app with a placeholder database URL; browser scenarios should not require real provider credentials. Set `E2E_APP=start` to run the same suite against the TanStack Start app.
154
+
155
+ ## 9. TanStack Start app
156
+
157
+ `apps/start` is the same application built with TanStack Start. It reuses every package and differs only in its framework layer:
158
+
159
+ | Concern | Next.js (`apps/web`) | TanStack Start (`apps/start`) |
160
+ | --------------------- | -------------------------------------- | --------------------------------------------------------------- |
161
+ | Environment file | `apps/web/.env.local` | `apps/start/.env.local`, from `apps/start/.env.example` |
162
+ | App URL and Supabase | `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_*` | `APP_URL`, `SUPABASE_URL`, `SUPABASE_PUBLISHABLE_KEY` |
163
+ | Sign-in and sign-up | Server actions | Form posts to `/auth/sign-in` and `/auth/sign-up` server routes |
164
+ | Session refresh | `src/proxy.ts` | Request middleware in `src/start.ts` |
165
+ | Cross-site protection | Built into server actions | `createCsrfMiddleware` in `src/start.ts` |
166
+ | Production output | Next.js standalone (`pnpm start`) | Nitro `.output` (`pnpm --filter @clubedge/start start`) |
167
+ | Docker image | `Dockerfile` | `apps/start/Dockerfile` (see below) |
168
+
169
+ Run it with `pnpm --filter @clubedge/start dev`. The database commands still read `DATABASE_URL` from `apps/web/.env.local`. Build its image from the repository root:
170
+
171
+ ```sh
172
+ docker build -f apps/start/Dockerfile --build-arg APP_DIR=apps/start --build-arg APP_PACKAGE=@clubedge/start -t clubedge-starter .
173
+ ```
174
+
175
+ In projects generated with the TanStack Start option, the app lives in `apps/web`, its Dockerfile is the root `Dockerfile`, and the build arguments are not needed.
154
176
 
155
177
  ## Before deploying
156
178
 
@@ -11,3 +11,7 @@ test-results/
11
11
  !.env.example
12
12
  !.env.test.example
13
13
  *.tsbuildinfo
14
+ .output/
15
+ .nitro/
16
+ .tanstack/
17
+ dist/
@@ -0,0 +1,27 @@
1
+ # Do not set NODE_ENV here: Vite reads it from env files, and vite build would then compile
2
+ # development JSX that crashes the production server. `vite dev` and `vite build` set it.
3
+ APP_URL=http://localhost:3000
4
+
5
+ # Supabase PostgreSQL: use the transaction pooler with prepare disabled (configured in packages/db).
6
+ DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:6543/postgres
7
+
8
+ # Supabase Auth (publishable key only; never use a service role key here).
9
+ # All auth runs on the server, so these need no browser prefix.
10
+ SUPABASE_URL=
11
+ SUPABASE_PUBLISHABLE_KEY=
12
+
13
+ # Optional cache and rate limits (Redis protocol; rediss:// enables TLS)
14
+ # Upstash example: rediss://default:YOUR_PASSWORD@your-database.upstash.io:6379
15
+ REDIS_URL=
16
+
17
+ # Storage adapter: s3 (works with AWS S3 and Cloudflare R2) or supabase
18
+ STORAGE_PROVIDER=s3
19
+ STORAGE_BUCKET=
20
+ STORAGE_REGION=auto
21
+ STORAGE_ENDPOINT=
22
+ STORAGE_ACCESS_KEY_ID=
23
+ STORAGE_SECRET_ACCESS_KEY=
24
+ STORAGE_PUBLIC_URL=
25
+
26
+ # Supabase Storage adapter
27
+ SUPABASE_STORAGE_BUCKET=
@@ -0,0 +1,11 @@
1
+ # TanStack Start app
2
+
3
+ This app mirrors `apps/web` with TanStack Start. Follow the root `AGENTS.md`, plus:
4
+
5
+ - Read the skills bundled with the installed packages before changing framework code: `node_modules/@tanstack/react-start/skills/` and `node_modules/@tanstack/start-client-core/skills/`. They match the installed version; APIs change often.
6
+ - Route loaders run on the server and in the browser. Keep database, provider, and secret access in `src/server/`, reached from loaders through `createServerFn` (see `src/server/auth.functions.ts`).
7
+ - Every server function and server route that touches private data must check the user itself. A route guard only protects the page, not the endpoint.
8
+ - Modules in `src/server/` start with `import "@tanstack/react-start/server-only";` so they can never reach the browser bundle.
9
+ - Forms post to server routes that answer with 303 redirects (`src/server/auth-forms.ts`), so they work before hydration. Do not throw a router `redirect` from a POST handler; it uses 307 and repeats the POST.
10
+ - Search params are plain strings (`src/router.tsx`). Build login links with `loginUrl()` so they match the Next.js app.
11
+ - `src/routeTree.gen.ts` is generated by `vite dev` and `vite build`. Commit it, but do not edit it by hand.
@@ -0,0 +1,38 @@
1
+ # Production image for the TanStack Start app. Build it from the repository root.
2
+ # Generated projects keep the app in apps/web; the starter repository builds it with
3
+ # docker build -f apps/start/Dockerfile --build-arg APP_DIR=apps/start \
4
+ # --build-arg APP_PACKAGE=@clubedge/start .
5
+ ARG APP_DIR=apps/web
6
+ ARG APP_PACKAGE=@clubedge/web
7
+
8
+ FROM node:22-alpine AS base
9
+ RUN corepack enable
10
+ WORKDIR /app
11
+
12
+ # Reduce the workspace to the app and the packages it depends on, split into manifests
13
+ # (for a cacheable install layer) and full sources. New packages are picked up automatically.
14
+ FROM base AS pruner
15
+ ARG APP_PACKAGE
16
+ COPY . .
17
+ RUN pnpm dlx turbo@^2.5.0 prune ${APP_PACKAGE} --docker
18
+
19
+ FROM base AS deps
20
+ COPY --from=pruner /app/out/json/ .
21
+ RUN pnpm install --frozen-lockfile
22
+
23
+ FROM base AS builder
24
+ COPY --from=deps /app/ .
25
+ COPY --from=pruner /app/out/full/ .
26
+ COPY tsconfig.base.json ./
27
+ RUN pnpm build
28
+
29
+ # Nitro bundles the server and its dependencies into .output, so no node_modules are needed.
30
+ FROM node:22-alpine AS runner
31
+ ARG APP_DIR
32
+ ENV NODE_ENV=production HOST=0.0.0.0 PORT=3000
33
+ WORKDIR /app
34
+ RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 app
35
+ COPY --from=builder --chown=app:nodejs /app/${APP_DIR}/.output ./.output
36
+ USER app
37
+ EXPOSE 3000
38
+ CMD ["node", ".output/server/index.mjs"]
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@clubedge/start",
3
+ "version": "0.4.0",
4
+ "private": true,
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "scripts": {
8
+ "dev": "vite dev",
9
+ "build": "vite build",
10
+ "start": "node --env-file-if-exists=.env.local .output/server/index.mjs",
11
+ "typecheck": "tsc --noEmit"
12
+ },
13
+ "dependencies": {
14
+ "@clubedge/auth": "workspace:*",
15
+ "@clubedge/cache": "workspace:*",
16
+ "@clubedge/core": "workspace:*",
17
+ "@clubedge/db": "workspace:*",
18
+ "@clubedge/storage": "workspace:*",
19
+ "@clubedge/ui": "workspace:*",
20
+ "@tanstack/react-router": "1.170.42",
21
+ "@tanstack/react-start": "1.168.61",
22
+ "lucide-react": "^0.468.0",
23
+ "react": "^19.2.0",
24
+ "react-dom": "^19.2.0",
25
+ "use-sync-external-store": "^1.7.0",
26
+ "zod": "^4.1.5"
27
+ },
28
+ "devDependencies": {
29
+ "@tailwindcss/vite": "^4.3.3",
30
+ "@types/react": "^19.1.13",
31
+ "@types/react-dom": "^19.1.9",
32
+ "@vitejs/plugin-react": "^6.1.2",
33
+ "nitro": "3.0.260903-beta",
34
+ "tailwindcss": "^4.1.13",
35
+ "vite": "^8.3.4"
36
+ }
37
+ }
@@ -0,0 +1 @@
1
+ <svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.cls-1{fill:#2563eb;}.cls-2,.cls-3{fill:#fff;}.cls-2{fill-rule:evenodd;}</style></defs><rect class="cls-1" width="512" height="512"/><path class="cls-2" d="M364.36,283a359.51,359.51,0,0,0-43.5-10.18,364.29,364.29,0,0,1,5.24-55.17l30-15.13,20.83,5.63v34.6A43.79,43.79,0,0,0,364.36,283Zm-66.88-13.4a395.53,395.53,0,0,0-82.94,0,390.43,390.43,0,0,0-3-39L256,253l44.45-22.42A390.43,390.43,0,0,0,297.48,269.6Zm-106.32,3.22c-39.75,6.75-76,19.56-106.55,36.9a175.47,175.47,0,0,1-2.52-29.64,173.14,173.14,0,0,1,31-99.15l72.81,36.72a364.29,364.29,0,0,1,5.24,55.17Zm230.41,60.63A174.34,174.34,0,0,1,352.17,425c-17.72-36.5-28.9-80.53-31-128.34,37.9,6.89,72.13,19.67,100.42,36.78Zm-89.78,103.2a174.19,174.19,0,0,1-151.57,0c20-40.75,32.48-90.09,34.24-143.46a373.23,373.23,0,0,1,83.1,0C299.32,346.56,311.75,395.9,331.79,436.65ZM159.85,425a174.36,174.36,0,0,1-69.41-91.56c28.3-17.11,62.53-29.89,100.42-36.78-2.11,47.81-13.3,91.84-31,128.34Z"/><path class="cls-2" d="M256,58l173,84.17-33.81,17.05L280.05,128.07c-6.92-4.44-16-6.2-24-6.2-7.37,0-15.32,1.45-21.88,4.94s-11.85,9.19-11.85,16.9,5.42,13.48,11.85,16.9,14.51,4.94,21.88,4.94a54.52,54.52,0,0,0,9.29-.78l77.41,20.93L256,229.42,83,142.17,256,58Z"/><path class="cls-2" d="M256,130.18c8.43,0,15.9,2.18,20.52,5.55l140,37.84v72.75a29.68,29.68,0,0,0-18.51,0V192l-132.36-35.8a45,45,0,0,1-9.6,1c-14,0-25.42-6.05-25.42-13.53S242,130.18,256,130.18Z"/><circle class="cls-3" cx="407.23" cy="274.28" r="22.68"/></svg>
@@ -0,0 +1,108 @@
1
+ import { ArrowRight, Eye, EyeOff, Loader2 } from "lucide-react";
2
+ import { createContext, useContext, useState, type ReactNode } from "react";
3
+
4
+ import { Button } from "@clubedge/ui/components/button";
5
+ import { Input } from "@clubedge/ui/components/input";
6
+ import { Label } from "@clubedge/ui/components/label";
7
+
8
+ // The form posts natively to a server route, so it works before hydration and without
9
+ // JavaScript. Once hydrated, the pending state disables repeat submissions.
10
+ const PendingContext = createContext(false);
11
+
12
+ export function AuthForm({ action, children }: { action: string; children: ReactNode }) {
13
+ const [pending, setPending] = useState(false);
14
+
15
+ return (
16
+ <form
17
+ action={action}
18
+ className="grid gap-5"
19
+ method="post"
20
+ onSubmit={(event) => {
21
+ if (pending) event.preventDefault();
22
+ else setPending(true);
23
+ }}
24
+ >
25
+ <PendingContext.Provider value={pending}>{children}</PendingContext.Provider>
26
+ </form>
27
+ );
28
+ }
29
+
30
+ export function PasswordField({ isSignUp }: { isSignUp: boolean }) {
31
+ const [visible, setVisible] = useState(false);
32
+ const pending = useContext(PendingContext);
33
+
34
+ return (
35
+ <div className="grid gap-2">
36
+ <Label htmlFor="password">Password</Label>
37
+ <div className="relative">
38
+ <Input
39
+ aria-describedby={isSignUp ? "password-hint" : undefined}
40
+ autoComplete={isSignUp ? "new-password" : "current-password"}
41
+ className="h-11 pr-11"
42
+ id="password"
43
+ maxLength={128}
44
+ minLength={8}
45
+ name="password"
46
+ placeholder={isSignUp ? "Create a password" : "Enter your password"}
47
+ // readOnly rather than disabled: disabled fields are left out of the submitted form.
48
+ readOnly={pending}
49
+ required
50
+ type={visible ? "text" : "password"}
51
+ />
52
+ <button
53
+ aria-label={visible ? "Hide password" : "Show password"}
54
+ aria-pressed={visible}
55
+ className="absolute inset-y-0 right-0 grid w-11 place-items-center rounded-r-md text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
56
+ onClick={() => setVisible((v) => !v)}
57
+ type="button"
58
+ >
59
+ {visible ? (
60
+ <EyeOff aria-hidden="true" className="size-4" />
61
+ ) : (
62
+ <Eye aria-hidden="true" className="size-4" />
63
+ )}
64
+ </button>
65
+ </div>
66
+ {isSignUp && (
67
+ <p className="text-xs text-muted-foreground" id="password-hint">
68
+ Use at least 8 characters.
69
+ </p>
70
+ )}
71
+ </div>
72
+ );
73
+ }
74
+
75
+ export function EmailField() {
76
+ const pending = useContext(PendingContext);
77
+
78
+ return (
79
+ <div className="grid gap-2">
80
+ <Label htmlFor="email">Email</Label>
81
+ <Input
82
+ autoComplete="email"
83
+ autoFocus
84
+ className="h-11"
85
+ id="email"
86
+ name="email"
87
+ placeholder="you@example.com"
88
+ readOnly={pending}
89
+ required
90
+ type="email"
91
+ />
92
+ </div>
93
+ );
94
+ }
95
+
96
+ export function SubmitButton({ children, disabled }: { children: ReactNode; disabled?: boolean }) {
97
+ const pending = useContext(PendingContext);
98
+
99
+ return (
100
+ <Button className="h-11 w-full justify-center" disabled={disabled || pending} type="submit">
101
+ {pending ? (
102
+ <Loader2 aria-hidden="true" className="animate-spin motion-reduce:animate-none" />
103
+ ) : null}
104
+ {children}
105
+ {!pending && <ArrowRight aria-hidden="true" />}
106
+ </Button>
107
+ );
108
+ }
@@ -0,0 +1,24 @@
1
+ type ClubedgeMarkProps = {
2
+ alt?: string;
3
+ className?: string;
4
+ priority?: boolean;
5
+ };
6
+
7
+ export function ClubedgeMark({
8
+ alt = "Clubedge",
9
+ className = "size-10",
10
+ priority = false,
11
+ }: ClubedgeMarkProps) {
12
+ return (
13
+ <img
14
+ alt={alt}
15
+ className={className}
16
+ decoding="async"
17
+ fetchPriority={priority ? "high" : "auto"}
18
+ height={512}
19
+ loading={priority ? "eager" : "lazy"}
20
+ src="/.well-known/logo.svg"
21
+ width={512}
22
+ />
23
+ );
24
+ }
@@ -0,0 +1,33 @@
1
+ import { Link } from "@tanstack/react-router";
2
+
3
+ import type { AuthUser } from "@clubedge/auth";
4
+ import { Button } from "@clubedge/ui/components/button";
5
+
6
+ type AccountButtonProps = {
7
+ user: AuthUser | null;
8
+ className?: string;
9
+ };
10
+
11
+ export function AccountButton({ user, className }: AccountButtonProps) {
12
+ if (!user) {
13
+ return (
14
+ <Button className={className} render={<Link to="/login" />} size="sm" variant="outline">
15
+ Sign in
16
+ </Button>
17
+ );
18
+ }
19
+
20
+ return (
21
+ <form action="/auth/sign-out" className={className} method="post">
22
+ <Button
23
+ className="w-full"
24
+ size="sm"
25
+ title={user.email ?? undefined}
26
+ type="submit"
27
+ variant="outline"
28
+ >
29
+ Sign out
30
+ </Button>
31
+ </form>
32
+ );
33
+ }
@@ -0,0 +1,90 @@
1
+ import { Activity, ArrowUpRight, Blocks, Github, LayoutDashboard } from "lucide-react";
2
+
3
+ import { ClubedgeMark } from "@/components/branding/clubedge-mark";
4
+ import { siteConfig } from "@/config/site";
5
+ import type { AuthUser } from "@clubedge/auth";
6
+ import {
7
+ Sidebar,
8
+ SidebarContent,
9
+ SidebarFooter,
10
+ SidebarGroup,
11
+ SidebarGroupLabel,
12
+ SidebarHeader,
13
+ SidebarMenu,
14
+ SidebarMenuButton,
15
+ SidebarMenuItem,
16
+ SidebarSeparator,
17
+ } from "@clubedge/ui/components/sidebar";
18
+
19
+ import { AccountButton } from "./account-button";
20
+
21
+ const navigation = [
22
+ { href: "#overview", label: "Overview", icon: LayoutDashboard },
23
+ { href: "#modules", label: "Modules", icon: Blocks },
24
+ { href: "#activity", label: "Setup status", icon: Activity },
25
+ ];
26
+
27
+ export function AppSidebar({ user }: { user: AuthUser | null }) {
28
+ return (
29
+ <Sidebar collapsible="icon" variant="inset">
30
+ <SidebarHeader>
31
+ <SidebarMenu>
32
+ <SidebarMenuItem>
33
+ <SidebarMenuButton
34
+ className="h-12"
35
+ render={<a href="#overview" aria-label={`${siteConfig.name} home`} />}
36
+ size="lg"
37
+ tooltip={siteConfig.name}
38
+ >
39
+ <ClubedgeMark alt="" className="size-8 shrink-0 rounded-lg" />
40
+ <span className="grid min-w-0 text-start leading-tight">
41
+ <span className="truncate font-semibold">{siteConfig.shortName}</span>
42
+ <span className="truncate text-xs text-sidebar-foreground/70">
43
+ {siteConfig.workspaceLabel}
44
+ </span>
45
+ </span>
46
+ </SidebarMenuButton>
47
+ </SidebarMenuItem>
48
+ </SidebarMenu>
49
+ </SidebarHeader>
50
+
51
+ <SidebarSeparator />
52
+
53
+ <SidebarContent>
54
+ <SidebarGroup>
55
+ <SidebarGroupLabel>Workspace</SidebarGroupLabel>
56
+ <SidebarMenu>
57
+ {navigation.map(({ href, label, icon: Icon }, index) => (
58
+ <SidebarMenuItem key={href}>
59
+ <SidebarMenuButton
60
+ render={<a href={href} aria-current={index === 0 ? "page" : undefined} />}
61
+ isActive={index === 0}
62
+ tooltip={label}
63
+ >
64
+ <Icon aria-hidden="true" />
65
+ <span>{label}</span>
66
+ </SidebarMenuButton>
67
+ </SidebarMenuItem>
68
+ ))}
69
+ </SidebarMenu>
70
+ </SidebarGroup>
71
+ </SidebarContent>
72
+
73
+ <SidebarFooter>
74
+ <SidebarMenu>
75
+ <SidebarMenuItem>
76
+ <SidebarMenuButton
77
+ render={<a href={siteConfig.links.repository} target="_blank" rel="noreferrer" />}
78
+ tooltip="Starter repository"
79
+ >
80
+ <Github aria-hidden="true" />
81
+ <span>Starter repository</span>
82
+ <ArrowUpRight aria-hidden="true" className="ms-auto" />
83
+ </SidebarMenuButton>
84
+ </SidebarMenuItem>
85
+ </SidebarMenu>
86
+ <AccountButton className="w-full" user={user} />
87
+ </SidebarFooter>
88
+ </Sidebar>
89
+ );
90
+ }
@@ -0,0 +1,46 @@
1
+ import { Check, Copy } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+
4
+ export function CopyCommand({ command }: { command: string }) {
5
+ const [copied, setCopied] = useState(false);
6
+
7
+ useEffect(() => {
8
+ if (!copied) return;
9
+ const timer = setTimeout(() => setCopied(false), 2000);
10
+ return () => clearTimeout(timer);
11
+ }, [copied]);
12
+
13
+ async function handleCopy() {
14
+ try {
15
+ await navigator.clipboard.writeText(command);
16
+ setCopied(true);
17
+ } catch {
18
+ // Clipboard can be unavailable (insecure context, denied permission).
19
+ // The command stays selectable, so we fail silently.
20
+ }
21
+ }
22
+
23
+ return (
24
+ <div className="flex items-center gap-2 rounded-xl border bg-card py-2 pl-4 pr-2 shadow-sm">
25
+ <code className="min-w-0 flex-1 overflow-x-auto whitespace-nowrap py-1.5 font-mono text-sm text-foreground">
26
+ <span aria-hidden="true" className="select-none text-muted-foreground">
27
+ ${" "}
28
+ </span>
29
+ {command}
30
+ </code>
31
+ <button
32
+ aria-label={copied ? "Command copied" : "Copy command"}
33
+ 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"
34
+ onClick={handleCopy}
35
+ type="button"
36
+ >
37
+ {copied ? (
38
+ <Check aria-hidden="true" className="size-4 text-primary" />
39
+ ) : (
40
+ <Copy aria-hidden="true" className="size-4" />
41
+ )}
42
+ <span aria-live="polite">{copied ? "Copied" : "Copy"}</span>
43
+ </button>
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,41 @@
1
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
2
+
3
+ type Theme = "light" | "dark";
4
+
5
+ type ThemeContextValue = {
6
+ theme: Theme;
7
+ setTheme: (theme: Theme) => void;
8
+ };
9
+
10
+ const ThemeContext = createContext<ThemeContextValue | null>(null);
11
+
12
+ export function ThemeProvider({ children }: { children: React.ReactNode }) {
13
+ const [theme, setThemeState] = useState<Theme>("light");
14
+
15
+ useEffect(() => {
16
+ setThemeState(document.documentElement.classList.contains("dark") ? "dark" : "light");
17
+ }, []);
18
+
19
+ const setTheme = useCallback((nextTheme: Theme) => {
20
+ document.documentElement.classList.toggle("dark", nextTheme === "dark");
21
+ document.documentElement.style.colorScheme = nextTheme;
22
+ try {
23
+ window.localStorage.setItem("clubedge-theme", nextTheme);
24
+ } catch {
25
+ // Keep the in-memory theme usable when browser storage is unavailable.
26
+ }
27
+ setThemeState(nextTheme);
28
+ }, []);
29
+
30
+ const value = useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
31
+
32
+ return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
33
+ }
34
+
35
+ export function useTheme() {
36
+ const context = useContext(ThemeContext);
37
+ if (!context) {
38
+ throw new Error("useTheme must be used within ThemeProvider.");
39
+ }
40
+ return context;
41
+ }
@@ -0,0 +1,21 @@
1
+ import { Moon, Sun } from "lucide-react";
2
+
3
+ import { Switch } from "@clubedge/ui/components/switch";
4
+ import { useTheme } from "./theme-provider";
5
+
6
+ export function ThemeToggle() {
7
+ const { theme, setTheme } = useTheme();
8
+
9
+ return (
10
+ <div className="flex items-center gap-2">
11
+ <Sun aria-hidden="true" className="size-4 text-muted-foreground" />
12
+ <Switch
13
+ aria-label="Toggle dark theme"
14
+ checked={theme === "dark"}
15
+ onCheckedChange={(checked) => setTheme(checked ? "dark" : "light")}
16
+ />
17
+ <Moon aria-hidden="true" className="size-4 text-muted-foreground" />
18
+ <span className="sr-only">Theme: {theme}</span>
19
+ </div>
20
+ );
21
+ }
@@ -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;