@stjoseph/prumo 0.0.1

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 (220) hide show
  1. package/.prumo-templates/api/auth.md +130 -0
  2. package/.prumo-templates/api/config.md +85 -0
  3. package/.prumo-templates/api/errors.md +82 -0
  4. package/.prumo-templates/api/logging.md +79 -0
  5. package/.prumo-templates/api/modules.md +117 -0
  6. package/.prumo-templates/api/observability.md +91 -0
  7. package/.prumo-templates/api/pagination.md +71 -0
  8. package/.prumo-templates/api/routes.md +89 -0
  9. package/.prumo-templates/client/data.md +124 -0
  10. package/.prumo-templates/client/forms.md +88 -0
  11. package/.prumo-templates/core/conventions.md +74 -0
  12. package/.prumo-templates/core/testing.md +100 -0
  13. package/.prumo-templates/core/tooling.md +118 -0
  14. package/.prumo-templates/database/entities.md +97 -0
  15. package/.prumo-templates/database/migrations.md +74 -0
  16. package/.prumo-templates/database/queries.md +79 -0
  17. package/.prumo-templates/database/testing.md +82 -0
  18. package/.prumo-templates/database/transactions.md +68 -0
  19. package/.prumo-templates/mobile/components.md +83 -0
  20. package/.prumo-templates/mobile/config.md +84 -0
  21. package/.prumo-templates/mobile/routing.md +74 -0
  22. package/.prumo-templates/mobile/storage.md +84 -0
  23. package/.prumo-templates/mobile/structure.md +79 -0
  24. package/.prumo-templates/monorepo/dependencies.md +84 -0
  25. package/.prumo-templates/monorepo/layout.md +89 -0
  26. package/.prumo-templates/monorepo/tasks.md +76 -0
  27. package/.prumo-templates/multi-tenancy/client.md +83 -0
  28. package/.prumo-templates/multi-tenancy/isolation.md +104 -0
  29. package/.prumo-templates/site/rendering.md +83 -0
  30. package/.prumo-templates/site/structure.md +68 -0
  31. package/.prumo-templates/web/components.md +106 -0
  32. package/.prumo-templates/web/routing.md +82 -0
  33. package/.prumo-templates/web/structure.md +92 -0
  34. package/LICENSE +21 -0
  35. package/README.md +110 -0
  36. package/dist/asker.js +28 -0
  37. package/dist/cli.js +62 -0
  38. package/dist/compose.js +222 -0
  39. package/dist/context.js +38 -0
  40. package/dist/generate.js +60 -0
  41. package/dist/jsonc.js +76 -0
  42. package/dist/names.js +10 -0
  43. package/dist/questions.js +63 -0
  44. package/package.json +55 -0
  45. package/templates/api/.env.example +12 -0
  46. package/templates/api/.githooks/install.mjs +18 -0
  47. package/templates/api/.githooks/pre-commit +2 -0
  48. package/templates/api/.githooks/pre-push +2 -0
  49. package/templates/api/README.md +35 -0
  50. package/templates/api/biome.jsonc +18 -0
  51. package/templates/api/docker/init/01-auth-schema.sql +1 -0
  52. package/templates/api/docker-compose.yml +20 -0
  53. package/templates/api/gitignore +5 -0
  54. package/templates/api/migrations/Migration20260914123239_create_profile.ts +13 -0
  55. package/templates/api/nest-cli.json +10 -0
  56. package/templates/api/package.json +69 -0
  57. package/templates/api/pnpm-workspace.yaml +9 -0
  58. package/templates/api/src/app.module.ts +46 -0
  59. package/templates/api/src/auth/auth.constants.ts +2 -0
  60. package/templates/api/src/auth/auth.controller.ts +19 -0
  61. package/templates/api/src/auth/auth.factory.spec.ts +29 -0
  62. package/templates/api/src/auth/auth.factory.ts +52 -0
  63. package/templates/api/src/auth/auth.guard.ts +43 -0
  64. package/templates/api/src/auth/auth.module.ts +30 -0
  65. package/templates/api/src/auth/auth.ts +4 -0
  66. package/templates/api/src/auth/current-user.decorator.ts +9 -0
  67. package/templates/api/src/auth/express.d.ts +10 -0
  68. package/templates/api/src/auth/public.decorator.ts +4 -0
  69. package/templates/api/src/config/env.module.ts +11 -0
  70. package/templates/api/src/config/env.ts +81 -0
  71. package/templates/api/src/errors/problem-details.filter.ts +86 -0
  72. package/templates/api/src/errors/validation-exception.factory.ts +30 -0
  73. package/templates/api/src/health/health.controller.ts +32 -0
  74. package/templates/api/src/health/health.module.ts +9 -0
  75. package/templates/api/src/main.ts +43 -0
  76. package/templates/api/src/mikro-orm.config.ts +4 -0
  77. package/templates/api/src/mikro-orm.factory.ts +14 -0
  78. package/templates/api/src/request-context/request-context.module.ts +9 -0
  79. package/templates/api/src/request-context/request-context.service.ts +17 -0
  80. package/templates/api/src/request-context/request-id.middleware.ts +19 -0
  81. package/templates/api/src/users/dto/update-profile.dto.ts +20 -0
  82. package/templates/api/src/users/entities/profile.entity.ts +27 -0
  83. package/templates/api/src/users/users.controller.ts +23 -0
  84. package/templates/api/src/users/users.module.ts +10 -0
  85. package/templates/api/src/users/users.service.spec.ts +44 -0
  86. package/templates/api/src/users/users.service.ts +36 -0
  87. package/templates/api/test/factories/profile.factory.ts +26 -0
  88. package/templates/api/test/factories/user.factory.ts +21 -0
  89. package/templates/api/test/global-setup.ts +20 -0
  90. package/templates/api/test/setup.ts +80 -0
  91. package/templates/api/tsconfig.build.json +8 -0
  92. package/templates/api/tsconfig.json +29 -0
  93. package/templates/api/vitest.config.mts +17 -0
  94. package/templates/mobile/.env.example +1 -0
  95. package/templates/mobile/.githooks/install.mjs +18 -0
  96. package/templates/mobile/.githooks/pre-commit +2 -0
  97. package/templates/mobile/.githooks/pre-push +2 -0
  98. package/templates/mobile/README.md +30 -0
  99. package/templates/mobile/app.config.ts +13 -0
  100. package/templates/mobile/app.json +39 -0
  101. package/templates/mobile/assets/expo.icon/Assets/expo-symbol 2.svg +3 -0
  102. package/templates/mobile/assets/expo.icon/Assets/grid.png +0 -0
  103. package/templates/mobile/assets/expo.icon/icon.json +35 -0
  104. package/templates/mobile/assets/images/android-icon-background.png +0 -0
  105. package/templates/mobile/assets/images/android-icon-foreground.png +0 -0
  106. package/templates/mobile/assets/images/android-icon-monochrome.png +0 -0
  107. package/templates/mobile/assets/images/icon.png +0 -0
  108. package/templates/mobile/assets/images/splash-icon.png +0 -0
  109. package/templates/mobile/babel.config.js +6 -0
  110. package/templates/mobile/biome.jsonc +21 -0
  111. package/templates/mobile/gitignore +11 -0
  112. package/templates/mobile/global.d.ts +1 -0
  113. package/templates/mobile/jest.config.js +21 -0
  114. package/templates/mobile/metro.config.js +4 -0
  115. package/templates/mobile/nativewind-env.d.ts +1 -0
  116. package/templates/mobile/package.json +63 -0
  117. package/templates/mobile/src/api-contract/api-error.ts +51 -0
  118. package/templates/mobile/src/api-contract/client.ts +26 -0
  119. package/templates/mobile/src/api-contract/index.ts +10 -0
  120. package/templates/mobile/src/api-contract/profile.ts +27 -0
  121. package/templates/mobile/src/app/(app)/_layout.tsx +18 -0
  122. package/templates/mobile/src/app/(app)/index.tsx +18 -0
  123. package/templates/mobile/src/app/(auth)/sign-in.tsx +24 -0
  124. package/templates/mobile/src/app/(auth)/sign-up.tsx +23 -0
  125. package/templates/mobile/src/app/_layout.tsx +54 -0
  126. package/templates/mobile/src/components/ui/button.tsx +36 -0
  127. package/templates/mobile/src/components/ui/field.tsx +30 -0
  128. package/templates/mobile/src/config.ts +3 -0
  129. package/templates/mobile/src/features/auth/auth-client.ts +27 -0
  130. package/templates/mobile/src/features/auth/intended-route.ts +11 -0
  131. package/templates/mobile/src/features/auth/session.ts +17 -0
  132. package/templates/mobile/src/features/auth/sign-in-form.spec.tsx +37 -0
  133. package/templates/mobile/src/features/auth/sign-in-form.tsx +80 -0
  134. package/templates/mobile/src/features/auth/sign-up-form.tsx +99 -0
  135. package/templates/mobile/src/features/clients/clients-context.tsx +21 -0
  136. package/templates/mobile/src/features/forms/form-error.tsx +16 -0
  137. package/templates/mobile/src/features/forms/server-errors.ts +33 -0
  138. package/templates/mobile/src/features/profile/profile-form.spec.tsx +58 -0
  139. package/templates/mobile/src/features/profile/profile-form.tsx +96 -0
  140. package/templates/mobile/src/features/storage/query-persister.ts +15 -0
  141. package/templates/mobile/src/features/storage/storage.ts +11 -0
  142. package/templates/mobile/src/global.css +3 -0
  143. package/templates/mobile/tailwind.config.js +9 -0
  144. package/templates/mobile/test/fake-transport.ts +23 -0
  145. package/templates/mobile/tsconfig.json +21 -0
  146. package/templates/site/.githooks/install.mjs +18 -0
  147. package/templates/site/.githooks/pre-commit +2 -0
  148. package/templates/site/.githooks/pre-push +2 -0
  149. package/templates/site/README.md +28 -0
  150. package/templates/site/biome.jsonc +12 -0
  151. package/templates/site/components.json +25 -0
  152. package/templates/site/gitignore +7 -0
  153. package/templates/site/next.config.ts +7 -0
  154. package/templates/site/package.json +40 -0
  155. package/templates/site/pnpm-workspace.yaml +5 -0
  156. package/templates/site/postcss.config.mjs +7 -0
  157. package/templates/site/src/app/globals.css +129 -0
  158. package/templates/site/src/app/layout.tsx +13 -0
  159. package/templates/site/src/app/page.spec.ts +10 -0
  160. package/templates/site/src/app/page.tsx +17 -0
  161. package/templates/site/src/lib/utils.ts +1 -0
  162. package/templates/site/tsconfig.json +34 -0
  163. package/templates/site/vitest.config.mts +8 -0
  164. package/templates/web/.env.example +1 -0
  165. package/templates/web/.githooks/install.mjs +18 -0
  166. package/templates/web/.githooks/pre-commit +2 -0
  167. package/templates/web/.githooks/pre-push +2 -0
  168. package/templates/web/README.md +29 -0
  169. package/templates/web/biome.jsonc +24 -0
  170. package/templates/web/components.json +25 -0
  171. package/templates/web/gitignore +6 -0
  172. package/templates/web/index.html +12 -0
  173. package/templates/web/package.json +55 -0
  174. package/templates/web/src/api-contract/api-error.ts +51 -0
  175. package/templates/web/src/api-contract/client.ts +26 -0
  176. package/templates/web/src/api-contract/index.ts +10 -0
  177. package/templates/web/src/api-contract/profile.ts +27 -0
  178. package/templates/web/src/app.spec.tsx +12 -0
  179. package/templates/web/src/app.tsx +14 -0
  180. package/templates/web/src/components/ui/button.tsx +57 -0
  181. package/templates/web/src/components/ui/field.tsx +224 -0
  182. package/templates/web/src/components/ui/input.tsx +19 -0
  183. package/templates/web/src/components/ui/label.tsx +17 -0
  184. package/templates/web/src/components/ui/separator.tsx +20 -0
  185. package/templates/web/src/config.ts +7 -0
  186. package/templates/web/src/features/auth/auth-client.ts +12 -0
  187. package/templates/web/src/features/auth/session.ts +17 -0
  188. package/templates/web/src/features/auth/sign-in-form.spec.tsx +30 -0
  189. package/templates/web/src/features/auth/sign-in-form.tsx +73 -0
  190. package/templates/web/src/features/auth/sign-up-form.tsx +79 -0
  191. package/templates/web/src/features/forms/form-error.tsx +15 -0
  192. package/templates/web/src/features/forms/server-errors.ts +33 -0
  193. package/templates/web/src/features/profile/profile-form.spec.tsx +58 -0
  194. package/templates/web/src/features/profile/profile-form.tsx +74 -0
  195. package/templates/web/src/index.css +130 -0
  196. package/templates/web/src/lib/utils.ts +1 -0
  197. package/templates/web/src/main.tsx +28 -0
  198. package/templates/web/src/routeTree.gen.ts +125 -0
  199. package/templates/web/src/router.tsx +21 -0
  200. package/templates/web/src/routes/__root.tsx +10 -0
  201. package/templates/web/src/routes/_authenticated/index.tsx +21 -0
  202. package/templates/web/src/routes/_authenticated.tsx +15 -0
  203. package/templates/web/src/routes/sign-in.tsx +20 -0
  204. package/templates/web/src/routes/sign-up.tsx +17 -0
  205. package/templates/web/test/fake-transport.ts +23 -0
  206. package/templates/web/test/render-app.tsx +17 -0
  207. package/templates/web/test/setup.ts +7 -0
  208. package/templates/web/tsconfig.json +21 -0
  209. package/templates/web/vite.config.ts +18 -0
  210. package/templates/workspace/.githooks/install.mjs +18 -0
  211. package/templates/workspace/.githooks/pre-commit +2 -0
  212. package/templates/workspace/.githooks/pre-push +2 -0
  213. package/templates/workspace/README.md +25 -0
  214. package/templates/workspace/biome.jsonc +8 -0
  215. package/templates/workspace/gitignore +5 -0
  216. package/templates/workspace/package.json +17 -0
  217. package/templates/workspace/packages/api-contract/package.json +20 -0
  218. package/templates/workspace/packages/api-contract/tsconfig.json +11 -0
  219. package/templates/workspace/pnpm-workspace.yaml +3 -0
  220. package/templates/workspace/tsconfig.base.json +10 -0
@@ -0,0 +1,74 @@
1
+ import { zodResolver } from '@hookform/resolvers/zod'
2
+ import { useMutation, useQueryClient } from '@tanstack/react-query'
3
+ import { useForm } from 'react-hook-form'
4
+ import { z } from 'zod'
5
+ import { type ApiClient, LOCALES, type Profile, profileQuery, updateProfile } from '@/api-contract'
6
+ import { Button } from '@/components/ui/button'
7
+ import { Field, FieldError, FieldGroup, FieldLabel } from '@/components/ui/field'
8
+ import { Input } from '@/components/ui/input'
9
+ import { FormError } from '@/features/forms/form-error'
10
+ import { applyServerError } from '@/features/forms/server-errors'
11
+
12
+ const schema = z.object({
13
+ displayName: z.string().min(1).max(80),
14
+ locale: z.enum(LOCALES),
15
+ timezone: z.string().min(1).max(64),
16
+ })
17
+
18
+ type ProfileValues = z.infer<typeof schema>
19
+
20
+ const FIELDS = ['displayName', 'locale', 'timezone'] as const
21
+
22
+ export function ProfileForm({ api, profile }: { api: ApiClient; profile: Profile }) {
23
+ const queryClient = useQueryClient()
24
+ const form = useForm<ProfileValues>({
25
+ resolver: zodResolver(schema),
26
+ mode: 'onTouched',
27
+ reValidateMode: 'onChange',
28
+ defaultValues: {
29
+ displayName: profile.displayName,
30
+ locale: profile.locale,
31
+ timezone: profile.timezone,
32
+ },
33
+ })
34
+ const { errors, isSubmitting } = form.formState
35
+
36
+ const mutation = useMutation({
37
+ mutationFn: (values: ProfileValues) => updateProfile(api, values),
38
+ onSuccess: () => queryClient.invalidateQueries({ queryKey: profileQuery(api).queryKey }),
39
+ })
40
+
41
+ async function onSubmit(values: ProfileValues) {
42
+ try {
43
+ await mutation.mutateAsync(values)
44
+ } catch (error) {
45
+ applyServerError(error, FIELDS, form.setError)
46
+ }
47
+ }
48
+
49
+ return (
50
+ <form onSubmit={form.handleSubmit(onSubmit)} noValidate className="flex flex-col gap-6">
51
+ <FormError errors={errors} />
52
+ <FieldGroup>
53
+ <Field data-invalid={errors.displayName !== undefined}>
54
+ <FieldLabel htmlFor="displayName">Display name</FieldLabel>
55
+ <Input id="displayName" {...form.register('displayName')} />
56
+ <FieldError errors={[errors.displayName]} />
57
+ </Field>
58
+ <Field data-invalid={errors.locale !== undefined}>
59
+ <FieldLabel htmlFor="locale">Locale</FieldLabel>
60
+ <Input id="locale" {...form.register('locale')} />
61
+ <FieldError errors={[errors.locale]} />
62
+ </Field>
63
+ <Field data-invalid={errors.timezone !== undefined}>
64
+ <FieldLabel htmlFor="timezone">Timezone</FieldLabel>
65
+ <Input id="timezone" {...form.register('timezone')} />
66
+ <FieldError errors={[errors.timezone]} />
67
+ </Field>
68
+ </FieldGroup>
69
+ <Button type="submit" disabled={isSubmitting}>
70
+ Save
71
+ </Button>
72
+ </form>
73
+ )
74
+ }
@@ -0,0 +1,130 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+ @import "shadcn/tailwind.css";
4
+ @import "@fontsource-variable/geist";
5
+
6
+ @custom-variant dark (&:is(.dark *));
7
+
8
+ @theme inline {
9
+ --font-heading: var(--font-sans);
10
+ --font-sans: "Geist Variable", sans-serif;
11
+ --color-sidebar-ring: var(--sidebar-ring);
12
+ --color-sidebar-border: var(--sidebar-border);
13
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
14
+ --color-sidebar-accent: var(--sidebar-accent);
15
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
16
+ --color-sidebar-primary: var(--sidebar-primary);
17
+ --color-sidebar-foreground: var(--sidebar-foreground);
18
+ --color-sidebar: var(--sidebar);
19
+ --color-chart-5: var(--chart-5);
20
+ --color-chart-4: var(--chart-4);
21
+ --color-chart-3: var(--chart-3);
22
+ --color-chart-2: var(--chart-2);
23
+ --color-chart-1: var(--chart-1);
24
+ --color-ring: var(--ring);
25
+ --color-input: var(--input);
26
+ --color-border: var(--border);
27
+ --color-destructive: var(--destructive);
28
+ --color-accent-foreground: var(--accent-foreground);
29
+ --color-accent: var(--accent);
30
+ --color-muted-foreground: var(--muted-foreground);
31
+ --color-muted: var(--muted);
32
+ --color-secondary-foreground: var(--secondary-foreground);
33
+ --color-secondary: var(--secondary);
34
+ --color-primary-foreground: var(--primary-foreground);
35
+ --color-primary: var(--primary);
36
+ --color-popover-foreground: var(--popover-foreground);
37
+ --color-popover: var(--popover);
38
+ --color-card-foreground: var(--card-foreground);
39
+ --color-card: var(--card);
40
+ --color-foreground: var(--foreground);
41
+ --color-background: var(--background);
42
+ --radius-sm: calc(var(--radius) * 0.6);
43
+ --radius-md: calc(var(--radius) * 0.8);
44
+ --radius-lg: var(--radius);
45
+ --radius-xl: calc(var(--radius) * 1.4);
46
+ --radius-2xl: calc(var(--radius) * 1.8);
47
+ --radius-3xl: calc(var(--radius) * 2.2);
48
+ --radius-4xl: calc(var(--radius) * 2.6);
49
+ }
50
+
51
+ :root {
52
+ --background: oklch(1 0 0);
53
+ --foreground: oklch(0.145 0 0);
54
+ --card: oklch(1 0 0);
55
+ --card-foreground: oklch(0.145 0 0);
56
+ --popover: oklch(1 0 0);
57
+ --popover-foreground: oklch(0.145 0 0);
58
+ --primary: oklch(0.205 0 0);
59
+ --primary-foreground: oklch(0.985 0 0);
60
+ --secondary: oklch(0.97 0 0);
61
+ --secondary-foreground: oklch(0.205 0 0);
62
+ --muted: oklch(0.97 0 0);
63
+ --muted-foreground: oklch(0.556 0 0);
64
+ --accent: oklch(0.97 0 0);
65
+ --accent-foreground: oklch(0.205 0 0);
66
+ --destructive: oklch(0.577 0.245 27.325);
67
+ --border: oklch(0.922 0 0);
68
+ --input: oklch(0.922 0 0);
69
+ --ring: oklch(0.708 0 0);
70
+ --chart-1: oklch(0.87 0 0);
71
+ --chart-2: oklch(0.556 0 0);
72
+ --chart-3: oklch(0.439 0 0);
73
+ --chart-4: oklch(0.371 0 0);
74
+ --chart-5: oklch(0.269 0 0);
75
+ --radius: 0.625rem;
76
+ --sidebar: oklch(0.985 0 0);
77
+ --sidebar-foreground: oklch(0.145 0 0);
78
+ --sidebar-primary: oklch(0.205 0 0);
79
+ --sidebar-primary-foreground: oklch(0.985 0 0);
80
+ --sidebar-accent: oklch(0.97 0 0);
81
+ --sidebar-accent-foreground: oklch(0.205 0 0);
82
+ --sidebar-border: oklch(0.922 0 0);
83
+ --sidebar-ring: oklch(0.708 0 0);
84
+ }
85
+
86
+ .dark {
87
+ --background: oklch(0.145 0 0);
88
+ --foreground: oklch(0.985 0 0);
89
+ --card: oklch(0.205 0 0);
90
+ --card-foreground: oklch(0.985 0 0);
91
+ --popover: oklch(0.205 0 0);
92
+ --popover-foreground: oklch(0.985 0 0);
93
+ --primary: oklch(0.922 0 0);
94
+ --primary-foreground: oklch(0.205 0 0);
95
+ --secondary: oklch(0.269 0 0);
96
+ --secondary-foreground: oklch(0.985 0 0);
97
+ --muted: oklch(0.269 0 0);
98
+ --muted-foreground: oklch(0.708 0 0);
99
+ --accent: oklch(0.269 0 0);
100
+ --accent-foreground: oklch(0.985 0 0);
101
+ --destructive: oklch(0.704 0.191 22.216);
102
+ --border: oklch(1 0 0 / 10%);
103
+ --input: oklch(1 0 0 / 15%);
104
+ --ring: oklch(0.556 0 0);
105
+ --chart-1: oklch(0.87 0 0);
106
+ --chart-2: oklch(0.556 0 0);
107
+ --chart-3: oklch(0.439 0 0);
108
+ --chart-4: oklch(0.371 0 0);
109
+ --chart-5: oklch(0.269 0 0);
110
+ --sidebar: oklch(0.205 0 0);
111
+ --sidebar-foreground: oklch(0.985 0 0);
112
+ --sidebar-primary: oklch(0.488 0.243 264.376);
113
+ --sidebar-primary-foreground: oklch(0.985 0 0);
114
+ --sidebar-accent: oklch(0.269 0 0);
115
+ --sidebar-accent-foreground: oklch(0.985 0 0);
116
+ --sidebar-border: oklch(1 0 0 / 10%);
117
+ --sidebar-ring: oklch(0.556 0 0);
118
+ }
119
+
120
+ @layer base {
121
+ * {
122
+ @apply border-border outline-ring/50;
123
+ }
124
+ body {
125
+ @apply bg-background text-foreground;
126
+ }
127
+ html {
128
+ @apply font-sans;
129
+ }
130
+ }
@@ -0,0 +1 @@
1
+ export { cn } from 'cn'
@@ -0,0 +1,28 @@
1
+ import '@/index.css'
2
+ import { QueryClient } from '@tanstack/react-query'
3
+ import { StrictMode } from 'react'
4
+ import { createRoot } from 'react-dom/client'
5
+ import { createClient, type Transport } from '@/api-contract'
6
+ import { createAuth } from '@/features/auth/auth-client'
7
+ import { App } from './app'
8
+ import { config } from './config'
9
+
10
+ const transport: Transport = (input, init) => fetch(input, { ...init, credentials: 'include' })
11
+
12
+ const context = {
13
+ queryClient: new QueryClient(),
14
+ api: createClient({ baseUrl: config.VITE_API_URL, fetch: transport }),
15
+ auth: createAuth({ baseUrl: config.VITE_API_URL, fetch: transport }),
16
+ }
17
+
18
+ const root = document.getElementById('root')
19
+
20
+ if (root === null) {
21
+ throw new Error('Missing #root element')
22
+ }
23
+
24
+ createRoot(root).render(
25
+ <StrictMode>
26
+ <App context={context} />
27
+ </StrictMode>,
28
+ )
@@ -0,0 +1,125 @@
1
+ /* eslint-disable */
2
+
3
+ // @ts-nocheck
4
+
5
+ // noinspection JSUnusedGlobalSymbols
6
+
7
+ // This file was automatically generated by TanStack Router.
8
+ // You should NOT make any changes in this file as it will be overwritten.
9
+ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
10
+
11
+ import { Route as rootRouteImport } from './routes/__root'
12
+ import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
13
+ import { Route as SignInRouteImport } from './routes/sign-in'
14
+ import { Route as SignUpRouteImport } from './routes/sign-up'
15
+ import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
16
+
17
+ const AuthenticatedRoute = AuthenticatedRouteImport.update({
18
+ id: '/_authenticated',
19
+ getParentRoute: () => rootRouteImport,
20
+ } as any)
21
+ const SignInRoute = SignInRouteImport.update({
22
+ id: '/sign-in',
23
+ path: '/sign-in',
24
+ getParentRoute: () => rootRouteImport,
25
+ } as any)
26
+ const SignUpRoute = SignUpRouteImport.update({
27
+ id: '/sign-up',
28
+ path: '/sign-up',
29
+ getParentRoute: () => rootRouteImport,
30
+ } as any)
31
+ const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
32
+ id: '/',
33
+ path: '/',
34
+ getParentRoute: () => AuthenticatedRoute,
35
+ } as any)
36
+
37
+ export interface FileRoutesByFullPath {
38
+ '/': typeof AuthenticatedIndexRoute
39
+ '/sign-in': typeof SignInRoute
40
+ '/sign-up': typeof SignUpRoute
41
+ }
42
+ export interface FileRoutesByTo {
43
+ '/sign-in': typeof SignInRoute
44
+ '/sign-up': typeof SignUpRoute
45
+ '/': typeof AuthenticatedIndexRoute
46
+ }
47
+ export interface FileRoutesById {
48
+ __root__: typeof rootRouteImport
49
+ '/_authenticated': typeof AuthenticatedRouteWithChildren
50
+ '/sign-in': typeof SignInRoute
51
+ '/sign-up': typeof SignUpRoute
52
+ '/_authenticated/': typeof AuthenticatedIndexRoute
53
+ }
54
+ export interface FileRouteTypes {
55
+ fileRoutesByFullPath: FileRoutesByFullPath
56
+ fullPaths: '/' | '/sign-in' | '/sign-up'
57
+ fileRoutesByTo: FileRoutesByTo
58
+ to: '/sign-in' | '/sign-up' | '/'
59
+ id:
60
+ | '__root__'
61
+ | '/_authenticated'
62
+ | '/sign-in'
63
+ | '/sign-up'
64
+ | '/_authenticated/'
65
+ fileRoutesById: FileRoutesById
66
+ }
67
+ export interface RootRouteChildren {
68
+ AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
69
+ SignInRoute: typeof SignInRoute
70
+ SignUpRoute: typeof SignUpRoute
71
+ }
72
+
73
+ declare module '@tanstack/react-router' {
74
+ interface FileRoutesByPath {
75
+ '/_authenticated': {
76
+ id: '/_authenticated'
77
+ path: ''
78
+ fullPath: '/'
79
+ preLoaderRoute: typeof AuthenticatedRouteImport
80
+ parentRoute: typeof rootRouteImport
81
+ }
82
+ '/sign-in': {
83
+ id: '/sign-in'
84
+ path: '/sign-in'
85
+ fullPath: '/sign-in'
86
+ preLoaderRoute: typeof SignInRouteImport
87
+ parentRoute: typeof rootRouteImport
88
+ }
89
+ '/sign-up': {
90
+ id: '/sign-up'
91
+ path: '/sign-up'
92
+ fullPath: '/sign-up'
93
+ preLoaderRoute: typeof SignUpRouteImport
94
+ parentRoute: typeof rootRouteImport
95
+ }
96
+ '/_authenticated/': {
97
+ id: '/_authenticated/'
98
+ path: '/'
99
+ fullPath: '/'
100
+ preLoaderRoute: typeof AuthenticatedIndexRouteImport
101
+ parentRoute: typeof AuthenticatedRoute
102
+ }
103
+ }
104
+ }
105
+
106
+ interface AuthenticatedRouteChildren {
107
+ AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
108
+ }
109
+
110
+ const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
111
+ AuthenticatedIndexRoute: AuthenticatedIndexRoute,
112
+ }
113
+
114
+ const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
115
+ AuthenticatedRouteChildren,
116
+ )
117
+
118
+ const rootRouteChildren: RootRouteChildren = {
119
+ AuthenticatedRoute: AuthenticatedRouteWithChildren,
120
+ SignInRoute: SignInRoute,
121
+ SignUpRoute: SignUpRoute,
122
+ }
123
+ export const routeTree = rootRouteImport
124
+ ._addFileChildren(rootRouteChildren)
125
+ ._addFileTypes<FileRouteTypes>()
@@ -0,0 +1,21 @@
1
+ import type { QueryClient } from '@tanstack/react-query'
2
+ import { createRouter, type RouterHistory } from '@tanstack/react-router'
3
+ import type { ApiClient } from '@/api-contract'
4
+ import type { AuthClient } from '@/features/auth/auth-client'
5
+ import { routeTree } from './routeTree.gen'
6
+
7
+ export type RouterContext = {
8
+ queryClient: QueryClient
9
+ api: ApiClient
10
+ auth: AuthClient
11
+ }
12
+
13
+ export function createAppRouter(context: RouterContext, history?: RouterHistory) {
14
+ return createRouter({ routeTree, context, history, defaultPreload: 'intent' })
15
+ }
16
+
17
+ declare module '@tanstack/react-router' {
18
+ interface Register {
19
+ router: ReturnType<typeof createAppRouter>
20
+ }
21
+ }
@@ -0,0 +1,10 @@
1
+ import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'
2
+ import type { RouterContext } from '@/router'
3
+
4
+ export const Route = createRootRouteWithContext<RouterContext>()({
5
+ component: () => (
6
+ <main className="mx-auto flex min-h-svh w-full max-w-sm flex-col justify-center gap-6 p-6">
7
+ <Outlet />
8
+ </main>
9
+ ),
10
+ })
@@ -0,0 +1,21 @@
1
+ import { useSuspenseQuery } from '@tanstack/react-query'
2
+ import { createFileRoute } from '@tanstack/react-router'
3
+ import { profileQuery } from '@/api-contract'
4
+ import { ProfileForm } from '@/features/profile/profile-form'
5
+
6
+ export const Route = createFileRoute('/_authenticated/')({
7
+ loader: ({ context }) => context.queryClient.ensureQueryData(profileQuery(context.api)),
8
+ component: ProfilePage,
9
+ })
10
+
11
+ function ProfilePage() {
12
+ const { api } = Route.useRouteContext()
13
+ const { data: profile } = useSuspenseQuery(profileQuery(api))
14
+
15
+ return (
16
+ <>
17
+ <h1 className="text-2xl font-semibold">Your profile</h1>
18
+ <ProfileForm api={api} profile={profile} />
19
+ </>
20
+ )
21
+ }
@@ -0,0 +1,15 @@
1
+ import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
2
+ import { sessionQuery } from '@/features/auth/session'
3
+
4
+ export const Route = createFileRoute('/_authenticated')({
5
+ beforeLoad: async ({ context, location }) => {
6
+ const session = await context.queryClient.ensureQueryData(sessionQuery(context.auth))
7
+
8
+ if (session === null) {
9
+ throw redirect({ to: '/sign-in', search: { redirect: location.href } })
10
+ }
11
+
12
+ return { session }
13
+ },
14
+ component: Outlet,
15
+ })
@@ -0,0 +1,20 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { z } from 'zod'
3
+ import { SignInForm } from '@/features/auth/sign-in-form'
4
+
5
+ export const Route = createFileRoute('/sign-in')({
6
+ validateSearch: z.object({ redirect: z.string().optional() }),
7
+ component: SignInPage,
8
+ })
9
+
10
+ function SignInPage() {
11
+ const { auth } = Route.useRouteContext()
12
+ const { redirect } = Route.useSearch()
13
+
14
+ return (
15
+ <>
16
+ <h1 className="text-2xl font-semibold">Sign in</h1>
17
+ <SignInForm auth={auth} redirect={redirect} />
18
+ </>
19
+ )
20
+ }
@@ -0,0 +1,17 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { SignUpForm } from '@/features/auth/sign-up-form'
3
+
4
+ export const Route = createFileRoute('/sign-up')({
5
+ component: SignUpPage,
6
+ })
7
+
8
+ function SignUpPage() {
9
+ const { auth } = Route.useRouteContext()
10
+
11
+ return (
12
+ <>
13
+ <h1 className="text-2xl font-semibold">Create an account</h1>
14
+ <SignUpForm auth={auth} />
15
+ </>
16
+ )
17
+ }
@@ -0,0 +1,23 @@
1
+ import type { Transport } from '@/api-contract'
2
+
3
+ export const API_URL = 'http://api.test'
4
+
5
+ type Handler = (request: Request) => Response | Promise<Response>
6
+
7
+ export function json(status: number, body: unknown, contentType = 'application/json'): Response {
8
+ return new Response(JSON.stringify(body), { status, headers: { 'content-type': contentType } })
9
+ }
10
+
11
+ export function fakeTransport(routes: Record<string, Handler>): Transport {
12
+ return async (input, init) => {
13
+ const request = new Request(input, init)
14
+ const key = `${request.method} ${new URL(request.url).pathname}`
15
+ const handler = routes[key]
16
+
17
+ if (handler === undefined) {
18
+ throw new Error(`No fake response for ${key}`)
19
+ }
20
+
21
+ return handler(request)
22
+ }
23
+ }
@@ -0,0 +1,17 @@
1
+ import { QueryClient } from '@tanstack/react-query'
2
+ import { createMemoryHistory } from '@tanstack/react-router'
3
+ import { render } from '@testing-library/react'
4
+ import { createClient, type Transport } from '@/api-contract'
5
+ import { App } from '@/app'
6
+ import { createAuth } from '@/features/auth/auth-client'
7
+ import { API_URL } from './fake-transport'
8
+
9
+ export function renderApp(path: string, transport: Transport) {
10
+ const context = {
11
+ queryClient: new QueryClient({ defaultOptions: { queries: { retry: false } } }),
12
+ api: createClient({ baseUrl: API_URL, fetch: transport }),
13
+ auth: createAuth({ baseUrl: API_URL, fetch: transport }),
14
+ }
15
+
16
+ return render(<App context={context} history={createMemoryHistory({ initialEntries: [path] })} />)
17
+ }
@@ -0,0 +1,7 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { cleanup } from '@testing-library/react'
3
+ import { afterEach } from 'vitest'
4
+
5
+ afterEach(() => {
6
+ cleanup()
7
+ })
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2023",
4
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "jsx": "react-jsx",
8
+ "strict": true,
9
+ "noUncheckedIndexedAccess": true,
10
+ "noFallthroughCasesInSwitch": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "skipLibCheck": true,
13
+ "isolatedModules": true,
14
+ "noEmit": true,
15
+ "paths": {
16
+ "@/*": ["./src/*"]
17
+ },
18
+ "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom/vitest"]
19
+ },
20
+ "include": ["src", "test", "vite.config.ts"]
21
+ }
@@ -0,0 +1,18 @@
1
+ import tailwindcss from '@tailwindcss/vite'
2
+ import { tanstackRouter } from '@tanstack/router-plugin/vite'
3
+ import react from '@vitejs/plugin-react'
4
+ import { defineConfig } from 'vitest/config'
5
+
6
+ export default defineConfig({
7
+ plugins: [tanstackRouter({ target: 'react', autoCodeSplitting: true }), react(), tailwindcss()],
8
+ resolve: {
9
+ alias: { '@': `${import.meta.dirname}/src` },
10
+ },
11
+ test: {
12
+ environment: 'jsdom',
13
+ globals: true,
14
+ include: ['src/**/*.spec.{ts,tsx}'],
15
+ setupFiles: ['./test/setup.ts'],
16
+ coverage: { provider: 'v8', include: ['src/**'] },
17
+ },
18
+ })
@@ -0,0 +1,18 @@
1
+ import { execFileSync } from 'node:child_process'
2
+
3
+ function insideRepository() {
4
+ try {
5
+ execFileSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' })
6
+ return true
7
+ } catch (error) {
8
+ // No git, or no repository: an install from an archive or inside a container, where hooks have no use.
9
+ if (error.code === 'ENOENT' || error.status === 128) {
10
+ return false
11
+ }
12
+ throw error
13
+ }
14
+ }
15
+
16
+ if (insideRepository()) {
17
+ execFileSync('git', ['config', 'core.hooksPath', '.githooks'], { stdio: 'inherit' })
18
+ }
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ pnpm biome check --staged --write
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ pnpm typecheck
@@ -0,0 +1,25 @@
1
+ # workspace
2
+
3
+ ## Requirements
4
+
5
+ Node 22.17 or later, pnpm, and Docker for the API.
6
+
7
+ ## Layout
8
+
9
+ | Directory | What it is |
10
+ |---|---|
11
+ | `apps/` | Deployable applications, one per type. Each has its own README with the commands to run it |
12
+ | `packages/api-contract` | The wire contract between the clients and the API, shipped as TypeScript source |
13
+
14
+ ## Everyday commands
15
+
16
+ | Command | What it does |
17
+ |---|---|
18
+ | `pnpm install` | Installs every app and package |
19
+ | `pnpm --filter <app> dev` | Starts one app. There is no root `dev` on purpose |
20
+ | `pnpm lint` · `pnpm typecheck` · `pnpm test` | Runs across every app and package |
21
+
22
+ ## Conventions
23
+
24
+ The rules this project follows live in `.prumo/`, and `AGENTS.md` points to them. Read those before
25
+ changing how something is done.
@@ -0,0 +1,8 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/2.5.13/schema.json",
3
+ "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
4
+ "files": { "includes": ["**", "!**/dist/**", "!**/coverage/**"] },
5
+ "formatter": { "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 },
6
+ "linter": { "rules": { "preset": "recommended" } },
7
+ "javascript": { "formatter": { "quoteStyle": "single", "semicolons": "asNeeded" } }
8
+ }
@@ -0,0 +1,5 @@
1
+ node_modules
2
+ dist
3
+ coverage
4
+ *.tsbuildinfo
5
+ .env
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "workspace",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "scripts": {
6
+ "lint": "pnpm -r lint",
7
+ "typecheck": "pnpm -r typecheck",
8
+ "test": "pnpm -r test",
9
+ "prepare": "node .githooks/install.mjs"
10
+ },
11
+ "devDependencies": {
12
+ "@biomejs/biome": "catalog:"
13
+ },
14
+ "engines": {
15
+ "node": ">=22.17.0"
16
+ }
17
+ }