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,40 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The app's surface primitive — a hairline border with a faint inner highlight
|
|
6
|
+
* (see the `surface` utility in resources/css/app.css) instead of a drop shadow.
|
|
7
|
+
*
|
|
8
|
+
* Pass `interactive` for cards that are links or buttons: it adds a hover lift
|
|
9
|
+
* and a border tint so the affordance is obvious without a shadow.
|
|
10
|
+
*/
|
|
11
|
+
export function Card({
|
|
12
|
+
className,
|
|
13
|
+
interactive = false,
|
|
14
|
+
...rest
|
|
15
|
+
}: ComponentProps<"div"> & { interactive?: boolean }) {
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
className={cn(
|
|
19
|
+
"surface rounded-xl",
|
|
20
|
+
interactive &&
|
|
21
|
+
"transition-[transform,border-color,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-primary/40",
|
|
22
|
+
className,
|
|
23
|
+
)}
|
|
24
|
+
{...rest}
|
|
25
|
+
/>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Small square glyph holder used at the top of a feature card. */
|
|
30
|
+
export function CardIcon({ className, ...rest }: ComponentProps<"div">) {
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
className={cn(
|
|
34
|
+
"grid size-10 place-items-center rounded-lg border border-border bg-accent text-accent-foreground",
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
{...rest}
|
|
38
|
+
/>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/** Inline code chip — for file paths, package names and commands used in prose. */
|
|
5
|
+
export function Code({ className, ...rest }: ComponentProps<"code">) {
|
|
6
|
+
return (
|
|
7
|
+
<code
|
|
8
|
+
className={cn(
|
|
9
|
+
"rounded-md border border-border bg-muted px-1.5 py-0.5 font-mono text-[0.85em] text-foreground",
|
|
10
|
+
className,
|
|
11
|
+
)}
|
|
12
|
+
{...rest}
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A terminal-styled block for a command the reader is meant to run. */
|
|
18
|
+
export function Command({ children, className, ...rest }: ComponentProps<"div">) {
|
|
19
|
+
return (
|
|
20
|
+
<div
|
|
21
|
+
className={cn(
|
|
22
|
+
"flex items-center gap-3 rounded-lg border border-border bg-muted px-3.5 py-2.5 font-mono text-sm",
|
|
23
|
+
className,
|
|
24
|
+
)}
|
|
25
|
+
{...rest}
|
|
26
|
+
>
|
|
27
|
+
<span aria-hidden="true" className="text-primary select-none">
|
|
28
|
+
$
|
|
29
|
+
</span>
|
|
30
|
+
<span className="overflow-x-auto text-foreground">{children}</span>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { useId, type ComponentProps, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Labelled form controls with error and hint text wired up.
|
|
6
|
+
*
|
|
7
|
+
* The point of these is the accessibility plumbing you would otherwise retype on
|
|
8
|
+
* every form: the label is bound to the control by id, the error and hint are
|
|
9
|
+
* announced via `aria-describedby`, and a failed field is marked `aria-invalid`
|
|
10
|
+
* rather than only turning red. Pass `error={errors.email}` straight from
|
|
11
|
+
* Inertia's shared `errors` prop.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface FieldProps {
|
|
15
|
+
label: string;
|
|
16
|
+
error?: string | undefined;
|
|
17
|
+
hint?: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CONTROL =
|
|
21
|
+
"w-full rounded-lg border bg-card px-3 py-2 text-sm text-foreground shadow-sm " +
|
|
22
|
+
"transition-colors placeholder:text-muted-foreground focus:outline-none " +
|
|
23
|
+
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring";
|
|
24
|
+
|
|
25
|
+
function Shell({
|
|
26
|
+
label,
|
|
27
|
+
error,
|
|
28
|
+
hint,
|
|
29
|
+
controlId,
|
|
30
|
+
errorId,
|
|
31
|
+
hintId,
|
|
32
|
+
children,
|
|
33
|
+
}: FieldProps & {
|
|
34
|
+
controlId: string;
|
|
35
|
+
errorId: string;
|
|
36
|
+
hintId: string;
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<div className="space-y-1.5">
|
|
41
|
+
<label htmlFor={controlId} className="block text-sm font-medium text-foreground">
|
|
42
|
+
{label}
|
|
43
|
+
</label>
|
|
44
|
+
|
|
45
|
+
{children}
|
|
46
|
+
|
|
47
|
+
{error ? (
|
|
48
|
+
<p id={errorId} role="alert" className="text-sm text-destructive">
|
|
49
|
+
{error}
|
|
50
|
+
</p>
|
|
51
|
+
) : hint ? (
|
|
52
|
+
<p id={hintId} className="text-sm text-muted-foreground">
|
|
53
|
+
{hint}
|
|
54
|
+
</p>
|
|
55
|
+
) : null}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function TextField({
|
|
61
|
+
label,
|
|
62
|
+
error,
|
|
63
|
+
hint,
|
|
64
|
+
className,
|
|
65
|
+
...rest
|
|
66
|
+
}: FieldProps & ComponentProps<"input">) {
|
|
67
|
+
const id = useId();
|
|
68
|
+
const controlId = rest.id ?? `${id}-control`;
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<Shell
|
|
72
|
+
label={label}
|
|
73
|
+
error={error}
|
|
74
|
+
hint={hint}
|
|
75
|
+
controlId={controlId}
|
|
76
|
+
errorId={`${id}-error`}
|
|
77
|
+
hintId={`${id}-hint`}
|
|
78
|
+
>
|
|
79
|
+
<input
|
|
80
|
+
{...rest}
|
|
81
|
+
id={controlId}
|
|
82
|
+
aria-invalid={error ? true : undefined}
|
|
83
|
+
aria-describedby={error ? `${id}-error` : hint ? `${id}-hint` : undefined}
|
|
84
|
+
className={cn(CONTROL, error ? "border-destructive" : "border-input", className)}
|
|
85
|
+
/>
|
|
86
|
+
</Shell>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function TextAreaField({
|
|
91
|
+
label,
|
|
92
|
+
error,
|
|
93
|
+
hint,
|
|
94
|
+
className,
|
|
95
|
+
rows = 5,
|
|
96
|
+
...rest
|
|
97
|
+
}: FieldProps & ComponentProps<"textarea">) {
|
|
98
|
+
const id = useId();
|
|
99
|
+
const controlId = rest.id ?? `${id}-control`;
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<Shell
|
|
103
|
+
label={label}
|
|
104
|
+
error={error}
|
|
105
|
+
hint={hint}
|
|
106
|
+
controlId={controlId}
|
|
107
|
+
errorId={`${id}-error`}
|
|
108
|
+
hintId={`${id}-hint`}
|
|
109
|
+
>
|
|
110
|
+
<textarea
|
|
111
|
+
{...rest}
|
|
112
|
+
id={controlId}
|
|
113
|
+
rows={rows}
|
|
114
|
+
aria-invalid={error ? true : undefined}
|
|
115
|
+
aria-describedby={error ? `${id}-error` : hint ? `${id}-hint` : undefined}
|
|
116
|
+
className={cn(
|
|
117
|
+
CONTROL,
|
|
118
|
+
"resize-y",
|
|
119
|
+
error ? "border-destructive" : "border-input",
|
|
120
|
+
className,
|
|
121
|
+
)}
|
|
122
|
+
/>
|
|
123
|
+
</Shell>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { usePage } from "@inertiajs/react";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { CheckIcon, CloseIcon } from "./Icons";
|
|
5
|
+
import type { SharedProps } from "../types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Auto-dismissing toasts driven by the shared `flash` prop.
|
|
9
|
+
*
|
|
10
|
+
* A route calls `http.flash("success", "…")` before redirecting; the flash
|
|
11
|
+
* survives exactly one request, `@zerotal/inertia` merges it into every page's
|
|
12
|
+
* props, and this component turns it into a toast. Rendered once in AppLayout,
|
|
13
|
+
* so no page has to think about it.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
interface Toast {
|
|
17
|
+
id: number;
|
|
18
|
+
kind: "success" | "error";
|
|
19
|
+
message: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const TONE: Record<Toast["kind"], string> = {
|
|
23
|
+
success: "border-success/40 text-success",
|
|
24
|
+
error: "border-destructive/40 text-destructive",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export default function FlashToasts() {
|
|
28
|
+
const { flash } = usePage<SharedProps>().props;
|
|
29
|
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
const next: Toast[] = [];
|
|
33
|
+
if (flash?.success) next.push({ id: Date.now(), kind: "success", message: flash.success });
|
|
34
|
+
if (flash?.error) next.push({ id: Date.now() + 1, kind: "error", message: flash.error });
|
|
35
|
+
if (next.length === 0) return;
|
|
36
|
+
|
|
37
|
+
setToasts((prev) => [...prev, ...next]);
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
setToasts((prev) => prev.filter((t) => !next.some((n) => n.id === t.id)));
|
|
40
|
+
}, 5000);
|
|
41
|
+
return () => clearTimeout(timer);
|
|
42
|
+
}, [flash?.success, flash?.error]);
|
|
43
|
+
|
|
44
|
+
if (toasts.length === 0) return null;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div
|
|
48
|
+
aria-live="polite"
|
|
49
|
+
className="pointer-events-none fixed inset-x-4 bottom-4 z-50 flex flex-col items-end gap-2 sm:inset-x-auto sm:right-6"
|
|
50
|
+
>
|
|
51
|
+
{toasts.map((toast) => (
|
|
52
|
+
<div
|
|
53
|
+
key={toast.id}
|
|
54
|
+
className={cn(
|
|
55
|
+
"pointer-events-auto flex w-full max-w-sm items-start gap-3 rounded-xl border bg-card p-3.5 shadow-lg",
|
|
56
|
+
TONE[toast.kind],
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
<span className="mt-0.5 shrink-0">
|
|
60
|
+
{toast.kind === "success" ? (
|
|
61
|
+
<CheckIcon className="size-4.5" />
|
|
62
|
+
) : (
|
|
63
|
+
<CloseIcon className="size-4.5" />
|
|
64
|
+
)}
|
|
65
|
+
</span>
|
|
66
|
+
<p className="flex-1 text-sm text-card-foreground">{toast.message}</p>
|
|
67
|
+
<button
|
|
68
|
+
type="button"
|
|
69
|
+
aria-label="Dismiss"
|
|
70
|
+
onClick={() => setToasts((prev) => prev.filter((t) => t.id !== toast.id))}
|
|
71
|
+
className="shrink-0 rounded text-muted-foreground transition-colors hover:text-foreground"
|
|
72
|
+
>
|
|
73
|
+
<CloseIcon className="size-4" />
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { SVGProps } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The handful of icons this starter needs, inlined.
|
|
5
|
+
*
|
|
6
|
+
* They are plain `stroke="currentColor"` SVGs, so they inherit text colour and
|
|
7
|
+
* follow the theme with no extra wiring — and no icon package to install. Reach
|
|
8
|
+
* for a real icon set once you outgrow these.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
type IconProps = SVGProps<SVGSVGElement>;
|
|
12
|
+
|
|
13
|
+
function Svg({ children, ...props }: IconProps) {
|
|
14
|
+
return (
|
|
15
|
+
<svg
|
|
16
|
+
viewBox="0 0 24 24"
|
|
17
|
+
fill="none"
|
|
18
|
+
stroke="currentColor"
|
|
19
|
+
strokeWidth={1.5}
|
|
20
|
+
strokeLinecap="round"
|
|
21
|
+
strokeLinejoin="round"
|
|
22
|
+
aria-hidden="true"
|
|
23
|
+
{...props}
|
|
24
|
+
>
|
|
25
|
+
{children}
|
|
26
|
+
</svg>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function SunIcon(props: IconProps) {
|
|
31
|
+
return (
|
|
32
|
+
<Svg {...props}>
|
|
33
|
+
<circle cx="12" cy="12" r="4" />
|
|
34
|
+
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
|
35
|
+
</Svg>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function MoonIcon(props: IconProps) {
|
|
40
|
+
return (
|
|
41
|
+
<Svg {...props}>
|
|
42
|
+
<path d="M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z" />
|
|
43
|
+
</Svg>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function MenuIcon(props: IconProps) {
|
|
48
|
+
return (
|
|
49
|
+
<Svg {...props}>
|
|
50
|
+
<path d="M4 7h16M4 12h16M4 17h16" />
|
|
51
|
+
</Svg>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function CloseIcon(props: IconProps) {
|
|
56
|
+
return (
|
|
57
|
+
<Svg {...props}>
|
|
58
|
+
<path d="M18 6 6 18M6 6l12 12" />
|
|
59
|
+
</Svg>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function ArrowRightIcon(props: IconProps) {
|
|
64
|
+
return (
|
|
65
|
+
<Svg {...props}>
|
|
66
|
+
<path d="M5 12h14M13 6l6 6-6 6" />
|
|
67
|
+
</Svg>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function CheckIcon(props: IconProps) {
|
|
72
|
+
return (
|
|
73
|
+
<Svg {...props}>
|
|
74
|
+
<path d="m20 6-11 11-5-5" />
|
|
75
|
+
</Svg>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function FolderIcon(props: IconProps) {
|
|
80
|
+
return (
|
|
81
|
+
<Svg {...props}>
|
|
82
|
+
<path d="M3 7a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.6.8l1 1.2H19a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
|
83
|
+
</Svg>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function LayersIcon(props: IconProps) {
|
|
88
|
+
return (
|
|
89
|
+
<Svg {...props}>
|
|
90
|
+
<path d="m12 3 9 5-9 5-9-5 9-5z" />
|
|
91
|
+
<path d="m3 12 9 5 9-5" />
|
|
92
|
+
<path d="m3 16.5 9 5 9-5" />
|
|
93
|
+
</Svg>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function ShieldIcon(props: IconProps) {
|
|
98
|
+
return (
|
|
99
|
+
<Svg {...props}>
|
|
100
|
+
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
|
101
|
+
<path d="m9 12 2 2 4-4" />
|
|
102
|
+
</Svg>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function DatabaseIcon(props: IconProps) {
|
|
107
|
+
return (
|
|
108
|
+
<Svg {...props}>
|
|
109
|
+
<path d="M3 6c0-1.7 4-3 9-3s9 1.3 9 3-4 3-9 3-9-1.3-9-3z" />
|
|
110
|
+
<path d="M3 6v6c0 1.7 4 3 9 3s9-1.3 9-3V6" />
|
|
111
|
+
<path d="M3 12v6c0 1.7 4 3 9 3s9-1.3 9-3v-6" />
|
|
112
|
+
</Svg>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function ClockIcon(props: IconProps) {
|
|
117
|
+
return (
|
|
118
|
+
<Svg {...props}>
|
|
119
|
+
<circle cx="12" cy="12" r="9" />
|
|
120
|
+
<path d="M12 7v5.2l3.2 2" />
|
|
121
|
+
</Svg>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function BoltIcon(props: IconProps) {
|
|
126
|
+
return (
|
|
127
|
+
<Svg {...props}>
|
|
128
|
+
<path d="m13 2-9 12h7l-1 8 9-12h-7l1-8z" />
|
|
129
|
+
</Svg>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function TerminalIcon(props: IconProps) {
|
|
134
|
+
return (
|
|
135
|
+
<Svg {...props}>
|
|
136
|
+
<path d="m5 8 4 4-4 4M12 16h7" />
|
|
137
|
+
<rect x="2" y="3" width="20" height="18" rx="2.5" />
|
|
138
|
+
</Svg>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function MailIcon(props: IconProps) {
|
|
143
|
+
return (
|
|
144
|
+
<Svg {...props}>
|
|
145
|
+
<rect x="2.5" y="5" width="19" height="14" rx="2.5" />
|
|
146
|
+
<path d="m3 7 8.1 5.4a1.6 1.6 0 0 0 1.8 0L21 7" />
|
|
147
|
+
</Svg>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
import { MoonIcon, SunIcon } from "./Icons";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Light/dark switch.
|
|
6
|
+
*
|
|
7
|
+
* The class on `<html>` is the single source of truth — every colour in the app
|
|
8
|
+
* is a CSS custom property that `.dark` redefines, so flipping the class
|
|
9
|
+
* re-themes the page with no re-render and no `dark:` variants scattered through
|
|
10
|
+
* the components. The choice is persisted under the same storage key the
|
|
11
|
+
* no-flash script in resources/app.html reads on boot.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const STORAGE_KEY = "zerotal-theme";
|
|
15
|
+
|
|
16
|
+
function isDark(): boolean {
|
|
17
|
+
return typeof document !== "undefined" && document.documentElement.classList.contains("dark");
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export default function ThemeToggle() {
|
|
21
|
+
const [dark, setDark] = useState(isDark);
|
|
22
|
+
|
|
23
|
+
const toggle = useCallback(() => {
|
|
24
|
+
const next = !isDark();
|
|
25
|
+
document.documentElement.classList.toggle("dark", next);
|
|
26
|
+
try {
|
|
27
|
+
localStorage.setItem(STORAGE_KEY, next ? "dark" : "light");
|
|
28
|
+
} catch {
|
|
29
|
+
// Storage can be unavailable (private mode). The class is still applied,
|
|
30
|
+
// the choice just won't survive a reload.
|
|
31
|
+
}
|
|
32
|
+
setDark(next);
|
|
33
|
+
}, []);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
onClick={toggle}
|
|
39
|
+
aria-pressed={dark}
|
|
40
|
+
aria-label={dark ? "Switch to light theme" : "Switch to dark theme"}
|
|
41
|
+
title={dark ? "Switch to light theme" : "Switch to dark theme"}
|
|
42
|
+
className="grid size-9 place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
43
|
+
>
|
|
44
|
+
{dark ? <MoonIcon className="size-4.5" /> : <SunIcon className="size-4.5" />}
|
|
45
|
+
</button>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
2
|
+
import { Link, usePage } from "@inertiajs/react";
|
|
3
|
+
import FlashToasts from "../Components/FlashToasts";
|
|
4
|
+
import ThemeToggle from "../Components/ThemeToggle";
|
|
5
|
+
import { CloseIcon, MenuIcon } from "../Components/Icons";
|
|
6
|
+
import { APP_NAME, DOCS_URL } from "../lib/site";
|
|
7
|
+
import { cn } from "../lib/cn";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shared application shell — decorative backdrop, sticky header with
|
|
11
|
+
* active-aware navigation, and a footer.
|
|
12
|
+
*
|
|
13
|
+
* Use it as an Inertia persistent layout so the header survives client-side
|
|
14
|
+
* navigations and only the page body re-renders:
|
|
15
|
+
*
|
|
16
|
+
* Page.layout = (page) => <AppLayout>{page}</AppLayout>;
|
|
17
|
+
*
|
|
18
|
+
* Header, content and footer all share the same `max-w-5xl px-6` container, so
|
|
19
|
+
* the nav and the page body line up at every breakpoint. Pages that want a
|
|
20
|
+
* narrower measure wrap their own content rather than changing this one.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const NAV = [
|
|
24
|
+
{ href: "/", label: "Home" },
|
|
25
|
+
{ href: "/about", label: "About" },
|
|
26
|
+
{ href: "/contact", label: "Contact" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const CONTAINER = "mx-auto w-full max-w-5xl px-6";
|
|
30
|
+
|
|
31
|
+
function isActive(url: string, href: string): boolean {
|
|
32
|
+
return href === "/" ? url === "/" : url.startsWith(href);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default function AppLayout({ children }: { children: ReactNode }) {
|
|
36
|
+
const { url } = usePage();
|
|
37
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
38
|
+
|
|
39
|
+
// Close the mobile menu once a visit lands, otherwise it stays open on top of
|
|
40
|
+
// the page the user just navigated to.
|
|
41
|
+
useEffect(() => setMenuOpen(false), [url]);
|
|
42
|
+
|
|
43
|
+
const navLink = (href: string) =>
|
|
44
|
+
cn(
|
|
45
|
+
"rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
46
|
+
isActive(url, href)
|
|
47
|
+
? "bg-accent text-accent-foreground"
|
|
48
|
+
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const navItems = NAV.map((item) => (
|
|
52
|
+
<Link
|
|
53
|
+
key={item.href}
|
|
54
|
+
href={item.href}
|
|
55
|
+
aria-current={isActive(url, item.href) ? "page" : undefined}
|
|
56
|
+
className={navLink(item.href)}
|
|
57
|
+
>
|
|
58
|
+
{item.label}
|
|
59
|
+
</Link>
|
|
60
|
+
));
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div className="relative flex min-h-dvh flex-col">
|
|
64
|
+
{/* Decorative only: the grid and the glow. Fixed, inert, and behind everything. */}
|
|
65
|
+
<div aria-hidden="true" className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
|
66
|
+
<div className="absolute inset-0 bg-grid" />
|
|
67
|
+
<div className="absolute inset-x-0 top-0 h-136 bg-aurora" />
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<a
|
|
71
|
+
href="#main"
|
|
72
|
+
className="sr-only focus:not-sr-only focus:absolute focus:top-3 focus:left-3 focus:z-50 focus:rounded-lg focus:border focus:border-border focus:bg-card focus:px-4 focus:py-2 focus:text-sm focus:font-medium"
|
|
73
|
+
>
|
|
74
|
+
Skip to content
|
|
75
|
+
</a>
|
|
76
|
+
|
|
77
|
+
<header className="sticky top-0 z-40 border-b border-border bg-background/75 backdrop-blur-md">
|
|
78
|
+
<div className={cn(CONTAINER, "flex h-16 items-center justify-between gap-4")}>
|
|
79
|
+
<Link
|
|
80
|
+
href="/"
|
|
81
|
+
className="flex min-w-0 items-center gap-2.5 font-semibold tracking-tight"
|
|
82
|
+
aria-label={`${APP_NAME} home`}
|
|
83
|
+
>
|
|
84
|
+
<img src="/zt.svg" alt="" className="size-8 shrink-0 rounded-lg" />
|
|
85
|
+
<span className="truncate">{APP_NAME}</span>
|
|
86
|
+
</Link>
|
|
87
|
+
|
|
88
|
+
<nav aria-label="Main" className="hidden items-center gap-1 sm:flex">
|
|
89
|
+
{navItems}
|
|
90
|
+
</nav>
|
|
91
|
+
|
|
92
|
+
<div className="flex items-center gap-2">
|
|
93
|
+
<a
|
|
94
|
+
href={DOCS_URL}
|
|
95
|
+
target="_blank"
|
|
96
|
+
rel="noreferrer"
|
|
97
|
+
className="hidden rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:block"
|
|
98
|
+
>
|
|
99
|
+
Docs
|
|
100
|
+
</a>
|
|
101
|
+
<ThemeToggle />
|
|
102
|
+
<button
|
|
103
|
+
type="button"
|
|
104
|
+
onClick={() => setMenuOpen((open) => !open)}
|
|
105
|
+
aria-expanded={menuOpen}
|
|
106
|
+
aria-controls="mobile-nav"
|
|
107
|
+
aria-label={menuOpen ? "Close menu" : "Open menu"}
|
|
108
|
+
className="grid size-9 place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:hidden"
|
|
109
|
+
>
|
|
110
|
+
{menuOpen ? <CloseIcon className="size-4.5" /> : <MenuIcon className="size-4.5" />}
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{menuOpen ? (
|
|
116
|
+
<nav
|
|
117
|
+
id="mobile-nav"
|
|
118
|
+
aria-label="Main"
|
|
119
|
+
className={cn(CONTAINER, "flex flex-col gap-1 border-t border-border py-3 sm:hidden")}
|
|
120
|
+
>
|
|
121
|
+
{navItems}
|
|
122
|
+
<a
|
|
123
|
+
href={DOCS_URL}
|
|
124
|
+
target="_blank"
|
|
125
|
+
rel="noreferrer"
|
|
126
|
+
className="rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
127
|
+
>
|
|
128
|
+
Docs
|
|
129
|
+
</a>
|
|
130
|
+
</nav>
|
|
131
|
+
) : null}
|
|
132
|
+
</header>
|
|
133
|
+
|
|
134
|
+
<main id="main" className={cn(CONTAINER, "flex-1 py-14 sm:py-20")}>
|
|
135
|
+
{children}
|
|
136
|
+
</main>
|
|
137
|
+
|
|
138
|
+
<footer className="border-t border-border">
|
|
139
|
+
<div
|
|
140
|
+
className={cn(
|
|
141
|
+
CONTAINER,
|
|
142
|
+
"flex flex-col items-center justify-between gap-3 py-8 text-sm text-muted-foreground sm:flex-row",
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
<p>
|
|
146
|
+
Built with <span className="font-medium text-foreground">Zerotal</span> — the Bun-native
|
|
147
|
+
TypeScript framework.
|
|
148
|
+
</p>
|
|
149
|
+
<a
|
|
150
|
+
href={DOCS_URL}
|
|
151
|
+
target="_blank"
|
|
152
|
+
rel="noreferrer"
|
|
153
|
+
className="font-medium transition-colors hover:text-foreground"
|
|
154
|
+
>
|
|
155
|
+
Documentation
|
|
156
|
+
</a>
|
|
157
|
+
</div>
|
|
158
|
+
</footer>
|
|
159
|
+
|
|
160
|
+
<FlashToasts />
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { createInertiaApp, type ResolvedComponent } from "@inertiajs/react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
import { pages } from "./pages.generated.ts";
|
|
4
|
+
import "../css/app.css";
|
|
5
|
+
|
|
6
|
+
createInertiaApp({
|
|
7
|
+
// Suffix every `<Head title="…" />` with the app name, so a page only has to
|
|
8
|
+
// name itself. Pages that set no title fall back to the app name alone.
|
|
9
|
+
title: (title) => (title ? `${title} — {{name}}` : "{{name}}"),
|
|
10
|
+
|
|
11
|
+
// Map a component name (e.g. "Users/Index") to its lazily-loaded module.
|
|
12
|
+
// `pages` is generated from the configured pages dir by `inertia:build`.
|
|
13
|
+
resolve: async (name): Promise<ResolvedComponent> => {
|
|
14
|
+
const page = pages[name];
|
|
15
|
+
if (!page) throw new Error(`Inertia page not found: "${name}"`);
|
|
16
|
+
return (await page()).default as ResolvedComponent;
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
// The top-of-page loading bar shown during slow visits. `--primary` is read off
|
|
20
|
+
// the document so the bar follows the theme instead of hardcoding a colour.
|
|
21
|
+
progress: {
|
|
22
|
+
color: getComputedStyle(document.documentElement).getPropertyValue("--primary").trim(),
|
|
23
|
+
delay: 200,
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
setup({ el, App, props }) {
|
|
27
|
+
createRoot(el).render(<App {...props} />);
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Ambient declarations for non-code assets imported from TypeScript and bundled
|
|
2
|
+
// by `bun zt serve` (see the `assets` block in config/app.ts). These let
|
|
3
|
+
// side-effect imports like `import "../css/app.css"` type-check.
|
|
4
|
+
declare module "*.css";
|
|
5
|
+
declare module "*.scss";
|
|
6
|
+
declare module "*.svg";
|
|
7
|
+
declare module "*.png";
|
|
8
|
+
declare module "*.jpg";
|
|
9
|
+
declare module "*.jpeg";
|
|
10
|
+
declare module "*.gif";
|
|
11
|
+
declare module "*.webp";
|
|
12
|
+
declare module "*.woff";
|
|
13
|
+
declare module "*.woff2";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Join class names, dropping anything falsy.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately tiny — this is the only thing the components here need from a
|
|
5
|
+
* class utility, and it keeps the starter free of a runtime dependency. Swap in
|
|
6
|
+
* `clsx` + `tailwind-merge` if you start composing conflicting utilities.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* cn("px-3 py-2", isActive && "bg-accent", className)
|
|
10
|
+
*/
|
|
11
|
+
export function cn(...parts: Array<string | false | null | undefined>): string {
|
|
12
|
+
return parts.filter(Boolean).join(" ");
|
|
13
|
+
}
|