@voltro/cli 0.27.0 → 0.29.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 +513 -0
- package/THIRD-PARTY-NOTICES.md +88 -2
- package/dist/{apiBuild-D22_EpoR.js → apiBuild-BESZbTjA.js} +4 -4
- package/dist/apiBuild-C8d74T6g.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-BPAtyWs8.js +2420 -0
- package/dist/checkCommand-DBvZvCh1.js +2 -0
- package/dist/cliRuntime-Oh517vCV.js +96 -0
- package/dist/{commands-jBX8no1I.js → commands-82BDEktg.js} +6476 -7672
- package/dist/dbCommand-CaIVTp-a.js +2 -0
- package/dist/{dbCommand-uuNCrFAb.js → dbCommand-bCBbk6tz.js} +3 -3
- package/dist/{dev-DcbIJrWg.js → dev-Cg4BKLmi.js} +3034 -2619
- package/dist/dev-DeEJj5T4.js +3 -0
- package/dist/fileConventions-Cof68_BL.js +33 -0
- package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-4Db40V23.js} +2 -2
- package/dist/frameworkTableAssembly-BTNirAAk.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspect-CUCCzw2I.js → inspect-BMvQpwqy.js} +211 -190
- package/dist/inspect-DHeaMZak.js +2 -0
- package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-DHYygE4s.js} +999 -961
- package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-C4R1EdHn.js} +1 -1
- package/dist/manifestBuild-Ck_90gZy.js +2 -0
- package/dist/sdkgen-CW5NbYF0.js +582 -0
- package/dist/{seedRunner-D6eu-u5U.js → seedRunner-Bqxgp7HZ.js} +60 -59
- package/dist/serveCommand-BXl8mhZD.js +1489 -0
- package/dist/serveEntry.js +3 -3
- package/dist/{start-BGXIf6zT.js → start-CsCIaW4-.js} +282 -269
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +2 -1
- package/templates/agent-docs/_index.md +2 -1
- package/templates/agent-docs/_manifest.json +10 -1
- package/templates/agent-docs/ai.md +135 -0
- package/templates/agent-docs/cli.md +124 -6
- package/templates/agent-docs/configuration.md +32 -0
- package/templates/agent-docs/data.md +282 -0
- package/templates/agent-docs/database/migrations.md +47 -0
- package/templates/agent-docs/database/misc.md +57 -0
- package/templates/agent-docs/database/schema.md +3 -1
- package/templates/agent-docs/deployment.md +56 -0
- package/templates/agent-docs/internationalization.md +161 -1
- package/templates/agent-docs/local-first-mobile.md +414 -0
- package/templates/agent-docs/routing.md +93 -2
- package/templates/agent-docs/schema-driven-ui.md +12 -0
- package/templates/agent-docs/templates/apibackends.md +1 -1
- package/templates/agent-docs/testing.md +42 -0
- package/templates/agent-docs/whats-new.md +165 -131
- package/templates/agent-docs/workflows.md +11 -0
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/.env.example +19 -0
- package/templates/apps/api-backend-sqlite/README.md +38 -0
- package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
- package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
- package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
- package/templates/apps/api-backend-sqlite/package.json +30 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
- package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
- package/templates/apps/api-backend-sqlite/template.json +6 -0
- package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
- package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/README.md +104 -0
- package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
- package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
- package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
- package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
- package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
- package/templates/apps/api-cms/actions/me.action.ts +16 -0
- package/templates/apps/api-cms/app.config.ts +61 -0
- package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
- package/templates/apps/api-cms/content/index.ts +18 -0
- package/templates/apps/api-cms/content/page.contentType.ts +24 -0
- package/templates/apps/api-cms/database/schema.ts +64 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
- package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
- package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
- package/templates/apps/api-cms/package.json +32 -0
- package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
- package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
- package/templates/apps/api-cms/template.json +6 -0
- package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
- package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
- package/templates/apps/api-cms/tsconfig.json +5 -0
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/README.md +103 -0
- package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
- package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
- package/templates/apps/api-saas-starter/app.config.ts +87 -0
- package/templates/apps/api-saas-starter/database/schema.ts +57 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
- package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
- package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
- package/templates/apps/api-saas-starter/package.json +32 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
- package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
- package/templates/apps/api-saas-starter/template.json +6 -0
- package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
- package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
- package/templates/apps/api-saas-starter/tsconfig.json +5 -0
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/README.md +88 -0
- package/templates/apps/api-status/app.config.ts +36 -0
- package/templates/apps/api-status/authz.ts +33 -0
- package/templates/apps/api-status/database/schema.ts +70 -0
- package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
- package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
- package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
- package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
- package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
- package/templates/apps/api-status/package.json +30 -0
- package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/components.list.query.ts +13 -0
- package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
- package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
- package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
- package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
- package/templates/apps/api-status/template.json +6 -0
- package/templates/apps/api-status/tests/status.test.ts +70 -0
- package/templates/apps/api-status/tsconfig.json +5 -0
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-auth/README.md +78 -0
- package/templates/apps/frontend-auth/app.config.ts +34 -0
- package/templates/apps/frontend-auth/package.json +32 -0
- package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
- package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
- package/templates/apps/frontend-auth/src/config.ts +11 -0
- package/templates/apps/frontend-auth/src/globals.css +105 -0
- package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
- package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
- package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
- package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
- package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
- package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
- package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
- package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
- package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
- package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
- package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
- package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
- package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
- package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
- package/templates/apps/frontend-auth/template.json +6 -0
- package/templates/apps/frontend-auth/tsconfig.json +5 -0
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/README.md +47 -0
- package/templates/apps/frontend-cms/app.config.ts +37 -0
- package/templates/apps/frontend-cms/package.json +33 -0
- package/templates/apps/frontend-cms/src/config.ts +8 -0
- package/templates/apps/frontend-cms/src/globals.css +105 -0
- package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
- package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
- package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
- package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
- package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
- package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
- package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
- package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
- package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
- package/templates/apps/frontend-cms/template.json +6 -0
- package/templates/apps/frontend-cms/tsconfig.json +5 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/README.md +71 -0
- package/templates/apps/frontend-portal/app.config.ts +37 -0
- package/templates/apps/frontend-portal/package.json +32 -0
- package/templates/apps/frontend-portal/src/config.ts +8 -0
- package/templates/apps/frontend-portal/src/globals.css +93 -0
- package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
- package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
- package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
- package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
- package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
- package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
- package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
- package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
- package/templates/apps/frontend-portal/template.json +6 -0
- package/templates/apps/frontend-portal/tsconfig.json +5 -0
- package/templates/apps/frontend-saas/README.md +73 -0
- package/templates/apps/frontend-saas/app.config.ts +46 -0
- package/templates/apps/frontend-saas/package.json +32 -0
- package/templates/apps/frontend-saas/src/config.ts +8 -0
- package/templates/apps/frontend-saas/src/globals.css +85 -0
- package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
- package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
- package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
- package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
- package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
- package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
- package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
- package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
- package/templates/apps/frontend-saas/template.json +6 -0
- package/templates/apps/frontend-saas/tsconfig.json +5 -0
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/README.md +51 -0
- package/templates/apps/frontend-status/app.config.ts +39 -0
- package/templates/apps/frontend-status/package.json +32 -0
- package/templates/apps/frontend-status/src/config.ts +7 -0
- package/templates/apps/frontend-status/src/globals.css +70 -0
- package/templates/apps/frontend-status/src/globals.d.ts +6 -0
- package/templates/apps/frontend-status/src/lib/status.ts +81 -0
- package/templates/apps/frontend-status/src/locales/de.ts +41 -0
- package/templates/apps/frontend-status/src/locales/en.ts +45 -0
- package/templates/apps/frontend-status/src/locales/index.ts +13 -0
- package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
- package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
- package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
- package/templates/apps/frontend-status/template.json +6 -0
- package/templates/apps/frontend-status/tsconfig.json +11 -0
- package/templates/baselines/compose/docker-compose.prod.yml +15 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
- package/dist/apiBuild-BrjrVJJh.js +0 -2
- package/dist/dbCommand-DrzXimKf.js +0 -2
- package/dist/dev-DNkso403.js +0 -3
- package/dist/fileConventions-3bffWssN.js +0 -30
- package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
- package/dist/inspect-gt8bq-Tz.js +0 -2
- package/dist/manifestBuild-ifczArzr.js +0 -2
- package/dist/serveCommand-DfkisVWP.js +0 -1310
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/* Plain CSS — no Tailwind, so there's no @source scanner to configure. */
|
|
2
|
+
:root {
|
|
3
|
+
--bg: #ffffff;
|
|
4
|
+
--fg: #0a0a0a;
|
|
5
|
+
--muted: #6b7280;
|
|
6
|
+
--accent: #4f46e5;
|
|
7
|
+
--danger: #dc2626;
|
|
8
|
+
--border: #e5e7eb;
|
|
9
|
+
--panel: #f9fafb;
|
|
10
|
+
}
|
|
11
|
+
:root.dark {
|
|
12
|
+
--bg: #0a0a0a;
|
|
13
|
+
--fg: #fafafa;
|
|
14
|
+
--muted: #9ca3af;
|
|
15
|
+
--accent: #818cf8;
|
|
16
|
+
--danger: #f87171;
|
|
17
|
+
--border: #1f2937;
|
|
18
|
+
--panel: #111827;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
* { box-sizing: border-box; }
|
|
22
|
+
body {
|
|
23
|
+
margin: 0;
|
|
24
|
+
background: var(--bg);
|
|
25
|
+
color: var(--fg);
|
|
26
|
+
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
27
|
+
line-height: 1.55;
|
|
28
|
+
}
|
|
29
|
+
a { color: inherit; }
|
|
30
|
+
.muted { color: var(--muted); }
|
|
31
|
+
.spacer { flex: 1; }
|
|
32
|
+
|
|
33
|
+
/* Buttons + inputs */
|
|
34
|
+
.btn {
|
|
35
|
+
display: inline-block; cursor: pointer; border: 0; border-radius: 8px;
|
|
36
|
+
background: var(--accent); color: #fff; padding: 0.55rem 1rem;
|
|
37
|
+
font: inherit; font-weight: 600; text-decoration: none;
|
|
38
|
+
}
|
|
39
|
+
.btn:disabled { opacity: 0.6; cursor: default; }
|
|
40
|
+
.btn.ghost { background: transparent; color: var(--accent); border: 1px solid var(--border); }
|
|
41
|
+
.link { background: none; border: 0; color: var(--accent); cursor: pointer; font: inherit; padding: 0; }
|
|
42
|
+
input {
|
|
43
|
+
font: inherit; padding: 0.5rem 0.65rem; border: 1px solid var(--border);
|
|
44
|
+
border-radius: 8px; background: var(--bg); color: var(--fg); min-width: 0;
|
|
45
|
+
}
|
|
46
|
+
.error-text { color: var(--danger); }
|
|
47
|
+
|
|
48
|
+
/* Marketing */
|
|
49
|
+
.marketing header {
|
|
50
|
+
display: flex; align-items: center; gap: 1rem;
|
|
51
|
+
padding: 1rem 1.5rem; border-bottom: 1px solid var(--border);
|
|
52
|
+
}
|
|
53
|
+
.marketing .brand { font-weight: 700; text-decoration: none; }
|
|
54
|
+
.hero { max-width: 40rem; margin: 0 auto; padding: 3rem 1.5rem; }
|
|
55
|
+
.hero h1 { margin: 0 0 0.75rem; }
|
|
56
|
+
.auth-form { display: flex; flex-direction: column; gap: 0.85rem; max-width: 22rem; margin-top: 1.25rem; }
|
|
57
|
+
.auth-form label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9rem; }
|
|
58
|
+
|
|
59
|
+
/* Dashboard shell */
|
|
60
|
+
.dash { display: grid; grid-template-columns: 15rem 1fr; min-height: 100vh; }
|
|
61
|
+
.dash aside { border-right: 1px solid var(--border); padding: 1.25rem; background: var(--panel); }
|
|
62
|
+
.dash aside .brand { font-weight: 700; margin-bottom: 1rem; }
|
|
63
|
+
.dash nav { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
64
|
+
.dash nav a { text-decoration: none; color: var(--muted); padding: 0.35rem 0; }
|
|
65
|
+
.dash nav a[aria-current="page"] { color: var(--fg); font-weight: 600; }
|
|
66
|
+
.dash main { padding: 1.5rem 2rem; }
|
|
67
|
+
.topbar { display: flex; align-items: center; margin-bottom: 1rem; }
|
|
68
|
+
@media (max-width: 40rem) { .dash { grid-template-columns: 1fr; } .dash aside { border-right: 0; border-bottom: 1px solid var(--border); } }
|
|
69
|
+
|
|
70
|
+
/* Bits */
|
|
71
|
+
.row { display: flex; gap: 0.6rem; align-items: center; flex-wrap: wrap; margin-top: 0.5rem; }
|
|
72
|
+
.empty { color: var(--muted); margin-top: 1.5rem; }
|
|
73
|
+
.cards { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); gap: 0.75rem; }
|
|
74
|
+
.card { border: 1px solid var(--border); border-radius: 10px; padding: 0.9rem 1rem; background: var(--panel); }
|
|
75
|
+
.card .value { font-weight: 600; }
|
|
76
|
+
.card .label { color: var(--muted); font-size: 0.85rem; margin-top: 0.2rem; }
|
|
77
|
+
.paywall { border: 1px solid var(--accent); border-radius: 12px; padding: 1rem 1.25rem; margin-top: 1.25rem; background: color-mix(in oklab, var(--accent) 8%, transparent); }
|
|
78
|
+
.paywall p { margin: 0.35rem 0 0.75rem; }
|
|
79
|
+
.facts { display: grid; grid-template-columns: max-content 1fr; gap: 0.4rem 1.5rem; margin-top: 1rem; }
|
|
80
|
+
.facts dt { color: var(--muted); }
|
|
81
|
+
.facts dd { margin: 0; font-weight: 600; }
|
|
82
|
+
.rows { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 0.4rem; }
|
|
83
|
+
.rows li { display: flex; justify-content: space-between; align-items: center; border: 1px solid var(--border); border-radius: 8px; padding: 0.6rem 0.9rem; background: var(--panel); }
|
|
84
|
+
.tag { font-size: 0.8rem; color: var(--muted); border: 1px solid var(--border); border-radius: 999px; padding: 0.05rem 0.55rem; }
|
|
85
|
+
code { background: color-mix(in oklab, var(--fg) 8%, transparent); padding: 0.1em 0.35em; border-radius: 4px; }
|
|
86
|
+
|
|
87
|
+
/* CMS editor shell */
|
|
88
|
+
.cms-top { display: flex; align-items: center; gap: 1rem; padding: 0.9rem 1.5rem; border-bottom: 1px solid var(--border); }
|
|
89
|
+
.cms-top .brand { font-weight: 700; }
|
|
90
|
+
.cms-grid { display: grid; grid-template-columns: 22rem 1fr; gap: 1.5rem; padding: 1.5rem; align-items: start; }
|
|
91
|
+
.cms-main { padding: 2rem 1.5rem; }
|
|
92
|
+
.cms-types { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
|
93
|
+
.chip { cursor: pointer; border: 1px solid var(--border); background: var(--panel); color: var(--fg); border-radius: 999px; padding: 0.25rem 0.7rem; font: inherit; }
|
|
94
|
+
.chip.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
|
95
|
+
.label { color: var(--muted); font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.03em; margin: 0 0 0.5rem; }
|
|
96
|
+
.cms-list .rows li { gap: 0.5rem; }
|
|
97
|
+
.cms-list .rows .grow { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
98
|
+
.cms-editor form [data-cms-form] { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
99
|
+
.cms-editor [data-cms-field] { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
100
|
+
.cms-editor [data-cms-field] label { color: var(--muted); font-size: 0.85rem; }
|
|
101
|
+
.cms-editor input, .cms-editor textarea, .cms-editor select { font: inherit; padding: 0.5rem 0.65rem; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); color: var(--fg); width: 100%; }
|
|
102
|
+
.cms-editor textarea { min-height: 6rem; }
|
|
103
|
+
.violations { color: var(--danger); list-style: none; padding: 0; margin: 0.75rem 0 0; font-size: 0.9rem; }
|
|
104
|
+
.ok { color: var(--accent); margin-top: 0.75rem; }
|
|
105
|
+
@media (max-width: 48rem) { .cms-grid { grid-template-columns: 1fr; } }
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Ambient declarations.
|
|
2
|
+
//
|
|
3
|
+
// `declare module '*.css'`: TypeScript with `moduleResolution: Bundler` rejects
|
|
4
|
+
// a bare `import './globals.css'` unless the asset module is declared. Vite
|
|
5
|
+
// resolves it at build time; the declaration is type-only.
|
|
6
|
+
declare module '*.css'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// The shapes this editor reads off the api-cms backend, kept LOCAL so the pages
|
|
2
|
+
// are self-contained. They mirror the api's descriptor outputs
|
|
3
|
+
// (`content.types`, `content.list`, `content.saveDraft`).
|
|
4
|
+
import type { FieldSchema } from '@voltro/cms/web'
|
|
5
|
+
|
|
6
|
+
// The caller's resolved identity, from `session.me`. The SSR gate reads it.
|
|
7
|
+
export interface Identity {
|
|
8
|
+
readonly type: string
|
|
9
|
+
readonly id: string | null
|
|
10
|
+
readonly tenantId: string | null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// One field of a content type, as `content.types` sends it (the wire subset of
|
|
14
|
+
// @voltro/cms's FieldSchema — enough for <ContentForm> to pick a widget).
|
|
15
|
+
export interface FieldDescriptor {
|
|
16
|
+
readonly name: string
|
|
17
|
+
readonly kind: string
|
|
18
|
+
readonly editorHint: string
|
|
19
|
+
readonly isOptional: boolean
|
|
20
|
+
readonly maxLength?: number
|
|
21
|
+
readonly literals?: ReadonlyArray<string>
|
|
22
|
+
readonly referenceType?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// One registered content type.
|
|
26
|
+
export interface ContentTypeDescriptor {
|
|
27
|
+
readonly name: string
|
|
28
|
+
readonly displayName: string
|
|
29
|
+
readonly pluralName: string
|
|
30
|
+
readonly fields: ReadonlyArray<FieldDescriptor>
|
|
31
|
+
readonly columns: ReadonlyArray<string>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A row in the editor's list (the `content.list` projection).
|
|
35
|
+
export interface ContentRow {
|
|
36
|
+
readonly id: string
|
|
37
|
+
readonly status: string
|
|
38
|
+
readonly title?: string
|
|
39
|
+
readonly slug?: string
|
|
40
|
+
readonly updatedAt: string | Date
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// `content.saveDraft` returns a typed RESULT union — success or per-field
|
|
44
|
+
// violations the form annotates. NOT a thrown error.
|
|
45
|
+
export type SaveResult =
|
|
46
|
+
| { readonly ok: true; readonly id: string; readonly status: string }
|
|
47
|
+
| { readonly ok: false; readonly violations: ReadonlyArray<{ field: string; rule: string; message: string }> }
|
|
48
|
+
|
|
49
|
+
// Turn the wire field list into the `{ fields }` shape <ContentForm> reads. The
|
|
50
|
+
// wire carries `kind`/`editorHint` as strings; ContentForm only reads
|
|
51
|
+
// `editorHint` + `maxLength`/`literals`, so the cast at this boundary is safe.
|
|
52
|
+
export const toContentFormType = (
|
|
53
|
+
type: ContentTypeDescriptor,
|
|
54
|
+
): { fields: Record<string, FieldSchema> } => {
|
|
55
|
+
const fields: Record<string, FieldSchema> = {}
|
|
56
|
+
for (const f of type.fields) fields[f.name] = f as unknown as FieldSchema
|
|
57
|
+
return { fields }
|
|
58
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// German catalog. Mirrors every key in `en.ts` — `defineLocale<typeof en>()`
|
|
2
|
+
// fails the build if a key is missing. Translate the prose only; keep ICU
|
|
3
|
+
// placeholders (`{type}`) identical to en.ts.
|
|
4
|
+
import { defineLocale } from '@voltro/i18n'
|
|
5
|
+
import en from './en'
|
|
6
|
+
|
|
7
|
+
export default defineLocale<typeof en>()({
|
|
8
|
+
'meta.login.title': 'Anmelden',
|
|
9
|
+
'meta.editor.title': 'Inhalte',
|
|
10
|
+
|
|
11
|
+
'auth.signIn.heading': 'Beim CMS anmelden',
|
|
12
|
+
'auth.signUp.heading': 'Redakteurskonto erstellen',
|
|
13
|
+
'auth.email': 'E-Mail',
|
|
14
|
+
'auth.password': 'Passwort',
|
|
15
|
+
'auth.signIn.submit': 'Anmelden',
|
|
16
|
+
'auth.signUp.submit': 'Konto erstellen',
|
|
17
|
+
'auth.toggle.toSignUp': 'Kein Konto? Erstelle eins',
|
|
18
|
+
'auth.toggle.toSignIn': 'Konto vorhanden? Anmelden',
|
|
19
|
+
'auth.pending': 'Bitte warten…',
|
|
20
|
+
'auth.error': 'Das hat nicht geklappt. Prüfe deine Angaben und versuche es erneut.',
|
|
21
|
+
|
|
22
|
+
'editor.brand': 'Inhalte',
|
|
23
|
+
'editor.signOut': 'Abmelden',
|
|
24
|
+
'lang.label': 'Sprache',
|
|
25
|
+
|
|
26
|
+
'editor.types.heading': 'Inhaltstypen',
|
|
27
|
+
'editor.list.heading': '{type}',
|
|
28
|
+
'editor.list.empty': 'Noch keine Entwürfe — erstelle einen mit dem Formular rechts.',
|
|
29
|
+
'editor.row.edit': 'Bearbeiten',
|
|
30
|
+
'editor.row.publish': 'Veröffentlichen',
|
|
31
|
+
'editor.row.unpublish': 'Zurückziehen',
|
|
32
|
+
'editor.status.draft': 'Entwurf',
|
|
33
|
+
'editor.status.published': 'veröffentlicht',
|
|
34
|
+
'editor.status.archived': 'archiviert',
|
|
35
|
+
|
|
36
|
+
'editor.form.new': 'Neu: {type}',
|
|
37
|
+
'editor.form.editing': 'Bearbeiten',
|
|
38
|
+
'editor.form.save': 'Entwurf speichern',
|
|
39
|
+
'editor.form.saving': 'Wird gespeichert…',
|
|
40
|
+
'editor.form.cancel': 'Abbrechen',
|
|
41
|
+
'editor.form.saved': 'Entwurf gespeichert.',
|
|
42
|
+
'editor.form.invalid': 'Bitte korrigiere die markierten Felder.',
|
|
43
|
+
'editor.form.loading': 'Typen werden geladen…',
|
|
44
|
+
|
|
45
|
+
'error.heading': 'Etwas ist schiefgelaufen',
|
|
46
|
+
'error.retry': 'Erneut versuchen',
|
|
47
|
+
'notFound.heading': 'Nicht gefunden',
|
|
48
|
+
'notFound.body': 'Diese Seite existiert nicht.',
|
|
49
|
+
'notFound.back': '← Zurück zu den Inhalten',
|
|
50
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Base catalog — the source of truth for this app's UI strings. Every key here
|
|
2
|
+
// MUST be mirrored in `de.ts`; `defineLocale<typeof en>()` enforces that parity
|
|
3
|
+
// at build time. Messages use ICU: `{name}` placeholders + `<code>…</code>` tags.
|
|
4
|
+
import { defineCatalog } from '@voltro/i18n'
|
|
5
|
+
|
|
6
|
+
export default defineCatalog({
|
|
7
|
+
// Browser tab titles — locale-aware via each page's `meta({ locale })`.
|
|
8
|
+
'meta.login.title': 'Sign in',
|
|
9
|
+
'meta.editor.title': 'Content',
|
|
10
|
+
|
|
11
|
+
// ---- Auth form (/login) ----
|
|
12
|
+
'auth.signIn.heading': 'Sign in to the CMS',
|
|
13
|
+
'auth.signUp.heading': 'Create an editor account',
|
|
14
|
+
'auth.email': 'Email',
|
|
15
|
+
'auth.password': 'Password',
|
|
16
|
+
'auth.signIn.submit': 'Sign in',
|
|
17
|
+
'auth.signUp.submit': 'Create account',
|
|
18
|
+
'auth.toggle.toSignUp': 'No account? Create one',
|
|
19
|
+
'auth.toggle.toSignIn': 'Have an account? Sign in',
|
|
20
|
+
'auth.pending': 'Please wait…',
|
|
21
|
+
'auth.error': 'That didn’t work. Check your details and try again.',
|
|
22
|
+
|
|
23
|
+
// ---- Editor shell ----
|
|
24
|
+
'editor.brand': 'Content',
|
|
25
|
+
'editor.signOut': 'Sign out',
|
|
26
|
+
'lang.label': 'Language',
|
|
27
|
+
|
|
28
|
+
// ---- Type list + rows ----
|
|
29
|
+
'editor.types.heading': 'Content types',
|
|
30
|
+
'editor.list.heading': '{type}',
|
|
31
|
+
'editor.list.empty': 'No drafts yet — create one with the form on the right.',
|
|
32
|
+
'editor.row.edit': 'Edit',
|
|
33
|
+
'editor.row.publish': 'Publish',
|
|
34
|
+
'editor.row.unpublish': 'Unpublish',
|
|
35
|
+
'editor.status.draft': 'draft',
|
|
36
|
+
'editor.status.published': 'published',
|
|
37
|
+
'editor.status.archived': 'archived',
|
|
38
|
+
|
|
39
|
+
// ---- Editing form ----
|
|
40
|
+
'editor.form.new': 'New {type}',
|
|
41
|
+
'editor.form.editing': 'Editing',
|
|
42
|
+
'editor.form.save': 'Save draft',
|
|
43
|
+
'editor.form.saving': 'Saving…',
|
|
44
|
+
'editor.form.cancel': 'Cancel',
|
|
45
|
+
'editor.form.saved': 'Draft saved.',
|
|
46
|
+
'editor.form.invalid': 'Please fix the highlighted fields.',
|
|
47
|
+
'editor.form.loading': 'Loading types…',
|
|
48
|
+
|
|
49
|
+
// ---- Scoped fallbacks ----
|
|
50
|
+
'error.heading': 'Something went wrong',
|
|
51
|
+
'error.retry': 'Try again',
|
|
52
|
+
'notFound.heading': 'Not found',
|
|
53
|
+
'notFound.body': 'That page doesn’t exist.',
|
|
54
|
+
'notFound.back': '← Back to content',
|
|
55
|
+
} as const)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Catalog lookup OUTSIDE React — for a page's `meta({ locale })` locale-aware
|
|
2
|
+
// <title>. This app is cookie-i18n (no `/<locale>` URL prefix), so @voltro/web
|
|
3
|
+
// hands `meta` the active locale from the `voltro:lang` cookie. Inside React use
|
|
4
|
+
// `<T>` / `useT()` via the framework's auto-wired <I18nProvider> instead.
|
|
5
|
+
import en from './en'
|
|
6
|
+
import de from './de'
|
|
7
|
+
|
|
8
|
+
// Both catalogs share `en`'s keys (defineLocale enforces the mirror), so a
|
|
9
|
+
// known-key lookup returns `string` — which is what `PageMeta.title` requires
|
|
10
|
+
// under exactOptionalPropertyTypes.
|
|
11
|
+
export type Messages = Record<keyof typeof en, string>
|
|
12
|
+
|
|
13
|
+
export const getCatalog = (locale?: string): Messages =>
|
|
14
|
+
(locale === 'de' ? de : en) as Messages
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// Error boundary scoped to the editor subtree. A render error (or a loader
|
|
2
|
+
// rejection that ISN'T a RedirectError / NotFoundError) bubbles here. `reset`
|
|
3
|
+
// re-runs the failed segment.
|
|
4
|
+
import type { ReactNode } from 'react'
|
|
5
|
+
import type { ErrorBoundaryProps } from '@voltro/web'
|
|
6
|
+
import { T } from '@voltro/i18n'
|
|
7
|
+
|
|
8
|
+
export default function EditorError({ error, reset }: ErrorBoundaryProps): ReactNode {
|
|
9
|
+
return (
|
|
10
|
+
<main style={{ padding: '2rem' }}>
|
|
11
|
+
<h1><T id="error.heading" /></h1>
|
|
12
|
+
<p className="muted">{String(error)}</p>
|
|
13
|
+
<p style={{ marginTop: '1rem' }}>
|
|
14
|
+
<button type="button" className="btn" onClick={reset}><T id="error.retry" /></button>
|
|
15
|
+
</p>
|
|
16
|
+
</main>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The editor auth GATE, tested at the loader. Server-side (a `query` fn is
|
|
2
|
+
// present) it asks the api's `session.me`: an anonymous identity must throw
|
|
3
|
+
// RedirectError to /login; a `user` identity resolves with the tenant. On a
|
|
4
|
+
// client navigation (`query` undefined) it resolves permissively.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test, vi } from 'vitest'
|
|
7
|
+
import { RedirectError } from '@voltro/web'
|
|
8
|
+
import { loader } from './layout'
|
|
9
|
+
import type { Identity } from '../../lib/api'
|
|
10
|
+
|
|
11
|
+
const queryReturning = (identity: Identity) =>
|
|
12
|
+
vi.fn(async (_tag: string, _input: unknown) => identity as unknown)
|
|
13
|
+
|
|
14
|
+
describe('editor gate — loader', () => {
|
|
15
|
+
test('redirects an anonymous caller to /login', async () => {
|
|
16
|
+
const query = queryReturning({ type: 'anonymous', id: null, tenantId: null })
|
|
17
|
+
await expect(loader({ query } as never)).rejects.toBeInstanceOf(RedirectError)
|
|
18
|
+
expect(query).toHaveBeenCalledWith('session.me', {})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('lets a signed-in user through and returns the tenant', async () => {
|
|
22
|
+
const query = queryReturning({ type: 'user', id: 'u_1', tenantId: 'acme' })
|
|
23
|
+
const data = await loader({ query } as never)
|
|
24
|
+
expect(data).toEqual({ tenantId: 'acme' })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('client navigation (no server query fn) resolves without re-gating', async () => {
|
|
28
|
+
const data = await loader({ query: undefined } as never)
|
|
29
|
+
expect(data).toEqual({ tenantId: null })
|
|
30
|
+
})
|
|
31
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The editor shell — and the AUTH GATE. This layout wraps every page in the
|
|
2
|
+
// silent `(app)` group (URLs `/`), and its `loader` runs server-side BEFORE the
|
|
3
|
+
// pages render: it asks the api who the caller is (`session.me`) and throws
|
|
4
|
+
// RedirectError to /login for anyone not signed in. The pages under here are
|
|
5
|
+
// `renderMode: 'ssr'` precisely so this runs per request.
|
|
6
|
+
import type { ReactNode } from 'react'
|
|
7
|
+
import { RedirectError, useLoaderData, type LoaderFn } from '@voltro/web'
|
|
8
|
+
import { T, useLocale, useT } from '@voltro/i18n'
|
|
9
|
+
import { LocaleSwitcher } from '@voltro/ui-shadcn'
|
|
10
|
+
import type { Identity } from '../../lib/api'
|
|
11
|
+
|
|
12
|
+
const LOCALES = [
|
|
13
|
+
{ code: 'en', label: 'English' },
|
|
14
|
+
{ code: 'de', label: 'Deutsch' },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
interface AppData {
|
|
18
|
+
readonly tenantId: string | null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const loader: LoaderFn<AppData> = async ({ query }) => {
|
|
22
|
+
// `query` is present ONLY server-side (the SSR full-page load). There, ask the
|
|
23
|
+
// api's `session.me` — it forwards the request's session cookie — and bounce
|
|
24
|
+
// an anonymous caller to /login.
|
|
25
|
+
if (query) {
|
|
26
|
+
const me = await query<Identity>('session.me', {})
|
|
27
|
+
if (me.type !== 'user') {
|
|
28
|
+
throw new RedirectError('/login?from=/')
|
|
29
|
+
}
|
|
30
|
+
return { tenantId: me.tenantId }
|
|
31
|
+
}
|
|
32
|
+
// Client navigation: the entry load already gated; every rpc is independently
|
|
33
|
+
// session-authed on the server (the HttpOnly cookie isn't readable in JS).
|
|
34
|
+
return { tenantId: null }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function AppLayout({ children }: { readonly children: ReactNode }): ReactNode {
|
|
38
|
+
useLoaderData<AppData>()
|
|
39
|
+
const locale = useLocale()
|
|
40
|
+
const langLabel = useT('lang.label')
|
|
41
|
+
|
|
42
|
+
const signOut = (): void => {
|
|
43
|
+
void fetch('/auth/csrf')
|
|
44
|
+
.then((r) => r.json() as Promise<{ csrfToken: string }>)
|
|
45
|
+
.then((c) =>
|
|
46
|
+
fetch('/auth/sign-out', { method: 'POST', headers: { 'x-csrf-token': c.csrfToken } }),
|
|
47
|
+
)
|
|
48
|
+
.finally(() => window.location.assign('/login'))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<div className="cms">
|
|
53
|
+
<header className="cms-top">
|
|
54
|
+
<span className="brand"><T id="editor.brand" /></span>
|
|
55
|
+
<span className="spacer" />
|
|
56
|
+
<LocaleSwitcher locales={LOCALES} current={locale} ariaLabel={langLabel} />
|
|
57
|
+
<button type="button" className="link" onClick={signOut}><T id="editor.signOut" /></button>
|
|
58
|
+
</header>
|
|
59
|
+
{children}
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Scoped 404 for the editor subtree. Fires when a URL under the group matches no
|
|
2
|
+
// page, and when a loader throws NotFoundError.
|
|
3
|
+
import type { ReactNode } from 'react'
|
|
4
|
+
import { T } from '@voltro/i18n'
|
|
5
|
+
|
|
6
|
+
export default function EditorNotFound(): ReactNode {
|
|
7
|
+
return (
|
|
8
|
+
<main style={{ padding: '2rem' }}>
|
|
9
|
+
<h1><T id="notFound.heading" /></h1>
|
|
10
|
+
<p className="muted"><T id="notFound.body" /></p>
|
|
11
|
+
<p style={{ marginTop: '1rem' }}><a href="/"><T id="notFound.back" /></a></p>
|
|
12
|
+
</main>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// The editor page. We mock `@voltro/client` (content.types fetch, the live list,
|
|
4
|
+
// the save mutation) and drive it: after the types load the page renders a type
|
|
5
|
+
// chip + an auto-generated <ContentForm> (real, from @voltro/cms/web), and a
|
|
6
|
+
// save that comes back `{ ok: false, violations }` annotates the form inline
|
|
7
|
+
// rather than throwing.
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'
|
|
10
|
+
import { act, createElement, type ReactNode } from 'react'
|
|
11
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
12
|
+
import { I18nProvider } from '@voltro/i18n'
|
|
13
|
+
import enCatalog from '../../locales/en'
|
|
14
|
+
|
|
15
|
+
const blogType = {
|
|
16
|
+
name: 'blogPost', displayName: 'Blog post', pluralName: 'Blog posts',
|
|
17
|
+
columns: ['title', 'slug', 'status'],
|
|
18
|
+
fields: [
|
|
19
|
+
{ name: 'title', kind: 'string', editorHint: 'text', isOptional: false },
|
|
20
|
+
{ name: 'body', kind: 'richText', editorHint: 'richText', isOptional: false },
|
|
21
|
+
],
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const typesRun = vi.fn<() => Promise<{ types: unknown }>>()
|
|
25
|
+
const getRun = vi.fn<() => Promise<{ row: unknown }>>()
|
|
26
|
+
const saveMutate = vi.fn<(input: unknown) => Promise<unknown>>()
|
|
27
|
+
const subData = vi.fn<() => { data: unknown }>()
|
|
28
|
+
|
|
29
|
+
vi.mock('@voltro/client', () => ({
|
|
30
|
+
useAction: (_api: string, tag: string) => ({
|
|
31
|
+
run: tag === 'content.types' ? typesRun : getRun,
|
|
32
|
+
pending: false,
|
|
33
|
+
}),
|
|
34
|
+
useMutation: (_api: string, tag: string) => ({
|
|
35
|
+
mutate: tag === 'content.saveDraft' ? saveMutate : vi.fn().mockResolvedValue({ ok: true }),
|
|
36
|
+
pending: false,
|
|
37
|
+
}),
|
|
38
|
+
useSubscription: () => subData(),
|
|
39
|
+
}))
|
|
40
|
+
|
|
41
|
+
const { default: Editor, meta, renderMode } = await import('./page')
|
|
42
|
+
|
|
43
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
44
|
+
|
|
45
|
+
let container: HTMLDivElement
|
|
46
|
+
let root: Root
|
|
47
|
+
|
|
48
|
+
const render = (node: ReactNode): void => {
|
|
49
|
+
container = document.createElement('div')
|
|
50
|
+
document.body.appendChild(container)
|
|
51
|
+
act(() => {
|
|
52
|
+
root = createRoot(container)
|
|
53
|
+
root.render(
|
|
54
|
+
createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }),
|
|
55
|
+
)
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Flush the mount-effect fetch (typesRun.then → setState) + its re-render.
|
|
60
|
+
const flush = async (): Promise<void> => {
|
|
61
|
+
await act(async () => { await Promise.resolve(); await Promise.resolve() })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
typesRun.mockReset(); typesRun.mockResolvedValue({ types: [blogType] })
|
|
66
|
+
getRun.mockReset(); getRun.mockResolvedValue({ row: null })
|
|
67
|
+
saveMutate.mockReset(); saveMutate.mockResolvedValue({ ok: true, id: 'blogPost_1', status: 'draft' })
|
|
68
|
+
subData.mockReset(); subData.mockReturnValue({ data: [] })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
afterEach(() => {
|
|
72
|
+
if (root) act(() => root.unmount())
|
|
73
|
+
container?.remove()
|
|
74
|
+
document.body.innerHTML = ''
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('editor — config', () => {
|
|
78
|
+
test('is an SSR page titled Content (so the parent gate runs per request)', () => {
|
|
79
|
+
expect(renderMode).toBe('ssr')
|
|
80
|
+
expect(meta({ locale: 'en' }).title).toBe('Content')
|
|
81
|
+
})
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
describe('editor — render', () => {
|
|
85
|
+
test('after the types load, renders the type chip and an auto-generated form', async () => {
|
|
86
|
+
render(createElement(Editor))
|
|
87
|
+
await flush()
|
|
88
|
+
expect(typesRun).toHaveBeenCalled()
|
|
89
|
+
expect(container.textContent).toContain('Blog post')
|
|
90
|
+
// <ContentForm> renders one [data-cms-field] per declared field.
|
|
91
|
+
expect(container.querySelectorAll('[data-cms-field]').length).toBe(2)
|
|
92
|
+
expect(container.querySelector('[data-cms-field="title"]')).not.toBeNull()
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('editor — validation', () => {
|
|
97
|
+
test('a save that returns { ok: false, violations } annotates the form', async () => {
|
|
98
|
+
saveMutate.mockResolvedValue({ ok: false, violations: [{ field: 'title', rule: 'required', message: 'missing required field' }] })
|
|
99
|
+
render(createElement(Editor))
|
|
100
|
+
await flush()
|
|
101
|
+
const form = container.querySelector('form')!
|
|
102
|
+
await act(async () => {
|
|
103
|
+
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
|
|
104
|
+
await Promise.resolve(); await Promise.resolve()
|
|
105
|
+
})
|
|
106
|
+
expect(saveMutate).toHaveBeenCalled()
|
|
107
|
+
expect(container.querySelector('.violations')).not.toBeNull()
|
|
108
|
+
expect(container.textContent).toContain('missing required field')
|
|
109
|
+
})
|
|
110
|
+
})
|