create-zerotal 1.0.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 +17 -0
- package/LICENSE +21 -0
- package/README.md +58 -0
- package/package.json +43 -0
- package/src/index.ts +136 -0
- package/src/prompts.ts +81 -0
- package/src/scaffold.ts +104 -0
- package/templates/admin/_env.example +2 -0
- package/templates/admin/_gitignore +4 -0
- package/templates/admin/app/admin/ProductResource.ts +192 -0
- package/templates/admin/app/admin/SettingsResource.ts +37 -0
- package/templates/admin/app/admin/UserResource.ts +103 -0
- package/templates/admin/app/admin/index.ts +46 -0
- package/templates/admin/app/admin/shared.ts +36 -0
- package/templates/admin/app/admin/widgets.ts +51 -0
- package/templates/admin/app/models/Product.ts +21 -0
- package/templates/admin/app/models/Setting.ts +14 -0
- package/templates/admin/app/models/User.ts +20 -0
- package/templates/admin/bootstrap/app.ts +16 -0
- package/templates/admin/bootstrap/providers.ts +23 -0
- package/templates/admin/config/app.ts +22 -0
- package/templates/admin/config/auth.ts +5 -0
- package/templates/admin/config/cache.ts +7 -0
- package/templates/admin/config/database.ts +14 -0
- package/templates/admin/config/session.ts +10 -0
- package/templates/admin/database/seeders/DatabaseSeeder.ts +50 -0
- package/templates/admin/package.json +18 -0
- package/templates/admin/tests/admin_test.ts +58 -0
- package/templates/admin/tsconfig.json +20 -0
- package/templates/admin/zt.ts +15 -0
- package/templates/api/_env.example +4 -0
- package/templates/api/_gitignore +5 -0
- package/templates/api/app/controllers/AuthController.ts +69 -0
- package/templates/api/app/controllers/WelcomeController.ts +10 -0
- package/templates/api/app/middleware/RequireAuth.ts +10 -0
- package/templates/api/app/models/User.ts +16 -0
- package/templates/api/bootstrap/app.ts +19 -0
- package/templates/api/config/app.ts +8 -0
- package/templates/api/config/database.ts +6 -0
- package/templates/api/config/notifications.ts +13 -0
- package/templates/api/config/queue.ts +6 -0
- package/templates/api/config/session.ts +10 -0
- package/templates/api/database/migrations/0001_create_users_table.ts +18 -0
- package/templates/api/package.json +16 -0
- package/templates/api/routes/index.ts +13 -0
- package/templates/api/tests/README.md +77 -0
- package/templates/api/tests/auth.test.ts +134 -0
- package/templates/api/tests/helpers.ts +36 -0
- package/templates/api/tsconfig.json +14 -0
- package/templates/api/zt.ts +16 -0
- package/templates/flow/_env.example +2 -0
- package/templates/flow/_gitignore +6 -0
- package/templates/flow/app/flow/layouts/app.tsx +52 -0
- package/templates/flow/app/flow/pages/about.tsx +35 -0
- package/templates/flow/app/flow/pages/contact.tsx +47 -0
- package/templates/flow/app/flow/pages/index.tsx +48 -0
- package/templates/flow/bootstrap/app.ts +8 -0
- package/templates/flow/bootstrap/providers.ts +6 -0
- package/templates/flow/config/app.ts +28 -0
- package/templates/flow/package.json +20 -0
- package/templates/flow/public/zt.svg +17 -0
- package/templates/flow/resources/css/app.css +1 -0
- package/templates/flow/resources/js/app.ts +10 -0
- package/templates/flow/resources/js/env.d.ts +23 -0
- package/templates/flow/tests/smoke.test.ts +56 -0
- package/templates/flow/tsconfig.json +18 -0
- package/templates/flow/zt.ts +15 -0
- package/templates/minimal/_env.example +2 -0
- package/templates/minimal/_gitignore +6 -0
- package/templates/minimal/bootstrap/app.ts +9 -0
- package/templates/minimal/config/app.ts +28 -0
- package/templates/minimal/package.json +20 -0
- package/templates/minimal/resources/css/app.css +1 -0
- package/templates/minimal/resources/js/app.ts +2 -0
- package/templates/minimal/resources/js/components/Logo.tsx +48 -0
- package/templates/minimal/resources/js/env.d.ts +13 -0
- package/templates/minimal/resources/js/layouts/AppLayout.tsx +56 -0
- package/templates/minimal/resources/js/pages/about.tsx +30 -0
- package/templates/minimal/resources/js/pages/contact.tsx +49 -0
- package/templates/minimal/resources/js/pages/welcome.tsx +42 -0
- package/templates/minimal/routes/index.ts +16 -0
- package/templates/minimal/tests/smoke.test.ts +51 -0
- package/templates/minimal/tsconfig.json +19 -0
- package/templates/minimal/zt.ts +15 -0
- package/templates/react/_env.example +4 -0
- package/templates/react/_gitignore +5 -0
- package/templates/react/app/exceptions/Handler.ts +55 -0
- package/templates/react/app/routes/about.ts +8 -0
- package/templates/react/app/routes/contact.ts +31 -0
- package/templates/react/app/routes/index.ts +11 -0
- package/templates/react/bootstrap/app.ts +12 -0
- package/templates/react/bootstrap/providers.ts +10 -0
- package/templates/react/config/app.ts +25 -0
- package/templates/react/config/inertia.ts +7 -0
- package/templates/react/config/session.ts +11 -0
- package/templates/react/package.json +26 -0
- package/templates/react/public/zt.svg +17 -0
- package/templates/react/resources/app.html +39 -0
- package/templates/react/resources/css/app.css +206 -0
- package/templates/react/resources/js/Components/Button.tsx +67 -0
- package/templates/react/resources/js/Components/Card.tsx +40 -0
- package/templates/react/resources/js/Components/Code.tsx +33 -0
- package/templates/react/resources/js/Components/Field.tsx +125 -0
- package/templates/react/resources/js/Components/FlashToasts.tsx +79 -0
- package/templates/react/resources/js/Components/Icons.tsx +149 -0
- package/templates/react/resources/js/Components/ThemeToggle.tsx +47 -0
- package/templates/react/resources/js/Layouts/AppLayout.tsx +163 -0
- package/templates/react/resources/js/app.tsx +29 -0
- package/templates/react/resources/js/env.d.ts +13 -0
- package/templates/react/resources/js/lib/cn.ts +13 -0
- package/templates/react/resources/js/lib/site.ts +11 -0
- package/templates/react/resources/js/pages/about.tsx +146 -0
- package/templates/react/resources/js/pages/contact.tsx +152 -0
- package/templates/react/resources/js/pages/error.tsx +91 -0
- package/templates/react/resources/js/pages/home.tsx +199 -0
- package/templates/react/resources/js/pages.generated.ts +13 -0
- package/templates/react/resources/js/types.ts +17 -0
- package/templates/react/tests/smoke.test.ts +69 -0
- package/templates/react/tsconfig.json +21 -0
- package/templates/react/zt.ts +15 -0
- package/templates/vue/_env.example +4 -0
- package/templates/vue/_gitignore +5 -0
- package/templates/vue/app/routes/about.ts +7 -0
- package/templates/vue/app/routes/contact.ts +7 -0
- package/templates/vue/app/routes/index.ts +8 -0
- package/templates/vue/bootstrap/app.ts +8 -0
- package/templates/vue/bootstrap/providers.ts +6 -0
- package/templates/vue/config/app.ts +25 -0
- package/templates/vue/config/inertia.ts +7 -0
- package/templates/vue/package.json +24 -0
- package/templates/vue/resources/app.html +13 -0
- package/templates/vue/resources/css/app.css +1 -0
- package/templates/vue/resources/js/Layouts/AppLayout.vue +63 -0
- package/templates/vue/resources/js/app.tsx +19 -0
- package/templates/vue/resources/js/env.d.ts +20 -0
- package/templates/vue/resources/js/pages/about.vue +41 -0
- package/templates/vue/resources/js/pages/contact.vue +54 -0
- package/templates/vue/resources/js/pages/home.vue +54 -0
- package/templates/vue/resources/js/pages.generated.ts +12 -0
- package/templates/vue/tests/smoke.test.ts +46 -0
- package/templates/vue/tsconfig.json +21 -0
- package/templates/vue/zt.ts +15 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The scaffold boots and serves its pages.
|
|
3
|
+
*
|
|
4
|
+
* A template ships with a `test` script, so it ships with something for that
|
|
5
|
+
* script to run — and this is the assertion that catches a broken starter:
|
|
6
|
+
* providers register, routes resolve, and a request completes. Delete it once
|
|
7
|
+
* you have real tests, or keep it as the shape to copy.
|
|
8
|
+
*/
|
|
9
|
+
import { beforeAll, afterAll, describe, test, expect } from 'bun:test';
|
|
10
|
+
import { createTestApp, type TestApp } from '@zerotal/testing';
|
|
11
|
+
|
|
12
|
+
let app: TestApp;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
app = await createTestApp(() => import('../bootstrap/app.ts').then((m) => m.default));
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
afterAll(() => app.close());
|
|
19
|
+
|
|
20
|
+
describe('scaffold', () => {
|
|
21
|
+
test('boots and renders the welcome page', async () => {
|
|
22
|
+
const res = await app.get('/');
|
|
23
|
+
|
|
24
|
+
res.assertOk();
|
|
25
|
+
// assertSeeText ignores the markup between the words, so a class change or a
|
|
26
|
+
// wrapping element does not fail a test about what the page says.
|
|
27
|
+
res.assertSeeText('Build server-driven apps with Zerotal');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('serves the other views', async () => {
|
|
31
|
+
(await app.get('/about')).assertOk();
|
|
32
|
+
(await app.get('/contact')).assertOk();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test('serves the JSON routes', async () => {
|
|
36
|
+
const res = await app.get('/api');
|
|
37
|
+
res.assertOk().assertJson({ message: 'Hello, API!' });
|
|
38
|
+
|
|
39
|
+
const withParam = await app.get('/api/42');
|
|
40
|
+
withParam.assertJsonPath('id', '42');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('answers HEAD on the home route, as probes and load balancers do', async () => {
|
|
44
|
+
const res = await app.head('/');
|
|
45
|
+
expect(res.status).toBeLessThan(400);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('returns 404 for an unregistered path', async () => {
|
|
49
|
+
(await app.get('/definitely-not-a-route')).assertNotFound();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"exactOptionalPropertyTypes": true,
|
|
8
|
+
"noUncheckedIndexedAccess": true,
|
|
9
|
+
"emitDecoratorMetadata": false,
|
|
10
|
+
"jsx": "react-jsx",
|
|
11
|
+
"jsxImportSource": "zerotal",
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"paths": {
|
|
14
|
+
"@app/*": ["./app/*"],
|
|
15
|
+
"@pages/*": ["./resources/js/pages/*"]
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"exclude": ["node_modules", ".zerotal"]
|
|
19
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// zt.ts — Zerotal CLI entry point.
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────
|
|
4
|
+
// Managed by the framework. All orchestration lives in @zerotal/core's
|
|
5
|
+
// startZerotal(); this file just hands it the app's bootstrap module.
|
|
6
|
+
//
|
|
7
|
+
// Run commands with: bun zt <command>
|
|
8
|
+
// bun zt serve [--port=3000] [--dev] Start the HTTP server
|
|
9
|
+
// bun zt worker Start the background worker
|
|
10
|
+
// bun zt test Run tests with the app booted
|
|
11
|
+
// bun zt list Show all available commands
|
|
12
|
+
// ─────────────────────────────────────────────────────────────────
|
|
13
|
+
import { startZerotal } from "zerotal";
|
|
14
|
+
|
|
15
|
+
await startZerotal(() => import("./bootstrap/app"));
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ExceptionHandler,
|
|
3
|
+
ZerotalError,
|
|
4
|
+
devSurfacesEnabled,
|
|
5
|
+
type HttpContext,
|
|
6
|
+
} from "zerotal";
|
|
7
|
+
import { inertia } from "@zerotal/inertia";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Renders HTTP errors as a page inside the app's own shell.
|
|
11
|
+
*
|
|
12
|
+
* Registered in bootstrap/app.ts. Without it, a mistyped URL lands on the
|
|
13
|
+
* framework's plain error page — correct, but jarring after the rest of the app.
|
|
14
|
+
* This hands the status to resources/js/pages/error.tsx instead.
|
|
15
|
+
*
|
|
16
|
+
* Three cases deliberately fall through to `super.render()`:
|
|
17
|
+
*
|
|
18
|
+
* - **A 5xx in development.** The framework's stack-trace page is far more
|
|
19
|
+
* useful than a tidy "something broke", so it keeps priority while dev
|
|
20
|
+
* surfaces are on. Client errors (404, 403, …) always render the app's page,
|
|
21
|
+
* including in development — otherwise you could never see your own.
|
|
22
|
+
* - **Inertia XHR.** A client-side visit expects the adapter's own error
|
|
23
|
+
* handling; substituting a page object here would break it.
|
|
24
|
+
* - **API clients.** Anything asking for JSON keeps getting JSON.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Statuses this app renders as a page; everything else uses the default. */
|
|
28
|
+
const RENDERED = new Set([403, 404, 419, 429, 500, 503]);
|
|
29
|
+
|
|
30
|
+
function statusOf(error: unknown): number {
|
|
31
|
+
return error instanceof ZerotalError ? error.status : 500;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class Handler extends ExceptionHandler {
|
|
35
|
+
override async render(error: unknown, ctx: HttpContext): Promise<Response> {
|
|
36
|
+
const status = statusOf(error);
|
|
37
|
+
const wantsHtml = (ctx.request.headers.get("Accept") ?? "").includes("text/html");
|
|
38
|
+
const isInertiaVisit = ctx.request.headers.get("X-Inertia") === "true";
|
|
39
|
+
|
|
40
|
+
if (isInertiaVisit || !wantsHtml || !RENDERED.has(status)) {
|
|
41
|
+
return super.render(error, ctx);
|
|
42
|
+
}
|
|
43
|
+
if (status >= 500 && devSurfacesEnabled()) {
|
|
44
|
+
return super.render(error, ctx);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `inertia()` writes the HTML onto the context rather than returning it, so
|
|
48
|
+
// re-wrap the body to carry the real status instead of a 200.
|
|
49
|
+
await inertia("error", { status });
|
|
50
|
+
const rendered = ctx.response;
|
|
51
|
+
if (!rendered) return super.render(error, ctx);
|
|
52
|
+
|
|
53
|
+
return new Response(rendered.body, { status, headers: rendered.headers });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { HttpContext } from "zerotal";
|
|
2
|
+
import { Inertia } from "@zerotal/inertia";
|
|
3
|
+
import { validate } from "zerotal/validator";
|
|
4
|
+
|
|
5
|
+
// GET /contact — render the form. Errors and old input from a failed POST arrive
|
|
6
|
+
// on their own through Inertia's shared `errors` / `old` props; this route does
|
|
7
|
+
// not have to pass them.
|
|
8
|
+
export const GET = async () => {
|
|
9
|
+
return Inertia.render("contact", {
|
|
10
|
+
title: "Get in touch",
|
|
11
|
+
});
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// POST /contact — validate, then redirect. The redirect is the important part:
|
|
15
|
+
// answering a POST with a redirect is what keeps the browser's back button and a
|
|
16
|
+
// page refresh from re-submitting the form.
|
|
17
|
+
export async function POST(http: HttpContext): Promise<void> {
|
|
18
|
+
// On failure this throws — the framework turns it into a 303 back to /contact
|
|
19
|
+
// with the messages in `errors` and the submitted values in `old`. Nothing
|
|
20
|
+
// below this line runs unless the input is valid, and `name` is a string.
|
|
21
|
+
const { name } = await validate(http, (r) => ({
|
|
22
|
+
name: r.string().trim().min(2).max(80),
|
|
23
|
+
email: r.string().trim().email(),
|
|
24
|
+
message: r.string().trim().min(10).max(2000),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
// Nothing is stored — this is where you would queue a job, send a notification
|
|
28
|
+
// or write a row. Delete the flash and put your own work here.
|
|
29
|
+
http.flash("success", `Thanks ${name}, your message came through.`);
|
|
30
|
+
http.redirect("/contact", 303);
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Inertia } from "@zerotal/inertia";
|
|
2
|
+
|
|
3
|
+
// GET / — the landing page. Whatever this route passes as the second argument
|
|
4
|
+
// arrives in resources/js/pages/home.tsx as React props.
|
|
5
|
+
export const GET = async () => {
|
|
6
|
+
return Inertia.render("home", {
|
|
7
|
+
title: "Everything in one TypeScript app",
|
|
8
|
+
message:
|
|
9
|
+
"Routes, pages, validation and data access share one language and one process — served by Bun.",
|
|
10
|
+
});
|
|
11
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Application, basePath } from "zerotal";
|
|
2
|
+
import providers from "./providers";
|
|
3
|
+
import { Handler } from "../app/exceptions/Handler";
|
|
4
|
+
|
|
5
|
+
const app = Application.create({ providers })
|
|
6
|
+
.fileBasedRouting({
|
|
7
|
+
web: basePath("app/routes"),
|
|
8
|
+
})
|
|
9
|
+
// Renders 404s and other HTTP errors through resources/js/pages/error.tsx.
|
|
10
|
+
.withExceptionHandler(Handler);
|
|
11
|
+
|
|
12
|
+
export default app;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { LogProvider } from "zerotal/logger";
|
|
2
|
+
import { SessionProvider } from "zerotal/session";
|
|
3
|
+
import { InertiaProvider } from "@zerotal/inertia";
|
|
4
|
+
|
|
5
|
+
// Order matters. Sessions come before Inertia because Inertia's shared props read
|
|
6
|
+
// the flash, error and old-input bags out of the session on every render — that is
|
|
7
|
+
// what carries a failed `validate()` back to the form it came from.
|
|
8
|
+
const providers = [LogProvider, SessionProvider, InertiaProvider];
|
|
9
|
+
|
|
10
|
+
export default providers;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { env, requireEnv } from "zerotal";
|
|
2
|
+
import { AppConfig } from "zerotal/config";
|
|
3
|
+
|
|
4
|
+
export default AppConfig({
|
|
5
|
+
name: "{{name}}",
|
|
6
|
+
url: env("APP_URL", "http://localhost:3000"),
|
|
7
|
+
/**
|
|
8
|
+
* The APP_KEY is used for encryption and should be set to a random 32
|
|
9
|
+
* character string. Use `bun zt key:generate` to generate a secure key.
|
|
10
|
+
*/
|
|
11
|
+
key: requireEnv("APP_KEY"),
|
|
12
|
+
|
|
13
|
+
cors: {
|
|
14
|
+
origin: env("CORS_ORIGIN", "*"),
|
|
15
|
+
credentials: false,
|
|
16
|
+
},
|
|
17
|
+
|
|
18
|
+
throttle: {
|
|
19
|
+
maxAttempts: 120,
|
|
20
|
+
windowSeconds: 60,
|
|
21
|
+
},
|
|
22
|
+
|
|
23
|
+
// Frontend assets are built by the Inertia pipeline (`bun zt inertia:build`),
|
|
24
|
+
// not the generic asset bundler — so no `assets` block is needed here.
|
|
25
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { env } from "zerotal";
|
|
2
|
+
import { SessionConfig } from "zerotal/session";
|
|
3
|
+
|
|
4
|
+
// Cookie-driver sessions, signed with APP_KEY. This is what carries flash
|
|
5
|
+
// messages and a failed validation's errors and old input from one request to
|
|
6
|
+
// the next. Switch `driver` to "redis" for a shared, server-side store.
|
|
7
|
+
export default SessionConfig({
|
|
8
|
+
secret: env("APP_KEY", ""),
|
|
9
|
+
cookie: "{{name}}_session",
|
|
10
|
+
lifetime: 60 * 60 * 24 * 7, // 7 days
|
|
11
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{name}}",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"zt": "bun zt.ts",
|
|
8
|
+
"dev": "bun zt.ts serve --dev",
|
|
9
|
+
"start": "bun zt.ts serve",
|
|
10
|
+
"build": "bun zt.ts inertia:build --production",
|
|
11
|
+
"test": "bun zt.ts test"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@inertiajs/react": "^2.0.0",
|
|
15
|
+
"@zerotal/inertia": "{{zerotal_version}}",
|
|
16
|
+
"react": "^19.0.0",
|
|
17
|
+
"react-dom": "^19.0.0",
|
|
18
|
+
"zerotal": "{{zerotal_version}}"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/react": "^19.0.0",
|
|
22
|
+
"@types/react-dom": "^19.0.0",
|
|
23
|
+
"bun-plugin-tailwind": "latest",
|
|
24
|
+
"tailwindcss": "^4.0.0"
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" role="img" aria-label="Zerotal">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="zt-grad" x1="0" y1="0" x2="64" y2="64" gradientUnits="userSpaceOnUse">
|
|
4
|
+
<stop stop-color="#818cf8" />
|
|
5
|
+
<stop offset="0.5" stop-color="#6366f1" />
|
|
6
|
+
<stop offset="1" stop-color="#7c3aed" />
|
|
7
|
+
</linearGradient>
|
|
8
|
+
<linearGradient id="zt-shine" x1="0" y1="0" x2="0" y2="64" gradientUnits="userSpaceOnUse">
|
|
9
|
+
<stop stop-color="#ffffff" stop-opacity="0.35" />
|
|
10
|
+
<stop offset="0.55" stop-color="#ffffff" stop-opacity="0" />
|
|
11
|
+
</linearGradient>
|
|
12
|
+
</defs>
|
|
13
|
+
<rect width="64" height="64" rx="18" fill="url(#zt-grad)" />
|
|
14
|
+
<rect width="64" height="64" rx="18" fill="url(#zt-shine)" />
|
|
15
|
+
<rect x="0.75" y="0.75" width="62.5" height="62.5" rx="17.25" stroke="#ffffff" stroke-opacity="0.18" stroke-width="1.5" />
|
|
16
|
+
<text x="32" y="41" text-anchor="middle" font-family="system-ui, -apple-system, Segoe UI, sans-serif" font-size="20" font-weight="800" letter-spacing="0.5" fill="#ffffff">ZT</text>
|
|
17
|
+
</svg>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>{{name}}</title>
|
|
7
|
+
|
|
8
|
+
<link rel="icon" href="/zt.svg" type="image/svg+xml" />
|
|
9
|
+
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
|
10
|
+
<meta name="theme-color" content="#191922" media="(prefers-color-scheme: dark)" />
|
|
11
|
+
|
|
12
|
+
<!--
|
|
13
|
+
Resolve the theme before the first paint. Without this the page renders in
|
|
14
|
+
light mode for a frame and then flips, which is very visible on a dark
|
|
15
|
+
background. Kept inline and synchronous on purpose — a deferred script is
|
|
16
|
+
already too late. The storage key matches the one @zerotal/flow-ui uses, so
|
|
17
|
+
an app and its admin panel stay on the same theme.
|
|
18
|
+
-->
|
|
19
|
+
<script>
|
|
20
|
+
(function () {
|
|
21
|
+
try {
|
|
22
|
+
var stored = localStorage.getItem("zerotal-theme");
|
|
23
|
+
var dark = stored
|
|
24
|
+
? stored === "dark"
|
|
25
|
+
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
26
|
+
document.documentElement.classList.toggle("dark", dark);
|
|
27
|
+
} catch (e) {
|
|
28
|
+
/* private mode / storage disabled — fall through to light */
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
</script>
|
|
32
|
+
|
|
33
|
+
<link rel="stylesheet" href="/assets/app.css" />
|
|
34
|
+
<script type="module" src="/assets/app.js"></script>
|
|
35
|
+
</head>
|
|
36
|
+
<body>
|
|
37
|
+
<!-- @inertia -->
|
|
38
|
+
</body>
|
|
39
|
+
</html>
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
/* Page components live outside this file's directory — name them explicitly so
|
|
4
|
+
* their utility classes survive Tailwind's content scan. */
|
|
5
|
+
@source "../js";
|
|
6
|
+
|
|
7
|
+
/* Dark mode is driven by a `.dark` class on <html>, not the OS preference, so the
|
|
8
|
+
* in-app toggle can override it. The no-flash script in resources/app.html sets
|
|
9
|
+
* the class before first paint. */
|
|
10
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
11
|
+
|
|
12
|
+
/* ── Design tokens ───────────────────────────────────────────────────────────
|
|
13
|
+
*
|
|
14
|
+
* Every colour in this app is one of the semantic tokens below — `bg-background`,
|
|
15
|
+
* `text-muted-foreground`, `border-border`, `bg-primary`. Nothing references a raw
|
|
16
|
+
* palette shade like `indigo-600`, which means re-branding is a single edit here
|
|
17
|
+
* rather than a find-and-replace across every component.
|
|
18
|
+
*
|
|
19
|
+
* The names match the tokens `@zerotal/flow-ui` defines, so an app, its admin
|
|
20
|
+
* panel and any copied-in flow-ui component all read from the same vocabulary.
|
|
21
|
+
*
|
|
22
|
+
* Colours are oklch: perceptually uniform, so lightening `--primary` by hand
|
|
23
|
+
* keeps its hue and saturation intact.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
:root {
|
|
27
|
+
color-scheme: light;
|
|
28
|
+
|
|
29
|
+
--radius: 0.75rem;
|
|
30
|
+
|
|
31
|
+
--background: oklch(0.994 0.003 286);
|
|
32
|
+
--foreground: oklch(0.185 0.015 286);
|
|
33
|
+
|
|
34
|
+
--card: oklch(1 0 0);
|
|
35
|
+
--card-foreground: oklch(0.185 0.015 286);
|
|
36
|
+
|
|
37
|
+
--muted: oklch(0.968 0.005 286);
|
|
38
|
+
--muted-foreground: oklch(0.53 0.016 286);
|
|
39
|
+
|
|
40
|
+
--primary: oklch(0.53 0.223 286);
|
|
41
|
+
--primary-foreground: oklch(0.99 0.004 286);
|
|
42
|
+
/* Explicit hover shade rather than an opacity fade, so a solid button stays
|
|
43
|
+
* solid over the grid background. */
|
|
44
|
+
--primary-hover: oklch(0.47 0.223 286);
|
|
45
|
+
|
|
46
|
+
--accent: oklch(0.958 0.018 286);
|
|
47
|
+
--accent-foreground: oklch(0.36 0.14 286);
|
|
48
|
+
|
|
49
|
+
--success: oklch(0.6 0.14 158);
|
|
50
|
+
--success-foreground: oklch(0.99 0.01 158);
|
|
51
|
+
|
|
52
|
+
--destructive: oklch(0.577 0.245 27.3);
|
|
53
|
+
--destructive-foreground: oklch(0.99 0.01 27.3);
|
|
54
|
+
|
|
55
|
+
--border: oklch(0.912 0.006 286);
|
|
56
|
+
--input: oklch(0.912 0.006 286);
|
|
57
|
+
--ring: oklch(0.53 0.223 286);
|
|
58
|
+
|
|
59
|
+
/* Decorative — the hero grid and the glow behind it. */
|
|
60
|
+
--grid-line: oklch(0.185 0.015 286 / 6%);
|
|
61
|
+
--glow-a: oklch(0.7 0.19 286 / 30%);
|
|
62
|
+
--glow-b: oklch(0.75 0.16 220 / 22%);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.dark {
|
|
66
|
+
color-scheme: dark;
|
|
67
|
+
|
|
68
|
+
--background: oklch(0.163 0.014 286);
|
|
69
|
+
--foreground: oklch(0.968 0.005 286);
|
|
70
|
+
|
|
71
|
+
--card: oklch(0.204 0.016 286);
|
|
72
|
+
--card-foreground: oklch(0.968 0.005 286);
|
|
73
|
+
|
|
74
|
+
--muted: oklch(0.253 0.016 286);
|
|
75
|
+
--muted-foreground: oklch(0.706 0.015 286);
|
|
76
|
+
|
|
77
|
+
--primary: oklch(0.71 0.17 288);
|
|
78
|
+
--primary-foreground: oklch(0.17 0.03 286);
|
|
79
|
+
--primary-hover: oklch(0.78 0.15 288);
|
|
80
|
+
|
|
81
|
+
--accent: oklch(0.28 0.04 286);
|
|
82
|
+
--accent-foreground: oklch(0.9 0.06 288);
|
|
83
|
+
|
|
84
|
+
--success: oklch(0.72 0.15 158);
|
|
85
|
+
--success-foreground: oklch(0.17 0.03 158);
|
|
86
|
+
|
|
87
|
+
--destructive: oklch(0.704 0.191 22.2);
|
|
88
|
+
--destructive-foreground: oklch(0.16 0.02 22.2);
|
|
89
|
+
|
|
90
|
+
--border: oklch(1 0 0 / 11%);
|
|
91
|
+
--input: oklch(1 0 0 / 15%);
|
|
92
|
+
--ring: oklch(0.71 0.17 288);
|
|
93
|
+
|
|
94
|
+
--grid-line: oklch(1 0 0 / 7%);
|
|
95
|
+
--glow-a: oklch(0.6 0.22 288 / 35%);
|
|
96
|
+
--glow-b: oklch(0.6 0.19 220 / 25%);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/* Bind the tokens to Tailwind utilities. `inline` keeps the utilities pointing at
|
|
100
|
+
* `var(--token)` rather than a snapshot, which is what lets `.dark` re-theme the
|
|
101
|
+
* whole page by redefining the variables. */
|
|
102
|
+
@theme inline {
|
|
103
|
+
--color-background: var(--background);
|
|
104
|
+
--color-foreground: var(--foreground);
|
|
105
|
+
--color-card: var(--card);
|
|
106
|
+
--color-card-foreground: var(--card-foreground);
|
|
107
|
+
--color-muted: var(--muted);
|
|
108
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
109
|
+
--color-primary: var(--primary);
|
|
110
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
111
|
+
--color-primary-hover: var(--primary-hover);
|
|
112
|
+
--color-accent: var(--accent);
|
|
113
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
114
|
+
--color-success: var(--success);
|
|
115
|
+
--color-success-foreground: var(--success-foreground);
|
|
116
|
+
--color-destructive: var(--destructive);
|
|
117
|
+
--color-destructive-foreground: var(--destructive-foreground);
|
|
118
|
+
--color-border: var(--border);
|
|
119
|
+
--color-input: var(--input);
|
|
120
|
+
--color-ring: var(--ring);
|
|
121
|
+
|
|
122
|
+
--radius-sm: calc(var(--radius) - 4px);
|
|
123
|
+
--radius-md: calc(var(--radius) - 2px);
|
|
124
|
+
--radius-lg: var(--radius);
|
|
125
|
+
--radius-xl: calc(var(--radius) + 4px);
|
|
126
|
+
--radius-2xl: calc(var(--radius) + 10px);
|
|
127
|
+
|
|
128
|
+
--font-sans:
|
|
129
|
+
ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
130
|
+
--font-mono:
|
|
131
|
+
ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, "Liberation Mono", monospace;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
@layer base {
|
|
135
|
+
html {
|
|
136
|
+
scroll-behavior: smooth;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
body {
|
|
140
|
+
background-color: var(--background);
|
|
141
|
+
color: var(--foreground);
|
|
142
|
+
-webkit-font-smoothing: antialiased;
|
|
143
|
+
text-rendering: optimizeLegibility;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/* One focus treatment for every interactive element, so a custom-styled button
|
|
147
|
+
* is as keyboard-navigable as a bare one. */
|
|
148
|
+
:focus-visible {
|
|
149
|
+
outline: 2px solid var(--ring);
|
|
150
|
+
outline-offset: 2px;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
::selection {
|
|
154
|
+
background-color: color-mix(in oklch, var(--primary) 24%, transparent);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
@media (prefers-reduced-motion: reduce) {
|
|
158
|
+
html {
|
|
159
|
+
scroll-behavior: auto;
|
|
160
|
+
}
|
|
161
|
+
*,
|
|
162
|
+
*::before,
|
|
163
|
+
*::after {
|
|
164
|
+
animation-duration: 0.01ms !important;
|
|
165
|
+
animation-iteration-count: 1 !important;
|
|
166
|
+
transition-duration: 0.01ms !important;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/* ── Decorative utilities ────────────────────────────────────────────────────
|
|
172
|
+
* Both are purely cosmetic and token-driven, so they follow the theme without a
|
|
173
|
+
* `dark:` variant anywhere. Delete them and the layout still holds. */
|
|
174
|
+
|
|
175
|
+
/** Faint graph-paper grid, faded out towards the bottom by a radial mask. */
|
|
176
|
+
@utility bg-grid {
|
|
177
|
+
background-image:
|
|
178
|
+
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
|
|
179
|
+
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
|
|
180
|
+
background-size: 64px 64px;
|
|
181
|
+
mask-image: radial-gradient(ellipse 75% 55% at 50% 0%, #000 30%, transparent 100%);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Soft two-tone aurora, drifting slowly. Sits behind the hero. */
|
|
185
|
+
@utility bg-aurora {
|
|
186
|
+
background-image:
|
|
187
|
+
radial-gradient(38rem 22rem at 22% 0%, var(--glow-a), transparent 70%),
|
|
188
|
+
radial-gradient(32rem 20rem at 78% 12%, var(--glow-b), transparent 70%);
|
|
189
|
+
animation: aurora-drift 22s ease-in-out infinite alternate;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
@keyframes aurora-drift {
|
|
193
|
+
from {
|
|
194
|
+
transform: translate3d(-1.5%, 0, 0) scale(1);
|
|
195
|
+
}
|
|
196
|
+
to {
|
|
197
|
+
transform: translate3d(1.5%, 1.5%, 0) scale(1.06);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Hairline card edge with an inner highlight — reads as depth without a shadow. */
|
|
202
|
+
@utility surface {
|
|
203
|
+
background-color: var(--card);
|
|
204
|
+
border: 1px solid var(--border);
|
|
205
|
+
box-shadow: inset 0 1px 0 0 oklch(1 0 0 / 6%);
|
|
206
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Link } from "@inertiajs/react";
|
|
2
|
+
import type { ComponentProps } from "react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The app's button styles, in one place.
|
|
7
|
+
*
|
|
8
|
+
* `Button` renders a `<button>`; `ButtonLink` renders an Inertia `<Link>` with
|
|
9
|
+
* identical styling, so a nav action and a form submit can't drift apart. Both
|
|
10
|
+
* are built from {@link buttonClass}, which you can also apply to a bare `<a>`
|
|
11
|
+
* for an external link.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export type ButtonVariant = "primary" | "secondary" | "ghost";
|
|
15
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
16
|
+
|
|
17
|
+
const BASE =
|
|
18
|
+
"inline-flex items-center justify-center gap-2 rounded-lg font-medium whitespace-nowrap " +
|
|
19
|
+
"transition-[background-color,border-color,color,transform] duration-150 " +
|
|
20
|
+
"active:translate-y-px disabled:pointer-events-none disabled:opacity-55";
|
|
21
|
+
|
|
22
|
+
const VARIANTS: Record<ButtonVariant, string> = {
|
|
23
|
+
primary: "bg-primary text-primary-foreground shadow-sm hover:bg-primary-hover",
|
|
24
|
+
secondary: "border border-border bg-card text-foreground hover:bg-muted",
|
|
25
|
+
ghost: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const SIZES: Record<ButtonSize, string> = {
|
|
29
|
+
sm: "h-8 px-3 text-sm",
|
|
30
|
+
md: "h-10 px-4 text-sm",
|
|
31
|
+
lg: "h-11 px-5 text-[0.95rem]",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function buttonClass(
|
|
35
|
+
variant: ButtonVariant = "primary",
|
|
36
|
+
size: ButtonSize = "md",
|
|
37
|
+
className?: string,
|
|
38
|
+
): string {
|
|
39
|
+
return cn(BASE, VARIANTS[variant], SIZES[size], className);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface StyleProps {
|
|
43
|
+
variant?: ButtonVariant;
|
|
44
|
+
size?: ButtonSize;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function Button({
|
|
48
|
+
variant,
|
|
49
|
+
size,
|
|
50
|
+
className,
|
|
51
|
+
type = "button",
|
|
52
|
+
...rest
|
|
53
|
+
}: StyleProps & ComponentProps<"button">) {
|
|
54
|
+
return <button type={type} className={buttonClass(variant, size, className)} {...rest} />;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// `size` is omitted from the Link props on purpose: Inertia's Link inherits the
|
|
58
|
+
// HTML `size` attribute (a number), which would intersect with this component's
|
|
59
|
+
// size scale and collapse the prop to `never`.
|
|
60
|
+
export function ButtonLink({
|
|
61
|
+
variant,
|
|
62
|
+
size,
|
|
63
|
+
className,
|
|
64
|
+
...rest
|
|
65
|
+
}: StyleProps & Omit<ComponentProps<typeof Link>, "size">) {
|
|
66
|
+
return <Link className={buttonClass(variant, size, className)} {...rest} />;
|
|
67
|
+
}
|