@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.
- package/CHANGELOG.md +8 -0
- package/README.md +29 -13
- package/dist/cli/args.js +5 -0
- package/dist/cli/ui.js +2 -0
- package/dist/core/customize.js +40 -1
- package/dist/core/manifest.js +41 -3
- package/dist/core/plan.js +68 -3
- package/dist/index.js +42 -7
- package/dist/utils/fs.js +3 -0
- package/package.json +5 -4
- package/template/.dockerignore +3 -0
- package/template/.github/workflows/ci.yml +51 -4
- package/template/.prettierignore +1 -0
- package/template/AGENTS.md +3 -2
- package/template/CONTRIBUTING.md +2 -2
- package/template/README.md +18 -5
- package/template/SETUP.md +23 -1
- package/template/_gitignore +4 -0
- package/template/apps/start/.env.example +27 -0
- package/template/apps/start/AGENTS.md +11 -0
- package/template/apps/start/Dockerfile +38 -0
- package/template/apps/start/package.json +37 -0
- package/template/apps/start/public/.well-known/logo.svg +1 -0
- package/template/apps/start/public/favicon.ico +0 -0
- package/template/apps/start/src/components/auth/auth-form-controls.tsx +108 -0
- package/template/apps/start/src/components/branding/clubedge-mark.tsx +24 -0
- package/template/apps/start/src/components/dashboard/account-button.tsx +33 -0
- package/template/apps/start/src/components/dashboard/app-sidebar.tsx +90 -0
- package/template/apps/start/src/components/landing/copy-command.tsx +46 -0
- package/template/apps/start/src/components/theme/theme-provider.tsx +41 -0
- package/template/apps/start/src/components/theme/theme-toggle.tsx +21 -0
- package/template/apps/start/src/config/site.json +11 -0
- package/template/apps/start/src/config/site.ts +20 -0
- package/template/apps/start/src/env/server.test.ts +48 -0
- package/template/apps/start/src/env/server.ts +67 -0
- package/template/apps/start/src/lib/login-url.test.ts +16 -0
- package/template/apps/start/src/lib/login-url.ts +16 -0
- package/template/apps/start/src/routeTree.gen.ts +220 -0
- package/template/apps/start/src/router.tsx +27 -0
- package/template/apps/start/src/routes/__root.tsx +59 -0
- package/template/apps/start/src/routes/api/health.ts +15 -0
- package/template/apps/start/src/routes/auth/callback.ts +10 -0
- package/template/apps/start/src/routes/auth/sign-in.ts +11 -0
- package/template/apps/start/src/routes/auth/sign-out.ts +11 -0
- package/template/apps/start/src/routes/auth/sign-up.ts +11 -0
- package/template/apps/start/src/routes/dashboard.tsx +351 -0
- package/template/apps/start/src/routes/index.tsx +233 -0
- package/template/apps/start/src/routes/login.tsx +164 -0
- package/template/apps/start/src/server/auth-forms.test.ts +169 -0
- package/template/apps/start/src/server/auth-forms.ts +118 -0
- package/template/apps/start/src/server/auth.functions.ts +27 -0
- package/template/apps/start/src/server/auth.ts +51 -0
- package/template/apps/start/src/server/cache.ts +38 -0
- package/template/apps/start/src/server/cookies.test.ts +41 -0
- package/template/apps/start/src/server/cookies.ts +31 -0
- package/template/apps/start/src/server/db.ts +16 -0
- package/template/apps/start/src/server/storage.ts +39 -0
- package/template/apps/start/src/server/users.ts +17 -0
- package/template/apps/start/src/start.ts +40 -0
- package/template/apps/start/src/styles.css +2 -0
- package/template/apps/start/tsconfig.json +19 -0
- package/template/apps/start/vite.config.ts +27 -0
- package/template/apps/web/package.json +2 -2
- package/template/{scripts → apps/web/scripts}/start-standalone.mjs +1 -2
- package/template/clubedge.template.json +17 -1
- package/template/e2e/app.ts +12 -0
- package/template/{apps/web/e2e → e2e}/auth.spec.ts +2 -2
- package/template/eslint.config.mjs +4 -0
- package/template/package.json +3 -3
- package/template/packages/auth/package.json +1 -1
- package/template/packages/cache/package.json +1 -1
- package/template/packages/core/package.json +1 -1
- package/template/packages/db/package.json +1 -1
- package/template/packages/storage/package.json +1 -1
- package/template/packages/ui/package.json +1 -1
- package/template/playwright.config.ts +6 -2
- package/template/pnpm-lock.yaml +1382 -19
- package/template/turbo.json +1 -1
- package/template/vitest.config.ts +2 -0
- package/template.lock.json +50 -5
- /package/template/{apps/web/e2e → e2e}/home.spec.ts +0 -0
package/template/README.md
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
[](https://github.com/Clubedge/clubedge-starter/actions/workflows/ci.yml)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
|
|
6
|
-
Clubedge Starter is a production-oriented, modular Next.js
|
|
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
|
|
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.** `
|
|
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
|
|
package/template/_gitignore
CHANGED
|
@@ -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>
|
|
Binary file
|
|
@@ -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;
|