create-zerotal 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +1 -1
  3. package/src/prompts.ts +6 -6
  4. package/src/scaffold.ts +9 -3
  5. package/templates/admin/README.md +41 -0
  6. package/templates/admin/_env.example +17 -0
  7. package/templates/admin/app/admin/index.ts +6 -0
  8. package/templates/admin/app/auth/passwords.ts +79 -0
  9. package/templates/admin/config/database.ts +4 -3
  10. package/templates/admin/database/migrations/0001_create_password_reset_tokens_table.ts +26 -0
  11. package/templates/admin/package.json +9 -1
  12. package/templates/admin/tsconfig.json +7 -2
  13. package/templates/api/README.md +39 -0
  14. package/templates/api/_env.example +13 -0
  15. package/templates/api/app/controllers/AuthController.ts +11 -8
  16. package/templates/api/app/controllers/WelcomeController.ts +1 -1
  17. package/templates/api/app/middleware/RequireAuth.ts +1 -1
  18. package/templates/api/app/models/User.ts +2 -2
  19. package/templates/api/bootstrap/app.ts +6 -6
  20. package/templates/api/config/app.ts +1 -1
  21. package/templates/api/config/database.ts +12 -3
  22. package/templates/api/config/notifications.ts +1 -1
  23. package/templates/api/config/queue.ts +1 -1
  24. package/templates/api/config/session.ts +2 -2
  25. package/templates/api/database/migrations/0001_create_users_table.ts +8 -8
  26. package/templates/api/package.json +9 -1
  27. package/templates/api/routes/index.ts +23 -8
  28. package/templates/api/tests/auth.test.ts +6 -3
  29. package/templates/api/tests/helpers.ts +6 -6
  30. package/templates/api/tsconfig.json +7 -3
  31. package/templates/flow/README.md +39 -0
  32. package/templates/flow/_env.example +17 -0
  33. package/templates/flow/app/auth/passwords.ts +79 -0
  34. package/templates/flow/app/flow/layouts/app.tsx +10 -1
  35. package/templates/flow/app/flow/pages/forgot-password.tsx +69 -0
  36. package/templates/flow/app/flow/pages/login.tsx +88 -0
  37. package/templates/flow/app/flow/pages/profile.tsx +194 -0
  38. package/templates/flow/app/flow/pages/register.tsx +120 -0
  39. package/templates/flow/app/flow/pages/reset-password.tsx +103 -0
  40. package/templates/flow/app/flow/ui.ts +27 -0
  41. package/templates/flow/app/models/User.ts +17 -0
  42. package/templates/flow/bootstrap/app.ts +8 -0
  43. package/templates/flow/bootstrap/providers.ts +6 -1
  44. package/templates/flow/config/auth.ts +7 -0
  45. package/templates/flow/config/database.ts +18 -0
  46. package/templates/flow/config/session.ts +14 -0
  47. package/templates/flow/database/migrations/0001_create_users_table.ts +18 -0
  48. package/templates/flow/database/migrations/0002_create_password_reset_tokens_table.ts +23 -0
  49. package/templates/flow/package.json +10 -4
  50. package/templates/flow/tests/smoke.test.ts +21 -1
  51. package/templates/flow/tsconfig.json +10 -3
  52. package/templates/minimal/README.md +39 -0
  53. package/templates/minimal/_env.example +9 -0
  54. package/templates/minimal/bootstrap/app.ts +2 -2
  55. package/templates/minimal/package.json +10 -5
  56. package/templates/minimal/tests/smoke.test.ts +1 -1
  57. package/templates/minimal/tsconfig.json +13 -4
  58. package/templates/react/README.md +40 -0
  59. package/templates/react/_env.example +18 -0
  60. package/templates/react/app/auth/passwords.ts +79 -0
  61. package/templates/react/app/models/User.ts +17 -0
  62. package/templates/react/app/routes/forgot-password.ts +24 -0
  63. package/templates/react/app/routes/login.ts +32 -0
  64. package/templates/react/app/routes/logout.ts +12 -0
  65. package/templates/react/app/routes/profile/password.ts +30 -0
  66. package/templates/react/app/routes/profile.ts +39 -0
  67. package/templates/react/app/routes/register.ts +39 -0
  68. package/templates/react/app/routes/reset-password.ts +37 -0
  69. package/templates/react/bootstrap/app.ts +8 -0
  70. package/templates/react/bootstrap/providers.ts +7 -4
  71. package/templates/react/config/auth.ts +7 -0
  72. package/templates/react/config/database.ts +18 -0
  73. package/templates/react/database/migrations/0001_create_users_table.ts +18 -0
  74. package/templates/react/database/migrations/0002_create_password_reset_tokens_table.ts +23 -0
  75. package/templates/react/package.json +8 -2
  76. package/templates/react/resources/js/Layouts/AppLayout.tsx +12 -2
  77. package/templates/react/resources/js/pages/forgot-password.tsx +65 -0
  78. package/templates/react/resources/js/pages/login.tsx +92 -0
  79. package/templates/react/resources/js/pages/profile.tsx +147 -0
  80. package/templates/react/resources/js/pages/register.tsx +101 -0
  81. package/templates/react/resources/js/pages/reset-password.tsx +80 -0
  82. package/templates/react/resources/js/pages.generated.ts +5 -0
  83. package/templates/react/tests/smoke.test.ts +58 -2
  84. package/templates/react/tsconfig.json +10 -3
  85. package/templates/vue/README.md +40 -0
  86. package/templates/vue/_env.example +18 -0
  87. package/templates/vue/app/auth/passwords.ts +79 -0
  88. package/templates/vue/app/exceptions/Handler.ts +55 -0
  89. package/templates/vue/app/models/User.ts +17 -0
  90. package/templates/vue/app/routes/forgot-password.ts +24 -0
  91. package/templates/vue/app/routes/login.ts +32 -0
  92. package/templates/vue/app/routes/logout.ts +12 -0
  93. package/templates/vue/app/routes/profile/password.ts +30 -0
  94. package/templates/vue/app/routes/profile.ts +39 -0
  95. package/templates/vue/app/routes/register.ts +39 -0
  96. package/templates/vue/app/routes/reset-password.ts +37 -0
  97. package/templates/vue/bootstrap/app.ts +15 -3
  98. package/templates/vue/bootstrap/providers.ts +8 -1
  99. package/templates/vue/config/auth.ts +7 -0
  100. package/templates/vue/config/database.ts +18 -0
  101. package/templates/vue/config/session.ts +11 -0
  102. package/templates/vue/database/migrations/0001_create_users_table.ts +18 -0
  103. package/templates/vue/database/migrations/0002_create_password_reset_tokens_table.ts +23 -0
  104. package/templates/vue/package.json +8 -2
  105. package/templates/vue/public/zt.svg +17 -0
  106. package/templates/vue/resources/app.html +27 -1
  107. package/templates/vue/resources/css/app.css +205 -0
  108. package/templates/vue/resources/js/Components/Button.vue +49 -0
  109. package/templates/vue/resources/js/Components/Card.vue +23 -0
  110. package/templates/vue/resources/js/Components/FlashToasts.vue +81 -0
  111. package/templates/vue/resources/js/Components/Icon.vue +40 -0
  112. package/templates/vue/resources/js/Components/TextField.vue +66 -0
  113. package/templates/vue/resources/js/Components/ThemeToggle.vue +46 -0
  114. package/templates/vue/resources/js/Layouts/AppLayout.vue +140 -25
  115. package/templates/vue/resources/js/lib/cn.ts +13 -0
  116. package/templates/vue/resources/js/lib/site.ts +11 -0
  117. package/templates/vue/resources/js/pages/error.vue +47 -0
  118. package/templates/vue/resources/js/pages/forgot-password.vue +47 -0
  119. package/templates/vue/resources/js/pages/login.vue +67 -0
  120. package/templates/vue/resources/js/pages/profile.vue +119 -0
  121. package/templates/vue/resources/js/pages/register.vue +77 -0
  122. package/templates/vue/resources/js/pages/reset-password.vue +58 -0
  123. package/templates/vue/resources/js/pages.generated.ts +10 -4
  124. package/templates/vue/resources/js/types.ts +17 -0
  125. package/templates/vue/tests/smoke.test.ts +50 -1
  126. package/templates/vue/tsconfig.json +10 -3
@@ -0,0 +1,40 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The handful of icons this starter needs, inlined.
4
+ *
5
+ * One component with a `name` rather than one file per glyph: in Vue an SFC is
6
+ * a file, and fourteen files for fourteen `<path>` elements is more ceremony
7
+ * than the icons are worth. Same paths and same stroke treatment as the React
8
+ * template, so both render identically.
9
+ *
10
+ * They inherit `currentColor`, so they follow the theme with no extra wiring.
11
+ */
12
+ const props = withDefaults(defineProps<{ name: IconName; class?: string }>(), {});
13
+
14
+ type IconName = "sun" | "moon" | "menu" | "close" | "check" | "arrow-right";
15
+
16
+ const PATHS: Record<IconName, string> = {
17
+ sun: "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",
18
+ moon: "M21 12.8A9 9 0 1 1 11.2 3 7 7 0 0 0 21 12.8z",
19
+ menu: "M4 7h16M4 12h16M4 17h16",
20
+ close: "M18 6 6 18M6 6l12 12",
21
+ check: "m20 6-11 11-5-5",
22
+ "arrow-right": "M5 12h14M13 6l6 6-6 6",
23
+ };
24
+ </script>
25
+
26
+ <template>
27
+ <svg
28
+ viewBox="0 0 24 24"
29
+ fill="none"
30
+ stroke="currentColor"
31
+ stroke-width="1.5"
32
+ stroke-linecap="round"
33
+ stroke-linejoin="round"
34
+ aria-hidden="true"
35
+ :class="props.class"
36
+ >
37
+ <circle v-if="name === 'sun'" cx="12" cy="12" r="4" />
38
+ <path :d="PATHS[name]" />
39
+ </svg>
40
+ </template>
@@ -0,0 +1,66 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Labelled text input with error and hint slots — the Vue twin of the React
4
+ * template's TextField, including its accessibility wiring: the label points at
5
+ * the control, and an error is announced via role="alert" and aria-describedby.
6
+ */
7
+ import { computed, useId } from "vue";
8
+ import { cn } from "../lib/cn";
9
+
10
+ const props = withDefaults(
11
+ defineProps<{
12
+ label: string;
13
+ modelValue: string;
14
+ error?: string | undefined;
15
+ hint?: string | undefined;
16
+ type?: string;
17
+ autocomplete?: string;
18
+ required?: boolean;
19
+ class?: string;
20
+ }>(),
21
+ { type: "text", required: false },
22
+ );
23
+
24
+ defineEmits<{ "update:modelValue": [value: string] }>();
25
+
26
+ const uid = useId();
27
+ const controlId = `${uid}-control`;
28
+ const errorId = `${uid}-error`;
29
+ const hintId = `${uid}-hint`;
30
+
31
+ const CONTROL =
32
+ "w-full rounded-lg border bg-card px-3 py-2 text-sm text-foreground shadow-sm " +
33
+ "transition-colors placeholder:text-muted-foreground focus:outline-none " +
34
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring";
35
+
36
+ const controlClass = computed(() =>
37
+ cn(CONTROL, props.error ? "border-destructive" : "border-input", props.class),
38
+ );
39
+ </script>
40
+
41
+ <template>
42
+ <div class="space-y-1.5">
43
+ <label :for="controlId" class="block text-sm font-medium text-foreground">
44
+ {{ label }}
45
+ </label>
46
+
47
+ <input
48
+ :id="controlId"
49
+ :type="type"
50
+ :value="modelValue"
51
+ :autocomplete="autocomplete"
52
+ :required="required"
53
+ :aria-invalid="error ? true : undefined"
54
+ :aria-describedby="error ? errorId : hint ? hintId : undefined"
55
+ :class="controlClass"
56
+ @input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
57
+ />
58
+
59
+ <p v-if="error" :id="errorId" role="alert" class="text-sm text-destructive">
60
+ {{ error }}
61
+ </p>
62
+ <p v-else-if="hint" :id="hintId" class="text-sm text-muted-foreground">
63
+ {{ hint }}
64
+ </p>
65
+ </div>
66
+ </template>
@@ -0,0 +1,46 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Light/dark switch.
4
+ *
5
+ * The class on `<html>` is the single source of truth — every colour is a CSS
6
+ * custom property that `.dark` redefines, so flipping the class re-themes the
7
+ * page with no re-render and no `dark:` variants through the components. The
8
+ * choice is persisted under the same key the no-flash script in
9
+ * resources/app.html reads on boot, so an app and its admin panel agree.
10
+ */
11
+ import { ref } from "vue";
12
+ import Icon from "./Icon.vue";
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
+ const dark = ref(isDark());
21
+
22
+ function toggle(): void {
23
+ const next = !isDark();
24
+ document.documentElement.classList.toggle("dark", next);
25
+ try {
26
+ localStorage.setItem(STORAGE_KEY, next ? "dark" : "light");
27
+ } catch {
28
+ // Storage can be unavailable (private mode). The class is still applied,
29
+ // the choice just won't survive a reload.
30
+ }
31
+ dark.value = next;
32
+ }
33
+ </script>
34
+
35
+ <template>
36
+ <button
37
+ type="button"
38
+ :aria-pressed="dark"
39
+ :aria-label="dark ? 'Switch to light theme' : 'Switch to dark theme'"
40
+ :title="dark ? 'Switch to light theme' : 'Switch to dark theme'"
41
+ class="grid size-9 place-items-center rounded-lg border border-border bg-card text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
42
+ @click="toggle"
43
+ >
44
+ <Icon :name="dark ? 'moon' : 'sun'" class="size-4.5" />
45
+ </button>
46
+ </template>
@@ -1,63 +1,178 @@
1
1
  <script setup lang="ts">
2
- import { computed } from "vue";
3
- import { Link, usePage } from "@inertiajs/vue3";
4
-
5
2
  /**
6
- * Shared application shell — sticky header with active-aware navigation.
3
+ * Shared application shell — decorative backdrop, sticky header with
4
+ * active-aware navigation, and a footer.
7
5
  *
8
6
  * Use it as an Inertia persistent layout so the header survives client-side
9
7
  * navigations and only the page body re-renders — in a page's script setup:
10
8
  * defineOptions({ layout: AppLayout });
9
+ *
10
+ * Header, content and footer all share the same `max-w-5xl px-6` container, so
11
+ * the nav and the page body line up at every breakpoint.
11
12
  */
13
+ import { computed, ref, watch } from "vue";
14
+ import { Link, usePage } from "@inertiajs/vue3";
15
+ import FlashToasts from "../Components/FlashToasts.vue";
16
+ import ThemeToggle from "../Components/ThemeToggle.vue";
17
+ import Icon from "../Components/Icon.vue";
18
+ import { APP_NAME, DOCS_URL } from "../lib/site";
19
+ import { cn } from "../lib/cn";
20
+ import type { SharedProps } from "../types";
21
+
12
22
  const NAV = [
13
23
  { href: "/", label: "Home" },
14
24
  { href: "/about", label: "About" },
15
25
  { href: "/contact", label: "Contact" },
16
26
  ];
17
27
 
18
- const navLink =
19
- "rounded-lg px-3 py-2 text-sm font-medium text-gray-600 transition hover:bg-gray-100 hover:text-gray-900";
20
- const activeLink = "bg-indigo-50 text-indigo-700";
28
+ // Shown to signed-in visitors and guests respectively, so the header always
29
+ // offers the next useful step rather than a link that would bounce them.
30
+ const AUTHED_NAV = [{ href: "/profile", label: "Profile" }];
31
+ const GUEST_NAV = [
32
+ { href: "/login", label: "Sign in" },
33
+ { href: "/register", label: "Register" },
34
+ ];
35
+
36
+ const CONTAINER = "mx-auto w-full max-w-5xl px-6";
21
37
 
22
- const page = usePage();
38
+ const page = usePage<SharedProps>();
23
39
  const url = computed(() => page.url);
40
+ const signedIn = computed(() => Boolean(page.props.auth?.user));
41
+ const navItems = computed(() => [...NAV, ...(signedIn.value ? AUTHED_NAV : GUEST_NAV)]);
42
+
43
+ const menuOpen = ref(false);
44
+
45
+ // Close the mobile menu once a visit lands, otherwise it stays open on top of
46
+ // the page the user just navigated to.
47
+ watch(url, () => (menuOpen.value = false));
24
48
 
25
49
  const isActive = (href: string): boolean =>
26
50
  href === "/" ? url.value === "/" : url.value.startsWith(href);
51
+
52
+ const navLink = (href: string): string =>
53
+ cn(
54
+ "rounded-lg px-3 py-2 text-sm font-medium transition-colors",
55
+ isActive(href)
56
+ ? "bg-accent text-accent-foreground"
57
+ : "text-muted-foreground hover:bg-muted hover:text-foreground",
58
+ );
27
59
  </script>
28
60
 
29
61
  <template>
30
- <div
31
- class="min-h-screen bg-linear-to-b from-white via-white to-indigo-50 text-gray-900 antialiased"
32
- >
33
- <header
34
- class="sticky top-0 z-10 border-b border-gray-200/70 bg-white/80 backdrop-blur"
62
+ <div class="relative flex min-h-dvh flex-col">
63
+ <!-- Decorative only: the grid and the glow. Fixed, inert, and behind everything. -->
64
+ <div aria-hidden="true" class="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
65
+ <div class="absolute inset-0 bg-grid" />
66
+ <div class="absolute inset-x-0 top-0 h-136 bg-aurora" />
67
+ </div>
68
+
69
+ <a
70
+ href="#main"
71
+ class="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"
35
72
  >
36
- <nav class="mx-auto flex max-w-5xl items-center justify-between px-6 py-3">
37
- <Link href="/" class="flex items-center gap-2 font-bold tracking-tight">
38
- <span
39
- class="grid h-7 w-7 place-items-center rounded-lg bg-indigo-600 text-sm text-white"
40
- >
41
- K
42
- </span>
43
- <span>Zerotal</span>
73
+ Skip to content
74
+ </a>
75
+
76
+ <header class="sticky top-0 z-40 border-b border-border bg-background/75 backdrop-blur-md">
77
+ <div :class="cn(CONTAINER, 'flex h-16 items-center justify-between gap-4')">
78
+ <Link
79
+ href="/"
80
+ class="flex min-w-0 items-center gap-2.5 font-semibold tracking-tight"
81
+ :aria-label="`${APP_NAME} home`"
82
+ >
83
+ <img src="/zt.svg" alt="" class="size-8 shrink-0 rounded-lg" />
84
+ <span class="truncate">{{ APP_NAME }}</span>
44
85
  </Link>
45
86
 
46
- <div class="flex items-center gap-1">
87
+ <nav aria-label="Main" class="hidden items-center gap-1 sm:flex">
47
88
  <Link
48
- v-for="item in NAV"
89
+ v-for="item in navItems"
49
90
  :key="item.href"
50
91
  :href="item.href"
51
- :class="[navLink, isActive(item.href) ? activeLink : '']"
92
+ :aria-current="isActive(item.href) ? 'page' : undefined"
93
+ :class="navLink(item.href)"
52
94
  >
53
95
  {{ item.label }}
54
96
  </Link>
97
+ </nav>
98
+
99
+ <div class="flex items-center gap-2">
100
+ <a
101
+ :href="DOCS_URL"
102
+ target="_blank"
103
+ rel="noreferrer"
104
+ class="hidden rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground sm:block"
105
+ >
106
+ Docs
107
+ </a>
108
+ <ThemeToggle />
109
+ <button
110
+ type="button"
111
+ :aria-expanded="menuOpen"
112
+ aria-controls="mobile-nav"
113
+ :aria-label="menuOpen ? 'Close menu' : 'Open menu'"
114
+ class="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"
115
+ @click="menuOpen = !menuOpen"
116
+ >
117
+ <Icon :name="menuOpen ? 'close' : 'menu'" class="size-4.5" />
118
+ </button>
55
119
  </div>
120
+ </div>
121
+
122
+ <nav
123
+ v-if="menuOpen"
124
+ id="mobile-nav"
125
+ aria-label="Main"
126
+ :class="cn(CONTAINER, 'flex flex-col gap-1 border-t border-border py-3 sm:hidden')"
127
+ >
128
+ <Link
129
+ v-for="item in navItems"
130
+ :key="item.href"
131
+ :href="item.href"
132
+ :aria-current="isActive(item.href) ? 'page' : undefined"
133
+ :class="navLink(item.href)"
134
+ >
135
+ {{ item.label }}
136
+ </Link>
137
+ <a
138
+ :href="DOCS_URL"
139
+ target="_blank"
140
+ rel="noreferrer"
141
+ class="rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
142
+ >
143
+ Docs
144
+ </a>
56
145
  </nav>
57
146
  </header>
58
147
 
59
- <main class="mx-auto max-w-3xl px-6 py-14">
148
+ <main id="main" :class="cn(CONTAINER, 'flex-1 py-14 sm:py-20')">
60
149
  <slot />
61
150
  </main>
151
+
152
+ <footer class="border-t border-border">
153
+ <div
154
+ :class="
155
+ cn(
156
+ CONTAINER,
157
+ 'flex flex-col items-center justify-between gap-3 py-8 text-sm text-muted-foreground sm:flex-row',
158
+ )
159
+ "
160
+ >
161
+ <p>
162
+ Built with <span class="font-medium text-foreground">Zerotal</span> — the Bun-native
163
+ TypeScript framework.
164
+ </p>
165
+ <a
166
+ :href="DOCS_URL"
167
+ target="_blank"
168
+ rel="noreferrer"
169
+ class="font-medium transition-colors hover:text-foreground"
170
+ >
171
+ Documentation
172
+ </a>
173
+ </div>
174
+ </footer>
175
+
176
+ <FlashToasts />
62
177
  </div>
63
178
  </template>
@@ -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
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Site-wide constants the UI reads.
3
+ *
4
+ * `APP_NAME` is stamped in when the project is scaffolded — the same value as
5
+ * `name` in config/app.ts and the `<title>` suffix in resources/js/app.tsx.
6
+ * Change all three together when you rename the app.
7
+ */
8
+ export const APP_NAME = "{{name}}";
9
+
10
+ /** Where the header and footer "Docs" links point. */
11
+ export const DOCS_URL = "https://github.com/zerotaldev/zerotal";
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The page behind every HTTP error the app renders to a browser.
4
+ *
5
+ * `app/exceptions/Handler.ts` picks the status and renders this component, so a
6
+ * missing URL lands inside the app's own shell instead of on a bare framework
7
+ * page. In development the framework's stack-trace page still wins for 500s —
8
+ * this is what production shows.
9
+ */
10
+ import { computed } from "vue";
11
+ import { Head, Link } from "@inertiajs/vue3";
12
+ import AppLayout from "../Layouts/AppLayout.vue";
13
+ import Button from "../Components/Button.vue";
14
+
15
+ defineOptions({ layout: AppLayout });
16
+ const props = defineProps<{ status: number }>();
17
+
18
+ const COPY: Record<number, { title: string; body: string }> = {
19
+ 403: { title: "Not allowed", body: "You are signed in, but this page is not yours to see." },
20
+ 404: {
21
+ title: "No page here",
22
+ body: "The URL does not match any route. Check the address, or head back to the start.",
23
+ },
24
+ 419: { title: "The page expired", body: "Load the page again and resubmit." },
25
+ 500: { title: "Something broke", body: "That is on us. Try again in a moment." },
26
+ };
27
+
28
+ const copy = computed(
29
+ () => COPY[props.status] ?? { title: "Something went wrong", body: "Try again in a moment." },
30
+ );
31
+ </script>
32
+
33
+ <template>
34
+ <Head :title="`${status} — ${copy.title}`" />
35
+
36
+ <div class="mx-auto max-w-lg text-center">
37
+ <p class="font-mono text-sm tracking-[0.2em] text-muted-foreground">{{ status }}</p>
38
+ <h1 class="mt-3 text-3xl font-bold tracking-tight text-balance sm:text-4xl">
39
+ {{ copy.title }}
40
+ </h1>
41
+ <p class="mt-4 text-lg leading-relaxed text-pretty text-muted-foreground">{{ copy.body }}</p>
42
+
43
+ <Link href="/" class="mt-8 inline-block">
44
+ <Button>Back to start</Button>
45
+ </Link>
46
+ </div>
47
+ </template>
@@ -0,0 +1,47 @@
1
+ <script setup lang="ts">
2
+ import { Head, Link, useForm } from "@inertiajs/vue3";
3
+ import AppLayout from "../Layouts/AppLayout.vue";
4
+ import Button from "../Components/Button.vue";
5
+ import Card from "../Components/Card.vue";
6
+ import TextField from "../Components/TextField.vue";
7
+
8
+ defineOptions({ layout: AppLayout });
9
+ defineProps<{ title: string }>();
10
+
11
+ const form = useForm({ email: "" });
12
+ </script>
13
+
14
+ <template>
15
+ <Head :title="title" />
16
+
17
+ <div class="mx-auto max-w-md">
18
+ <header>
19
+ <h1 class="text-3xl font-bold tracking-tight text-balance">{{ title }}</h1>
20
+ <p class="mt-2 text-muted-foreground">
21
+ Give us the address you signed up with and we will send a link.
22
+ </p>
23
+ </header>
24
+
25
+ <Card class="mt-8 p-6 sm:p-8">
26
+ <form class="space-y-5" @submit.prevent="form.post('/forgot-password')">
27
+ <TextField
28
+ v-model="form.email"
29
+ label="Email"
30
+ type="email"
31
+ autocomplete="email"
32
+ hint="In development the link is written to the server log rather than emailed."
33
+ :error="form.errors.email"
34
+ required
35
+ />
36
+
37
+ <Button type="submit" :disabled="form.processing">
38
+ {{ form.processing ? "Sending…" : "Send reset link" }}
39
+ </Button>
40
+ </form>
41
+ </Card>
42
+
43
+ <p class="mt-6 text-sm text-muted-foreground">
44
+ <Link href="/login" class="text-accent hover:underline">Back to sign in</Link>
45
+ </p>
46
+ </div>
47
+ </template>
@@ -0,0 +1,67 @@
1
+ <script setup lang="ts">
2
+ import { Head, Link, useForm, usePage } from "@inertiajs/vue3";
3
+ import AppLayout from "../Layouts/AppLayout.vue";
4
+ import Button from "../Components/Button.vue";
5
+ import Card from "../Components/Card.vue";
6
+ import TextField from "../Components/TextField.vue";
7
+ import type { SharedProps } from "../types";
8
+
9
+ defineOptions({ layout: AppLayout });
10
+ defineProps<{ title: string }>();
11
+
12
+ const page = usePage<SharedProps>();
13
+ const old = page.props.old;
14
+
15
+ const form = useForm({
16
+ email: typeof old["email"] === "string" ? old["email"] : "",
17
+ password: "",
18
+ remember: false,
19
+ });
20
+ </script>
21
+
22
+ <template>
23
+ <Head :title="title" />
24
+
25
+ <div class="mx-auto max-w-md">
26
+ <header>
27
+ <h1 class="text-3xl font-bold tracking-tight text-balance">{{ title }}</h1>
28
+ <p class="mt-2 text-muted-foreground">Enter your details to continue.</p>
29
+ </header>
30
+
31
+ <Card class="mt-8 p-6 sm:p-8">
32
+ <form class="space-y-5" @submit.prevent="form.post('/login')">
33
+ <TextField
34
+ v-model="form.email"
35
+ label="Email"
36
+ type="email"
37
+ autocomplete="email"
38
+ :error="form.errors.email"
39
+ required
40
+ />
41
+
42
+ <TextField
43
+ v-model="form.password"
44
+ label="Password"
45
+ type="password"
46
+ autocomplete="current-password"
47
+ :error="form.errors.password"
48
+ required
49
+ />
50
+
51
+ <label class="flex items-center gap-2 text-sm text-muted-foreground">
52
+ <input v-model="form.remember" type="checkbox" class="rounded border-input" />
53
+ Remember me
54
+ </label>
55
+
56
+ <Button type="submit" :disabled="form.processing">
57
+ {{ form.processing ? "Signing in…" : "Sign in" }}
58
+ </Button>
59
+ </form>
60
+ </Card>
61
+
62
+ <div class="mt-6 flex items-center justify-between text-sm">
63
+ <Link href="/forgot-password" class="text-accent hover:underline">Forgot your password?</Link>
64
+ <Link href="/register" class="text-accent hover:underline">Create an account</Link>
65
+ </div>
66
+ </div>
67
+ </template>
@@ -0,0 +1,119 @@
1
+ <script setup lang="ts">
2
+ import { Head, router, useForm, usePage } from "@inertiajs/vue3";
3
+ import AppLayout from "../Layouts/AppLayout.vue";
4
+ import Button from "../Components/Button.vue";
5
+ import Card from "../Components/Card.vue";
6
+ import TextField from "../Components/TextField.vue";
7
+ import type { SharedProps } from "../types";
8
+
9
+ defineOptions({ layout: AppLayout });
10
+ defineProps<{ title: string }>();
11
+
12
+ // `auth.user` is shared into every page by @zerotal/inertia, so this route does
13
+ // not have to pass the signed-in user as a prop.
14
+ const auth = usePage<SharedProps>().props.auth;
15
+
16
+ const details = useForm({
17
+ name: auth.user?.name ?? "",
18
+ email: auth.user?.email ?? "",
19
+ });
20
+
21
+ const password = useForm({
22
+ current_password: "",
23
+ password: "",
24
+ password_confirmation: "",
25
+ });
26
+
27
+ function changePassword(): void {
28
+ // Clear the fields whichever way it goes — a failed attempt should not leave
29
+ // the old password sitting in the DOM.
30
+ password.post("/profile/password", {
31
+ preserveScroll: true,
32
+ onFinish: () => password.reset(),
33
+ });
34
+ }
35
+ </script>
36
+
37
+ <template>
38
+ <Head :title="title" />
39
+
40
+ <div class="mx-auto max-w-2xl space-y-6">
41
+ <header>
42
+ <h1 class="text-3xl font-bold tracking-tight text-balance">{{ title }}</h1>
43
+ <p class="mt-2 text-muted-foreground">Update your details or change your password.</p>
44
+ </header>
45
+
46
+ <Card class="p-6 sm:p-8">
47
+ <h2 class="text-lg font-semibold">Details</h2>
48
+
49
+ <form
50
+ class="mt-4 space-y-5"
51
+ @submit.prevent="details.post('/profile', { preserveScroll: true })"
52
+ >
53
+ <TextField
54
+ v-model="details.name"
55
+ label="Name"
56
+ autocomplete="name"
57
+ :error="details.errors.name"
58
+ required
59
+ />
60
+ <TextField
61
+ v-model="details.email"
62
+ label="Email"
63
+ type="email"
64
+ autocomplete="email"
65
+ :error="details.errors.email"
66
+ required
67
+ />
68
+
69
+ <Button type="submit" :disabled="details.processing">
70
+ {{ details.processing ? "Saving…" : "Save details" }}
71
+ </Button>
72
+ </form>
73
+ </Card>
74
+
75
+ <Card class="p-6 sm:p-8">
76
+ <h2 class="text-lg font-semibold">Password</h2>
77
+
78
+ <form class="mt-4 space-y-5" @submit.prevent="changePassword">
79
+ <TextField
80
+ v-model="password.current_password"
81
+ label="Current password"
82
+ type="password"
83
+ autocomplete="current-password"
84
+ :error="password.errors.current_password"
85
+ required
86
+ />
87
+ <TextField
88
+ v-model="password.password"
89
+ label="New password"
90
+ type="password"
91
+ autocomplete="new-password"
92
+ hint="At least 8 characters."
93
+ :error="password.errors.password"
94
+ required
95
+ />
96
+ <TextField
97
+ v-model="password.password_confirmation"
98
+ label="Confirm new password"
99
+ type="password"
100
+ autocomplete="new-password"
101
+ :error="password.errors.password_confirmation"
102
+ required
103
+ />
104
+
105
+ <Button type="submit" :disabled="password.processing">
106
+ {{ password.processing ? "Updating…" : "Change password" }}
107
+ </Button>
108
+ </form>
109
+ </Card>
110
+
111
+ <Card class="p-6 sm:p-8">
112
+ <h2 class="text-lg font-semibold">Session</h2>
113
+ <p class="mt-1 text-sm text-muted-foreground">Sign out of this browser.</p>
114
+ <Button type="button" variant="secondary" class="mt-4" @click="router.post('/logout')">
115
+ Sign out
116
+ </Button>
117
+ </Card>
118
+ </div>
119
+ </template>