create-m5kdev 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/src/__tests__/create.smoke.test.d.ts +1 -0
  2. package/dist/src/__tests__/create.smoke.test.js +56 -0
  3. package/dist/src/__tests__/create.test.d.ts +1 -0
  4. package/dist/src/__tests__/create.test.js +55 -0
  5. package/dist/src/__tests__/runCli.test.d.ts +1 -0
  6. package/dist/src/__tests__/runCli.test.js +44 -0
  7. package/dist/src/__tests__/strings.test.d.ts +1 -0
  8. package/dist/src/__tests__/strings.test.js +24 -0
  9. package/dist/src/constants.d.ts +3 -0
  10. package/dist/src/constants.js +9 -0
  11. package/dist/src/create.d.ts +7 -0
  12. package/dist/src/create.js +36 -0
  13. package/dist/src/fs.d.ts +5 -0
  14. package/dist/src/fs.js +60 -0
  15. package/dist/src/index.d.ts +2 -0
  16. package/dist/src/index.js +9 -0
  17. package/dist/src/paths.d.ts +1 -0
  18. package/dist/src/paths.js +14 -0
  19. package/dist/src/prompts.d.ts +2 -0
  20. package/dist/src/prompts.js +55 -0
  21. package/dist/src/runCli.d.ts +9 -0
  22. package/dist/src/runCli.js +107 -0
  23. package/dist/src/strings.d.ts +6 -0
  24. package/dist/src/strings.js +47 -0
  25. package/dist/src/types.d.ts +18 -0
  26. package/dist/src/types.js +2 -0
  27. package/dist/tsconfig.tsbuildinfo +1 -0
  28. package/package.json +38 -0
  29. package/templates/minimal-app/.gitignore.tpl +9 -0
  30. package/templates/minimal-app/AGENTS.md.tpl +29 -0
  31. package/templates/minimal-app/README.md.tpl +35 -0
  32. package/templates/minimal-app/apps/email/package.json.tpl +27 -0
  33. package/templates/minimal-app/apps/email/src/components/BaseEmail.tsx.tpl +117 -0
  34. package/templates/minimal-app/apps/email/src/emails/accountDeletionEmail.tsx.tpl +26 -0
  35. package/templates/minimal-app/apps/email/src/emails/organizationInviteEmail.tsx.tpl +31 -0
  36. package/templates/minimal-app/apps/email/src/emails/passwordResetEmail.tsx.tpl +25 -0
  37. package/templates/minimal-app/apps/email/src/emails/verificationEmail.tsx.tpl +26 -0
  38. package/templates/minimal-app/apps/email/src/index.ts.tpl +32 -0
  39. package/templates/minimal-app/apps/email/tsconfig.json.tpl +11 -0
  40. package/templates/minimal-app/apps/server/AGENTS.md.tpl +30 -0
  41. package/templates/minimal-app/apps/server/drizzle/seed.ts.tpl +111 -0
  42. package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +18 -0
  43. package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +38 -0
  44. package/templates/minimal-app/apps/server/package.json.tpl +50 -0
  45. package/templates/minimal-app/apps/server/src/db.ts.tpl +33 -0
  46. package/templates/minimal-app/apps/server/src/index.ts.tpl +34 -0
  47. package/templates/minimal-app/apps/server/src/lib/auth.ts.tpl +172 -0
  48. package/templates/minimal-app/apps/server/src/lib/localEmailService.ts.tpl +58 -0
  49. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +23 -0
  50. package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +106 -0
  51. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +150 -0
  52. package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +44 -0
  53. package/templates/minimal-app/apps/server/src/repository.ts.tpl +6 -0
  54. package/templates/minimal-app/apps/server/src/service.ts.tpl +12 -0
  55. package/templates/minimal-app/apps/server/src/trpc.ts.tpl +11 -0
  56. package/templates/minimal-app/apps/server/src/types.ts.tpl +3 -0
  57. package/templates/minimal-app/apps/server/src/utils/trpc.ts.tpl +27 -0
  58. package/templates/minimal-app/apps/server/tsconfig.json.tpl +13 -0
  59. package/templates/minimal-app/apps/server/tsup.config.ts.tpl +9 -0
  60. package/templates/minimal-app/apps/shared/.env.example.tpl +17 -0
  61. package/templates/minimal-app/apps/shared/.env.tpl +19 -0
  62. package/templates/minimal-app/apps/shared/package.json.tpl +24 -0
  63. package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -0
  64. package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +76 -0
  65. package/templates/minimal-app/apps/shared/tsconfig.json.tpl +12 -0
  66. package/templates/minimal-app/apps/webapp/AGENTS.md.tpl +18 -0
  67. package/templates/minimal-app/apps/webapp/index.html.tpl +22 -0
  68. package/templates/minimal-app/apps/webapp/package.json.tpl +52 -0
  69. package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +13 -0
  70. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +139 -0
  71. package/templates/minimal-app/apps/webapp/src/Providers.tsx.tpl +28 -0
  72. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +53 -0
  73. package/templates/minimal-app/apps/webapp/src/components/TrpcQueryProvider.tsx.tpl +61 -0
  74. package/templates/minimal-app/apps/webapp/src/hero.ts.tpl +99 -0
  75. package/templates/minimal-app/apps/webapp/src/index.css.tpl +75 -0
  76. package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -0
  77. package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +650 -0
  78. package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -0
  79. package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +4 -0
  80. package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -0
  81. package/templates/minimal-app/apps/webapp/translations/en/blog-app.json.tpl +107 -0
  82. package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +16 -0
  83. package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -0
  84. package/templates/minimal-app/biome.json.tpl +76 -0
  85. package/templates/minimal-app/package.json.tpl +21 -0
  86. package/templates/minimal-app/pnpm-workspace.yaml.tpl +58 -0
  87. package/templates/minimal-app/turbo.json.tpl +26 -0
@@ -0,0 +1,650 @@
1
+ import {
2
+ POST_FILTER_VALUES,
3
+ POSTS_PAGE_SIZE,
4
+ } from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.constants";
5
+ import type {
6
+ PostCreateInputSchema,
7
+ PostPublishInputSchema,
8
+ PostSoftDeleteInputSchema,
9
+ PostsListInputSchema,
10
+ PostsListOutputSchema,
11
+ PostUpdateInputSchema,
12
+ } from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.schema";
13
+ import {
14
+ Button,
15
+ Card,
16
+ CardBody,
17
+ CardHeader,
18
+ Chip,
19
+ Input,
20
+ Modal,
21
+ ModalBody,
22
+ ModalContent,
23
+ ModalFooter,
24
+ ModalHeader,
25
+ Select,
26
+ SelectItem,
27
+ Skeleton,
28
+ Textarea,
29
+ } from "@heroui/react";
30
+ import { useDialog } from "@m5kdev/web-ui/components/DialogProvider";
31
+ import { type UseQueryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
32
+ import {
33
+ ArrowLeftIcon,
34
+ ArrowRightIcon,
35
+ EyeIcon,
36
+ FilePenLineIcon,
37
+ PencilLineIcon,
38
+ PlusIcon,
39
+ SendHorizontalIcon,
40
+ Trash2Icon,
41
+ } from "lucide-react";
42
+ import { parseAsInteger, parseAsString, parseAsStringLiteral, useQueryState } from "nuqs";
43
+ import {
44
+ type FormEvent,
45
+ startTransition,
46
+ useDeferredValue,
47
+ useEffect,
48
+ useMemo,
49
+ useState,
50
+ } from "react";
51
+ import { useTranslation } from "react-i18next";
52
+ import { toast } from "sonner";
53
+ import { useTRPC } from "@/utils/trpc";
54
+
55
+ type PostStatusFilter = (typeof POST_FILTER_VALUES)[number];
56
+
57
+ interface EditorState {
58
+ id?: string;
59
+ title: string;
60
+ slug: string;
61
+ excerpt: string;
62
+ content: string;
63
+ }
64
+
65
+ const STATUS_PARSER = parseAsStringLiteral(POST_FILTER_VALUES).withDefault("all");
66
+
67
+ function formatDate(value: Date | null | undefined): string {
68
+ if (!value) {
69
+ return "Not scheduled";
70
+ }
71
+
72
+ return new Intl.DateTimeFormat(undefined, {
73
+ dateStyle: "medium",
74
+ timeStyle: "short",
75
+ }).format(new Date(value));
76
+ }
77
+
78
+ function getReadingTime(content: string): string {
79
+ const words = content.trim().split(/\s+/).filter(Boolean).length;
80
+ const minutes = Math.max(1, Math.ceil(words / 180));
81
+ return `${minutes} min read`;
82
+ }
83
+
84
+ export function PostsRoute() {
85
+ const { t } = useTranslation("blog-app");
86
+ const trpc = useTRPC();
87
+ const queryClient = useQueryClient();
88
+ const showDialog = useDialog();
89
+
90
+ const [search, setSearch] = useQueryState("search", parseAsString.withDefault(""));
91
+ const [status, setStatus] = useQueryState<PostStatusFilter>("status", STATUS_PARSER);
92
+ const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));
93
+ const [selectedPostId, setSelectedPostId] = useState<string | undefined>(undefined);
94
+ const [isEditorOpen, setIsEditorOpen] = useState(false);
95
+ const [editorState, setEditorState] = useState<EditorState>({
96
+ title: "",
97
+ slug: "",
98
+ excerpt: "",
99
+ content: "",
100
+ });
101
+
102
+ const deferredSearch = useDeferredValue(search);
103
+
104
+ const listInput = useMemo<PostsListInputSchema>(
105
+ () => ({
106
+ page,
107
+ limit: POSTS_PAGE_SIZE,
108
+ search: deferredSearch || undefined,
109
+ status: status === "all" ? undefined : status,
110
+ sort: "updatedAt",
111
+ order: "desc",
112
+ }),
113
+ [deferredSearch, page, status]
114
+ );
115
+
116
+ const { data, isLoading, isFetching } = useQuery(
117
+ trpc.posts.list.queryOptions(listInput) as unknown as UseQueryOptions<PostsListOutputSchema>
118
+ );
119
+
120
+ const postsData = data;
121
+ const rows = postsData?.rows ?? [];
122
+ const total = postsData?.total ?? 0;
123
+ const pageCount = Math.max(1, Math.ceil(total / POSTS_PAGE_SIZE));
124
+
125
+ useEffect(() => {
126
+ if (!rows.length) {
127
+ setSelectedPostId(undefined);
128
+ return;
129
+ }
130
+
131
+ const selectedStillExists = rows.some((row) => row.id === selectedPostId);
132
+ if (!selectedStillExists) {
133
+ setSelectedPostId(rows[0]?.id);
134
+ }
135
+ }, [rows, selectedPostId]);
136
+
137
+ const selectedPost = useMemo(
138
+ () => rows.find((row) => row.id === selectedPostId) ?? rows[0],
139
+ [rows, selectedPostId]
140
+ );
141
+
142
+ const invalidateList = async () => {
143
+ await queryClient.invalidateQueries(trpc.posts.list.queryFilter());
144
+ };
145
+
146
+ const createMutation = useMutation(
147
+ trpc.posts.create.mutationOptions({
148
+ onSuccess: async () => {
149
+ toast.success(t("posts.toast.created"));
150
+ setIsEditorOpen(false);
151
+ setEditorState({ title: "", slug: "", excerpt: "", content: "" });
152
+ await invalidateList();
153
+ },
154
+ })
155
+ );
156
+
157
+ const updateMutation = useMutation(
158
+ trpc.posts.update.mutationOptions({
159
+ onSuccess: async () => {
160
+ toast.success(t("posts.toast.updated"));
161
+ setIsEditorOpen(false);
162
+ await invalidateList();
163
+ },
164
+ })
165
+ );
166
+
167
+ const publishMutation = useMutation(
168
+ trpc.posts.publish.mutationOptions({
169
+ onSuccess: async () => {
170
+ toast.success(t("posts.toast.published"));
171
+ await invalidateList();
172
+ },
173
+ })
174
+ );
175
+
176
+ const deleteMutation = useMutation(
177
+ trpc.posts.softDelete.mutationOptions({
178
+ onSuccess: async () => {
179
+ toast.success(t("posts.toast.deleted"));
180
+ await invalidateList();
181
+ },
182
+ })
183
+ );
184
+
185
+ const openCreate = () => {
186
+ setEditorState({ title: "", slug: "", excerpt: "", content: "" });
187
+ setIsEditorOpen(true);
188
+ };
189
+
190
+ const openEdit = (row: PostsListOutputSchema["rows"][number]) => {
191
+ setEditorState({
192
+ id: row.id,
193
+ title: row.title,
194
+ slug: row.slug,
195
+ excerpt: row.excerpt ?? "",
196
+ content: row.content,
197
+ });
198
+ setIsEditorOpen(true);
199
+ };
200
+
201
+ const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
202
+ event.preventDefault();
203
+
204
+ const payload = {
205
+ title: editorState.title.trim(),
206
+ slug: editorState.slug.trim() || undefined,
207
+ excerpt: editorState.excerpt.trim() || undefined,
208
+ content: editorState.content.trim(),
209
+ } satisfies Omit<PostCreateInputSchema, never>;
210
+
211
+ if (!payload.title || !payload.content) {
212
+ toast.error(t("posts.toast.validation"));
213
+ return;
214
+ }
215
+
216
+ if (editorState.id) {
217
+ await updateMutation.mutateAsync({
218
+ id: editorState.id,
219
+ ...payload,
220
+ } as PostUpdateInputSchema);
221
+ return;
222
+ }
223
+
224
+ await createMutation.mutateAsync(payload);
225
+ };
226
+
227
+ const onPublish = async (id: string) => {
228
+ await publishMutation.mutateAsync({ id } satisfies PostPublishInputSchema);
229
+ };
230
+
231
+ const onDelete = (id: string) => {
232
+ showDialog({
233
+ title: t("posts.deleteDialog.title"),
234
+ description: t("posts.deleteDialog.body"),
235
+ color: "danger",
236
+ cancelable: true,
237
+ confirmLabel: t("posts.deleteDialog.confirm"),
238
+ cancelLabel: t("posts.deleteDialog.cancel"),
239
+ onConfirm: () => {
240
+ void deleteMutation.mutateAsync({ id } satisfies PostSoftDeleteInputSchema);
241
+ },
242
+ });
243
+ };
244
+
245
+ const stats = useMemo(() => {
246
+ const published = rows.filter((row) => row.status === "published").length;
247
+ const drafts = rows.filter((row) => row.status === "draft").length;
248
+
249
+ return {
250
+ published,
251
+ drafts,
252
+ total,
253
+ };
254
+ }, [rows, total]);
255
+
256
+ return (
257
+ <div className="grid gap-6">
258
+ <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">
259
+ <div>
260
+ <p className="text-[0.68rem] font-semibold uppercase tracking-[0.32em] text-amber-700/80">
261
+ {t("posts.hero.eyebrow")}
262
+ </p>
263
+ <h2 className="mt-3 font-editorial text-5xl leading-none text-ink">
264
+ {t("posts.hero.title")}
265
+ </h2>
266
+ <p className="mt-4 max-w-2xl text-sm leading-7 text-muted-ink">{t("posts.hero.body")}</p>
267
+ <div className="mt-6 flex flex-wrap gap-3">
268
+ <Button
269
+ radius="full"
270
+ color="primary"
271
+ startContent={<PlusIcon className="h-4 w-4" />}
272
+ onPress={openCreate}
273
+ >
274
+ {t("posts.hero.new")}
275
+ </Button>
276
+ <Chip radius="full" variant="flat" color="secondary">
277
+ {isFetching ? t("posts.hero.syncing") : t("posts.hero.synced")}
278
+ </Chip>
279
+ </div>
280
+ </div>
281
+
282
+ <div className="grid gap-3 rounded-[28px] border border-white/70 bg-white/72 p-4">
283
+ <div className="grid grid-cols-3 gap-3">
284
+ <StatCard label={t("posts.stats.total")} value={stats.total} accent="amber" />
285
+ <StatCard label={t("posts.stats.published")} value={stats.published} accent="emerald" />
286
+ <StatCard label={t("posts.stats.drafts")} value={stats.drafts} accent="stone" />
287
+ </div>
288
+ <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_220px]">
289
+ <Input
290
+ aria-label={t("posts.filters.searchLabel")}
291
+ radius="lg"
292
+ variant="bordered"
293
+ label={t("posts.filters.searchLabel")}
294
+ placeholder={t("posts.filters.searchPlaceholder")}
295
+ value={search}
296
+ onValueChange={(value) => {
297
+ startTransition(() => {
298
+ void setSearch(value || null);
299
+ void setPage(1);
300
+ });
301
+ }}
302
+ />
303
+ <Select
304
+ aria-label={t("posts.filters.statusLabel")}
305
+ label={t("posts.filters.statusLabel")}
306
+ radius="lg"
307
+ variant="bordered"
308
+ selectedKeys={[status]}
309
+ onSelectionChange={(keys) => {
310
+ const nextValue = Array.from(keys)[0] as PostStatusFilter | undefined;
311
+ startTransition(() => {
312
+ void setStatus(nextValue ?? "all");
313
+ void setPage(1);
314
+ });
315
+ }}
316
+ >
317
+ <SelectItem key="all">{t("posts.filters.all")}</SelectItem>
318
+ <SelectItem key="draft">{t("posts.filters.draft")}</SelectItem>
319
+ <SelectItem key="published">{t("posts.filters.published")}</SelectItem>
320
+ </Select>
321
+ </div>
322
+ </div>
323
+ </section>
324
+
325
+ <div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(360px,0.85fr)]">
326
+ <section className="grid gap-4">
327
+ {isLoading ? (
328
+ <>
329
+ <Skeleton className="h-44 rounded-[28px]" />
330
+ <Skeleton className="h-44 rounded-[28px]" />
331
+ <Skeleton className="h-44 rounded-[28px]" />
332
+ </>
333
+ ) : rows.length === 0 ? (
334
+ <Card className="rounded-[30px] border border-dashed border-amber-300/70 bg-panel shadow-[0_18px_40px_rgba(81,50,24,0.1)]">
335
+ <CardBody className="items-start gap-4 px-6 py-10">
336
+ <Chip color="secondary" variant="flat">
337
+ {t("posts.empty.eyebrow")}
338
+ </Chip>
339
+ <div>
340
+ <h3 className="font-editorial text-3xl text-ink">{t("posts.empty.title")}</h3>
341
+ <p className="mt-3 max-w-xl text-sm leading-7 text-muted-ink">
342
+ {t("posts.empty.body")}
343
+ </p>
344
+ </div>
345
+ <Button color="primary" radius="full" onPress={openCreate}>
346
+ {t("posts.empty.action")}
347
+ </Button>
348
+ </CardBody>
349
+ </Card>
350
+ ) : (
351
+ rows.map((row) => {
352
+ const isSelected = selectedPost?.id === row.id;
353
+ const isPublishing =
354
+ publishMutation.isPending && publishMutation.variables?.id === row.id;
355
+ const isDeleting =
356
+ deleteMutation.isPending && deleteMutation.variables?.id === row.id;
357
+
358
+ return (
359
+ <Card
360
+ key={row.id}
361
+ isPressable
362
+ onPress={() => setSelectedPostId(row.id)}
363
+ className={
364
+ isSelected
365
+ ? "rounded-[30px] border border-emerald-300 bg-emerald-950 text-emerald-50 shadow-[0_20px_44px_rgba(31,79,70,0.24)]"
366
+ : "rounded-[30px] border border-white/70 bg-panel shadow-[0_18px_40px_rgba(81,50,24,0.1)]"
367
+ }
368
+ >
369
+ <CardHeader className="flex items-start justify-between gap-4 px-5 pt-5">
370
+ <div className="space-y-3">
371
+ <div className="flex flex-wrap items-center gap-2">
372
+ <Chip
373
+ size="sm"
374
+ color={row.status === "published" ? "success" : "secondary"}
375
+ variant={isSelected ? "solid" : "flat"}
376
+ >
377
+ {row.status === "published"
378
+ ? t("posts.filters.published")
379
+ : t("posts.filters.draft")}
380
+ </Chip>
381
+ <span className={isSelected ? "text-emerald-100/80" : "text-muted-ink"}>
382
+ {getReadingTime(row.content)}
383
+ </span>
384
+ </div>
385
+ <div>
386
+ <h3 className="font-editorial text-3xl leading-none">{row.title}</h3>
387
+ <p
388
+ className={
389
+ isSelected
390
+ ? "mt-3 text-sm leading-7 text-emerald-100/80"
391
+ : "mt-3 text-sm leading-7 text-muted-ink"
392
+ }
393
+ >
394
+ {row.excerpt}
395
+ </p>
396
+ </div>
397
+ </div>
398
+ <Button
399
+ isIconOnly
400
+ radius="full"
401
+ variant={isSelected ? "solid" : "flat"}
402
+ className={
403
+ isSelected ? "bg-emerald-100 text-emerald-950" : "bg-white/80 text-ink"
404
+ }
405
+ >
406
+ <EyeIcon className="h-4 w-4" />
407
+ </Button>
408
+ </CardHeader>
409
+ <CardBody className="gap-4 px-5 pb-5">
410
+ <div className="flex flex-wrap items-center gap-3 text-sm">
411
+ <span className={isSelected ? "text-emerald-100/80" : "text-muted-ink"}>
412
+ {t("posts.meta.updated")}: {formatDate(row.updatedAt ?? row.createdAt)}
413
+ </span>
414
+ {row.publishedAt ? (
415
+ <span className={isSelected ? "text-emerald-100/80" : "text-muted-ink"}>
416
+ {t("posts.meta.published")}: {formatDate(row.publishedAt)}
417
+ </span>
418
+ ) : null}
419
+ </div>
420
+ <div className="flex flex-wrap gap-2">
421
+ <Button
422
+ radius="full"
423
+ variant={isSelected ? "solid" : "flat"}
424
+ className={isSelected ? "bg-emerald-100 text-emerald-950" : ""}
425
+ startContent={<PencilLineIcon className="h-4 w-4" />}
426
+ onPress={() => openEdit(row)}
427
+ >
428
+ {t("posts.actions.edit")}
429
+ </Button>
430
+ {row.status === "draft" ? (
431
+ <Button
432
+ radius="full"
433
+ color="secondary"
434
+ variant={isSelected ? "solid" : "flat"}
435
+ isLoading={isPublishing}
436
+ startContent={<SendHorizontalIcon className="h-4 w-4" />}
437
+ onPress={() => void onPublish(row.id)}
438
+ >
439
+ {t("posts.actions.publish")}
440
+ </Button>
441
+ ) : null}
442
+ <Button
443
+ radius="full"
444
+ color="danger"
445
+ variant="flat"
446
+ isLoading={isDeleting}
447
+ startContent={<Trash2Icon className="h-4 w-4" />}
448
+ onPress={() => onDelete(row.id)}
449
+ >
450
+ {t("posts.actions.delete")}
451
+ </Button>
452
+ </div>
453
+ </CardBody>
454
+ </Card>
455
+ );
456
+ })
457
+ )}
458
+
459
+ {rows.length > 0 ? (
460
+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/70 bg-white/70 px-4 py-3">
461
+ <p className="text-sm text-muted-ink">
462
+ {t("posts.pagination.summary", {
463
+ page,
464
+ pageCount,
465
+ })}
466
+ </p>
467
+ <div className="flex items-center gap-2">
468
+ <Button
469
+ radius="full"
470
+ variant="flat"
471
+ isDisabled={page <= 1}
472
+ startContent={<ArrowLeftIcon className="h-4 w-4" />}
473
+ onPress={() => {
474
+ startTransition(() => {
475
+ void setPage(Math.max(1, page - 1));
476
+ });
477
+ }}
478
+ >
479
+ {t("posts.pagination.previous")}
480
+ </Button>
481
+ <Button
482
+ radius="full"
483
+ variant="flat"
484
+ isDisabled={page >= pageCount}
485
+ endContent={<ArrowRightIcon className="h-4 w-4" />}
486
+ onPress={() => {
487
+ startTransition(() => {
488
+ void setPage(Math.min(pageCount, page + 1));
489
+ });
490
+ }}
491
+ >
492
+ {t("posts.pagination.next")}
493
+ </Button>
494
+ </div>
495
+ </div>
496
+ ) : null}
497
+ </section>
498
+
499
+ <aside className="sticky top-6 h-fit">
500
+ <Card className="rounded-[30px] border border-white/70 bg-panel shadow-[0_20px_44px_rgba(81,50,24,0.1)]">
501
+ <CardHeader className="items-start justify-between px-5 pt-5">
502
+ <div>
503
+ <p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-amber-700/80">
504
+ {t("posts.preview.eyebrow")}
505
+ </p>
506
+ <h3 className="mt-3 font-editorial text-4xl leading-none text-ink">
507
+ {selectedPost?.title ?? t("posts.preview.emptyTitle")}
508
+ </h3>
509
+ </div>
510
+ {selectedPost ? (
511
+ <Chip
512
+ color={selectedPost.status === "published" ? "success" : "secondary"}
513
+ variant="flat"
514
+ >
515
+ {selectedPost.status === "published"
516
+ ? t("posts.filters.published")
517
+ : t("posts.filters.draft")}
518
+ </Chip>
519
+ ) : null}
520
+ </CardHeader>
521
+ <CardBody className="gap-5 px-5 pb-5">
522
+ {selectedPost ? (
523
+ <>
524
+ <div className="rounded-[26px] border border-amber-200/70 bg-amber-50/80 p-4">
525
+ <p className="text-sm leading-7 text-ink/80">{selectedPost.excerpt}</p>
526
+ </div>
527
+ <div className="flex flex-wrap gap-2">
528
+ <Chip variant="flat" color="secondary">
529
+ {getReadingTime(selectedPost.content)}
530
+ </Chip>
531
+ <Chip variant="flat">
532
+ {t("posts.preview.updated")}:{" "}
533
+ {formatDate(selectedPost.updatedAt ?? selectedPost.createdAt)}
534
+ </Chip>
535
+ </div>
536
+ <div className="prose prose-stone max-w-none text-sm leading-7 text-muted-ink">
537
+ <p>{selectedPost.content}</p>
538
+ </div>
539
+ <Button
540
+ radius="full"
541
+ variant="flat"
542
+ startContent={<FilePenLineIcon className="h-4 w-4" />}
543
+ onPress={() => openEdit(selectedPost)}
544
+ >
545
+ {t("posts.preview.openEditor")}
546
+ </Button>
547
+ </>
548
+ ) : (
549
+ <div className="rounded-[26px] border border-dashed border-amber-300/70 bg-amber-50/70 p-6 text-sm leading-7 text-muted-ink">
550
+ {t("posts.preview.emptyBody")}
551
+ </div>
552
+ )}
553
+ </CardBody>
554
+ </Card>
555
+ </aside>
556
+ </div>
557
+
558
+ <Modal
559
+ isOpen={isEditorOpen}
560
+ onOpenChange={setIsEditorOpen}
561
+ size="4xl"
562
+ scrollBehavior="inside"
563
+ >
564
+ <ModalContent>
565
+ <form onSubmit={onSubmit}>
566
+ <ModalHeader className="flex flex-col gap-2">
567
+ <p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-amber-700/80">
568
+ {editorState.id ? t("posts.editor.editEyebrow") : t("posts.editor.newEyebrow")}
569
+ </p>
570
+ <h3 className="font-editorial text-4xl leading-none text-ink">
571
+ {editorState.id ? t("posts.editor.editTitle") : t("posts.editor.newTitle")}
572
+ </h3>
573
+ </ModalHeader>
574
+ <ModalBody className="grid gap-4 pb-2">
575
+ <Input
576
+ label={t("posts.editor.fields.title")}
577
+ radius="lg"
578
+ variant="bordered"
579
+ value={editorState.title}
580
+ onValueChange={(value) => setEditorState((state) => ({ ...state, title: value }))}
581
+ isRequired
582
+ />
583
+ <Input
584
+ label={t("posts.editor.fields.slug")}
585
+ radius="lg"
586
+ variant="bordered"
587
+ value={editorState.slug}
588
+ onValueChange={(value) => setEditorState((state) => ({ ...state, slug: value }))}
589
+ />
590
+ <Textarea
591
+ label={t("posts.editor.fields.excerpt")}
592
+ radius="lg"
593
+ variant="bordered"
594
+ minRows={3}
595
+ value={editorState.excerpt}
596
+ onValueChange={(value) => setEditorState((state) => ({ ...state, excerpt: value }))}
597
+ />
598
+ <Textarea
599
+ label={t("posts.editor.fields.content")}
600
+ radius="lg"
601
+ variant="bordered"
602
+ minRows={10}
603
+ value={editorState.content}
604
+ onValueChange={(value) => setEditorState((state) => ({ ...state, content: value }))}
605
+ isRequired
606
+ />
607
+ </ModalBody>
608
+ <ModalFooter>
609
+ <Button radius="full" variant="light" onPress={() => setIsEditorOpen(false)}>
610
+ {t("posts.editor.cancel")}
611
+ </Button>
612
+ <Button
613
+ radius="full"
614
+ color="primary"
615
+ type="submit"
616
+ isLoading={createMutation.isPending || updateMutation.isPending}
617
+ >
618
+ {editorState.id ? t("posts.editor.save") : t("posts.editor.create")}
619
+ </Button>
620
+ </ModalFooter>
621
+ </form>
622
+ </ModalContent>
623
+ </Modal>
624
+ </div>
625
+ );
626
+ }
627
+
628
+ function StatCard({
629
+ label,
630
+ value,
631
+ accent,
632
+ }: {
633
+ label: string;
634
+ value: number;
635
+ accent: "amber" | "emerald" | "stone";
636
+ }) {
637
+ const accentClass =
638
+ accent === "amber"
639
+ ? "border-amber-200 bg-amber-50/90 text-amber-900"
640
+ : accent === "emerald"
641
+ ? "border-emerald-200 bg-emerald-50/90 text-emerald-900"
642
+ : "border-stone-200 bg-stone-100/90 text-stone-900";
643
+
644
+ return (
645
+ <div className={`rounded-[24px] border px-4 py-4 ${accentClass}`}>
646
+ <p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em]">{label}</p>
647
+ <p className="mt-3 font-editorial text-4xl leading-none">{value}</p>
648
+ </div>
649
+ );
650
+ }
@@ -0,0 +1,13 @@
1
+ // @ts-expect-error-next-line: virtual module
2
+ import resources from "virtual:i18next-loader";
3
+ import i18n from "i18next";
4
+ import { initReactI18next } from "react-i18next";
5
+
6
+ i18n.use(initReactI18next).init({
7
+ fallbackLng: "en",
8
+ debug: import.meta.env.MODE === "development",
9
+ interpolation: {
10
+ escapeValue: false,
11
+ },
12
+ resources,
13
+ });
@@ -0,0 +1,4 @@
1
+ import type { AppRouter } from "{{PACKAGE_SCOPE}}/server/types";
2
+ import { createTRPCContext } from "@trpc/tanstack-react-query";
3
+
4
+ export const { TRPCProvider, useTRPC, useTRPCClient } = createTRPCContext<AppRouter>();
@@ -0,0 +1 @@
1
+ /// <reference types="vite/client" />