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,37 @@
|
|
|
1
|
+
import { Resource, text, textInput, toggle, formSection } from "@zerotal/admin";
|
|
2
|
+
import { Setting } from "@app/models/Setting";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Site settings — one row, edited directly.
|
|
6
|
+
*
|
|
7
|
+
* `singular` collapses the usual list → view → edit chain into a single route:
|
|
8
|
+
* `/admin/settings` opens the form. There is nothing to list when there is only
|
|
9
|
+
* ever one record, and no id worth putting in a URL.
|
|
10
|
+
*/
|
|
11
|
+
export class SettingsResource extends Resource {
|
|
12
|
+
static override model = Setting;
|
|
13
|
+
static override singular = true;
|
|
14
|
+
static override slug = "settings";
|
|
15
|
+
static override label = "Settings";
|
|
16
|
+
static override pluralLabel = "Settings";
|
|
17
|
+
static override navigationIcon = "shield";
|
|
18
|
+
static override navigationGroup = "System";
|
|
19
|
+
static override navigationSort = 99;
|
|
20
|
+
|
|
21
|
+
static override columns() {
|
|
22
|
+
return [text("siteName").label("Site name")];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static override form() {
|
|
26
|
+
return [
|
|
27
|
+
formSection("Identity")
|
|
28
|
+
.description("How the site presents itself")
|
|
29
|
+
.columns(2)
|
|
30
|
+
.schema([
|
|
31
|
+
textInput("siteName").label("Site name").required().default("My Shop").maxLength(80),
|
|
32
|
+
textInput("supportEmail").label("Support email").email().default("support@example.com"),
|
|
33
|
+
toggle("ordersOpen").label("Accepting orders").default(true).columnSpan(2),
|
|
34
|
+
]),
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Resource,
|
|
3
|
+
text,
|
|
4
|
+
imageColumn,
|
|
5
|
+
textInput,
|
|
6
|
+
select,
|
|
7
|
+
formSection,
|
|
8
|
+
section,
|
|
9
|
+
textEntry,
|
|
10
|
+
imageEntry,
|
|
11
|
+
createAction,
|
|
12
|
+
exportAction,
|
|
13
|
+
} from "@zerotal/admin";
|
|
14
|
+
import { Hash } from "zerotal/auth";
|
|
15
|
+
import { User } from "@app/models/User";
|
|
16
|
+
import { humanDate } from "@app/admin/shared";
|
|
17
|
+
|
|
18
|
+
const ROLES = {
|
|
19
|
+
admin: "Administrator",
|
|
20
|
+
editor: "Editor",
|
|
21
|
+
support: "Support",
|
|
22
|
+
viewer: "Viewer",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Panel users. Sits outside every cluster — access is nobody's business area. */
|
|
26
|
+
export class UserResource extends Resource {
|
|
27
|
+
static override model = User;
|
|
28
|
+
static override navigationIcon = "shield";
|
|
29
|
+
static override navigationGroup = "System";
|
|
30
|
+
static override navigationSort = 90;
|
|
31
|
+
static override recordTitleAttribute = "name";
|
|
32
|
+
static override defaultSort = { column: "created_at", direction: "desc" as const };
|
|
33
|
+
|
|
34
|
+
static override headerActions() {
|
|
35
|
+
return [createAction(), exportAction()];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
static override columns() {
|
|
39
|
+
return [
|
|
40
|
+
imageColumn("avatarUrl").label("").circular().exportable(false),
|
|
41
|
+
text("name").searchable().sortable(),
|
|
42
|
+
text("email").searchable().copyable(),
|
|
43
|
+
text("roles")
|
|
44
|
+
.label("Roles")
|
|
45
|
+
.format((v) => (Array.isArray(v) && v.length > 0 ? v.join(", ") : "—")),
|
|
46
|
+
text("createdAt")
|
|
47
|
+
.label("Joined")
|
|
48
|
+
.sortable()
|
|
49
|
+
.format((v) => humanDate(v)),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
static override form() {
|
|
54
|
+
return [
|
|
55
|
+
formSection("Identity")
|
|
56
|
+
.columns(2)
|
|
57
|
+
.schema([
|
|
58
|
+
textInput("name").required().minLength(2).maxLength(120).autocomplete("name"),
|
|
59
|
+
textInput("email").email().required().maxLength(160).autocomplete("email"),
|
|
60
|
+
textInput("avatarUrl").label("Avatar URL").url().columnSpan(2),
|
|
61
|
+
select("roles").label("Roles").multiple().searchable().options(ROLES).columnSpan(2),
|
|
62
|
+
]),
|
|
63
|
+
formSection("Security").schema([
|
|
64
|
+
// Only asked for on create, and hashed on the way to the database — the
|
|
65
|
+
// panel never holds a plaintext password.
|
|
66
|
+
textInput("password")
|
|
67
|
+
.password()
|
|
68
|
+
.required()
|
|
69
|
+
.minLength(8)
|
|
70
|
+
.helperText("At least 8 characters.")
|
|
71
|
+
.visibleOn("create")
|
|
72
|
+
.columnSpan(2)
|
|
73
|
+
.mutate((value) => Hash.make(String(value))),
|
|
74
|
+
]),
|
|
75
|
+
];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static override infolist() {
|
|
79
|
+
return [
|
|
80
|
+
section("Profile")
|
|
81
|
+
.icon("shield")
|
|
82
|
+
.columns(2)
|
|
83
|
+
.schema([
|
|
84
|
+
imageEntry("avatarUrl").label("Avatar").circular().height(64),
|
|
85
|
+
textEntry("name").weight("semibold").size("lg"),
|
|
86
|
+
textEntry("email").icon("mail").copyable(),
|
|
87
|
+
textEntry("roles")
|
|
88
|
+
.label("Roles")
|
|
89
|
+
.format((v) => (Array.isArray(v) && v.length > 0 ? v.join(", ") : "—")),
|
|
90
|
+
textEntry("createdAt").label("Joined").icon("calendar").since(),
|
|
91
|
+
]),
|
|
92
|
+
];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static override emptyState() {
|
|
96
|
+
return {
|
|
97
|
+
heading: "No users yet",
|
|
98
|
+
description: "Invite the people who will run this panel.",
|
|
99
|
+
icon: "shield",
|
|
100
|
+
actions: [createAction()],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Admin panel wiring. AdminProvider loads this file on boot, so every
|
|
2
|
+
// Panel.configure / register call below runs once at startup.
|
|
3
|
+
//
|
|
4
|
+
// This is the whole application: there is no file-based routing and no
|
|
5
|
+
// hand-written pages. Add a resource, register it here, and its list, view,
|
|
6
|
+
// create and edit pages exist.
|
|
7
|
+
//
|
|
8
|
+
// One panel is enough for most apps. If you ever need a second audience on a
|
|
9
|
+
// second path, `Panel.make("console", { path: "/app" })` gives you an
|
|
10
|
+
// independent registry that shares this one's models and sign-in.
|
|
11
|
+
import { Panel } from "@zerotal/admin";
|
|
12
|
+
import { AuthMiddleware, GuestMiddleware } from "zerotal/auth";
|
|
13
|
+
|
|
14
|
+
import { ProductResource } from "@app/admin/ProductResource";
|
|
15
|
+
import { UserResource } from "@app/admin/UserResource";
|
|
16
|
+
import { SettingsResource } from "@app/admin/SettingsResource";
|
|
17
|
+
import { dashboardWidgets } from "@app/admin/widgets";
|
|
18
|
+
|
|
19
|
+
Panel.configure({
|
|
20
|
+
brand: "{{name}}",
|
|
21
|
+
tagline: "Admin",
|
|
22
|
+
path: "/admin",
|
|
23
|
+
// Every panel route is behind a signed-in session, and the guard sends people
|
|
24
|
+
// to the panel's own login rather than the framework default. Without a
|
|
25
|
+
// `middleware` here the panel refuses to serve outside local development —
|
|
26
|
+
// it exposes full CRUD, so the safe default is closed.
|
|
27
|
+
middleware: [AuthMiddleware.with({ redirectTo: "/admin/login" })],
|
|
28
|
+
userMenu: {
|
|
29
|
+
label: "Account",
|
|
30
|
+
items: [
|
|
31
|
+
{ label: "Profile", href: "/admin/profile", icon: "users" },
|
|
32
|
+
{ label: "Settings", href: "/admin/settings", icon: "shield" },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// The panel's own login and profile screens.
|
|
38
|
+
Panel.auth({
|
|
39
|
+
enabled: true,
|
|
40
|
+
heading: "{{name}}",
|
|
41
|
+
guestMiddleware: [GuestMiddleware],
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
Panel.widgets(...dashboardWidgets());
|
|
45
|
+
|
|
46
|
+
Panel.register(ProductResource, UserResource, SettingsResource);
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Formatting helpers shared by the resources. */
|
|
2
|
+
|
|
3
|
+
/** Render minor units as money — prices are stored as integers, never floats. */
|
|
4
|
+
export function money(value: unknown, currency = "USD"): string {
|
|
5
|
+
const cents = Number(value);
|
|
6
|
+
if (!Number.isFinite(cents)) return "—";
|
|
7
|
+
return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(cents / 100);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Trim a value to `n` characters with an ellipsis, for table cells. */
|
|
11
|
+
export function excerpt(value: unknown, n = 60): string {
|
|
12
|
+
const s = String(value ?? "").trim();
|
|
13
|
+
if (!s) return "—";
|
|
14
|
+
return s.length > n ? `${s.slice(0, n).trimEnd()}…` : s;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Make a URL slug from arbitrary text. */
|
|
18
|
+
export function slugify(value: string): string {
|
|
19
|
+
return value
|
|
20
|
+
.toLowerCase()
|
|
21
|
+
.trim()
|
|
22
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
23
|
+
.replace(/^-+|-+$/g, "")
|
|
24
|
+
.slice(0, 80);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Coerce an empty form value to null, for nullable columns. */
|
|
28
|
+
export function nullIfEmpty(value: unknown): unknown {
|
|
29
|
+
return value === "" || value == null ? null : value;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Format a timestamp column as a relative time. */
|
|
33
|
+
export function humanDate(value: unknown): string {
|
|
34
|
+
const c = value as { diffForHumans?: () => string } | null | undefined;
|
|
35
|
+
return c?.diffForHumans ? c.diffForHumans() : "—";
|
|
36
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { statsWidget, stat, chartWidget } from "@zerotal/admin";
|
|
2
|
+
import type { DashboardWidget } from "@zerotal/admin";
|
|
3
|
+
import { Product } from "@app/models/Product";
|
|
4
|
+
import { User } from "@app/models/User";
|
|
5
|
+
import { money } from "@app/admin/shared";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The dashboard. A widget is a data provider — the panel owns the markup — so
|
|
9
|
+
* these stay easy to read and easy to test.
|
|
10
|
+
*
|
|
11
|
+
* `.poll()` re-renders on an interval, which is worth it for numbers someone
|
|
12
|
+
* actually watches and wasteful for the rest.
|
|
13
|
+
*/
|
|
14
|
+
export function dashboardWidgets(): DashboardWidget[] {
|
|
15
|
+
return [
|
|
16
|
+
statsWidget(async () => {
|
|
17
|
+
const [products, active, users, value] = await Promise.all([
|
|
18
|
+
Product.count(),
|
|
19
|
+
Product.query().where("status", "active").count(),
|
|
20
|
+
User.count(),
|
|
21
|
+
Product.query()
|
|
22
|
+
.get()
|
|
23
|
+
.then((rows) =>
|
|
24
|
+
(rows as unknown as { price?: number; stock?: number }[]).reduce(
|
|
25
|
+
(sum, r) => sum + Number(r.price ?? 0) * Number(r.stock ?? 0),
|
|
26
|
+
0,
|
|
27
|
+
),
|
|
28
|
+
),
|
|
29
|
+
]);
|
|
30
|
+
|
|
31
|
+
return [
|
|
32
|
+
stat("Products", products).icon("collection").tone("primary"),
|
|
33
|
+
stat("Active", active).icon("check-circle").tone("success"),
|
|
34
|
+
stat("Stock value", money(value)).icon("database"),
|
|
35
|
+
stat("Users", users).icon("users").tone("muted"),
|
|
36
|
+
];
|
|
37
|
+
}).poll("30s"),
|
|
38
|
+
|
|
39
|
+
chartWidget("Catalogue by status", async () => {
|
|
40
|
+
const statuses = ["draft", "active", "discontinued"];
|
|
41
|
+
const counts = await Promise.all(
|
|
42
|
+
statuses.map((s) => Product.query().where("status", s).count()),
|
|
43
|
+
);
|
|
44
|
+
return {
|
|
45
|
+
type: "doughnut",
|
|
46
|
+
labels: ["Draft", "Active", "Discontinued"],
|
|
47
|
+
datasets: [{ data: counts }],
|
|
48
|
+
};
|
|
49
|
+
}),
|
|
50
|
+
];
|
|
51
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { BaseModel, column, table } from "zerotal/orm";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Something the shop sells. Soft-deleted, so a discontinued product can be
|
|
5
|
+
* restored rather than lost.
|
|
6
|
+
*/
|
|
7
|
+
@table("products")
|
|
8
|
+
export class Product extends BaseModel {
|
|
9
|
+
static override softDeletes = true;
|
|
10
|
+
static override fillable = ["name", "sku", "description", "price", "stock", "status", "featured"];
|
|
11
|
+
|
|
12
|
+
@column() name!: string;
|
|
13
|
+
@column() sku!: string;
|
|
14
|
+
@column({ nullable: true }) description?: string;
|
|
15
|
+
/** Price in minor units, so money never rides on a float. */
|
|
16
|
+
@column("integer") price!: number;
|
|
17
|
+
@column({ cast: "integer", nullable: true }) stock?: number;
|
|
18
|
+
/** draft | active | discontinued — drives the filter tabs and a select column. */
|
|
19
|
+
@column({ nullable: true }) status?: string;
|
|
20
|
+
@column({ cast: "boolean", nullable: true }) featured?: boolean;
|
|
21
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { BaseModel, column, table } from "zerotal/orm";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Site-wide configuration — exactly one row, edited through a singular resource
|
|
5
|
+
* rather than a list. See `app/admin/SettingsResource.ts`.
|
|
6
|
+
*/
|
|
7
|
+
@table("settings")
|
|
8
|
+
export class Setting extends BaseModel {
|
|
9
|
+
static override fillable = ["siteName", "supportEmail", "ordersOpen"];
|
|
10
|
+
|
|
11
|
+
@column() siteName!: string;
|
|
12
|
+
@column({ nullable: true }) supportEmail?: string;
|
|
13
|
+
@column({ cast: "boolean", nullable: true }) ordersOpen?: boolean;
|
|
14
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { BaseModelWith, column, table } from "zerotal/orm";
|
|
2
|
+
import { Authenticatable } from "zerotal/auth";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A person who signs into the panel. Also the author side of the blog: posts
|
|
6
|
+
* belong to a user.
|
|
7
|
+
*/
|
|
8
|
+
@table("users")
|
|
9
|
+
export class User extends BaseModelWith(Authenticatable) {
|
|
10
|
+
static override hidden = ["password"];
|
|
11
|
+
static override fillable = ["name", "email", "password", "roles", "avatarUrl"];
|
|
12
|
+
|
|
13
|
+
@column() name!: string;
|
|
14
|
+
@column() email!: string;
|
|
15
|
+
@column() password!: string;
|
|
16
|
+
/** Assigned roles — drives a multi-select field and a badge column. */
|
|
17
|
+
@column({ cast: "json", nullable: true }) roles?: string[];
|
|
18
|
+
/** Profile picture, shown as a circular image entry on the view page. */
|
|
19
|
+
@column({ nullable: true }) avatarUrl?: string;
|
|
20
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Application } from "zerotal";
|
|
2
|
+
import providers from "./providers";
|
|
3
|
+
import type { User } from "../app/models/User.ts";
|
|
4
|
+
|
|
5
|
+
// Tell @zerotal/auth which model `Auth.user()` returns. The empty body is the
|
|
6
|
+
// point: this augmentation exists to bind the type, not to add members.
|
|
7
|
+
declare module "zerotal/auth" {
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
|
9
|
+
interface UserModel extends User {}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// No file-based routing: this app is the admin panel. AdminProvider loads
|
|
13
|
+
// `app/admin/index.ts` on boot and mounts every route from what it registers.
|
|
14
|
+
const app = Application.create({ providers });
|
|
15
|
+
|
|
16
|
+
export default app;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { LogProvider } from "zerotal/logger";
|
|
2
|
+
import { DatabaseProvider } from "zerotal/orm";
|
|
3
|
+
import { CacheProvider } from "zerotal/cache";
|
|
4
|
+
import { SessionProvider } from "zerotal/session";
|
|
5
|
+
import { AuthProvider } from "zerotal/auth";
|
|
6
|
+
import { FlowProvider } from "@zerotal/flow";
|
|
7
|
+
import { AdminProvider } from "@zerotal/admin";
|
|
8
|
+
|
|
9
|
+
// Order matters: the database backs every resource, the cache holds the panel's
|
|
10
|
+
// navigation-badge counts, and the session underpins auth. FlowProvider is
|
|
11
|
+
// listed explicitly (before AdminProvider, which also `dependsOn` it) so its CLI
|
|
12
|
+
// tooling boots in `bun zt`, where the admin panel itself does not run.
|
|
13
|
+
const providers = [
|
|
14
|
+
LogProvider,
|
|
15
|
+
DatabaseProvider,
|
|
16
|
+
CacheProvider,
|
|
17
|
+
SessionProvider,
|
|
18
|
+
AuthProvider,
|
|
19
|
+
FlowProvider,
|
|
20
|
+
AdminProvider,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export default providers;
|
|
@@ -0,0 +1,22 @@
|
|
|
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 signs the session cookie) and should be
|
|
9
|
+
* a random 32 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
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { env } from "zerotal";
|
|
2
|
+
import { DatabaseConfig } from "zerotal/orm";
|
|
3
|
+
|
|
4
|
+
export default DatabaseConfig({
|
|
5
|
+
driver: "sqlite",
|
|
6
|
+
// `bun zt test` sets ZT_DB_URL (defaults to :memory:) so the suite runs
|
|
7
|
+
// against an isolated database instead of your local dev file.
|
|
8
|
+
url: env("DATABASE_URL", env("ZT_DB_URL", "./database/db.sqlite")),
|
|
9
|
+
|
|
10
|
+
// Additive schema sync for local development — creates every demo table from
|
|
11
|
+
// the models at boot, so `bun run dev` works with no migration step. Hard-off
|
|
12
|
+
// in production, where you run generated migrations instead.
|
|
13
|
+
synchronize: env("APP_ENV", "local") !== "production",
|
|
14
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { env } from "zerotal";
|
|
2
|
+
import { SessionConfig } from "zerotal/session";
|
|
3
|
+
|
|
4
|
+
export default SessionConfig({
|
|
5
|
+
driver: "cookie",
|
|
6
|
+
// Cookie sessions are signed, so the driver needs the app key.
|
|
7
|
+
secret: env("APP_KEY", ""),
|
|
8
|
+
cookie: env("SESSION_COOKIE", "zerotal_session"),
|
|
9
|
+
lifetime: 60 * 24 * 7,
|
|
10
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Seeder } from "zerotal/orm";
|
|
2
|
+
import { Hash } from "zerotal/auth";
|
|
3
|
+
import { User } from "@app/models/User";
|
|
4
|
+
import { Setting } from "@app/models/Setting";
|
|
5
|
+
import { Product } from "@app/models/Product";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Demo data, so the panel has something to show on the first run.
|
|
9
|
+
*
|
|
10
|
+
* Deterministic on purpose — a reseed produces the same catalogue rather than
|
|
11
|
+
* noise. Run it with `bun run seed`.
|
|
12
|
+
*/
|
|
13
|
+
export class DatabaseSeeder extends Seeder {
|
|
14
|
+
async run(): Promise<void> {
|
|
15
|
+
await User.create({
|
|
16
|
+
name: "Admin",
|
|
17
|
+
email: "admin@example.com",
|
|
18
|
+
password: await Hash.make("password"),
|
|
19
|
+
roles: ["admin"],
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
await Setting.create({
|
|
23
|
+
siteName: "{{name}}",
|
|
24
|
+
supportEmail: "support@example.com",
|
|
25
|
+
ordersOpen: true,
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const names = [
|
|
29
|
+
"Desk Lamp",
|
|
30
|
+
"Mechanical Keyboard",
|
|
31
|
+
"USB-C Hub",
|
|
32
|
+
"Standing Desk",
|
|
33
|
+
"Monitor Arm",
|
|
34
|
+
"Laptop Stand",
|
|
35
|
+
];
|
|
36
|
+
const statuses = ["active", "active", "draft", "discontinued"];
|
|
37
|
+
|
|
38
|
+
for (const [i, name] of names.entries()) {
|
|
39
|
+
await Product.create({
|
|
40
|
+
name,
|
|
41
|
+
sku: `SKU-${String(1000 + i)}`,
|
|
42
|
+
description: `A dependable ${name.toLowerCase()} for everyday work.`,
|
|
43
|
+
price: 1999 + i * 1500,
|
|
44
|
+
stock: i % 5 === 0 ? 4 : 12 + i * 3,
|
|
45
|
+
status: statuses[i % statuses.length]!,
|
|
46
|
+
featured: i % 3 === 0,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
+
"seed": "bun zt.ts db:seed",
|
|
11
|
+
"test": "bun zt.ts test"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@zerotal/admin": "{{zerotal_version}}",
|
|
15
|
+
"@zerotal/flow": "{{zerotal_version}}",
|
|
16
|
+
"zerotal": "{{zerotal_version}}"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { beforeAll, describe, test, expect } from "bun:test";
|
|
2
|
+
import { createTestApp, type TestApp } from "zerotal/testing";
|
|
3
|
+
import { Panel } from "@zerotal/admin";
|
|
4
|
+
import { User } from "@app/models/User.ts";
|
|
5
|
+
import { Product } from "@app/models/Product.ts";
|
|
6
|
+
|
|
7
|
+
// Boots the real app: AdminProvider auto-loads app/admin/index.ts and mounts
|
|
8
|
+
// the panel, so these tests exercise the wiring the browser gets.
|
|
9
|
+
let app: TestApp;
|
|
10
|
+
let staff: User;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
Bun.env.APP_KEY ??= "test-app-key-aaaaaaaaaaaaaaaaaaaaaaaa";
|
|
14
|
+
app = await createTestApp(() => import("../bootstrap/app.ts").then((m) => m.default));
|
|
15
|
+
|
|
16
|
+
staff = await User.create({
|
|
17
|
+
name: "Ada Admin",
|
|
18
|
+
email: `staff-${Date.now()}@example.com`,
|
|
19
|
+
password: "password",
|
|
20
|
+
roles: ["admin"],
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("the panel", () => {
|
|
25
|
+
test("registers the resources", () => {
|
|
26
|
+
expect(Panel.default().resources().map((r) => r.getSlug()).sort()).toEqual([
|
|
27
|
+
"products",
|
|
28
|
+
"settings",
|
|
29
|
+
"users",
|
|
30
|
+
]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("sends guests to the login page", async () => {
|
|
34
|
+
const res = await app.actingAsGuest().get("/admin", { Accept: "text/html" });
|
|
35
|
+
res.assertRedirect("/admin/login");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("shows the dashboard to a signed-in user", async () => {
|
|
39
|
+
const res = await app.actingAs(staff).get("/admin", { Accept: "text/html" });
|
|
40
|
+
res.assertOk();
|
|
41
|
+
res.assertSee("Products");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("lists products with their filter tabs", async () => {
|
|
45
|
+
await Product.create({ name: "Test Widget", sku: "SKU-T1", price: 4999, status: "active" });
|
|
46
|
+
|
|
47
|
+
const res = await app.actingAs(staff).get("/admin/products", { Accept: "text/html" });
|
|
48
|
+
res.assertOk();
|
|
49
|
+
res.assertSee("Test Widget");
|
|
50
|
+
res.assertSee("Drafts"); // a filter tab
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("opens settings straight onto its form, with no record id", async () => {
|
|
54
|
+
const res = await app.actingAs(staff).get("/admin/settings", { Accept: "text/html" });
|
|
55
|
+
res.assertOk();
|
|
56
|
+
res.assertSee("Site name");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
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/flow",
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
"allowImportingTsExtensions": true,
|
|
15
|
+
"paths": {
|
|
16
|
+
"@app/*": ["./app/*"]
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"exclude": ["node_modules", ".zerotal"]
|
|
20
|
+
}
|
|
@@ -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 db:seed Populate the demo data
|
|
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"));
|