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.
- package/dist/src/__tests__/create.smoke.test.d.ts +1 -0
- package/dist/src/__tests__/create.smoke.test.js +56 -0
- package/dist/src/__tests__/create.test.d.ts +1 -0
- package/dist/src/__tests__/create.test.js +55 -0
- package/dist/src/__tests__/runCli.test.d.ts +1 -0
- package/dist/src/__tests__/runCli.test.js +44 -0
- package/dist/src/__tests__/strings.test.d.ts +1 -0
- package/dist/src/__tests__/strings.test.js +24 -0
- package/dist/src/constants.d.ts +3 -0
- package/dist/src/constants.js +9 -0
- package/dist/src/create.d.ts +7 -0
- package/dist/src/create.js +36 -0
- package/dist/src/fs.d.ts +5 -0
- package/dist/src/fs.js +60 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +9 -0
- package/dist/src/paths.d.ts +1 -0
- package/dist/src/paths.js +14 -0
- package/dist/src/prompts.d.ts +2 -0
- package/dist/src/prompts.js +55 -0
- package/dist/src/runCli.d.ts +9 -0
- package/dist/src/runCli.js +107 -0
- package/dist/src/strings.d.ts +6 -0
- package/dist/src/strings.js +47 -0
- package/dist/src/types.d.ts +18 -0
- package/dist/src/types.js +2 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +38 -0
- package/templates/minimal-app/.gitignore.tpl +9 -0
- package/templates/minimal-app/AGENTS.md.tpl +29 -0
- package/templates/minimal-app/README.md.tpl +35 -0
- package/templates/minimal-app/apps/email/package.json.tpl +27 -0
- package/templates/minimal-app/apps/email/src/components/BaseEmail.tsx.tpl +117 -0
- package/templates/minimal-app/apps/email/src/emails/accountDeletionEmail.tsx.tpl +26 -0
- package/templates/minimal-app/apps/email/src/emails/organizationInviteEmail.tsx.tpl +31 -0
- package/templates/minimal-app/apps/email/src/emails/passwordResetEmail.tsx.tpl +25 -0
- package/templates/minimal-app/apps/email/src/emails/verificationEmail.tsx.tpl +26 -0
- package/templates/minimal-app/apps/email/src/index.ts.tpl +32 -0
- package/templates/minimal-app/apps/email/tsconfig.json.tpl +11 -0
- package/templates/minimal-app/apps/server/AGENTS.md.tpl +30 -0
- package/templates/minimal-app/apps/server/drizzle/seed.ts.tpl +111 -0
- package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +18 -0
- package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +38 -0
- package/templates/minimal-app/apps/server/package.json.tpl +50 -0
- package/templates/minimal-app/apps/server/src/db.ts.tpl +33 -0
- package/templates/minimal-app/apps/server/src/index.ts.tpl +34 -0
- package/templates/minimal-app/apps/server/src/lib/auth.ts.tpl +172 -0
- package/templates/minimal-app/apps/server/src/lib/localEmailService.ts.tpl +58 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +23 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +106 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +150 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +44 -0
- package/templates/minimal-app/apps/server/src/repository.ts.tpl +6 -0
- package/templates/minimal-app/apps/server/src/service.ts.tpl +12 -0
- package/templates/minimal-app/apps/server/src/trpc.ts.tpl +11 -0
- package/templates/minimal-app/apps/server/src/types.ts.tpl +3 -0
- package/templates/minimal-app/apps/server/src/utils/trpc.ts.tpl +27 -0
- package/templates/minimal-app/apps/server/tsconfig.json.tpl +13 -0
- package/templates/minimal-app/apps/server/tsup.config.ts.tpl +9 -0
- package/templates/minimal-app/apps/shared/.env.example.tpl +17 -0
- package/templates/minimal-app/apps/shared/.env.tpl +19 -0
- package/templates/minimal-app/apps/shared/package.json.tpl +24 -0
- package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -0
- package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +76 -0
- package/templates/minimal-app/apps/shared/tsconfig.json.tpl +12 -0
- package/templates/minimal-app/apps/webapp/AGENTS.md.tpl +18 -0
- package/templates/minimal-app/apps/webapp/index.html.tpl +22 -0
- package/templates/minimal-app/apps/webapp/package.json.tpl +52 -0
- package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +13 -0
- package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +139 -0
- package/templates/minimal-app/apps/webapp/src/Providers.tsx.tpl +28 -0
- package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +53 -0
- package/templates/minimal-app/apps/webapp/src/components/TrpcQueryProvider.tsx.tpl +61 -0
- package/templates/minimal-app/apps/webapp/src/hero.ts.tpl +99 -0
- package/templates/minimal-app/apps/webapp/src/index.css.tpl +75 -0
- package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -0
- package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +650 -0
- package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -0
- package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +4 -0
- package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -0
- package/templates/minimal-app/apps/webapp/translations/en/blog-app.json.tpl +107 -0
- package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +16 -0
- package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -0
- package/templates/minimal-app/biome.json.tpl +76 -0
- package/templates/minimal-app/package.json.tpl +21 -0
- package/templates/minimal-app/pnpm-workspace.yaml.tpl +58 -0
- 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 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|