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.
- package/package.json +1 -1
- package/templates/minimal-app/.cursor/rules/frontend-component-guide.mdc +95 -0
- package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +59 -0
- package/templates/minimal-app/.cursor/rules/frontend-form-guide.mdc +80 -0
- package/templates/minimal-app/.cursor/rules/frontend-hook-guide.mdc +79 -0
- package/templates/minimal-app/.cursor/rules/frontend-i18n-guide.mdc +45 -0
- package/templates/minimal-app/.cursor/rules/module-db-guide.mdc +1 -1
- package/templates/minimal-app/.cursor/rules/module-grants-guide.mdc +1 -1
- package/templates/minimal-app/.cursor/rules/module-module-guide.mdc +1 -1
- package/templates/minimal-app/.cursor/rules/module-repository-guide.mdc +1 -1
- package/templates/minimal-app/.cursor/rules/module-schema-guide.mdc +1 -1
- package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +1 -1
- package/templates/minimal-app/.cursor/rules/module-trpc-guide.mdc +1 -1
- package/templates/minimal-app/AGENTS.md.tpl +7 -0
- package/templates/minimal-app/apps/email/package.json.tpl +3 -3
- package/templates/minimal-app/apps/webapp/AGENTS.md.tpl +24 -18
- package/templates/minimal-app/apps/webapp/public/logo.svg +6 -0
- package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +59 -153
- package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +100 -111
- package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +307 -751
- package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostCard.tsx.tpl +138 -0
- package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostEditorModal.tsx.tpl +124 -0
- package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostActions.ts.tpl +116 -0
- package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsList.ts.tpl +92 -0
- package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsRoute.ts.tpl +13 -0
- package/templates/minimal-app/apps/webapp/src/modules/posts/posts.utils.ts.tpl +16 -0
- package/templates/minimal-app/apps/webapp/src/navigation/navigation.config.tsx.tpl +85 -0
- package/templates/minimal-app/apps/webapp/translations/en/blog-app.json.tpl +2 -0
- package/templates/minimal-app/pnpm-workspace.yaml.tpl +6 -5
|
@@ -1,751 +1,307 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
+
}
|