create-m5kdev 0.25.1 → 0.25.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 (29) hide show
  1. package/package.json +1 -1
  2. package/templates/minimal-app/.cursor/rules/frontend-component-guide.mdc +95 -0
  3. package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +59 -0
  4. package/templates/minimal-app/.cursor/rules/frontend-form-guide.mdc +80 -0
  5. package/templates/minimal-app/.cursor/rules/frontend-hook-guide.mdc +79 -0
  6. package/templates/minimal-app/.cursor/rules/frontend-i18n-guide.mdc +45 -0
  7. package/templates/minimal-app/.cursor/rules/module-db-guide.mdc +1 -1
  8. package/templates/minimal-app/.cursor/rules/module-grants-guide.mdc +1 -1
  9. package/templates/minimal-app/.cursor/rules/module-module-guide.mdc +1 -1
  10. package/templates/minimal-app/.cursor/rules/module-repository-guide.mdc +1 -1
  11. package/templates/minimal-app/.cursor/rules/module-schema-guide.mdc +1 -1
  12. package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +1 -1
  13. package/templates/minimal-app/.cursor/rules/module-trpc-guide.mdc +1 -1
  14. package/templates/minimal-app/AGENTS.md.tpl +7 -0
  15. package/templates/minimal-app/apps/email/package.json.tpl +3 -3
  16. package/templates/minimal-app/apps/webapp/AGENTS.md.tpl +24 -18
  17. package/templates/minimal-app/apps/webapp/public/logo.svg +6 -0
  18. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +59 -153
  19. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +100 -111
  20. package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +307 -751
  21. package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostCard.tsx.tpl +138 -0
  22. package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostEditorModal.tsx.tpl +124 -0
  23. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostActions.ts.tpl +116 -0
  24. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsList.ts.tpl +92 -0
  25. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsRoute.ts.tpl +13 -0
  26. package/templates/minimal-app/apps/webapp/src/modules/posts/posts.utils.ts.tpl +16 -0
  27. package/templates/minimal-app/apps/webapp/src/navigation/navigation.config.tsx.tpl +85 -0
  28. package/templates/minimal-app/apps/webapp/translations/en/blog-app.json.tpl +2 -0
  29. package/templates/minimal-app/pnpm-workspace.yaml.tpl +6 -5
@@ -1,751 +1,307 @@
1
- import {
2
- Button,
3
- Card,
4
- Chip,
5
- Input,
6
- Label,
7
- ListBox,
8
- Modal,
9
- Select,
10
- Skeleton,
11
- TextArea,
12
- } from "@heroui/react";
13
- import { useDialog } from "@m5kdev/web-ui/components/DialogProvider";
14
- import {
15
- POST_FILTER_VALUES,
16
- POSTS_PAGE_SIZE,
17
- } from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.constants";
18
- import type {
19
- PostCreateInputSchema,
20
- PostPublishInputSchema,
21
- PostSoftDeleteInputSchema,
22
- PostsListInputSchema,
23
- PostsListOutputSchema,
24
- PostUpdateInputSchema,
25
- } from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.schema";
26
- import { type UseQueryOptions, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
27
- import {
28
- ArrowLeftIcon,
29
- ArrowRightIcon,
30
- EyeIcon,
31
- FilePenLineIcon,
32
- PencilLineIcon,
33
- PlusIcon,
34
- SendHorizontalIcon,
35
- Trash2Icon,
36
- } from "lucide-react";
37
- import { parseAsInteger, parseAsString, parseAsStringLiteral, useQueryState } from "nuqs";
38
- import {
39
- type FormEvent,
40
- startTransition,
41
- useDeferredValue,
42
- useEffect,
43
- useId,
44
- useMemo,
45
- useState,
46
- } from "react";
47
- import { useTranslation } from "react-i18next";
48
- import { toast } from "sonner";
49
- import { useTRPC } from "@/utils/trpc";
50
-
51
- type PostStatusFilter = (typeof POST_FILTER_VALUES)[number];
52
-
53
- interface EditorState {
54
- id?: string;
55
- title: string;
56
- slug: string;
57
- excerpt: string;
58
- content: string;
59
- }
60
-
61
- const STATUS_PARSER = parseAsStringLiteral(POST_FILTER_VALUES).withDefault("all");
62
-
63
- function formatDate(value: Date | null | undefined): string {
64
- if (!value) {
65
- return "Not scheduled";
66
- }
67
-
68
- return new Intl.DateTimeFormat(undefined, {
69
- dateStyle: "medium",
70
- timeStyle: "short",
71
- }).format(new Date(value));
72
- }
73
-
74
- function getReadingTime(content: string): string {
75
- const words = content.trim().split(/\s+/).filter(Boolean).length;
76
- const minutes = Math.max(1, Math.ceil(words / 180));
77
- return `${minutes} min read`;
78
- }
79
-
80
- export function PostsRoute() {
81
- const { t } = useTranslation("blog{{PACKAGE_SCOPE}}");
82
- const trpc = useTRPC();
83
- const queryClient = useQueryClient();
84
- const showDialog = useDialog();
85
-
86
- const editorTitleId = useId();
87
- const editorSlugId = useId();
88
- const editorExcerptId = useId();
89
- const editorContentId = useId();
90
- const searchFieldId = useId();
91
- const statusFieldId = useId();
92
-
93
- const [search, setSearch] = useQueryState("search", parseAsString.withDefault(""));
94
- const [status, setStatus] = useQueryState<PostStatusFilter>("status", STATUS_PARSER);
95
- const [page, setPage] = useQueryState("page", parseAsInteger.withDefault(1));
96
- const [selectedPostId, setSelectedPostId] = useState<string | undefined>(undefined);
97
- const [isEditorOpen, setIsEditorOpen] = useState(false);
98
- const [editorState, setEditorState] = useState<EditorState>({
99
- title: "",
100
- slug: "",
101
- excerpt: "",
102
- content: "",
103
- });
104
-
105
- const deferredSearch = useDeferredValue(search);
106
-
107
- const listInput = useMemo<PostsListInputSchema>(
108
- () => ({
109
- page,
110
- limit: POSTS_PAGE_SIZE,
111
- search: deferredSearch || undefined,
112
- status: status === "all" ? undefined : status,
113
- sort: "updatedAt",
114
- order: "desc",
115
- }),
116
- [deferredSearch, page, status]
117
- );
118
-
119
- const { data, isLoading, isFetching } = useQuery(
120
- trpc.posts.list.queryOptions(listInput) as unknown as UseQueryOptions<PostsListOutputSchema>
121
- );
122
-
123
- const postsData = data;
124
- const rows = postsData?.rows ?? [];
125
- const total = postsData?.total ?? 0;
126
- const pageCount = Math.max(1, Math.ceil(total / POSTS_PAGE_SIZE));
127
-
128
- useEffect(() => {
129
- if (!rows.length) {
130
- setSelectedPostId(undefined);
131
- return;
132
- }
133
-
134
- const selectedStillExists = rows.some((row) => row.id === selectedPostId);
135
- if (!selectedStillExists) {
136
- setSelectedPostId(rows[0]?.id);
137
- }
138
- }, [rows, selectedPostId]);
139
-
140
- const selectedPost = useMemo(
141
- () => rows.find((row) => row.id === selectedPostId) ?? rows[0],
142
- [rows, selectedPostId]
143
- );
144
-
145
- const invalidateList = async () => {
146
- await queryClient.invalidateQueries(trpc.posts.list.queryFilter());
147
- };
148
-
149
- const createMutation = useMutation(
150
- trpc.posts.create.mutationOptions({
151
- onSuccess: async () => {
152
- toast.success(t("posts.toast.created"));
153
- setIsEditorOpen(false);
154
- setEditorState({ title: "", slug: "", excerpt: "", content: "" });
155
- await invalidateList();
156
- },
157
- })
158
- );
159
-
160
- const updateMutation = useMutation(
161
- trpc.posts.update.mutationOptions({
162
- onSuccess: async () => {
163
- toast.success(t("posts.toast.updated"));
164
- setIsEditorOpen(false);
165
- await invalidateList();
166
- },
167
- })
168
- );
169
-
170
- const publishMutation = useMutation(
171
- trpc.posts.publish.mutationOptions({
172
- onSuccess: async () => {
173
- toast.success(t("posts.toast.published"));
174
- await invalidateList();
175
- },
176
- })
177
- );
178
-
179
- const deleteMutation = useMutation(
180
- trpc.posts.softDelete.mutationOptions({
181
- onSuccess: async () => {
182
- toast.success(t("posts.toast.deleted"));
183
- await invalidateList();
184
- },
185
- })
186
- );
187
-
188
- const openCreate = () => {
189
- setEditorState({ title: "", slug: "", excerpt: "", content: "" });
190
- setIsEditorOpen(true);
191
- };
192
-
193
- const openEdit = (row: PostsListOutputSchema["rows"][number]) => {
194
- setEditorState({
195
- id: row.id,
196
- title: row.title,
197
- slug: row.slug,
198
- excerpt: row.excerpt ?? "",
199
- content: row.content,
200
- });
201
- setIsEditorOpen(true);
202
- };
203
-
204
- const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
205
- event.preventDefault();
206
-
207
- const payload = {
208
- title: editorState.title.trim(),
209
- slug: editorState.slug.trim() || undefined,
210
- excerpt: editorState.excerpt.trim() || undefined,
211
- content: editorState.content.trim(),
212
- } satisfies Omit<PostCreateInputSchema, never>;
213
-
214
- if (!payload.title || !payload.content) {
215
- toast.error(t("posts.toast.validation"));
216
- return;
217
- }
218
-
219
- if (editorState.id) {
220
- await updateMutation.mutateAsync({
221
- id: editorState.id,
222
- ...payload,
223
- } as PostUpdateInputSchema);
224
- return;
225
- }
226
-
227
- await createMutation.mutateAsync(payload);
228
- };
229
-
230
- const onPublish = async (id: string) => {
231
- await publishMutation.mutateAsync({ id } satisfies PostPublishInputSchema);
232
- };
233
-
234
- const onDelete = (id: string) => {
235
- showDialog({
236
- title: t("posts.deleteDialog.title"),
237
- description: t("posts.deleteDialog.body"),
238
- intent: "danger",
239
- cancelable: true,
240
- confirmLabel: t("posts.deleteDialog.confirm"),
241
- cancelLabel: t("posts.deleteDialog.cancel"),
242
- onConfirm: () => {
243
- void deleteMutation.mutateAsync({ id } satisfies PostSoftDeleteInputSchema);
244
- },
245
- });
246
- };
247
-
248
- const stats = useMemo(() => {
249
- const published = rows.filter((row) => row.status === "published").length;
250
- const drafts = rows.filter((row) => row.status === "draft").length;
251
-
252
- return {
253
- published,
254
- drafts,
255
- total,
256
- };
257
- }, [rows, total]);
258
-
259
- return (
260
- <div className="grid gap-6">
261
- <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">
262
- <div>
263
- <p className="text-[0.68rem] font-semibold uppercase tracking-[0.32em] text-amber-700/80">
264
- {t("posts.hero.eyebrow")}
265
- </p>
266
- <h2 className="mt-3 font-editorial text-5xl leading-none text-ink">
267
- {t("posts.hero.title")}
268
- </h2>
269
- <p className="mt-4 max-w-2xl text-sm leading-7 text-muted-ink">{t("posts.hero.body")}</p>
270
- <div className="mt-6 flex flex-wrap gap-3">
271
- <Button className="rounded-full" variant="primary" onPress={openCreate}>
272
- <span className="inline-flex items-center gap-2">
273
- <PlusIcon className="h-4 w-4" />
274
- {t("posts.hero.new")}
275
- </span>
276
- </Button>
277
- <Chip className="rounded-full" variant="soft" color="default">
278
- {isFetching ? t("posts.hero.syncing") : t("posts.hero.synced")}
279
- </Chip>
280
- </div>
281
- </div>
282
-
283
- <div className="grid gap-3 rounded-[28px] border border-white/70 bg-white/72 p-4">
284
- <div className="grid grid-cols-3 gap-3">
285
- <StatCard label={t("posts.stats.total")} value={stats.total} accent="amber" />
286
- <StatCard label={t("posts.stats.published")} value={stats.published} accent="emerald" />
287
- <StatCard label={t("posts.stats.drafts")} value={stats.drafts} accent="stone" />
288
- </div>
289
- <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_220px]">
290
- <div className="grid gap-2">
291
- <Label className="text-sm font-medium" htmlFor={searchFieldId}>
292
- {t("posts.filters.searchLabel")}
293
- </Label>
294
- <Input
295
- id={searchFieldId}
296
- aria-label={t("posts.filters.searchLabel")}
297
- className="rounded-lg"
298
- variant="secondary"
299
- placeholder={t("posts.filters.searchPlaceholder")}
300
- value={search}
301
- onChange={(event) => {
302
- const value = event.target.value;
303
- startTransition(() => {
304
- void setSearch(value || null);
305
- void setPage(1);
306
- });
307
- }}
308
- />
309
- </div>
310
- <div className="grid gap-2">
311
- <Label className="text-sm font-medium" htmlFor={statusFieldId}>
312
- {t("posts.filters.statusLabel")}
313
- </Label>
314
- <Select
315
- aria-label={t("posts.filters.statusLabel")}
316
- className="w-full"
317
- variant="secondary"
318
- selectedKey={status}
319
- onSelectionChange={(key) => {
320
- if (key === null) {
321
- return;
322
- }
323
- const nextValue = String(key) as PostStatusFilter;
324
- startTransition(() => {
325
- void setStatus(nextValue);
326
- void setPage(1);
327
- });
328
- }}
329
- >
330
- <Select.Trigger id={statusFieldId} className="rounded-lg w-full">
331
- <Select.Value />
332
- <Select.Indicator />
333
- </Select.Trigger>
334
- <Select.Popover>
335
- <ListBox>
336
- <ListBox.Item
337
- className="text-sm"
338
- id={`${statusFieldId}-all`}
339
- textValue={t("posts.filters.all")}
340
- >
341
- {t("posts.filters.all")}
342
- <ListBox.ItemIndicator />
343
- </ListBox.Item>
344
- <ListBox.Item
345
- className="text-sm"
346
- id={`${statusFieldId}-draft`}
347
- textValue={t("posts.filters.draft")}
348
- >
349
- {t("posts.filters.draft")}
350
- <ListBox.ItemIndicator />
351
- </ListBox.Item>
352
- <ListBox.Item
353
- className="text-sm"
354
- id={`${statusFieldId}-published`}
355
- textValue={t("posts.filters.published")}
356
- >
357
- {t("posts.filters.published")}
358
- <ListBox.ItemIndicator />
359
- </ListBox.Item>
360
- </ListBox>
361
- </Select.Popover>
362
- </Select>
363
- </div>
364
- </div>
365
- </div>
366
- </section>
367
-
368
- <div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(360px,0.85fr)]">
369
- <section className="grid gap-4">
370
- {isLoading ? (
371
- <>
372
- <Skeleton className="h-44 rounded-[28px]" animationType="pulse" />
373
- <Skeleton className="h-44 rounded-[28px]" animationType="pulse" />
374
- <Skeleton className="h-44 rounded-[28px]" animationType="pulse" />
375
- </>
376
- ) : rows.length === 0 ? (
377
- <Card className="rounded-[30px] border border-dashed border-amber-300/70 bg-panel shadow-[0_18px_40px_rgba(81,50,24,0.1)]">
378
- <Card.Content className="flex flex-col items-start gap-4 px-6 py-10">
379
- <Chip color="default" variant="soft">
380
- {t("posts.empty.eyebrow")}
381
- </Chip>
382
- <div>
383
- <h3 className="font-editorial text-3xl text-ink">{t("posts.empty.title")}</h3>
384
- <p className="mt-3 max-w-xl text-sm leading-7 text-muted-ink">
385
- {t("posts.empty.body")}
386
- </p>
387
- </div>
388
- <Button className="rounded-full" variant="primary" onPress={openCreate}>
389
- {t("posts.empty.action")}
390
- </Button>
391
- </Card.Content>
392
- </Card>
393
- ) : (
394
- rows.map((row) => {
395
- const isSelected = selectedPost?.id === row.id;
396
- const isPublishing =
397
- publishMutation.isPending && publishMutation.variables?.id === row.id;
398
- const isDeleting =
399
- deleteMutation.isPending && deleteMutation.variables?.id === row.id;
400
-
401
- return (
402
- // biome-ignore lint/a11y/useSemanticElements: post row card with nested action buttons
403
- <Card
404
- key={row.id}
405
- role="button"
406
- tabIndex={0}
407
- onClick={(event) => {
408
- if ((event.target as HTMLElement).closest("button")) {
409
- return;
410
- }
411
- setSelectedPostId(row.id);
412
- }}
413
- onKeyDown={(event) => {
414
- if (event.key === "Enter" || event.key === " ") {
415
- event.preventDefault();
416
- setSelectedPostId(row.id);
417
- }
418
- }}
419
- className={
420
- isSelected
421
- ? "cursor-pointer rounded-[30px] border border-emerald-300 bg-emerald-950 text-emerald-50 shadow-[0_20px_44px_rgba(31,79,70,0.24)]"
422
- : "cursor-pointer rounded-[30px] border border-white/70 bg-panel shadow-[0_18px_40px_rgba(81,50,24,0.1)]"
423
- }
424
- >
425
- <Card.Header className="flex items-start justify-between gap-4 px-5 pt-5">
426
- <div className="space-y-3">
427
- <div className="flex flex-wrap items-center gap-2">
428
- <Chip
429
- size="sm"
430
- color={row.status === "published" ? "success" : "default"}
431
- variant={isSelected ? "primary" : "soft"}
432
- >
433
- {row.status === "published"
434
- ? t("posts.filters.published")
435
- : t("posts.filters.draft")}
436
- </Chip>
437
- <span className={isSelected ? "text-emerald-100/80" : "text-muted-ink"}>
438
- {getReadingTime(row.content)}
439
- </span>
440
- </div>
441
- <div>
442
- <h3 className="font-editorial text-3xl leading-none">{row.title}</h3>
443
- <p
444
- className={
445
- isSelected
446
- ? "mt-3 text-sm leading-7 text-emerald-100/80"
447
- : "mt-3 text-sm leading-7 text-muted-ink"
448
- }
449
- >
450
- {row.excerpt}
451
- </p>
452
- </div>
453
- </div>
454
- <Button
455
- isIconOnly
456
- className={`rounded-full ${isSelected ? "bg-emerald-100 text-emerald-950" : "bg-white/80 text-ink"}`}
457
- variant={isSelected ? "secondary" : "ghost"}
458
- >
459
- <EyeIcon className="h-4 w-4" />
460
- </Button>
461
- </Card.Header>
462
- <Card.Content className="flex flex-col gap-4 px-5 pb-5">
463
- <div className="flex flex-wrap items-center gap-3 text-sm">
464
- <span className={isSelected ? "text-emerald-100/80" : "text-muted-ink"}>
465
- {t("posts.meta.updated")}: {formatDate(row.updatedAt ?? row.createdAt)}
466
- </span>
467
- {row.publishedAt ? (
468
- <span className={isSelected ? "text-emerald-100/80" : "text-muted-ink"}>
469
- {t("posts.meta.published")}: {formatDate(row.publishedAt)}
470
- </span>
471
- ) : null}
472
- </div>
473
- <div className="flex flex-wrap gap-2">
474
- <Button
475
- className={`rounded-full ${isSelected ? "bg-emerald-100 text-emerald-950" : ""}`}
476
- variant={isSelected ? "secondary" : "ghost"}
477
- onPress={() => openEdit(row)}
478
- >
479
- <span className="inline-flex items-center gap-2">
480
- <PencilLineIcon className="h-4 w-4" />
481
- {t("posts.actions.edit")}
482
- </span>
483
- </Button>
484
- {row.status === "draft" ? (
485
- <Button
486
- className="rounded-full"
487
- variant="secondary"
488
- isPending={isPublishing}
489
- onPress={() => void onPublish(row.id)}
490
- >
491
- <span className="inline-flex items-center gap-2">
492
- <SendHorizontalIcon className="h-4 w-4" />
493
- {t("posts.actions.publish")}
494
- </span>
495
- </Button>
496
- ) : null}
497
- <Button
498
- className="rounded-full"
499
- variant="danger"
500
- isPending={isDeleting}
501
- onPress={() => onDelete(row.id)}
502
- >
503
- <span className="inline-flex items-center gap-2">
504
- <Trash2Icon className="h-4 w-4" />
505
- {t("posts.actions.delete")}
506
- </span>
507
- </Button>
508
- </div>
509
- </Card.Content>
510
- </Card>
511
- );
512
- })
513
- )}
514
-
515
- {rows.length > 0 ? (
516
- <div className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/70 bg-white/70 px-4 py-3">
517
- <p className="text-sm text-muted-ink">
518
- {t("posts.pagination.summary", {
519
- page,
520
- pageCount,
521
- })}
522
- </p>
523
- <div className="flex items-center gap-2">
524
- <Button
525
- className="rounded-full"
526
- variant="ghost"
527
- isDisabled={page <= 1}
528
- onPress={() => {
529
- startTransition(() => {
530
- void setPage(Math.max(1, page - 1));
531
- });
532
- }}
533
- >
534
- <span className="inline-flex items-center gap-2">
535
- <ArrowLeftIcon className="h-4 w-4" />
536
- {t("posts.pagination.previous")}
537
- </span>
538
- </Button>
539
- <Button
540
- className="rounded-full"
541
- variant="ghost"
542
- isDisabled={page >= pageCount}
543
- onPress={() => {
544
- startTransition(() => {
545
- void setPage(Math.min(pageCount, page + 1));
546
- });
547
- }}
548
- >
549
- <span className="inline-flex items-center gap-2">
550
- {t("posts.pagination.next")}
551
- <ArrowRightIcon className="h-4 w-4" />
552
- </span>
553
- </Button>
554
- </div>
555
- </div>
556
- ) : null}
557
- </section>
558
-
559
- <aside className="sticky top-6 h-fit">
560
- <Card className="rounded-[30px] border border-white/70 bg-panel shadow-[0_20px_44px_rgba(81,50,24,0.1)]">
561
- <Card.Header className="flex items-start justify-between px-5 pt-5">
562
- <div>
563
- <p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-amber-700/80">
564
- {t("posts.preview.eyebrow")}
565
- </p>
566
- <h3 className="mt-3 font-editorial text-4xl leading-none text-ink">
567
- {selectedPost?.title ?? t("posts.preview.emptyTitle")}
568
- </h3>
569
- </div>
570
- {selectedPost ? (
571
- <Chip
572
- color={selectedPost.status === "published" ? "success" : "default"}
573
- variant="soft"
574
- >
575
- {selectedPost.status === "published"
576
- ? t("posts.filters.published")
577
- : t("posts.filters.draft")}
578
- </Chip>
579
- ) : null}
580
- </Card.Header>
581
- <Card.Content className="flex flex-col gap-5 px-5 pb-5">
582
- {selectedPost ? (
583
- <>
584
- <div className="rounded-[26px] border border-amber-200/70 bg-amber-50/80 p-4">
585
- <p className="text-sm leading-7 text-ink/80">{selectedPost.excerpt}</p>
586
- </div>
587
- <div className="flex flex-wrap gap-2">
588
- <Chip variant="soft" color="default">
589
- {getReadingTime(selectedPost.content)}
590
- </Chip>
591
- <Chip variant="soft">
592
- {t("posts.preview.updated")}:{" "}
593
- {formatDate(selectedPost.updatedAt ?? selectedPost.createdAt)}
594
- </Chip>
595
- </div>
596
- <div className="prose prose-stone max-w-none text-sm leading-7 text-muted-ink">
597
- <p>{selectedPost.content}</p>
598
- </div>
599
- <Button
600
- className="rounded-full"
601
- variant="ghost"
602
- onPress={() => openEdit(selectedPost)}
603
- >
604
- <span className="inline-flex items-center gap-2">
605
- <FilePenLineIcon className="h-4 w-4" />
606
- {t("posts.preview.openEditor")}
607
- </span>
608
- </Button>
609
- </>
610
- ) : (
611
- <div className="rounded-[26px] border border-dashed border-amber-300/70 bg-amber-50/70 p-6 text-sm leading-7 text-muted-ink">
612
- {t("posts.preview.emptyBody")}
613
- </div>
614
- )}
615
- </Card.Content>
616
- </Card>
617
- </aside>
618
- </div>
619
-
620
- <Modal
621
- isOpen={isEditorOpen}
622
- onOpenChange={(open) => {
623
- if (!open) {
624
- setIsEditorOpen(false);
625
- }
626
- }}
627
- >
628
- <Modal.Backdrop>
629
- <Modal.Container scroll="inside" size="lg" className="max-w-5xl">
630
- <Modal.Dialog>
631
- <form className="contents" onSubmit={onSubmit}>
632
- <Modal.Header className="flex flex-col gap-2 px-6 pt-6">
633
- <p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-amber-700/80">
634
- {editorState.id ? t("posts.editor.editEyebrow") : t("posts.editor.newEyebrow")}
635
- </p>
636
- <Modal.Heading className="font-editorial text-4xl leading-none text-ink">
637
- {editorState.id ? t("posts.editor.editTitle") : t("posts.editor.newTitle")}
638
- </Modal.Heading>
639
- </Modal.Header>
640
- <Modal.Body className="grid gap-4 px-6 pb-2">
641
- <div className="grid gap-2">
642
- <Label className="text-sm font-medium" htmlFor={editorTitleId}>
643
- {t("posts.editor.fields.title")}
644
- </Label>
645
- <Input
646
- id={editorTitleId}
647
- className="rounded-lg"
648
- variant="secondary"
649
- value={editorState.title}
650
- onChange={(event) =>
651
- setEditorState((state) => ({ ...state, title: event.target.value }))
652
- }
653
- required
654
- />
655
- </div>
656
- <div className="grid gap-2">
657
- <Label className="text-sm font-medium" htmlFor={editorSlugId}>
658
- {t("posts.editor.fields.slug")}
659
- </Label>
660
- <Input
661
- id={editorSlugId}
662
- className="rounded-lg"
663
- variant="secondary"
664
- value={editorState.slug}
665
- onChange={(event) =>
666
- setEditorState((state) => ({ ...state, slug: event.target.value }))
667
- }
668
- />
669
- </div>
670
- <div className="grid gap-2">
671
- <Label className="text-sm font-medium" htmlFor={editorExcerptId}>
672
- {t("posts.editor.fields.excerpt")}
673
- </Label>
674
- <TextArea
675
- id={editorExcerptId}
676
- className="rounded-lg min-h-[5.5rem]"
677
- variant="secondary"
678
- rows={3}
679
- value={editorState.excerpt}
680
- onChange={(event) =>
681
- setEditorState((state) => ({ ...state, excerpt: event.target.value }))
682
- }
683
- />
684
- </div>
685
- <div className="grid gap-2">
686
- <Label className="text-sm font-medium" htmlFor={editorContentId}>
687
- {t("posts.editor.fields.content")}
688
- </Label>
689
- <TextArea
690
- id={editorContentId}
691
- className="rounded-lg min-h-[12rem]"
692
- variant="secondary"
693
- rows={10}
694
- value={editorState.content}
695
- onChange={(event) =>
696
- setEditorState((state) => ({ ...state, content: event.target.value }))
697
- }
698
- required
699
- />
700
- </div>
701
- </Modal.Body>
702
- <Modal.Footer className="px-6 pb-6">
703
- <Button
704
- className="rounded-full"
705
- variant="tertiary"
706
- type="button"
707
- onPress={() => setIsEditorOpen(false)}
708
- >
709
- {t("posts.editor.cancel")}
710
- </Button>
711
- <Button
712
- className="rounded-full"
713
- variant="primary"
714
- type="submit"
715
- isPending={createMutation.isPending || updateMutation.isPending}
716
- >
717
- {editorState.id ? t("posts.editor.save") : t("posts.editor.create")}
718
- </Button>
719
- </Modal.Footer>
720
- </form>
721
- </Modal.Dialog>
722
- </Modal.Container>
723
- </Modal.Backdrop>
724
- </Modal>
725
- </div>
726
- );
727
- }
728
-
729
- function StatCard({
730
- label,
731
- value,
732
- accent,
733
- }: {
734
- label: string;
735
- value: number;
736
- accent: "amber" | "emerald" | "stone";
737
- }) {
738
- const accentClass =
739
- accent === "amber"
740
- ? "border-amber-200 bg-amber-50/90 text-amber-900"
741
- : accent === "emerald"
742
- ? "border-emerald-200 bg-emerald-50/90 text-emerald-900"
743
- : "border-stone-200 bg-stone-100/90 text-stone-900";
744
-
745
- return (
746
- <div className={`rounded-[24px] border px-4 py-4 ${accentClass}`}>
747
- <p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em]">{label}</p>
748
- <p className="mt-3 font-editorial text-4xl leading-none">{value}</p>
749
- </div>
750
- );
751
- }
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
+ }