@voltro/cli 0.28.0 → 0.30.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 +655 -0
- package/THIRD-PARTY-NOTICES.md +90 -4
- package/dist/{apiBuild-eUM32r1u.js → apiBuild-BceikpQf.js} +3 -3
- package/dist/apiBuild-CxvR1Dwt.js +2 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-BMr-X48w.js +2427 -0
- package/dist/checkCommand-BOk9y264.js +2 -0
- package/dist/cliRuntime-Oh517vCV.js +96 -0
- package/dist/{commands-CSkrUI1h.js → commands-D4Y1-Nlu.js} +8575 -9366
- package/dist/{dbCommand-CpYgmSw4.js → dbCommand-C7hbl5pk.js} +345 -266
- package/dist/dbCommand-D8nhyppR.js +2 -0
- package/dist/{dev-DlBWWnJQ.js → dev-Cr-teYsz.js} +1 -1
- package/dist/{dev-CEZwJhmb.js → dev-DmnkH-wr.js} +2939 -1832
- package/dist/fileConventions-Cof68_BL.js +33 -0
- package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-BzAajuUv.js} +115 -99
- package/dist/frameworkTableAssembly-DU0aOxjL.js +2 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +1 -1
- package/dist/{inspect-CUCCzw2I.js → inspect-BUUjt773.js} +349 -192
- package/dist/inspect-mmBuRXmy.js +2 -0
- package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-BeJdP_3b.js} +1068 -978
- package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-BLrVuSlM.js} +1 -1
- package/dist/manifestBuild-Dj8Jjoto.js +2 -0
- package/dist/sdkgen-CqpOq_fy.js +582 -0
- package/dist/serveCommand-Ciq_wIY8.js +1577 -0
- package/dist/serveEntry.js +2 -2
- package/dist/{start-BGXIf6zT.js → start-ocqB1seX.js} +283 -270
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.core.md +50 -0
- package/templates/AGENTS.md +54 -2
- package/templates/agent-docs/_index.md +4 -2
- package/templates/agent-docs/_manifest.json +26 -5
- package/templates/agent-docs/ai.md +191 -0
- package/templates/agent-docs/cli.md +143 -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/internationalization.md +169 -8
- package/templates/agent-docs/local-first-mobile.md +426 -0
- package/templates/agent-docs/routing.md +94 -3
- package/templates/agent-docs/schema-driven-ui.md +12 -0
- package/templates/agent-docs/security.md +17 -0
- package/templates/agent-docs/templates/apibackends.md +210 -1
- package/templates/agent-docs/templates/appshells.md +144 -1
- package/templates/agent-docs/templates/mobile.md +60 -0
- package/templates/agent-docs/templates/overview.md +17 -7
- package/templates/agent-docs/whats-new.md +238 -158
- package/templates/agent-docs/workflows.md +376 -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-collab/README.md +70 -0
- package/templates/apps/api-collab/app.config.ts +28 -0
- package/templates/apps/api-collab/database/schema.ts +63 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
- package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
- package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
- package/templates/apps/api-collab/package.json +30 -0
- package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
- package/templates/apps/api-collab/queries/documents.query.ts +23 -0
- package/templates/apps/api-collab/template.json +6 -0
- package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
- package/templates/apps/api-collab/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/app.config.ts +1 -1
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/app.config.ts +1 -1
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-app/src/locales/index.ts +1 -1
- package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
- 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/app.config.ts +1 -1
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
- package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
- 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-collab/README.md +80 -0
- package/templates/apps/frontend-collab/app.config.ts +38 -0
- package/templates/apps/frontend-collab/package.json +33 -0
- package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
- package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
- package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
- package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
- package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
- package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
- package/templates/apps/frontend-collab/template.json +6 -0
- package/templates/apps/frontend-collab/tsconfig.json +11 -0
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/app.config.ts +1 -1
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
- 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/app.config.ts +1 -1
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
- package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr/app.config.ts +1 -1
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
- package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
- package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
- package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
- 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/apps/mobile-app/README.md +58 -0
- package/templates/apps/mobile-app/app.config.ts +33 -0
- package/templates/apps/mobile-app/babel.config.js +5 -0
- package/templates/apps/mobile-app/metro.config.js +20 -0
- package/templates/apps/mobile-app/package.json +33 -0
- package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
- package/templates/apps/mobile-app/src/app/index.tsx +62 -0
- package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
- package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
- package/templates/apps/mobile-app/src/client.ts +74 -0
- package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
- package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
- package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
- package/templates/apps/mobile-app/src/persistence.ts +38 -0
- package/templates/apps/mobile-app/template.json +6 -0
- package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
- package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
- package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
- package/templates/apps/mobile-app/tsconfig.json +10 -0
- package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
- package/templates/baselines/compose/docker-compose.prod.yml +15 -0
- package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
- package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
- package/dist/apiBuild-DgBS9ayv.js +0 -2
- package/dist/dbCommand-DvguqlzF.js +0 -2
- 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-ZTn-dPFa.js +0 -1425
|
@@ -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
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// The editor — URL `/`. Introspects the api's registered content types
|
|
2
|
+
// (`content.types`) and generates a form per type with @voltro/cms's
|
|
3
|
+
// `<ContentForm>`; lists a type's drafts live (`content.list`), saves them
|
|
4
|
+
// (`content.saveDraft`, with per-field validation surfaced inline), and
|
|
5
|
+
// publishes / unpublishes them. One page, no dynamic routes — the selected type
|
|
6
|
+
// and the row being edited are component state.
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
|
9
|
+
import type { PageMeta } from '@voltro/web'
|
|
10
|
+
import { useAction, useMutation, useSubscription } from '@voltro/client'
|
|
11
|
+
import { ContentForm } from '@voltro/cms/web'
|
|
12
|
+
import { T, useT } from '@voltro/i18n'
|
|
13
|
+
import { getCatalog } from '../../locales'
|
|
14
|
+
import {
|
|
15
|
+
toContentFormType,
|
|
16
|
+
type ContentRow,
|
|
17
|
+
type ContentTypeDescriptor,
|
|
18
|
+
type SaveResult,
|
|
19
|
+
} from '../../lib/api'
|
|
20
|
+
|
|
21
|
+
export const renderMode = 'ssr' as const
|
|
22
|
+
|
|
23
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
24
|
+
title: getCatalog(locale)['meta.editor.title'],
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
interface Violation { readonly field: string; readonly rule: string; readonly message: string }
|
|
28
|
+
|
|
29
|
+
export default function Editor(): ReactNode {
|
|
30
|
+
const types = useAction<Record<string, never>, { types: ReadonlyArray<ContentTypeDescriptor> }>(
|
|
31
|
+
'cms', 'content.types',
|
|
32
|
+
)
|
|
33
|
+
const getOne = useAction<{ type: string; id: string; status: string }, { row: Record<string, unknown> | null }>(
|
|
34
|
+
'cms', 'content.get',
|
|
35
|
+
)
|
|
36
|
+
const save = useMutation<{ type: string; values: Record<string, unknown> }, SaveResult>('cms', 'content.saveDraft')
|
|
37
|
+
const publish = useMutation<{ type: string; id: string }, { ok: boolean }>('cms', 'content.publish')
|
|
38
|
+
const unpublish = useMutation<{ type: string; id: string }, { ok: boolean }>('cms', 'content.unpublish')
|
|
39
|
+
|
|
40
|
+
const [typeList, setTypeList] = useState<ReadonlyArray<ContentTypeDescriptor>>([])
|
|
41
|
+
const [active, setActive] = useState<string | null>(null)
|
|
42
|
+
const [values, setValues] = useState<Record<string, unknown>>({})
|
|
43
|
+
const [editingId, setEditingId] = useState<string | null>(null)
|
|
44
|
+
const [violations, setViolations] = useState<ReadonlyArray<Violation>>([])
|
|
45
|
+
const [saved, setSaved] = useState(false)
|
|
46
|
+
|
|
47
|
+
// Load the content types once; select the first.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
void types.run({}).then((r) => {
|
|
50
|
+
setTypeList(r.types)
|
|
51
|
+
setActive((cur) => cur ?? r.types[0]?.name ?? null)
|
|
52
|
+
})
|
|
53
|
+
}, []) // mount-only fetch of the content-type descriptors
|
|
54
|
+
|
|
55
|
+
const activeType = typeList.find((t) => t.name === active) ?? null
|
|
56
|
+
|
|
57
|
+
// Live drafts of the active type. Memoised input so a stable object identity
|
|
58
|
+
// avoids resubscribing every render; an unset/unknown type yields an empty list.
|
|
59
|
+
const listInput = useMemo(() => ({ type: active ?? '—', status: 'draft' as const }), [active])
|
|
60
|
+
const { data: rows } = useSubscription<ReadonlyArray<ContentRow>>('cms', 'content.list', listInput)
|
|
61
|
+
const drafts = rows ?? []
|
|
62
|
+
|
|
63
|
+
const resetForm = (): void => { setValues({}); setEditingId(null); setViolations([]) }
|
|
64
|
+
|
|
65
|
+
const onSelectType = (name: string): void => { setActive(name); resetForm(); setSaved(false) }
|
|
66
|
+
|
|
67
|
+
const onEdit = (id: string): void => {
|
|
68
|
+
if (active === null) return
|
|
69
|
+
void getOne.run({ type: active, id, status: 'draft' }).then((r) => {
|
|
70
|
+
if (r.row) { setValues(r.row); setEditingId(id); setViolations([]); setSaved(false) }
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const onSubmit = (e: FormEvent): void => {
|
|
75
|
+
e.preventDefault()
|
|
76
|
+
if (active === null) return
|
|
77
|
+
const payload = editingId ? { ...values, id: editingId } : values
|
|
78
|
+
void save.mutate({ type: active, values: payload }).then((res) => {
|
|
79
|
+
if (res.ok) { resetForm(); setSaved(true) } else { setViolations(res.violations); setSaved(false) }
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const onPublish = (id: string): void => { if (active) void publish.mutate({ type: active, id }) }
|
|
84
|
+
const onUnpublish = (id: string): void => { if (active) void unpublish.mutate({ type: active, id }) }
|
|
85
|
+
|
|
86
|
+
const statusKey = (s: string): 'editor.status.draft' | 'editor.status.published' | 'editor.status.archived' =>
|
|
87
|
+
s === 'published' ? 'editor.status.published' : s === 'archived' ? 'editor.status.archived' : 'editor.status.draft'
|
|
88
|
+
|
|
89
|
+
if (typeList.length === 0) {
|
|
90
|
+
return <section className="cms-main"><p className="empty"><T id="editor.form.loading" /></p></section>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="cms-grid">
|
|
95
|
+
{/* Left: type chooser + live draft list */}
|
|
96
|
+
<section className="cms-list">
|
|
97
|
+
<h2 className="label"><T id="editor.types.heading" /></h2>
|
|
98
|
+
<nav className="cms-types">
|
|
99
|
+
{typeList.map((t) => (
|
|
100
|
+
<button
|
|
101
|
+
key={t.name} type="button"
|
|
102
|
+
className={t.name === active ? 'chip active' : 'chip'}
|
|
103
|
+
onClick={() => onSelectType(t.name)}
|
|
104
|
+
>
|
|
105
|
+
{t.displayName}
|
|
106
|
+
</button>
|
|
107
|
+
))}
|
|
108
|
+
</nav>
|
|
109
|
+
|
|
110
|
+
<h3 className="label" style={{ marginTop: '1rem' }}>
|
|
111
|
+
<T id="editor.list.heading" values={{ type: activeType?.pluralName ?? '' }} />
|
|
112
|
+
</h3>
|
|
113
|
+
{drafts.length === 0 ? (
|
|
114
|
+
<p className="empty"><T id="editor.list.empty" /></p>
|
|
115
|
+
) : (
|
|
116
|
+
<ul className="rows">
|
|
117
|
+
{drafts.map((row) => (
|
|
118
|
+
<li key={row.id}>
|
|
119
|
+
<span className="grow">{row.title ?? row.slug ?? row.id}</span>
|
|
120
|
+
<span className="tag"><T id={statusKey(row.status)} /></span>
|
|
121
|
+
<button type="button" className="link" onClick={() => onEdit(row.id)}><T id="editor.row.edit" /></button>
|
|
122
|
+
<button type="button" className="link" onClick={() => onPublish(row.id)}><T id="editor.row.publish" /></button>
|
|
123
|
+
<button type="button" className="link" onClick={() => onUnpublish(row.id)}><T id="editor.row.unpublish" /></button>
|
|
124
|
+
</li>
|
|
125
|
+
))}
|
|
126
|
+
</ul>
|
|
127
|
+
)}
|
|
128
|
+
</section>
|
|
129
|
+
|
|
130
|
+
{/* Right: the auto-generated form */}
|
|
131
|
+
<section className="cms-editor">
|
|
132
|
+
<h2>
|
|
133
|
+
{editingId
|
|
134
|
+
? <T id="editor.form.editing" />
|
|
135
|
+
: <T id="editor.form.new" values={{ type: activeType?.displayName ?? '' }} />}
|
|
136
|
+
</h2>
|
|
137
|
+
{activeType ? (
|
|
138
|
+
<form onSubmit={onSubmit}>
|
|
139
|
+
<ContentForm contentType={toContentFormType(activeType)} value={values} onChange={setValues} />
|
|
140
|
+
{violations.length > 0 ? (
|
|
141
|
+
<ul className="violations" role="alert">
|
|
142
|
+
{violations.map((v) => <li key={v.field + v.rule}>{v.field}: {v.message}</li>)}
|
|
143
|
+
</ul>
|
|
144
|
+
) : null}
|
|
145
|
+
{saved ? <p className="ok"><T id="editor.form.saved" /></p> : null}
|
|
146
|
+
<div className="row" style={{ marginTop: '0.75rem' }}>
|
|
147
|
+
<button type="submit" className="btn" disabled={save.pending}>
|
|
148
|
+
<T id={save.pending ? 'editor.form.saving' : 'editor.form.save'} />
|
|
149
|
+
</button>
|
|
150
|
+
{editingId ? (
|
|
151
|
+
<button type="button" className="btn ghost" onClick={resetForm}><T id="editor.form.cancel" /></button>
|
|
152
|
+
) : null}
|
|
153
|
+
</div>
|
|
154
|
+
</form>
|
|
155
|
+
) : null}
|
|
156
|
+
</section>
|
|
157
|
+
</div>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Root layout — wraps EVERY page (marketing + dashboard). Thin: it loads the
|
|
2
|
+
// stylesheet and lets each AREA layout ((marketing)/layout.tsx,
|
|
3
|
+
// dashboard/layout.tsx) supply its own chrome. Layouts nest, so a dashboard
|
|
4
|
+
// page is wrapped root → dashboard.
|
|
5
|
+
//
|
|
6
|
+
// Don't render <html>/<head>/<body> here — the framework shell owns them.
|
|
7
|
+
import type { ReactNode } from 'react'
|
|
8
|
+
import '../globals.css'
|
|
9
|
+
|
|
10
|
+
export default function RootLayout({ children }: { readonly children: ReactNode }): ReactNode {
|
|
11
|
+
return <>{children}</>
|
|
12
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// The auth form. We assert config + that it renders email/password fields and
|
|
4
|
+
// toggles between sign-in and sign-up. The network submit (CSRF + POST /auth/*)
|
|
5
|
+
// is exercised against a running api by `voltro dev`, not unit-mocked here.
|
|
6
|
+
|
|
7
|
+
import { afterEach, describe, expect, test } from 'vitest'
|
|
8
|
+
import { act, createElement, type ReactNode } from 'react'
|
|
9
|
+
import { createRoot, type Root } from 'react-dom/client'
|
|
10
|
+
import { I18nProvider } from '@voltro/i18n'
|
|
11
|
+
import enCatalog from '../../locales/en'
|
|
12
|
+
|
|
13
|
+
const { default: Login, meta, renderMode } = await import('./page')
|
|
14
|
+
|
|
15
|
+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
|
|
16
|
+
|
|
17
|
+
let container: HTMLDivElement
|
|
18
|
+
let root: Root
|
|
19
|
+
|
|
20
|
+
const render = (node: ReactNode): void => {
|
|
21
|
+
container = document.createElement('div')
|
|
22
|
+
document.body.appendChild(container)
|
|
23
|
+
act(() => {
|
|
24
|
+
root = createRoot(container)
|
|
25
|
+
root.render(
|
|
26
|
+
createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }),
|
|
27
|
+
)
|
|
28
|
+
})
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
if (root) act(() => root.unmount())
|
|
33
|
+
container?.remove()
|
|
34
|
+
document.body.innerHTML = ''
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('login — config', () => {
|
|
38
|
+
test('is a static page titled Sign in', () => {
|
|
39
|
+
expect(renderMode).toBe('static')
|
|
40
|
+
expect(meta({ locale: 'en' }).title).toBe('Sign in')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('login — form', () => {
|
|
45
|
+
test('renders email + password inputs and defaults to sign-in', () => {
|
|
46
|
+
render(createElement(Login))
|
|
47
|
+
expect(container.querySelector('input[type="email"]')).not.toBeNull()
|
|
48
|
+
expect(container.querySelector('input[type="password"]')).not.toBeNull()
|
|
49
|
+
expect(container.querySelector('h1')?.textContent).toBe('Sign in to the CMS')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('toggling switches to the create-account heading', () => {
|
|
53
|
+
render(createElement(Login))
|
|
54
|
+
const toggle = container.querySelector('.link')!
|
|
55
|
+
act(() => { toggle.dispatchEvent(new MouseEvent('click', { bubbles: true })) })
|
|
56
|
+
expect(container.querySelector('h1')?.textContent).toBe('Create an editor account')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Sign in / sign up — URL `/login`. A REAL auth form: it POSTs to the api's
|
|
2
|
+
// `/auth/*` routes (contributed by @voltro/plugin-auth on api-cms), which set
|
|
3
|
+
// the HttpOnly session cookie. State-changing auth routes are CSRF-protected, so
|
|
4
|
+
// we fetch a token first. On success we do a FULL navigation so the editor's SSR
|
|
5
|
+
// gate re-runs with the freshly-set cookie.
|
|
6
|
+
import type { ReactNode } from 'react'
|
|
7
|
+
import { useState, type FormEvent } from 'react'
|
|
8
|
+
import type { PageMeta } from '@voltro/web'
|
|
9
|
+
import { T, useT } from '@voltro/i18n'
|
|
10
|
+
import { getCatalog } from '../../locales'
|
|
11
|
+
|
|
12
|
+
export const renderMode = 'static' as const
|
|
13
|
+
|
|
14
|
+
export const meta = ({ locale }: { readonly locale: string }): PageMeta => ({
|
|
15
|
+
title: getCatalog(locale)['meta.login.title'],
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
type Mode = 'signIn' | 'signUp'
|
|
19
|
+
|
|
20
|
+
async function submitAuth(mode: Mode, email: string, password: string): Promise<boolean> {
|
|
21
|
+
const csrf = (await fetch('/auth/csrf').then((r) => r.json())) as { csrfToken: string }
|
|
22
|
+
const path = mode === 'signUp' ? '/auth/sign-up' : '/auth/sign-in'
|
|
23
|
+
const res = await fetch(path, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'content-type': 'application/json', 'x-csrf-token': csrf.csrfToken },
|
|
26
|
+
body: JSON.stringify({ email, password }),
|
|
27
|
+
})
|
|
28
|
+
return res.ok
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default function Login(): ReactNode {
|
|
32
|
+
const [mode, setMode] = useState<Mode>('signIn')
|
|
33
|
+
const [email, setEmail] = useState('')
|
|
34
|
+
const [password, setPassword] = useState('')
|
|
35
|
+
const [pending, setPending] = useState(false)
|
|
36
|
+
const [failed, setFailed] = useState(false)
|
|
37
|
+
const emailLabel = useT('auth.email')
|
|
38
|
+
const passwordLabel = useT('auth.password')
|
|
39
|
+
|
|
40
|
+
const onSubmit = (e: FormEvent): void => {
|
|
41
|
+
e.preventDefault()
|
|
42
|
+
setPending(true)
|
|
43
|
+
setFailed(false)
|
|
44
|
+
void submitAuth(mode, email, password)
|
|
45
|
+
.then((ok) => {
|
|
46
|
+
if (ok) {
|
|
47
|
+
const from = new URLSearchParams(window.location.search).get('from') ?? '/'
|
|
48
|
+
window.location.assign(from)
|
|
49
|
+
} else {
|
|
50
|
+
setFailed(true)
|
|
51
|
+
setPending(false)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
.catch(() => {
|
|
55
|
+
setFailed(true)
|
|
56
|
+
setPending(false)
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<section className="hero">
|
|
62
|
+
<h1><T id={mode === 'signUp' ? 'auth.signUp.heading' : 'auth.signIn.heading'} /></h1>
|
|
63
|
+
<form className="auth-form" onSubmit={onSubmit}>
|
|
64
|
+
<label>
|
|
65
|
+
<span><T id="auth.email" /></span>
|
|
66
|
+
<input
|
|
67
|
+
type="email" required aria-label={emailLabel} value={email}
|
|
68
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
69
|
+
/>
|
|
70
|
+
</label>
|
|
71
|
+
<label>
|
|
72
|
+
<span><T id="auth.password" /></span>
|
|
73
|
+
<input
|
|
74
|
+
type="password" required aria-label={passwordLabel} value={password}
|
|
75
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
76
|
+
/>
|
|
77
|
+
</label>
|
|
78
|
+
{failed ? <p className="error-text"><T id="auth.error" /></p> : null}
|
|
79
|
+
<button type="submit" className="btn" disabled={pending}>
|
|
80
|
+
<T id={pending ? 'auth.pending' : mode === 'signUp' ? 'auth.signUp.submit' : 'auth.signIn.submit'} />
|
|
81
|
+
</button>
|
|
82
|
+
</form>
|
|
83
|
+
<p style={{ marginTop: '1rem' }}>
|
|
84
|
+
<button
|
|
85
|
+
type="button" className="link"
|
|
86
|
+
onClick={() => { setMode((m) => (m === 'signIn' ? 'signUp' : 'signIn')); setFailed(false) }}
|
|
87
|
+
>
|
|
88
|
+
<T id={mode === 'signIn' ? 'auth.toggle.toSignUp' : 'auth.toggle.toSignIn'} />
|
|
89
|
+
</button>
|
|
90
|
+
</p>
|
|
91
|
+
</section>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "frontend-cms",
|
|
3
|
+
"kind": "web",
|
|
4
|
+
"summary": "The CMS editor UI for the api-cms backend: a sign-in page (POST /auth/*), an SSR auth gate (session.me → redirect anonymous editors to /login), and a content editor that fetches the registered content types (content.types) and auto-generates a form per type with @voltro/cms's <ContentForm> — create/edit drafts (content.saveDraft, validation surfaced inline), live-list them (content.list), and publish/unpublish. Bilingual (en/de).",
|
|
5
|
+
"tags": ["web", "frontend", "cms", "editor", "content", "auth", "ssr", "ContentForm"]
|
|
6
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# {{projectName}} / {{appName}}
|
|
2
|
+
|
|
3
|
+
Voltro collaborative-editing frontend (template: **frontend-collab**).
|
|
4
|
+
|
|
5
|
+
A document editor whose textarea is bound to a `crdtText()` body over the
|
|
6
|
+
framework's reactive loop. Type in **two browser tabs at once** and the edits
|
|
7
|
+
**converge** — no last-write-wins loser — because the api merges every update
|
|
8
|
+
into the shared document server-side, then broadcasts the merged result back
|
|
9
|
+
live. Zero infra: the merge and the broadcast are in-process on a single
|
|
10
|
+
`voltro dev`.
|
|
11
|
+
|
|
12
|
+
## Scaffold the pair
|
|
13
|
+
|
|
14
|
+
`frontend-collab` consumes a sibling api — scaffold it **together with
|
|
15
|
+
[`api-collab`](../api-collab)** (which exposes the `documents.list` subscription
|
|
16
|
+
+ `documents.create` / `documents.setBody` mutations):
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
voltro create-project collab --api=api-collab --web=frontend-collab
|
|
20
|
+
cd collab
|
|
21
|
+
pnpm install
|
|
22
|
+
pnpm dev # boots BOTH apps (api on :4000, web on its portRange port)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Open the web app, click **Create document**, then open a **second tab** on the
|
|
26
|
+
same URL and type in both. Every keystroke merges — both tabs' edits survive.
|
|
27
|
+
|
|
28
|
+
## How it works
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
Tab A api (api-collab)
|
|
32
|
+
───── ────────────────
|
|
33
|
+
local crdtText() handle documents.setBody (crdtText 'body' column)
|
|
34
|
+
│ keystroke → insert/delete └─ runtime folds the update into the
|
|
35
|
+
│ → handle.encode() ──rpc──► STORED state (mergeCrdtStates) → merged
|
|
36
|
+
▼ └─► reactive delta ──WS──► every tab
|
|
37
|
+
useSubscription('app',
|
|
38
|
+
'documents.list') ◄──WS── merged body ── handle.merge(body) → textarea
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
1. **`useSubscription('app', 'documents.list')`** streams the shared document,
|
|
42
|
+
including its **merged** `body` (encoded CRDT bytes), on every write.
|
|
43
|
+
2. A **local `crdtText()` handle** (`@voltro/local-first`) is seeded from that
|
|
44
|
+
body. Each keystroke applies a contiguous insert/delete to the handle and
|
|
45
|
+
sends `handle.encode()` via **`useMutation('app', 'documents.setBody')`**.
|
|
46
|
+
3. The api's **authoritative server-side merge** folds the update into the
|
|
47
|
+
stored state before broadcasting — so concurrent edits converge.
|
|
48
|
+
4. Incoming server bodies are **folded back** into the local handle
|
|
49
|
+
(idempotent), keeping every tab in sync.
|
|
50
|
+
|
|
51
|
+
This page is exactly the app-level binding the local-first docs describe as the
|
|
52
|
+
`SyncClient` transport: `push` = the `setBody` mutation, `onRemoteState` = the
|
|
53
|
+
`documents` subscription.
|
|
54
|
+
|
|
55
|
+
## Not wired here: presence cursors
|
|
56
|
+
|
|
57
|
+
Live "who else is editing" cursors (`usePresence` from
|
|
58
|
+
`@voltro/local-first/react`) need a `PresenceChannel` bound to the app's
|
|
59
|
+
broadcast broker — a runtime binding, not zero-infra in a single dev process
|
|
60
|
+
across tabs — so this template omits them. The CRDT text convergence above needs
|
|
61
|
+
none of that; it rides the reactive engine you already have.
|
|
62
|
+
|
|
63
|
+
## Files
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
app.config.ts type:web + the `apis: { app }` wiring
|
|
67
|
+
package.json depends on @{{projectName}}/api (the sibling) + @voltro/local-first
|
|
68
|
+
src/pages/
|
|
69
|
+
layout.tsx root shell (header + <main>)
|
|
70
|
+
page.tsx the collaborative editor — subscription + CRDT handle + setBody
|
|
71
|
+
page.test.tsx render + edit-dispatch tests (jsdom)
|
|
72
|
+
src/locales/{en,de}.ts bilingual UI strings
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Multi-tenancy note
|
|
76
|
+
|
|
77
|
+
The dev `AuthMiddleware` resolves the tenant from the `x-tenant` header,
|
|
78
|
+
defaulting to `'acme'` (the `TENANT` constant in `page.tsx`). The `documents`
|
|
79
|
+
table carries the `tenant()` mixin, so reads/writes are auto-scoped. Wire real
|
|
80
|
+
auth before production (see the authentication docs).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Web app for the {{projectName}} project. Read by `voltro dev` / `voltro start`.
|
|
2
|
+
//
|
|
3
|
+
// This is the COLLABORATIVE-EDITING frontend — a textarea bound to a
|
|
4
|
+
// `crdtText()` document body over the framework's reactive loop. Open it in two
|
|
5
|
+
// browser tabs and type in both: the edits CONVERGE (no last-write-wins loser)
|
|
6
|
+
// because the api's server-authoritative CRDT merge folds every update into the
|
|
7
|
+
// stored state, then the reactive engine broadcasts the merged row back. Zero
|
|
8
|
+
// infra — the merge + broadcast are in-process on a single `voltro dev`.
|
|
9
|
+
//
|
|
10
|
+
// `port` is allocated by `voltro create-project` / `voltro add-app` from the
|
|
11
|
+
// project's portRange (in project.json), hard-pinned per app.
|
|
12
|
+
import { defineEnv, envVar } from '@voltro/env'
|
|
13
|
+
|
|
14
|
+
// Typed PUBLIC environment — public vars are validated + baked into the browser
|
|
15
|
+
// bundle at build time and read via `publicEnv` from '@voltro/env/public'.
|
|
16
|
+
// NEVER put a secret here (it ships to every visitor).
|
|
17
|
+
export const env = defineEnv({
|
|
18
|
+
VOLTRO_PUBLIC_APP_NAME: envVar.string({ access: 'public', default: '{{capProjectName}}' }),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
type: 'web' as const,
|
|
23
|
+
name: '{{capProjectName}}{{capAppName}}',
|
|
24
|
+
port: {{port}},
|
|
25
|
+
env,
|
|
26
|
+
// Bilingual by default — the framework auto-wires a cookie-driven
|
|
27
|
+
// <I18nProvider> from these codes and loads the matching
|
|
28
|
+
// `src/locales/<code>.ts` catalog, so `<T id="…" />` / `useT()` work app-wide.
|
|
29
|
+
locales: ['en', 'de'] as const,
|
|
30
|
+
defaultLocale: 'en' as const,
|
|
31
|
+
// The api this frontend consumes. The NAME ('app') is the lookup key the
|
|
32
|
+
// hooks use — `useSubscription('app', 'documents.list')`, `useMutation('app',
|
|
33
|
+
// 'documents.setBody')`. The `package` resolves the sibling api in this
|
|
34
|
+
// project (scaffold it with `--api=api-collab`).
|
|
35
|
+
apis: {
|
|
36
|
+
app: { package: '@{{projectName}}/api' },
|
|
37
|
+
},
|
|
38
|
+
}
|