@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.
Files changed (354) hide show
  1. package/CHANGELOG.md +655 -0
  2. package/THIRD-PARTY-NOTICES.md +90 -4
  3. package/dist/{apiBuild-eUM32r1u.js → apiBuild-BceikpQf.js} +3 -3
  4. package/dist/apiBuild-CxvR1Dwt.js +2 -0
  5. package/dist/bin.js +3 -3
  6. package/dist/checkCommand-BMr-X48w.js +2427 -0
  7. package/dist/checkCommand-BOk9y264.js +2 -0
  8. package/dist/cliRuntime-Oh517vCV.js +96 -0
  9. package/dist/{commands-CSkrUI1h.js → commands-D4Y1-Nlu.js} +8575 -9366
  10. package/dist/{dbCommand-CpYgmSw4.js → dbCommand-C7hbl5pk.js} +345 -266
  11. package/dist/dbCommand-D8nhyppR.js +2 -0
  12. package/dist/{dev-DlBWWnJQ.js → dev-Cr-teYsz.js} +1 -1
  13. package/dist/{dev-CEZwJhmb.js → dev-DmnkH-wr.js} +2939 -1832
  14. package/dist/fileConventions-Cof68_BL.js +33 -0
  15. package/dist/{frameworkTableAssembly-BwHU9Euq.js → frameworkTableAssembly-BzAajuUv.js} +115 -99
  16. package/dist/frameworkTableAssembly-DU0aOxjL.js +2 -0
  17. package/dist/index.d.ts +7 -2
  18. package/dist/index.js +1 -1
  19. package/dist/{inspect-CUCCzw2I.js → inspect-BUUjt773.js} +349 -192
  20. package/dist/inspect-mmBuRXmy.js +2 -0
  21. package/dist/{inspectMetrics-BU90mvJN.js → inspectMetrics-BeJdP_3b.js} +1068 -978
  22. package/dist/{manifestBuild-BnzAxp2O.js → manifestBuild-BLrVuSlM.js} +1 -1
  23. package/dist/manifestBuild-Dj8Jjoto.js +2 -0
  24. package/dist/sdkgen-CqpOq_fy.js +582 -0
  25. package/dist/serveCommand-Ciq_wIY8.js +1577 -0
  26. package/dist/serveEntry.js +2 -2
  27. package/dist/{start-BGXIf6zT.js → start-ocqB1seX.js} +283 -270
  28. package/dist/startEntry.js +2 -2
  29. package/package.json +17 -17
  30. package/templates/AGENTS.core.md +50 -0
  31. package/templates/AGENTS.md +54 -2
  32. package/templates/agent-docs/_index.md +4 -2
  33. package/templates/agent-docs/_manifest.json +26 -5
  34. package/templates/agent-docs/ai.md +191 -0
  35. package/templates/agent-docs/cli.md +143 -6
  36. package/templates/agent-docs/configuration.md +32 -0
  37. package/templates/agent-docs/data.md +282 -0
  38. package/templates/agent-docs/database/migrations.md +47 -0
  39. package/templates/agent-docs/database/misc.md +57 -0
  40. package/templates/agent-docs/database/schema.md +3 -1
  41. package/templates/agent-docs/internationalization.md +169 -8
  42. package/templates/agent-docs/local-first-mobile.md +426 -0
  43. package/templates/agent-docs/routing.md +94 -3
  44. package/templates/agent-docs/schema-driven-ui.md +12 -0
  45. package/templates/agent-docs/security.md +17 -0
  46. package/templates/agent-docs/templates/apibackends.md +210 -1
  47. package/templates/agent-docs/templates/appshells.md +144 -1
  48. package/templates/agent-docs/templates/mobile.md +60 -0
  49. package/templates/agent-docs/templates/overview.md +17 -7
  50. package/templates/agent-docs/whats-new.md +238 -158
  51. package/templates/agent-docs/workflows.md +376 -0
  52. package/templates/apps/api-ai/package.json +7 -7
  53. package/templates/apps/api-auth/package.json +8 -8
  54. package/templates/apps/api-backend/package.json +7 -7
  55. package/templates/apps/api-backend-deactivation/package.json +7 -7
  56. package/templates/apps/api-backend-mail/package.json +8 -8
  57. package/templates/apps/api-backend-mariadb/package.json +9 -9
  58. package/templates/apps/api-backend-sqlite/.env.example +19 -0
  59. package/templates/apps/api-backend-sqlite/README.md +38 -0
  60. package/templates/apps/api-backend-sqlite/app.config.ts +35 -0
  61. package/templates/apps/api-backend-sqlite/database/schema.ts +54 -0
  62. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.server.ts +19 -0
  63. package/templates/apps/api-backend-sqlite/mutations/notes.create.mutation.ts +37 -0
  64. package/templates/apps/api-backend-sqlite/package.json +30 -0
  65. package/templates/apps/api-backend-sqlite/queries/notes.query.server.ts +14 -0
  66. package/templates/apps/api-backend-sqlite/queries/notes.query.ts +20 -0
  67. package/templates/apps/api-backend-sqlite/template.json +6 -0
  68. package/templates/apps/api-backend-sqlite/tests/notes.create.test.ts +50 -0
  69. package/templates/apps/api-backend-sqlite/tsconfig.json +5 -0
  70. package/templates/apps/api-backend-storage/package.json +8 -8
  71. package/templates/apps/api-cms/README.md +104 -0
  72. package/templates/apps/api-cms/actions/content.get.action.server.ts +27 -0
  73. package/templates/apps/api-cms/actions/content.get.action.ts +19 -0
  74. package/templates/apps/api-cms/actions/content.types.action.server.ts +26 -0
  75. package/templates/apps/api-cms/actions/content.types.action.ts +40 -0
  76. package/templates/apps/api-cms/actions/me.action.server.ts +18 -0
  77. package/templates/apps/api-cms/actions/me.action.ts +16 -0
  78. package/templates/apps/api-cms/app.config.ts +61 -0
  79. package/templates/apps/api-cms/content/blogPost.contentType.ts +39 -0
  80. package/templates/apps/api-cms/content/index.ts +18 -0
  81. package/templates/apps/api-cms/content/page.contentType.ts +24 -0
  82. package/templates/apps/api-cms/database/schema.ts +64 -0
  83. package/templates/apps/api-cms/mutations/content.publish.mutation.server.ts +19 -0
  84. package/templates/apps/api-cms/mutations/content.publish.mutation.ts +15 -0
  85. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.server.ts +36 -0
  86. package/templates/apps/api-cms/mutations/content.saveDraft.mutation.ts +32 -0
  87. package/templates/apps/api-cms/mutations/content.unpublish.mutation.server.ts +19 -0
  88. package/templates/apps/api-cms/mutations/content.unpublish.mutation.ts +11 -0
  89. package/templates/apps/api-cms/package.json +32 -0
  90. package/templates/apps/api-cms/queries/content.list.query.server.ts +0 -0
  91. package/templates/apps/api-cms/queries/content.list.query.ts +27 -0
  92. package/templates/apps/api-cms/template.json +6 -0
  93. package/templates/apps/api-cms/tests/content.descriptors.test.ts +64 -0
  94. package/templates/apps/api-cms/tests/content.write.test.ts +85 -0
  95. package/templates/apps/api-cms/tsconfig.json +5 -0
  96. package/templates/apps/api-collab/README.md +70 -0
  97. package/templates/apps/api-collab/app.config.ts +28 -0
  98. package/templates/apps/api-collab/database/schema.ts +63 -0
  99. package/templates/apps/api-collab/mutations/documents.create.mutation.server.ts +19 -0
  100. package/templates/apps/api-collab/mutations/documents.create.mutation.ts +36 -0
  101. package/templates/apps/api-collab/mutations/documents.setBody.mutation.server.ts +20 -0
  102. package/templates/apps/api-collab/mutations/documents.setBody.mutation.ts +30 -0
  103. package/templates/apps/api-collab/package.json +30 -0
  104. package/templates/apps/api-collab/queries/documents.query.server.ts +16 -0
  105. package/templates/apps/api-collab/queries/documents.query.ts +23 -0
  106. package/templates/apps/api-collab/template.json +6 -0
  107. package/templates/apps/api-collab/tests/documents.setBody.test.ts +89 -0
  108. package/templates/apps/api-collab/tsconfig.json +5 -0
  109. package/templates/apps/api-data-advanced/package.json +8 -8
  110. package/templates/apps/api-durable/package.json +8 -8
  111. package/templates/apps/api-feature-flags/package.json +9 -9
  112. package/templates/apps/api-governance/package.json +8 -8
  113. package/templates/apps/api-kv/package.json +8 -8
  114. package/templates/apps/api-moderation/package.json +8 -8
  115. package/templates/apps/api-observability/package.json +8 -8
  116. package/templates/apps/api-ratelimit/package.json +8 -8
  117. package/templates/apps/api-rbac/package.json +8 -8
  118. package/templates/apps/api-rest/package.json +7 -7
  119. package/templates/apps/api-saas/package.json +11 -11
  120. package/templates/apps/api-saas-starter/README.md +103 -0
  121. package/templates/apps/api-saas-starter/actions/me.action.server.ts +18 -0
  122. package/templates/apps/api-saas-starter/actions/me.action.ts +20 -0
  123. package/templates/apps/api-saas-starter/app.config.ts +87 -0
  124. package/templates/apps/api-saas-starter/database/schema.ts +57 -0
  125. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.server.ts +26 -0
  126. package/templates/apps/api-saas-starter/mutations/invites.create.mutation.ts +18 -0
  127. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.server.ts +29 -0
  128. package/templates/apps/api-saas-starter/mutations/projects.create.mutation.ts +18 -0
  129. package/templates/apps/api-saas-starter/package.json +32 -0
  130. package/templates/apps/api-saas-starter/queries/invites.list.query.server.ts +14 -0
  131. package/templates/apps/api-saas-starter/queries/invites.list.query.ts +17 -0
  132. package/templates/apps/api-saas-starter/queries/projects.list.query.server.ts +14 -0
  133. package/templates/apps/api-saas-starter/queries/projects.list.query.ts +18 -0
  134. package/templates/apps/api-saas-starter/template.json +6 -0
  135. package/templates/apps/api-saas-starter/tests/projects.create.test.ts +62 -0
  136. package/templates/apps/api-saas-starter/tests/session.test.ts +33 -0
  137. package/templates/apps/api-saas-starter/tsconfig.json +5 -0
  138. package/templates/apps/api-search/package.json +8 -8
  139. package/templates/apps/api-status/README.md +88 -0
  140. package/templates/apps/api-status/app.config.ts +36 -0
  141. package/templates/apps/api-status/authz.ts +33 -0
  142. package/templates/apps/api-status/database/schema.ts +70 -0
  143. package/templates/apps/api-status/mutations/components.create.mutation.server.ts +19 -0
  144. package/templates/apps/api-status/mutations/components.create.mutation.ts +16 -0
  145. package/templates/apps/api-status/mutations/incidents.create.mutation.server.ts +25 -0
  146. package/templates/apps/api-status/mutations/incidents.create.mutation.ts +22 -0
  147. package/templates/apps/api-status/mutations/incidents.resolve.mutation.server.ts +24 -0
  148. package/templates/apps/api-status/mutations/incidents.resolve.mutation.ts +19 -0
  149. package/templates/apps/api-status/mutations/incidents.update.mutation.server.ts +26 -0
  150. package/templates/apps/api-status/mutations/incidents.update.mutation.ts +21 -0
  151. package/templates/apps/api-status/package.json +30 -0
  152. package/templates/apps/api-status/queries/components.list.query.server.ts +14 -0
  153. package/templates/apps/api-status/queries/components.list.query.ts +13 -0
  154. package/templates/apps/api-status/queries/incidents.live.query.server.ts +14 -0
  155. package/templates/apps/api-status/queries/incidents.live.query.ts +20 -0
  156. package/templates/apps/api-status/queries/updates.list.query.server.ts +14 -0
  157. package/templates/apps/api-status/queries/updates.list.query.ts +17 -0
  158. package/templates/apps/api-status/template.json +6 -0
  159. package/templates/apps/api-status/tests/status.test.ts +70 -0
  160. package/templates/apps/api-status/tsconfig.json +5 -0
  161. package/templates/apps/api-versioning/package.json +8 -8
  162. package/templates/apps/api-webhooks/package.json +9 -9
  163. package/templates/apps/changelog/package.json +6 -6
  164. package/templates/apps/edge-functions/package.json +2 -2
  165. package/templates/apps/frontend-admin/app.config.ts +1 -1
  166. package/templates/apps/frontend-admin/package.json +8 -8
  167. package/templates/apps/frontend-admin/src/locales/index.ts +1 -1
  168. package/templates/apps/frontend-app/app.config.ts +1 -1
  169. package/templates/apps/frontend-app/package.json +8 -8
  170. package/templates/apps/frontend-app/src/locales/index.ts +1 -1
  171. package/templates/apps/frontend-app/src/pages/layout.tsx +1 -1
  172. package/templates/apps/frontend-app/src/pages/page.tsx +1 -1
  173. package/templates/apps/frontend-auth/README.md +78 -0
  174. package/templates/apps/frontend-auth/app.config.ts +34 -0
  175. package/templates/apps/frontend-auth/package.json +32 -0
  176. package/templates/apps/frontend-auth/src/components/AuthShell.tsx +35 -0
  177. package/templates/apps/frontend-auth/src/components/PasswordStrength.tsx +33 -0
  178. package/templates/apps/frontend-auth/src/config.ts +11 -0
  179. package/templates/apps/frontend-auth/src/globals.css +105 -0
  180. package/templates/apps/frontend-auth/src/globals.d.ts +6 -0
  181. package/templates/apps/frontend-auth/src/lib/auth.ts +34 -0
  182. package/templates/apps/frontend-auth/src/lib/redirect.test.ts +24 -0
  183. package/templates/apps/frontend-auth/src/lib/redirect.ts +29 -0
  184. package/templates/apps/frontend-auth/src/locales/de.ts +66 -0
  185. package/templates/apps/frontend-auth/src/locales/en.ts +76 -0
  186. package/templates/apps/frontend-auth/src/locales/index.ts +14 -0
  187. package/templates/apps/frontend-auth/src/pages/forgot/page.tsx +51 -0
  188. package/templates/apps/frontend-auth/src/pages/layout.tsx +12 -0
  189. package/templates/apps/frontend-auth/src/pages/login/page.test.tsx +53 -0
  190. package/templates/apps/frontend-auth/src/pages/login/page.tsx +67 -0
  191. package/templates/apps/frontend-auth/src/pages/logout/page.tsx +28 -0
  192. package/templates/apps/frontend-auth/src/pages/magic/page.tsx +51 -0
  193. package/templates/apps/frontend-auth/src/pages/page.tsx +26 -0
  194. package/templates/apps/frontend-auth/src/pages/reset/page.test.tsx +51 -0
  195. package/templates/apps/frontend-auth/src/pages/reset/page.tsx +63 -0
  196. package/templates/apps/frontend-auth/src/pages/signup/page.tsx +60 -0
  197. package/templates/apps/frontend-auth/src/pages/verify/page.tsx +50 -0
  198. package/templates/apps/frontend-auth/template.json +6 -0
  199. package/templates/apps/frontend-auth/tsconfig.json +5 -0
  200. package/templates/apps/frontend-blank/app.config.ts +1 -1
  201. package/templates/apps/frontend-blank/package.json +7 -7
  202. package/templates/apps/frontend-blank/src/locales/index.ts +1 -1
  203. package/templates/apps/frontend-blank/src/pages/layout.tsx +1 -1
  204. package/templates/apps/frontend-blank/src/pages/page.tsx +1 -1
  205. package/templates/apps/frontend-cms/README.md +47 -0
  206. package/templates/apps/frontend-cms/app.config.ts +37 -0
  207. package/templates/apps/frontend-cms/package.json +33 -0
  208. package/templates/apps/frontend-cms/src/config.ts +8 -0
  209. package/templates/apps/frontend-cms/src/globals.css +105 -0
  210. package/templates/apps/frontend-cms/src/globals.d.ts +6 -0
  211. package/templates/apps/frontend-cms/src/lib/api.ts +58 -0
  212. package/templates/apps/frontend-cms/src/locales/de.ts +50 -0
  213. package/templates/apps/frontend-cms/src/locales/en.ts +55 -0
  214. package/templates/apps/frontend-cms/src/locales/index.ts +14 -0
  215. package/templates/apps/frontend-cms/src/pages/(app)/error.tsx +18 -0
  216. package/templates/apps/frontend-cms/src/pages/(app)/layout.test.tsx +31 -0
  217. package/templates/apps/frontend-cms/src/pages/(app)/layout.tsx +62 -0
  218. package/templates/apps/frontend-cms/src/pages/(app)/not-found.tsx +14 -0
  219. package/templates/apps/frontend-cms/src/pages/(app)/page.test.tsx +110 -0
  220. package/templates/apps/frontend-cms/src/pages/(app)/page.tsx +159 -0
  221. package/templates/apps/frontend-cms/src/pages/layout.tsx +12 -0
  222. package/templates/apps/frontend-cms/src/pages/login/page.test.tsx +58 -0
  223. package/templates/apps/frontend-cms/src/pages/login/page.tsx +93 -0
  224. package/templates/apps/frontend-cms/template.json +6 -0
  225. package/templates/apps/frontend-cms/tsconfig.json +5 -0
  226. package/templates/apps/frontend-collab/README.md +80 -0
  227. package/templates/apps/frontend-collab/app.config.ts +38 -0
  228. package/templates/apps/frontend-collab/package.json +33 -0
  229. package/templates/apps/frontend-collab/src/locales/de.ts +29 -0
  230. package/templates/apps/frontend-collab/src/locales/en.ts +31 -0
  231. package/templates/apps/frontend-collab/src/locales/index.ts +15 -0
  232. package/templates/apps/frontend-collab/src/pages/layout.tsx +39 -0
  233. package/templates/apps/frontend-collab/src/pages/page.test.tsx +150 -0
  234. package/templates/apps/frontend-collab/src/pages/page.tsx +186 -0
  235. package/templates/apps/frontend-collab/template.json +6 -0
  236. package/templates/apps/frontend-collab/tsconfig.json +11 -0
  237. package/templates/apps/frontend-contact/package.json +7 -7
  238. package/templates/apps/frontend-dashboard/app.config.ts +1 -1
  239. package/templates/apps/frontend-dashboard/package.json +7 -7
  240. package/templates/apps/frontend-dashboard/src/locales/index.ts +1 -1
  241. package/templates/apps/frontend-docs/package.json +7 -7
  242. package/templates/apps/frontend-i18n/package.json +6 -6
  243. package/templates/apps/frontend-landing/package.json +7 -7
  244. package/templates/apps/frontend-portal/README.md +71 -0
  245. package/templates/apps/frontend-portal/app.config.ts +37 -0
  246. package/templates/apps/frontend-portal/package.json +32 -0
  247. package/templates/apps/frontend-portal/src/config.ts +8 -0
  248. package/templates/apps/frontend-portal/src/globals.css +93 -0
  249. package/templates/apps/frontend-portal/src/globals.d.ts +6 -0
  250. package/templates/apps/frontend-portal/src/lib/api.ts +62 -0
  251. package/templates/apps/frontend-portal/src/locales/de.ts +86 -0
  252. package/templates/apps/frontend-portal/src/locales/en.ts +93 -0
  253. package/templates/apps/frontend-portal/src/locales/index.ts +14 -0
  254. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.test.tsx +71 -0
  255. package/templates/apps/frontend-portal/src/pages/(portal)/api-keys/page.tsx +124 -0
  256. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.test.tsx +86 -0
  257. package/templates/apps/frontend-portal/src/pages/(portal)/billing/page.tsx +97 -0
  258. package/templates/apps/frontend-portal/src/pages/(portal)/error.tsx +19 -0
  259. package/templates/apps/frontend-portal/src/pages/(portal)/invoices/page.tsx +45 -0
  260. package/templates/apps/frontend-portal/src/pages/(portal)/layout.test.tsx +31 -0
  261. package/templates/apps/frontend-portal/src/pages/(portal)/layout.tsx +63 -0
  262. package/templates/apps/frontend-portal/src/pages/(portal)/not-found.tsx +15 -0
  263. package/templates/apps/frontend-portal/src/pages/(portal)/page.tsx +39 -0
  264. package/templates/apps/frontend-portal/src/pages/(portal)/profile/page.tsx +79 -0
  265. package/templates/apps/frontend-portal/src/pages/layout.tsx +12 -0
  266. package/templates/apps/frontend-portal/src/pages/login/page.test.tsx +58 -0
  267. package/templates/apps/frontend-portal/src/pages/login/page.tsx +96 -0
  268. package/templates/apps/frontend-portal/template.json +6 -0
  269. package/templates/apps/frontend-portal/tsconfig.json +5 -0
  270. package/templates/apps/frontend-saas/README.md +73 -0
  271. package/templates/apps/frontend-saas/app.config.ts +46 -0
  272. package/templates/apps/frontend-saas/package.json +32 -0
  273. package/templates/apps/frontend-saas/src/config.ts +8 -0
  274. package/templates/apps/frontend-saas/src/globals.css +85 -0
  275. package/templates/apps/frontend-saas/src/globals.d.ts +6 -0
  276. package/templates/apps/frontend-saas/src/lib/api.ts +47 -0
  277. package/templates/apps/frontend-saas/src/locales/de.ts +72 -0
  278. package/templates/apps/frontend-saas/src/locales/en.ts +81 -0
  279. package/templates/apps/frontend-saas/src/locales/index.ts +14 -0
  280. package/templates/apps/frontend-saas/src/pages/(marketing)/layout.tsx +30 -0
  281. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.test.tsx +58 -0
  282. package/templates/apps/frontend-saas/src/pages/(marketing)/login/page.tsx +96 -0
  283. package/templates/apps/frontend-saas/src/pages/(marketing)/page.tsx +27 -0
  284. package/templates/apps/frontend-saas/src/pages/dashboard/billing/page.tsx +72 -0
  285. package/templates/apps/frontend-saas/src/pages/dashboard/error.tsx +20 -0
  286. package/templates/apps/frontend-saas/src/pages/dashboard/layout.test.tsx +32 -0
  287. package/templates/apps/frontend-saas/src/pages/dashboard/layout.tsx +69 -0
  288. package/templates/apps/frontend-saas/src/pages/dashboard/not-found.tsx +17 -0
  289. package/templates/apps/frontend-saas/src/pages/dashboard/page.test.tsx +113 -0
  290. package/templates/apps/frontend-saas/src/pages/dashboard/page.tsx +76 -0
  291. package/templates/apps/frontend-saas/src/pages/dashboard/team/page.tsx +73 -0
  292. package/templates/apps/frontend-saas/src/pages/layout.tsx +12 -0
  293. package/templates/apps/frontend-saas/template.json +6 -0
  294. package/templates/apps/frontend-saas/tsconfig.json +5 -0
  295. package/templates/apps/frontend-spa/app.config.ts +1 -1
  296. package/templates/apps/frontend-spa/package.json +7 -7
  297. package/templates/apps/frontend-spa/src/locales/index.ts +1 -1
  298. package/templates/apps/frontend-spa/src/pages/layout.tsx +1 -1
  299. package/templates/apps/frontend-spa/src/pages/page.tsx +1 -1
  300. package/templates/apps/frontend-ssr/app.config.ts +1 -1
  301. package/templates/apps/frontend-ssr/package.json +7 -7
  302. package/templates/apps/frontend-ssr/src/locales/index.ts +1 -1
  303. package/templates/apps/frontend-ssr/src/pages/layout.tsx +1 -1
  304. package/templates/apps/frontend-ssr/src/pages/page.tsx +1 -1
  305. package/templates/apps/frontend-ssr-api/app.config.ts +1 -1
  306. package/templates/apps/frontend-ssr-api/package.json +8 -8
  307. package/templates/apps/frontend-ssr-api/src/locales/index.ts +1 -1
  308. package/templates/apps/frontend-ssr-api/src/pages/layout.tsx +1 -1
  309. package/templates/apps/frontend-static-blog/package.json +6 -6
  310. package/templates/apps/frontend-status/README.md +51 -0
  311. package/templates/apps/frontend-status/app.config.ts +39 -0
  312. package/templates/apps/frontend-status/package.json +32 -0
  313. package/templates/apps/frontend-status/src/config.ts +7 -0
  314. package/templates/apps/frontend-status/src/globals.css +70 -0
  315. package/templates/apps/frontend-status/src/globals.d.ts +6 -0
  316. package/templates/apps/frontend-status/src/lib/status.ts +81 -0
  317. package/templates/apps/frontend-status/src/locales/de.ts +41 -0
  318. package/templates/apps/frontend-status/src/locales/en.ts +45 -0
  319. package/templates/apps/frontend-status/src/locales/index.ts +13 -0
  320. package/templates/apps/frontend-status/src/pages/layout.tsx +27 -0
  321. package/templates/apps/frontend-status/src/pages/page.test.tsx +123 -0
  322. package/templates/apps/frontend-status/src/pages/page.tsx +136 -0
  323. package/templates/apps/frontend-status/template.json +6 -0
  324. package/templates/apps/frontend-status/tsconfig.json +11 -0
  325. package/templates/apps/mobile-app/README.md +58 -0
  326. package/templates/apps/mobile-app/app.config.ts +33 -0
  327. package/templates/apps/mobile-app/babel.config.js +5 -0
  328. package/templates/apps/mobile-app/metro.config.js +20 -0
  329. package/templates/apps/mobile-app/package.json +33 -0
  330. package/templates/apps/mobile-app/src/app/_layout.tsx +61 -0
  331. package/templates/apps/mobile-app/src/app/index.tsx +62 -0
  332. package/templates/apps/mobile-app/src/app/orders/[id].tsx +19 -0
  333. package/templates/apps/mobile-app/src/app/settings.tsx +51 -0
  334. package/templates/apps/mobile-app/src/client.ts +74 -0
  335. package/templates/apps/mobile-app/src/lib/deeplinks.ts +39 -0
  336. package/templates/apps/mobile-app/src/lib/notifications.ts +31 -0
  337. package/templates/apps/mobile-app/src/lib/sync.ts +29 -0
  338. package/templates/apps/mobile-app/src/persistence.ts +38 -0
  339. package/templates/apps/mobile-app/template.json +6 -0
  340. package/templates/apps/mobile-app/tests/deeplinks.test.ts +32 -0
  341. package/templates/apps/mobile-app/tests/notifications.test.ts +26 -0
  342. package/templates/apps/mobile-app/tests/sync.test.ts +34 -0
  343. package/templates/apps/mobile-app/tsconfig.json +10 -0
  344. package/templates/apps/mobile-app/voltro.mobile.ts +11 -0
  345. package/templates/baselines/compose/docker-compose.prod.yml +15 -0
  346. package/templates/baselines/compose-mariadb/docker-compose.prod.yml +15 -0
  347. package/templates/patches/@effect__cluster@0.60.0.patch +6 -6
  348. package/dist/apiBuild-DgBS9ayv.js +0 -2
  349. package/dist/dbCommand-DvguqlzF.js +0 -2
  350. package/dist/fileConventions-3bffWssN.js +0 -30
  351. package/dist/frameworkTableAssembly-lrjZtk0G.js +0 -2
  352. package/dist/inspect-gt8bq-Tz.js +0 -2
  353. package/dist/manifestBuild-ifczArzr.js +0 -2
  354. 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,5 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "include": ["**/*.ts", "**/*.tsx"],
4
+ "exclude": ["node_modules", "dist"]
5
+ }
@@ -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
+ }