create-m5kdev 0.25.2 → 0.26.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 (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,307 +1,278 @@
1
- import { Button, Card, Chip, Input, Label, ListBox, Select, Skeleton } from "@heroui/react";
2
- import {
3
- ArrowLeftIcon,
4
- ArrowRightIcon,
5
- FilePenLineIcon,
6
- PlusIcon,
7
- } from "lucide-react";
8
- import { useId, useState } from "react";
9
- import { useTranslation } from "react-i18next";
10
- import { PostCard } from "./components/PostCard";
11
- import { PostEditorModal } from "./components/PostEditorModal";
12
- import type { PostRow, PostStatusFilter } from "./hooks/usePostsList";
13
- import { usePostsRoute } from "./hooks/usePostsRoute";
14
- import { formatDate, getReadingTime } from "./posts.utils";
15
-
16
- /** Editor modal state: null = closed, {} = create, { post } = edit. */
17
- type EditorState = { post?: PostRow } | null;
18
-
19
- export function PostsRoute() {
20
- const { t } = useTranslation("blog{{PACKAGE_SCOPE}}");
21
- const posts = usePostsRoute();
22
-
23
- const searchFieldId = useId();
24
- const statusFieldId = useId();
25
-
26
- // ephemeral UI state stays in the component; data and actions come from the hook
27
- const [selectedPostId, setSelectedPostId] = useState<string | undefined>(undefined);
28
- const [editor, setEditor] = useState<EditorState>(null);
29
-
30
- // derived, not synced: fall back to the first row when the selection is gone
31
- const selectedPost = posts.rows.find((row) => row.id === selectedPostId) ?? posts.rows[0];
32
-
33
- return (
34
- <div className="grid gap-6">
35
- <section className="grid gap-4 rounded-[30px] border border-amber-200/70 bg-panel px-5 py-5 shadow-[0_18px_40px_rgba(81,50,24,0.12)] lg:grid-cols-[minmax(0,1.7fr)_minmax(320px,0.9fr)] lg:px-6">
36
- <div>
37
- <p className="text-[0.68rem] font-semibold uppercase tracking-[0.32em] text-amber-700/80">
38
- {t("posts.hero.eyebrow")}
39
- </p>
40
- <h2 className="mt-3 font-editorial text-5xl leading-none text-ink">
41
- {t("posts.hero.title")}
42
- </h2>
43
- <p className="mt-4 max-w-2xl text-sm leading-7 text-muted-ink">{t("posts.hero.body")}</p>
44
- <div className="mt-6 flex flex-wrap gap-3">
45
- <Button className="rounded-full" variant="primary" onPress={() => setEditor({})}>
46
- <span className="inline-flex items-center gap-2">
47
- <PlusIcon className="h-4 w-4" />
48
- {t("posts.hero.new")}
49
- </span>
50
- </Button>
51
- <Chip className="rounded-full" variant="soft" color="default">
52
- {posts.isFetching ? t("posts.hero.syncing") : t("posts.hero.synced")}
53
- </Chip>
54
- </div>
55
- </div>
56
-
57
- <div className="grid gap-3 rounded-[28px] border border-white/70 bg-white/72 p-4">
58
- <div className="grid grid-cols-3 gap-3">
59
- <StatCard label={t("posts.stats.total")} value={posts.stats.total} accent="amber" />
60
- <StatCard
61
- label={t("posts.stats.published")}
62
- value={posts.stats.published}
63
- accent="emerald"
64
- />
65
- <StatCard label={t("posts.stats.drafts")} value={posts.stats.drafts} accent="stone" />
66
- </div>
67
- <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_220px]">
68
- <div className="grid gap-2">
69
- <Label className="text-sm font-medium" htmlFor={searchFieldId}>
70
- {t("posts.filters.searchLabel")}
71
- </Label>
72
- <Input
73
- id={searchFieldId}
74
- aria-label={t("posts.filters.searchLabel")}
75
- className="rounded-lg"
76
- variant="secondary"
77
- placeholder={t("posts.filters.searchPlaceholder")}
78
- value={posts.search}
79
- onChange={(event) => posts.setSearch(event.target.value)}
80
- />
81
- </div>
82
- <div className="grid gap-2">
83
- <Label className="text-sm font-medium" htmlFor={statusFieldId}>
84
- {t("posts.filters.statusLabel")}
85
- </Label>
86
- <Select
87
- aria-label={t("posts.filters.statusLabel")}
88
- className="w-full"
89
- variant="secondary"
90
- selectedKey={posts.status}
91
- onSelectionChange={(key) => {
92
- if (key === null) {
93
- return;
94
- }
95
- posts.setStatus(String(key) as PostStatusFilter);
96
- }}
97
- >
98
- <Select.Trigger id={statusFieldId} className="rounded-lg w-full">
99
- <Select.Value />
100
- <Select.Indicator />
101
- </Select.Trigger>
102
- <Select.Popover>
103
- <ListBox>
104
- <ListBox.Item
105
- className="text-sm"
106
- id={`${statusFieldId}-all`}
107
- textValue={t("posts.filters.all")}
108
- >
109
- {t("posts.filters.all")}
110
- <ListBox.ItemIndicator />
111
- </ListBox.Item>
112
- <ListBox.Item
113
- className="text-sm"
114
- id={`${statusFieldId}-draft`}
115
- textValue={t("posts.filters.draft")}
116
- >
117
- {t("posts.filters.draft")}
118
- <ListBox.ItemIndicator />
119
- </ListBox.Item>
120
- <ListBox.Item
121
- className="text-sm"
122
- id={`${statusFieldId}-published`}
123
- textValue={t("posts.filters.published")}
124
- >
125
- {t("posts.filters.published")}
126
- <ListBox.ItemIndicator />
127
- </ListBox.Item>
128
- </ListBox>
129
- </Select.Popover>
130
- </Select>
131
- </div>
132
- </div>
133
- </div>
134
- </section>
135
-
136
- <div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(360px,0.85fr)]">
137
- <section className="grid gap-4">
138
- {posts.isLoading ? (
139
- <>
140
- <Skeleton className="h-44 rounded-[28px]" animationType="pulse" />
141
- <Skeleton className="h-44 rounded-[28px]" animationType="pulse" />
142
- <Skeleton className="h-44 rounded-[28px]" animationType="pulse" />
143
- </>
144
- ) : posts.rows.length === 0 ? (
145
- <Card className="rounded-[30px] border border-dashed border-amber-300/70 bg-panel shadow-[0_18px_40px_rgba(81,50,24,0.1)]">
146
- <Card.Content className="flex flex-col items-start gap-4 px-6 py-10">
147
- <Chip color="default" variant="soft">
148
- {t("posts.empty.eyebrow")}
149
- </Chip>
150
- <div>
151
- <h3 className="font-editorial text-3xl text-ink">{t("posts.empty.title")}</h3>
152
- <p className="mt-3 max-w-xl text-sm leading-7 text-muted-ink">
153
- {t("posts.empty.body")}
154
- </p>
155
- </div>
156
- <Button className="rounded-full" variant="primary" onPress={() => setEditor({})}>
157
- {t("posts.empty.action")}
158
- </Button>
159
- </Card.Content>
160
- </Card>
161
- ) : (
162
- posts.rows.map((row) => (
163
- <PostCard
164
- key={row.id}
165
- row={row}
166
- isSelected={selectedPost?.id === row.id}
167
- isPublishing={posts.publishingId === row.id}
168
- isDeleting={posts.deletingId === row.id}
169
- onSelect={() => setSelectedPostId(row.id)}
170
- onEdit={() => setEditor({ post: row })}
171
- onPublish={() => posts.publishPost(row.id)}
172
- onDelete={() => posts.deletePost(row.id)}
173
- />
174
- ))
175
- )}
176
-
177
- {posts.rows.length > 0 ? (
178
- <div className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/70 bg-white/70 px-4 py-3">
179
- <p className="text-sm text-muted-ink">
180
- {t("posts.pagination.summary", {
181
- page: posts.page,
182
- pageCount: posts.pageCount,
183
- })}
184
- </p>
185
- <div className="flex items-center gap-2">
186
- <Button
187
- className="rounded-full"
188
- variant="ghost"
189
- isDisabled={posts.page <= 1}
190
- onPress={() => posts.goToPage(posts.page - 1)}
191
- >
192
- <span className="inline-flex items-center gap-2">
193
- <ArrowLeftIcon className="h-4 w-4" />
194
- {t("posts.pagination.previous")}
195
- </span>
196
- </Button>
197
- <Button
198
- className="rounded-full"
199
- variant="ghost"
200
- isDisabled={posts.page >= posts.pageCount}
201
- onPress={() => posts.goToPage(posts.page + 1)}
202
- >
203
- <span className="inline-flex items-center gap-2">
204
- {t("posts.pagination.next")}
205
- <ArrowRightIcon className="h-4 w-4" />
206
- </span>
207
- </Button>
208
- </div>
209
- </div>
210
- ) : null}
211
- </section>
212
-
213
- <aside className="sticky top-6 h-fit">
214
- <Card className="rounded-[30px] border border-white/70 bg-panel shadow-[0_20px_44px_rgba(81,50,24,0.1)]">
215
- <Card.Header className="flex items-start justify-between px-5 pt-5">
216
- <div>
217
- <p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-amber-700/80">
218
- {t("posts.preview.eyebrow")}
219
- </p>
220
- <h3 className="mt-3 font-editorial text-4xl leading-none text-ink">
221
- {selectedPost?.title ?? t("posts.preview.emptyTitle")}
222
- </h3>
223
- </div>
224
- {selectedPost ? (
225
- <Chip
226
- color={selectedPost.status === "published" ? "success" : "default"}
227
- variant="soft"
228
- >
229
- {selectedPost.status === "published"
230
- ? t("posts.filters.published")
231
- : t("posts.filters.draft")}
232
- </Chip>
233
- ) : null}
234
- </Card.Header>
235
- <Card.Content className="flex flex-col gap-5 px-5 pb-5">
236
- {selectedPost ? (
237
- <>
238
- <div className="rounded-[26px] border border-amber-200/70 bg-amber-50/80 p-4">
239
- <p className="text-sm leading-7 text-ink/80">{selectedPost.excerpt}</p>
240
- </div>
241
- <div className="flex flex-wrap gap-2">
242
- <Chip variant="soft" color="default">
243
- {getReadingTime(selectedPost.content)}
244
- </Chip>
245
- <Chip variant="soft">
246
- {t("posts.preview.updated")}:{" "}
247
- {formatDate(selectedPost.updatedAt ?? selectedPost.createdAt)}
248
- </Chip>
249
- </div>
250
- <div className="prose prose-stone max-w-none text-sm leading-7 text-muted-ink">
251
- <p>{selectedPost.content}</p>
252
- </div>
253
- <Button
254
- className="rounded-full"
255
- variant="ghost"
256
- onPress={() => setEditor({ post: selectedPost })}
257
- >
258
- <span className="inline-flex items-center gap-2">
259
- <FilePenLineIcon className="h-4 w-4" />
260
- {t("posts.preview.openEditor")}
261
- </span>
262
- </Button>
263
- </>
264
- ) : (
265
- <div className="rounded-[26px] border border-dashed border-amber-300/70 bg-amber-50/70 p-6 text-sm leading-7 text-muted-ink">
266
- {t("posts.preview.emptyBody")}
267
- </div>
268
- )}
269
- </Card.Content>
270
- </Card>
271
- </aside>
272
- </div>
273
-
274
- <PostEditorModal
275
- isOpen={editor !== null}
276
- post={editor?.post}
277
- isSaving={posts.isSaving}
278
- onClose={() => setEditor(null)}
279
- onSave={posts.savePost}
280
- />
281
- </div>
282
- );
283
- }
284
-
285
- function StatCard({
286
- label,
287
- value,
288
- accent,
289
- }: {
290
- label: string;
291
- value: number;
292
- accent: "amber" | "emerald" | "stone";
293
- }) {
294
- const accentClass =
295
- accent === "amber"
296
- ? "border-amber-200 bg-amber-50/90 text-amber-900"
297
- : accent === "emerald"
298
- ? "border-emerald-200 bg-emerald-50/90 text-emerald-900"
299
- : "border-stone-200 bg-stone-100/90 text-stone-900";
300
-
301
- return (
302
- <div className={`rounded-[24px] border px-4 py-4 ${accentClass}`}>
303
- <p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em]">{label}</p>
304
- <p className="mt-3 font-editorial text-4xl leading-none">{value}</p>
305
- </div>
306
- );
307
- }
1
+ import {
2
+ Button,
3
+ Card,
4
+ Chip,
5
+ EmptyState,
6
+ Label,
7
+ ListBox,
8
+ Pagination,
9
+ SearchField,
10
+ Select,
11
+ Skeleton,
12
+ } from "@heroui/react";
13
+ import { FilePenLineIcon, FileTextIcon, PlusIcon } from "lucide-react";
14
+ import { useId, useState } from "react";
15
+ import { useTranslation } from "react-i18next";
16
+ import { PostCard } from "./components/PostCard";
17
+ import { PostEditorModal } from "./components/PostEditorModal";
18
+ import type { PostRow, PostStatusFilter } from "./hooks/usePostsList";
19
+ import { usePostsRoute } from "./hooks/usePostsRoute";
20
+ import { formatDate, getReadingTime } from "./posts.utils";
21
+
22
+ /** Editor modal state: null = closed, {} = create, { post } = edit. */
23
+ type EditorState = { post?: PostRow } | null;
24
+
25
+ const STATUS_OPTIONS = ["all", "draft", "published"] as const satisfies readonly PostStatusFilter[];
26
+
27
+ export function PostsRoute() {
28
+ const { t } = useTranslation("{{APP_SLUG}}");
29
+ const posts = usePostsRoute();
30
+
31
+ const statusFieldId = useId();
32
+
33
+ const [selectedPostId, setSelectedPostId] = useState<string | undefined>(undefined);
34
+ const [editor, setEditor] = useState<EditorState>(null);
35
+
36
+ const selectedPost = posts.rows.find((row) => row.id === selectedPostId) ?? posts.rows[0];
37
+ const isFirstPage = posts.page <= 1;
38
+ const isLastPage = posts.page >= posts.pageCount;
39
+
40
+ return (
41
+ <div className="p-4 grid gap-6" data-testid="posts-route">
42
+ <Card>
43
+ <Card.Header className="flex flex-col gap-1">
44
+ <p className="text-xs font-medium uppercase tracking-wide text-muted">
45
+ {t("posts.hero.eyebrow")}
46
+ </p>
47
+ <Card.Title>{t("posts.hero.title")}</Card.Title>
48
+ <Card.Description>{t("posts.hero.body")}</Card.Description>
49
+ </Card.Header>
50
+ <Card.Content className="grid gap-4">
51
+ <div className="flex flex-wrap items-center gap-3">
52
+ <Button variant="primary" onPress={() => setEditor({})}>
53
+ <span className="inline-flex items-center gap-2">
54
+ <PlusIcon className="h-4 w-4" />
55
+ {t("posts.hero.new")}
56
+ </span>
57
+ </Button>
58
+ <Chip variant="soft" color="default">
59
+ {posts.isFetching ? t("posts.hero.syncing") : t("posts.hero.synced")}
60
+ </Chip>
61
+ </div>
62
+
63
+ <div className="grid grid-cols-3 gap-3">
64
+ <Card variant="secondary">
65
+ <Card.Header>
66
+ <Card.Description>{t("posts.stats.total")}</Card.Description>
67
+ <Card.Title>{posts.stats.total}</Card.Title>
68
+ </Card.Header>
69
+ </Card>
70
+ <Card variant="secondary">
71
+ <Card.Header>
72
+ <Card.Description>{t("posts.stats.published")}</Card.Description>
73
+ <Card.Title>{posts.stats.published}</Card.Title>
74
+ </Card.Header>
75
+ </Card>
76
+ <Card variant="secondary">
77
+ <Card.Header>
78
+ <Card.Description>{t("posts.stats.drafts")}</Card.Description>
79
+ <Card.Title>{posts.stats.drafts}</Card.Title>
80
+ </Card.Header>
81
+ </Card>
82
+ </div>
83
+
84
+ <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_220px]">
85
+ <SearchField
86
+ aria-label={t("posts.filters.searchLabel")}
87
+ name="search"
88
+ variant="secondary"
89
+ value={posts.search}
90
+ onChange={posts.setSearch}
91
+ >
92
+ <Label>{t("posts.filters.searchLabel")}</Label>
93
+ <SearchField.Group>
94
+ <SearchField.SearchIcon />
95
+ <SearchField.Input placeholder={t("posts.filters.searchPlaceholder")} />
96
+ <SearchField.ClearButton />
97
+ </SearchField.Group>
98
+ </SearchField>
99
+
100
+ <div className="grid gap-2">
101
+ <Label className="text-sm font-medium" htmlFor={statusFieldId}>
102
+ {t("posts.filters.statusLabel")}
103
+ </Label>
104
+ <Select
105
+ aria-label={t("posts.filters.statusLabel")}
106
+ className="w-full"
107
+ variant="secondary"
108
+ selectedKey={posts.status}
109
+ onSelectionChange={(key) => {
110
+ if (key === null) {
111
+ return;
112
+ }
113
+ posts.setStatus(String(key) as PostStatusFilter);
114
+ }}
115
+ >
116
+ <Select.Trigger id={statusFieldId} className="w-full">
117
+ <Select.Value />
118
+ <Select.Indicator />
119
+ </Select.Trigger>
120
+ <Select.Popover>
121
+ <ListBox>
122
+ {STATUS_OPTIONS.map((status) => (
123
+ <ListBox.Item
124
+ key={status}
125
+ id={`${statusFieldId}-${status}`}
126
+ textValue={t(`posts.filters.${status === "all" ? "all" : status}`)}
127
+ >
128
+ {t(`posts.filters.${status === "all" ? "all" : status}`)}
129
+ <ListBox.ItemIndicator />
130
+ </ListBox.Item>
131
+ ))}
132
+ </ListBox>
133
+ </Select.Popover>
134
+ </Select>
135
+ </div>
136
+ </div>
137
+ </Card.Content>
138
+ </Card>
139
+
140
+ <div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(360px,0.85fr)]">
141
+ <section className="grid gap-4">
142
+ {posts.isLoading ? (
143
+ <>
144
+ <Skeleton className="h-44" animationType="pulse" />
145
+ <Skeleton className="h-44" animationType="pulse" />
146
+ <Skeleton className="h-44" animationType="pulse" />
147
+ </>
148
+ ) : posts.rows.length === 0 ? (
149
+ <EmptyState className="flex flex-col items-center gap-4 py-10 text-center">
150
+ <FileTextIcon className="size-6 text-muted" />
151
+ <Chip color="default" variant="soft">
152
+ {t("posts.empty.eyebrow")}
153
+ </Chip>
154
+ <div className="grid gap-2">
155
+ <p className="text-lg font-semibold">{t("posts.empty.title")}</p>
156
+ <p className="max-w-xl text-sm text-muted">{t("posts.empty.body")}</p>
157
+ </div>
158
+ <Button variant="primary" onPress={() => setEditor({})}>
159
+ {t("posts.empty.action")}
160
+ </Button>
161
+ </EmptyState>
162
+ ) : (
163
+ posts.rows.map((row) => (
164
+ <PostCard
165
+ key={row.id}
166
+ row={row}
167
+ isSelected={selectedPost?.id === row.id}
168
+ isPublishing={posts.publishingId === row.id}
169
+ isDeleting={posts.deletingId === row.id}
170
+ onSelect={() => setSelectedPostId(row.id)}
171
+ onEdit={() => setEditor({ post: row })}
172
+ onPublish={() => posts.publishPost(row.id)}
173
+ onDelete={() => posts.deletePost(row.id)}
174
+ />
175
+ ))
176
+ )}
177
+
178
+ {posts.rows.length > 0 ? (
179
+ <Pagination size="sm">
180
+ <Pagination.Summary>
181
+ {t("posts.pagination.summary", {
182
+ page: posts.page,
183
+ pageCount: posts.pageCount,
184
+ })}
185
+ </Pagination.Summary>
186
+ <Pagination.Content>
187
+ <Pagination.Item>
188
+ <Pagination.Previous
189
+ isDisabled={isFirstPage}
190
+ onPress={() => posts.goToPage(posts.page - 1)}
191
+ >
192
+ <Pagination.PreviousIcon />
193
+ {t("posts.pagination.previous")}
194
+ </Pagination.Previous>
195
+ </Pagination.Item>
196
+ <Pagination.Item>
197
+ <Pagination.Next
198
+ isDisabled={isLastPage}
199
+ onPress={() => posts.goToPage(posts.page + 1)}
200
+ >
201
+ {t("posts.pagination.next")}
202
+ <Pagination.NextIcon />
203
+ </Pagination.Next>
204
+ </Pagination.Item>
205
+ </Pagination.Content>
206
+ </Pagination>
207
+ ) : null}
208
+ </section>
209
+
210
+ <aside className="sticky top-6 h-fit">
211
+ <Card>
212
+ <Card.Header className="flex items-start justify-between gap-4">
213
+ <div className="grid gap-1">
214
+ <p className="text-xs font-medium uppercase tracking-wide text-muted">
215
+ {t("posts.preview.eyebrow")}
216
+ </p>
217
+ <Card.Title>{selectedPost?.title ?? t("posts.preview.emptyTitle")}</Card.Title>
218
+ </div>
219
+ {selectedPost ? (
220
+ <Chip
221
+ color={selectedPost.status === "published" ? "success" : "default"}
222
+ variant="soft"
223
+ >
224
+ {selectedPost.status === "published"
225
+ ? t("posts.filters.published")
226
+ : t("posts.filters.draft")}
227
+ </Chip>
228
+ ) : null}
229
+ </Card.Header>
230
+ <Card.Content className="flex flex-col gap-4">
231
+ {selectedPost ? (
232
+ <>
233
+ {selectedPost.excerpt ? (
234
+ <Card variant="secondary">
235
+ <Card.Content>
236
+ <p className="text-sm text-muted">{selectedPost.excerpt}</p>
237
+ </Card.Content>
238
+ </Card>
239
+ ) : null}
240
+ <div className="flex flex-wrap gap-2">
241
+ <Chip variant="soft" color="default">
242
+ {getReadingTime(selectedPost.content)}
243
+ </Chip>
244
+ <Chip variant="soft">
245
+ {t("posts.preview.updated")}:{" "}
246
+ {formatDate(selectedPost.updatedAt ?? selectedPost.createdAt)}
247
+ </Chip>
248
+ </div>
249
+ <p className="text-sm leading-7 text-muted">{selectedPost.content}</p>
250
+ </>
251
+ ) : (
252
+ <Card.Description>{t("posts.preview.emptyBody")}</Card.Description>
253
+ )}
254
+ </Card.Content>
255
+ {selectedPost ? (
256
+ <Card.Footer>
257
+ <Button variant="ghost" onPress={() => setEditor({ post: selectedPost })}>
258
+ <span className="inline-flex items-center gap-2">
259
+ <FilePenLineIcon className="h-4 w-4" />
260
+ {t("posts.preview.openEditor")}
261
+ </span>
262
+ </Button>
263
+ </Card.Footer>
264
+ ) : null}
265
+ </Card>
266
+ </aside>
267
+ </div>
268
+
269
+ <PostEditorModal
270
+ isOpen={editor !== null}
271
+ post={editor?.post}
272
+ isSaving={posts.isSaving}
273
+ onClose={() => setEditor(null)}
274
+ onSave={posts.savePost}
275
+ />
276
+ </div>
277
+ );
278
+ }