create-m5kdev 0.25.3 → 0.26.2

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 (143) hide show
  1. package/dist/src/create.cjs +18 -1
  2. package/dist/src/create.cjs.map +1 -1
  3. package/dist/src/fs.cjs +65 -5
  4. package/dist/src/fs.cjs.map +1 -1
  5. package/dist/src/fs.d.cts +13 -2
  6. package/dist/src/index.cjs +0 -0
  7. package/dist/src/paths.cjs +2 -2
  8. package/dist/src/prompts.cjs +22 -0
  9. package/dist/src/prompts.cjs.map +1 -1
  10. package/dist/src/runCli.cjs +13 -2
  11. package/dist/src/runCli.cjs.map +1 -1
  12. package/dist/src/types.d.cts +9 -1
  13. package/package.json +8 -6
  14. package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +46 -3
  15. package/templates/minimal-app/.cursor/rules/module-db-guide.mdc +15 -1
  16. package/templates/minimal-app/.cursor/rules/module-dto-guide.mdc +60 -0
  17. package/templates/minimal-app/.cursor/rules/module-repository-guide.mdc +98 -193
  18. package/templates/minimal-app/.cursor/rules/module-schema-guide.mdc +53 -173
  19. package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +133 -240
  20. package/templates/minimal-app/.cursor/rules/module-trpc-guide.mdc +71 -113
  21. package/templates/minimal-app/README.md.tpl +3 -3
  22. package/templates/minimal-app/apps/.gitignore.tpl +13 -0
  23. package/templates/minimal-app/apps/e2e/package.json.tpl +23 -0
  24. package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +39 -0
  25. package/templates/minimal-app/apps/e2e/playwright.expo.config.ts.tpl +38 -0
  26. package/templates/minimal-app/apps/e2e/servers.ts.tpl +100 -0
  27. package/templates/minimal-app/apps/e2e/tests/email.spec.ts.tpl +47 -0
  28. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +73 -0
  29. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +517 -0
  30. package/templates/minimal-app/apps/e2e/tests/expo-waitlist.spec.ts.tpl +94 -0
  31. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +289 -0
  32. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +516 -0
  33. package/templates/minimal-app/apps/e2e/tests/waitlist.spec.ts.tpl +107 -0
  34. package/templates/minimal-app/apps/e2e/tsconfig.json.tpl +10 -0
  35. package/templates/minimal-app/apps/email/package.json.tpl +34 -34
  36. package/templates/minimal-app/apps/email/src/emails/accountDeletionEmail.tsx.tpl +35 -34
  37. package/templates/minimal-app/apps/email/src/emails/organizationInviteEmail.tsx.tpl +34 -33
  38. package/templates/minimal-app/apps/email/src/emails/passwordResetEmail.tsx.tpl +35 -34
  39. package/templates/minimal-app/apps/email/src/emails/verificationEmail.tsx.tpl +37 -36
  40. package/templates/minimal-app/apps/email/src/emails/waitlistEmails.tsx.tpl +87 -0
  41. package/templates/minimal-app/apps/email/src/index.ts.tpl +72 -32
  42. package/templates/minimal-app/apps/email/src/resources.ts.tpl +65 -36
  43. package/templates/minimal-app/apps/email/tsconfig.json.tpl +16 -11
  44. package/templates/minimal-app/apps/expo/.gitignore.tpl +44 -0
  45. package/templates/minimal-app/apps/expo/.prettierignore +13 -0
  46. package/templates/minimal-app/apps/expo/.prettierrc.json.tpl +9 -0
  47. package/templates/minimal-app/apps/expo/CHANGELOG.md.tpl +156 -0
  48. package/templates/minimal-app/apps/expo/README.md.tpl +44 -0
  49. package/templates/minimal-app/apps/expo/app.json.tpl +36 -0
  50. package/templates/minimal-app/apps/expo/assets/images/adaptive-icon.png +0 -0
  51. package/templates/minimal-app/apps/expo/assets/images/favicon.png +0 -0
  52. package/templates/minimal-app/apps/expo/assets/images/icon.png +0 -0
  53. package/templates/minimal-app/apps/expo/assets/images/splash-icon.png +0 -0
  54. package/templates/minimal-app/apps/expo/eslint.config.js.tpl +8 -0
  55. package/templates/minimal-app/apps/expo/expo-env.d.ts.tpl +3 -0
  56. package/templates/minimal-app/apps/expo/metro.config.js.tpl +16 -0
  57. package/templates/minimal-app/apps/expo/package.json.tpl +59 -0
  58. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +31 -0
  59. package/templates/minimal-app/apps/expo/src/app/claim-account.tsx.tpl +151 -0
  60. package/templates/minimal-app/apps/expo/src/app/forgot-password.tsx.tpl +53 -0
  61. package/templates/minimal-app/apps/expo/src/app/index.tsx.tpl +8 -0
  62. package/templates/minimal-app/apps/expo/src/app/login.tsx.tpl +71 -0
  63. package/templates/minimal-app/apps/expo/src/app/logout.tsx.tpl +19 -0
  64. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +85 -0
  65. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +40 -0
  66. package/templates/minimal-app/apps/expo/src/app/reset-password.tsx.tpl +75 -0
  67. package/templates/minimal-app/apps/expo/src/app/signup.tsx.tpl +193 -0
  68. package/templates/minimal-app/apps/expo/src/components/AuthScaffold.tsx.tpl +34 -0
  69. package/templates/minimal-app/apps/expo/src/components/FormTextField.tsx.tpl +37 -0
  70. package/templates/minimal-app/apps/expo/src/components/LoadingScreen.tsx.tpl +10 -0
  71. package/templates/minimal-app/apps/expo/src/components/StatusText.tsx.tpl +22 -0
  72. package/templates/minimal-app/apps/expo/src/config.ts.tpl +9 -0
  73. package/templates/minimal-app/apps/expo/src/global.css.tpl +11 -0
  74. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +20 -0
  75. package/templates/minimal-app/apps/expo/tsconfig.json.tpl +11 -0
  76. package/templates/minimal-app/apps/expo/uniwind-env.d.ts.tpl +1 -0
  77. package/templates/minimal-app/apps/expo/uniwind-types.d.ts.tpl +10 -0
  78. package/templates/minimal-app/apps/server/AGENTS.md.tpl +39 -39
  79. package/templates/minimal-app/apps/server/drizzle/0000_wealthy_absorbing_man.sql +268 -0
  80. package/templates/minimal-app/apps/server/drizzle/db.ts.tpl +26 -0
  81. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +1890 -0
  82. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +13 -0
  83. package/templates/minimal-app/apps/server/drizzle/reset.ts.tpl +33 -0
  84. package/templates/minimal-app/apps/server/drizzle/seed.e2e.ts.tpl +244 -0
  85. package/templates/minimal-app/apps/server/drizzle/seed.ts.tpl +181 -180
  86. package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +14 -21
  87. package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +33 -35
  88. package/templates/minimal-app/apps/server/package.json.tpl +56 -52
  89. package/templates/minimal-app/apps/server/src/app.ts.tpl +142 -126
  90. package/templates/minimal-app/apps/server/src/index.ts.tpl +58 -58
  91. package/templates/minimal-app/apps/server/src/lib/auth.ts.tpl +12 -12
  92. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +25 -25
  93. package/templates/minimal-app/apps/server/src/modules/posts/posts.dto.ts.tpl +23 -0
  94. package/templates/minimal-app/apps/server/src/modules/posts/posts.grants.ts.tpl +30 -40
  95. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +54 -54
  96. package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +67 -112
  97. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +104 -170
  98. package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +37 -48
  99. package/templates/minimal-app/apps/server/src/modules/posts/posts.utils.ts.tpl +19 -0
  100. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +279 -0
  101. package/templates/minimal-app/apps/server/src/schema.ts.tpl +20 -0
  102. package/templates/minimal-app/apps/server/src/types.ts.tpl +1 -1
  103. package/templates/minimal-app/apps/server/tsconfig.json.tpl +20 -14
  104. package/templates/minimal-app/apps/server/tsup.config.ts.tpl +9 -9
  105. package/templates/minimal-app/apps/shared/package.json.tpl +24 -24
  106. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +28 -2
  107. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +3 -3
  108. package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -5
  109. package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +25 -76
  110. package/templates/minimal-app/apps/shared/tsconfig.json.tpl +16 -12
  111. package/templates/minimal-app/apps/webapp/index.html.tpl +22 -22
  112. package/templates/minimal-app/apps/webapp/package.json.tpl +53 -52
  113. package/templates/minimal-app/apps/webapp/public/{push-sw.js → push-sw.js.tpl} +34 -34
  114. package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +44 -13
  115. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +18 -12
  116. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +33 -40
  117. package/templates/minimal-app/apps/webapp/src/index.css.tpl +15 -75
  118. package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -26
  119. package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +278 -307
  120. package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostCard.tsx.tpl +96 -138
  121. package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostEditorModal.tsx.tpl +118 -124
  122. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostActions.ts.tpl +108 -116
  123. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsList.ts.tpl +15 -9
  124. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsRoute.ts.tpl +13 -13
  125. package/templates/minimal-app/apps/webapp/src/modules/posts/posts.utils.ts.tpl +16 -16
  126. package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -13
  127. package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +10 -6
  128. package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -1
  129. package/templates/minimal-app/apps/webapp/translations/{app.json.tpl → en/app.json.tpl} +7 -7
  130. package/templates/minimal-app/apps/webapp/translations/en/{blog-app.json.tpl → {{APP_SLUG}}.json.tpl} +135 -135
  131. package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +17 -17
  132. package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -31
  133. package/templates/minimal-app/template.manifest.json +21 -0
  134. package/templates/minimal-app/apps/server/drizzle/generate-schema.ts.tpl +0 -31
  135. package/templates/minimal-app/apps/server/src/db.ts.tpl +0 -7
  136. package/templates/minimal-app/apps/server/src/generated/schema.ts.tpl +0 -13
  137. package/templates/minimal-app/apps/server/src/utils/trpc.ts.tpl +0 -8
  138. package/templates/minimal-app/apps/shared/src/modules/app/locale.constants.ts.tpl +0 -7
  139. package/templates/minimal-app/apps/shared/src/modules/app/roles.constants.ts.tpl +0 -17
  140. package/templates/minimal-app/apps/webapp/src/Providers.tsx.tpl +0 -36
  141. package/templates/minimal-app/apps/webapp/src/modules/notification/PushNotificationsPanel.tsx.tpl +0 -89
  142. package/templates/minimal-app/apps/webapp/src/navigation/navigation.config.tsx.tpl +0 -85
  143. /package/templates/minimal-app/apps/webapp/public/{logo.svg → logo.svg.tpl} +0 -0
@@ -1,138 +1,96 @@
1
- import { Button, Card, Chip } from "@heroui/react";
2
- import { EyeIcon, PencilLineIcon, SendHorizontalIcon, Trash2Icon } from "lucide-react";
3
- import { useTranslation } from "react-i18next";
4
- import type { PostRow } from "../hooks/usePostsList";
5
- import { formatDate, getReadingTime } from "../posts.utils";
6
-
7
- interface PostCardProps {
8
- row: PostRow;
9
- isSelected: boolean;
10
- isPublishing: boolean;
11
- isDeleting: boolean;
12
- onSelect: () => void;
13
- onEdit: () => void;
14
- onPublish: () => void;
15
- onDelete: () => void;
16
- }
17
-
18
- export function PostCard({
19
- row,
20
- isSelected,
21
- isPublishing,
22
- isDeleting,
23
- onSelect,
24
- onEdit,
25
- onPublish,
26
- onDelete,
27
- }: PostCardProps) {
28
- const { t } = useTranslation("blog{{PACKAGE_SCOPE}}");
29
-
30
- return (
31
- // biome-ignore lint/a11y/useSemanticElements: post row card with nested action buttons
32
- <Card
33
- role="button"
34
- tabIndex={0}
35
- onClick={(event) => {
36
- if ((event.target as HTMLElement).closest("button")) {
37
- return;
38
- }
39
- onSelect();
40
- }}
41
- onKeyDown={(event) => {
42
- if (event.key === "Enter" || event.key === " ") {
43
- event.preventDefault();
44
- onSelect();
45
- }
46
- }}
47
- className={
48
- isSelected
49
- ? "cursor-pointer rounded-[30px] border border-emerald-300 bg-emerald-950 text-emerald-50 shadow-[0_20px_44px_rgba(31,79,70,0.24)]"
50
- : "cursor-pointer rounded-[30px] border border-white/70 bg-panel shadow-[0_18px_40px_rgba(81,50,24,0.1)]"
51
- }
52
- >
53
- <Card.Header className="flex items-start justify-between gap-4 px-5 pt-5">
54
- <div className="space-y-3">
55
- <div className="flex flex-wrap items-center gap-2">
56
- <Chip
57
- size="sm"
58
- color={row.status === "published" ? "success" : "default"}
59
- variant={isSelected ? "primary" : "soft"}
60
- >
61
- {row.status === "published" ? t("posts.filters.published") : t("posts.filters.draft")}
62
- </Chip>
63
- <span className={isSelected ? "text-emerald-100/80" : "text-muted-ink"}>
64
- {getReadingTime(row.content)}
65
- </span>
66
- </div>
67
- <div>
68
- <h3 className="font-editorial text-3xl leading-none">{row.title}</h3>
69
- <p
70
- className={
71
- isSelected
72
- ? "mt-3 text-sm leading-7 text-emerald-100/80"
73
- : "mt-3 text-sm leading-7 text-muted-ink"
74
- }
75
- >
76
- {row.excerpt}
77
- </p>
78
- </div>
79
- </div>
80
- <Button
81
- isIconOnly
82
- className={`rounded-full ${isSelected ? "bg-emerald-100 text-emerald-950" : "bg-white/80 text-ink"}`}
83
- variant={isSelected ? "secondary" : "ghost"}
84
- >
85
- <EyeIcon className="h-4 w-4" />
86
- </Button>
87
- </Card.Header>
88
- <Card.Content className="flex flex-col gap-4 px-5 pb-5">
89
- <div className="flex flex-wrap items-center gap-3 text-sm">
90
- <span className={isSelected ? "text-emerald-100/80" : "text-muted-ink"}>
91
- {t("posts.meta.updated")}: {formatDate(row.updatedAt ?? row.createdAt)}
92
- </span>
93
- {row.publishedAt ? (
94
- <span className={isSelected ? "text-emerald-100/80" : "text-muted-ink"}>
95
- {t("posts.meta.published")}: {formatDate(row.publishedAt)}
96
- </span>
97
- ) : null}
98
- </div>
99
- <div className="flex flex-wrap gap-2">
100
- <Button
101
- className={`rounded-full ${isSelected ? "bg-emerald-100 text-emerald-950" : ""}`}
102
- variant={isSelected ? "secondary" : "ghost"}
103
- onPress={onEdit}
104
- >
105
- <span className="inline-flex items-center gap-2">
106
- <PencilLineIcon className="h-4 w-4" />
107
- {t("posts.actions.edit")}
108
- </span>
109
- </Button>
110
- {row.status === "draft" ? (
111
- <Button
112
- className="rounded-full"
113
- variant="secondary"
114
- isPending={isPublishing}
115
- onPress={onPublish}
116
- >
117
- <span className="inline-flex items-center gap-2">
118
- <SendHorizontalIcon className="h-4 w-4" />
119
- {t("posts.actions.publish")}
120
- </span>
121
- </Button>
122
- ) : null}
123
- <Button
124
- className="rounded-full"
125
- variant="danger"
126
- isPending={isDeleting}
127
- onPress={onDelete}
128
- >
129
- <span className="inline-flex items-center gap-2">
130
- <Trash2Icon className="h-4 w-4" />
131
- {t("posts.actions.delete")}
132
- </span>
133
- </Button>
134
- </div>
135
- </Card.Content>
136
- </Card>
137
- );
138
- }
1
+ import { Button, Card, Chip } from "@heroui/react";
2
+ import { EyeIcon, PencilLineIcon, SendHorizontalIcon, Trash2Icon } from "lucide-react";
3
+ import { useTranslation } from "react-i18next";
4
+ import type { PostRow } from "../hooks/usePostsList";
5
+ import { formatDate, getReadingTime } from "../posts.utils";
6
+
7
+ interface PostCardProps {
8
+ row: PostRow;
9
+ isSelected: boolean;
10
+ isPublishing: boolean;
11
+ isDeleting: boolean;
12
+ onSelect: () => void;
13
+ onEdit: () => void;
14
+ onPublish: () => void;
15
+ onDelete: () => void;
16
+ }
17
+
18
+ export function PostCard({
19
+ row,
20
+ isSelected,
21
+ isPublishing,
22
+ isDeleting,
23
+ onSelect,
24
+ onEdit,
25
+ onPublish,
26
+ onDelete,
27
+ }: PostCardProps) {
28
+ const { t } = useTranslation("{{APP_SLUG}}");
29
+
30
+ return (
31
+ <Card variant={isSelected ? "secondary" : undefined}>
32
+ <Card.Header className="flex items-start justify-between gap-4">
33
+ <div className="grid gap-3">
34
+ <div className="flex flex-wrap items-center gap-2">
35
+ <Chip
36
+ size="sm"
37
+ color={row.status === "published" ? "success" : "default"}
38
+ variant="soft"
39
+ >
40
+ {row.status === "published" ? t("posts.filters.published") : t("posts.filters.draft")}
41
+ </Chip>
42
+ <Chip size="sm" variant="soft" color="default">
43
+ {getReadingTime(row.content)}
44
+ </Chip>
45
+ </div>
46
+ <div className="grid gap-1">
47
+ <Card.Title>{row.title}</Card.Title>
48
+ {row.excerpt ? <Card.Description>{row.excerpt}</Card.Description> : null}
49
+ </div>
50
+ </div>
51
+ <Button
52
+ isIconOnly
53
+ variant={isSelected ? "secondary" : "ghost"}
54
+ aria-label={t("posts.preview.eyebrow")}
55
+ onPress={onSelect}
56
+ >
57
+ <EyeIcon className="h-4 w-4" />
58
+ </Button>
59
+ </Card.Header>
60
+ <Card.Content>
61
+ <div className="flex flex-wrap gap-2">
62
+ <Chip size="sm" variant="soft">
63
+ {t("posts.meta.updated")}: {formatDate(row.updatedAt ?? row.createdAt)}
64
+ </Chip>
65
+ {row.publishedAt ? (
66
+ <Chip size="sm" variant="soft">
67
+ {t("posts.meta.published")}: {formatDate(row.publishedAt)}
68
+ </Chip>
69
+ ) : null}
70
+ </div>
71
+ </Card.Content>
72
+ <Card.Footer className="flex flex-wrap gap-2">
73
+ <Button variant="ghost" onPress={onEdit}>
74
+ <span className="inline-flex items-center gap-2">
75
+ <PencilLineIcon className="h-4 w-4" />
76
+ {t("posts.actions.edit")}
77
+ </span>
78
+ </Button>
79
+ {row.status === "draft" ? (
80
+ <Button variant="secondary" isPending={isPublishing} onPress={onPublish}>
81
+ <span className="inline-flex items-center gap-2">
82
+ <SendHorizontalIcon className="h-4 w-4" />
83
+ {t("posts.actions.publish")}
84
+ </span>
85
+ </Button>
86
+ ) : null}
87
+ <Button variant="danger" isPending={isDeleting} onPress={onDelete}>
88
+ <span className="inline-flex items-center gap-2">
89
+ <Trash2Icon className="h-4 w-4" />
90
+ {t("posts.actions.delete")}
91
+ </span>
92
+ </Button>
93
+ </Card.Footer>
94
+ </Card>
95
+ );
96
+ }
@@ -1,124 +1,118 @@
1
- import { Button, FieldError, Form, Input, Label, Modal, TextArea, TextField } from "@heroui/react";
2
- import type { PostCreateInputSchema } from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.schema";
3
- import type { FormEvent } from "react";
4
- import { useTranslation } from "react-i18next";
5
- import type { PostRow } from "../hooks/usePostsList";
6
-
7
- interface PostEditorModalProps {
8
- isOpen: boolean;
9
- /** Row being edited; undefined means creating a new post. */
10
- post?: PostRow;
11
- isSaving: boolean;
12
- onClose: () => void;
13
- onSave: (payload: PostCreateInputSchema, id?: string) => Promise<boolean>;
14
- }
15
-
16
- /**
17
- * Uncontrolled HeroUI form: native HTML validation (`isRequired`) guards the
18
- * fields and values are read from FormData on submit — no form library, no
19
- * per-field state.
20
- */
21
- export function PostEditorModal({ isOpen, post, isSaving, onClose, onSave }: PostEditorModalProps) {
22
- const { t } = useTranslation("blog{{PACKAGE_SCOPE}}");
23
-
24
- const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
25
- event.preventDefault();
26
- const formData = new FormData(event.currentTarget);
27
- const payload: PostCreateInputSchema = {
28
- title: String(formData.get("title") ?? "").trim(),
29
- slug: String(formData.get("slug") ?? "").trim() || undefined,
30
- excerpt: String(formData.get("excerpt") ?? "").trim() || undefined,
31
- content: String(formData.get("content") ?? "").trim(),
32
- };
33
-
34
- if (await onSave(payload, post?.id)) {
35
- onClose();
36
- }
37
- };
38
-
39
- return (
40
- <Modal
41
- isOpen={isOpen}
42
- onOpenChange={(open) => {
43
- if (!open) {
44
- onClose();
45
- }
46
- }}
47
- >
48
- <Modal.Backdrop>
49
- <Modal.Container scroll="inside" size="lg" className="max-w-5xl">
50
- <Modal.Dialog>
51
- {/* key remounts the form so defaultValue resets when switching rows */}
52
- <Form key={post?.id ?? "new"} className="contents" onSubmit={onSubmit}>
53
- <Modal.Header className="flex flex-col gap-2 px-6 pt-6">
54
- <p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-amber-700/80">
55
- {post ? t("posts.editor.editEyebrow") : t("posts.editor.newEyebrow")}
56
- </p>
57
- <Modal.Heading className="font-editorial text-4xl leading-none text-ink">
58
- {post ? t("posts.editor.editTitle") : t("posts.editor.newTitle")}
59
- </Modal.Heading>
60
- </Modal.Header>
61
- <Modal.Body className="grid gap-4 px-6 pb-2">
62
- <TextField
63
- isRequired
64
- name="title"
65
- defaultValue={post?.title ?? ""}
66
- variant="secondary"
67
- className="grid gap-2"
68
- >
69
- <Label className="text-sm font-medium">{t("posts.editor.fields.title")}</Label>
70
- <Input className="rounded-lg" />
71
- <FieldError />
72
- </TextField>
73
- <TextField
74
- name="slug"
75
- defaultValue={post?.slug ?? ""}
76
- variant="secondary"
77
- className="grid gap-2"
78
- >
79
- <Label className="text-sm font-medium">{t("posts.editor.fields.slug")}</Label>
80
- <Input className="rounded-lg" />
81
- <FieldError />
82
- </TextField>
83
- <TextField
84
- name="excerpt"
85
- defaultValue={post?.excerpt ?? ""}
86
- variant="secondary"
87
- className="grid gap-2"
88
- >
89
- <Label className="text-sm font-medium">{t("posts.editor.fields.excerpt")}</Label>
90
- <TextArea className="rounded-lg min-h-[5.5rem]" rows={3} />
91
- <FieldError />
92
- </TextField>
93
- <TextField
94
- isRequired
95
- name="content"
96
- defaultValue={post?.content ?? ""}
97
- variant="secondary"
98
- className="grid gap-2"
99
- >
100
- <Label className="text-sm font-medium">{t("posts.editor.fields.content")}</Label>
101
- <TextArea className="rounded-lg min-h-[12rem]" rows={10} />
102
- <FieldError />
103
- </TextField>
104
- </Modal.Body>
105
- <Modal.Footer className="px-6 pb-6">
106
- <Button className="rounded-full" variant="tertiary" type="button" onPress={onClose}>
107
- {t("posts.editor.cancel")}
108
- </Button>
109
- <Button
110
- className="rounded-full"
111
- variant="primary"
112
- type="submit"
113
- isPending={isSaving}
114
- >
115
- {post ? t("posts.editor.save") : t("posts.editor.create")}
116
- </Button>
117
- </Modal.Footer>
118
- </Form>
119
- </Modal.Dialog>
120
- </Modal.Container>
121
- </Modal.Backdrop>
122
- </Modal>
123
- );
124
- }
1
+ import { Button, FieldError, Form, Input, Label, Modal, TextArea, TextField } from "@heroui/react";
2
+ import type { PostCreateInputSchema } from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.schema";
3
+ import type { FormEvent } from "react";
4
+ import { useTranslation } from "react-i18next";
5
+ import type { PostRow } from "../hooks/usePostsList";
6
+
7
+ interface PostEditorModalProps {
8
+ isOpen: boolean;
9
+ /** Row being edited; undefined means creating a new post. */
10
+ post?: PostRow;
11
+ isSaving: boolean;
12
+ onClose: () => void;
13
+ onSave: (payload: PostCreateInputSchema, id?: string) => Promise<boolean>;
14
+ }
15
+
16
+ /**
17
+ * Uncontrolled HeroUI form: native HTML validation (`isRequired`) guards the
18
+ * fields and values are read from FormData on submit — no form library, no
19
+ * per-field state.
20
+ */
21
+ export function PostEditorModal({ isOpen, post, isSaving, onClose, onSave }: PostEditorModalProps) {
22
+ const { t } = useTranslation("{{APP_SLUG}}");
23
+
24
+ const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
25
+ event.preventDefault();
26
+ const formData = new FormData(event.currentTarget);
27
+ const payload: PostCreateInputSchema = {
28
+ title: String(formData.get("title") ?? "").trim(),
29
+ slug: String(formData.get("slug") ?? "").trim() || undefined,
30
+ excerpt: String(formData.get("excerpt") ?? "").trim() || undefined,
31
+ content: String(formData.get("content") ?? "").trim(),
32
+ };
33
+
34
+ if (await onSave(payload, post?.id)) {
35
+ onClose();
36
+ }
37
+ };
38
+
39
+ return (
40
+ <Modal
41
+ isOpen={isOpen}
42
+ onOpenChange={(open) => {
43
+ if (!open) {
44
+ onClose();
45
+ }
46
+ }}
47
+ >
48
+ <Modal.Backdrop>
49
+ <Modal.Container scroll="inside" size="lg" className="max-w-5xl">
50
+ <Modal.Dialog>
51
+ <Form key={post?.id ?? "new"} className="contents" onSubmit={onSubmit}>
52
+ <Modal.Header className="flex flex-col gap-1">
53
+ <p className="text-xs font-medium uppercase tracking-wide text-muted">
54
+ {post ? t("posts.editor.editEyebrow") : t("posts.editor.newEyebrow")}
55
+ </p>
56
+ <Modal.Heading>
57
+ {post ? t("posts.editor.editTitle") : t("posts.editor.newTitle")}
58
+ </Modal.Heading>
59
+ </Modal.Header>
60
+ <Modal.Body className="grid gap-4">
61
+ <TextField
62
+ isRequired
63
+ name="title"
64
+ defaultValue={post?.title ?? ""}
65
+ variant="secondary"
66
+ className="grid gap-2"
67
+ >
68
+ <Label>{t("posts.editor.fields.title")}</Label>
69
+ <Input />
70
+ <FieldError />
71
+ </TextField>
72
+ <TextField
73
+ name="slug"
74
+ defaultValue={post?.slug ?? ""}
75
+ variant="secondary"
76
+ className="grid gap-2"
77
+ >
78
+ <Label>{t("posts.editor.fields.slug")}</Label>
79
+ <Input />
80
+ <FieldError />
81
+ </TextField>
82
+ <TextField
83
+ name="excerpt"
84
+ defaultValue={post?.excerpt ?? ""}
85
+ variant="secondary"
86
+ className="grid gap-2"
87
+ >
88
+ <Label>{t("posts.editor.fields.excerpt")}</Label>
89
+ <TextArea className="min-h-[5.5rem]" rows={3} />
90
+ <FieldError />
91
+ </TextField>
92
+ <TextField
93
+ isRequired
94
+ name="content"
95
+ defaultValue={post?.content ?? ""}
96
+ variant="secondary"
97
+ className="grid gap-2"
98
+ >
99
+ <Label>{t("posts.editor.fields.content")}</Label>
100
+ <TextArea className="min-h-[12rem]" rows={10} />
101
+ <FieldError />
102
+ </TextField>
103
+ </Modal.Body>
104
+ <Modal.Footer>
105
+ <Button variant="tertiary" type="button" onPress={onClose}>
106
+ {t("posts.editor.cancel")}
107
+ </Button>
108
+ <Button variant="primary" type="submit" isPending={isSaving}>
109
+ {post ? t("posts.editor.save") : t("posts.editor.create")}
110
+ </Button>
111
+ </Modal.Footer>
112
+ </Form>
113
+ </Modal.Dialog>
114
+ </Modal.Container>
115
+ </Modal.Backdrop>
116
+ </Modal>
117
+ );
118
+ }