create-m5kdev 0.25.1 → 0.25.3
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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Button, Card, Chip } from "@heroui/react";
|
|
2
|
+
import { EyeIcon, PencilLineIcon, SendHorizontalIcon, Trash2Icon } from "lucide-react";
|
|
3
|
+
import { useTranslation } from "react-i18next";
|
|
4
|
+
import type { PostRow } from "../hooks/usePostsList";
|
|
5
|
+
import { formatDate, getReadingTime } from "../posts.utils";
|
|
6
|
+
|
|
7
|
+
interface PostCardProps {
|
|
8
|
+
row: PostRow;
|
|
9
|
+
isSelected: boolean;
|
|
10
|
+
isPublishing: boolean;
|
|
11
|
+
isDeleting: boolean;
|
|
12
|
+
onSelect: () => void;
|
|
13
|
+
onEdit: () => void;
|
|
14
|
+
onPublish: () => void;
|
|
15
|
+
onDelete: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function PostCard({
|
|
19
|
+
row,
|
|
20
|
+
isSelected,
|
|
21
|
+
isPublishing,
|
|
22
|
+
isDeleting,
|
|
23
|
+
onSelect,
|
|
24
|
+
onEdit,
|
|
25
|
+
onPublish,
|
|
26
|
+
onDelete,
|
|
27
|
+
}: PostCardProps) {
|
|
28
|
+
const { t } = useTranslation("blog{{PACKAGE_SCOPE}}");
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
// biome-ignore lint/a11y/useSemanticElements: post row card with nested action buttons
|
|
32
|
+
<Card
|
|
33
|
+
role="button"
|
|
34
|
+
tabIndex={0}
|
|
35
|
+
onClick={(event) => {
|
|
36
|
+
if ((event.target as HTMLElement).closest("button")) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
onSelect();
|
|
40
|
+
}}
|
|
41
|
+
onKeyDown={(event) => {
|
|
42
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
43
|
+
event.preventDefault();
|
|
44
|
+
onSelect();
|
|
45
|
+
}
|
|
46
|
+
}}
|
|
47
|
+
className={
|
|
48
|
+
isSelected
|
|
49
|
+
? "cursor-pointer rounded-[30px] border border-emerald-300 bg-emerald-950 text-emerald-50 shadow-[0_20px_44px_rgba(31,79,70,0.24)]"
|
|
50
|
+
: "cursor-pointer rounded-[30px] border border-white/70 bg-panel shadow-[0_18px_40px_rgba(81,50,24,0.1)]"
|
|
51
|
+
}
|
|
52
|
+
>
|
|
53
|
+
<Card.Header className="flex items-start justify-between gap-4 px-5 pt-5">
|
|
54
|
+
<div className="space-y-3">
|
|
55
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
56
|
+
<Chip
|
|
57
|
+
size="sm"
|
|
58
|
+
color={row.status === "published" ? "success" : "default"}
|
|
59
|
+
variant={isSelected ? "primary" : "soft"}
|
|
60
|
+
>
|
|
61
|
+
{row.status === "published" ? t("posts.filters.published") : t("posts.filters.draft")}
|
|
62
|
+
</Chip>
|
|
63
|
+
<span className={isSelected ? "text-emerald-100/80" : "text-muted-ink"}>
|
|
64
|
+
{getReadingTime(row.content)}
|
|
65
|
+
</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div>
|
|
68
|
+
<h3 className="font-editorial text-3xl leading-none">{row.title}</h3>
|
|
69
|
+
<p
|
|
70
|
+
className={
|
|
71
|
+
isSelected
|
|
72
|
+
? "mt-3 text-sm leading-7 text-emerald-100/80"
|
|
73
|
+
: "mt-3 text-sm leading-7 text-muted-ink"
|
|
74
|
+
}
|
|
75
|
+
>
|
|
76
|
+
{row.excerpt}
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
<Button
|
|
81
|
+
isIconOnly
|
|
82
|
+
className={`rounded-full ${isSelected ? "bg-emerald-100 text-emerald-950" : "bg-white/80 text-ink"}`}
|
|
83
|
+
variant={isSelected ? "secondary" : "ghost"}
|
|
84
|
+
>
|
|
85
|
+
<EyeIcon className="h-4 w-4" />
|
|
86
|
+
</Button>
|
|
87
|
+
</Card.Header>
|
|
88
|
+
<Card.Content className="flex flex-col gap-4 px-5 pb-5">
|
|
89
|
+
<div className="flex flex-wrap items-center gap-3 text-sm">
|
|
90
|
+
<span className={isSelected ? "text-emerald-100/80" : "text-muted-ink"}>
|
|
91
|
+
{t("posts.meta.updated")}: {formatDate(row.updatedAt ?? row.createdAt)}
|
|
92
|
+
</span>
|
|
93
|
+
{row.publishedAt ? (
|
|
94
|
+
<span className={isSelected ? "text-emerald-100/80" : "text-muted-ink"}>
|
|
95
|
+
{t("posts.meta.published")}: {formatDate(row.publishedAt)}
|
|
96
|
+
</span>
|
|
97
|
+
) : null}
|
|
98
|
+
</div>
|
|
99
|
+
<div className="flex flex-wrap gap-2">
|
|
100
|
+
<Button
|
|
101
|
+
className={`rounded-full ${isSelected ? "bg-emerald-100 text-emerald-950" : ""}`}
|
|
102
|
+
variant={isSelected ? "secondary" : "ghost"}
|
|
103
|
+
onPress={onEdit}
|
|
104
|
+
>
|
|
105
|
+
<span className="inline-flex items-center gap-2">
|
|
106
|
+
<PencilLineIcon className="h-4 w-4" />
|
|
107
|
+
{t("posts.actions.edit")}
|
|
108
|
+
</span>
|
|
109
|
+
</Button>
|
|
110
|
+
{row.status === "draft" ? (
|
|
111
|
+
<Button
|
|
112
|
+
className="rounded-full"
|
|
113
|
+
variant="secondary"
|
|
114
|
+
isPending={isPublishing}
|
|
115
|
+
onPress={onPublish}
|
|
116
|
+
>
|
|
117
|
+
<span className="inline-flex items-center gap-2">
|
|
118
|
+
<SendHorizontalIcon className="h-4 w-4" />
|
|
119
|
+
{t("posts.actions.publish")}
|
|
120
|
+
</span>
|
|
121
|
+
</Button>
|
|
122
|
+
) : null}
|
|
123
|
+
<Button
|
|
124
|
+
className="rounded-full"
|
|
125
|
+
variant="danger"
|
|
126
|
+
isPending={isDeleting}
|
|
127
|
+
onPress={onDelete}
|
|
128
|
+
>
|
|
129
|
+
<span className="inline-flex items-center gap-2">
|
|
130
|
+
<Trash2Icon className="h-4 w-4" />
|
|
131
|
+
{t("posts.actions.delete")}
|
|
132
|
+
</span>
|
|
133
|
+
</Button>
|
|
134
|
+
</div>
|
|
135
|
+
</Card.Content>
|
|
136
|
+
</Card>
|
|
137
|
+
);
|
|
138
|
+
}
|
package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostEditorModal.tsx.tpl
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { Button, FieldError, Form, Input, Label, Modal, TextArea, TextField } from "@heroui/react";
|
|
2
|
+
import type { PostCreateInputSchema } from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.schema";
|
|
3
|
+
import type { FormEvent } from "react";
|
|
4
|
+
import { useTranslation } from "react-i18next";
|
|
5
|
+
import type { PostRow } from "../hooks/usePostsList";
|
|
6
|
+
|
|
7
|
+
interface PostEditorModalProps {
|
|
8
|
+
isOpen: boolean;
|
|
9
|
+
/** Row being edited; undefined means creating a new post. */
|
|
10
|
+
post?: PostRow;
|
|
11
|
+
isSaving: boolean;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
onSave: (payload: PostCreateInputSchema, id?: string) => Promise<boolean>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Uncontrolled HeroUI form: native HTML validation (`isRequired`) guards the
|
|
18
|
+
* fields and values are read from FormData on submit — no form library, no
|
|
19
|
+
* per-field state.
|
|
20
|
+
*/
|
|
21
|
+
export function PostEditorModal({ isOpen, post, isSaving, onClose, onSave }: PostEditorModalProps) {
|
|
22
|
+
const { t } = useTranslation("blog{{PACKAGE_SCOPE}}");
|
|
23
|
+
|
|
24
|
+
const onSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
25
|
+
event.preventDefault();
|
|
26
|
+
const formData = new FormData(event.currentTarget);
|
|
27
|
+
const payload: PostCreateInputSchema = {
|
|
28
|
+
title: String(formData.get("title") ?? "").trim(),
|
|
29
|
+
slug: String(formData.get("slug") ?? "").trim() || undefined,
|
|
30
|
+
excerpt: String(formData.get("excerpt") ?? "").trim() || undefined,
|
|
31
|
+
content: String(formData.get("content") ?? "").trim(),
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
if (await onSave(payload, post?.id)) {
|
|
35
|
+
onClose();
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Modal
|
|
41
|
+
isOpen={isOpen}
|
|
42
|
+
onOpenChange={(open) => {
|
|
43
|
+
if (!open) {
|
|
44
|
+
onClose();
|
|
45
|
+
}
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
<Modal.Backdrop>
|
|
49
|
+
<Modal.Container scroll="inside" size="lg" className="max-w-5xl">
|
|
50
|
+
<Modal.Dialog>
|
|
51
|
+
{/* key remounts the form so defaultValue resets when switching rows */}
|
|
52
|
+
<Form key={post?.id ?? "new"} className="contents" onSubmit={onSubmit}>
|
|
53
|
+
<Modal.Header className="flex flex-col gap-2 px-6 pt-6">
|
|
54
|
+
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-amber-700/80">
|
|
55
|
+
{post ? t("posts.editor.editEyebrow") : t("posts.editor.newEyebrow")}
|
|
56
|
+
</p>
|
|
57
|
+
<Modal.Heading className="font-editorial text-4xl leading-none text-ink">
|
|
58
|
+
{post ? t("posts.editor.editTitle") : t("posts.editor.newTitle")}
|
|
59
|
+
</Modal.Heading>
|
|
60
|
+
</Modal.Header>
|
|
61
|
+
<Modal.Body className="grid gap-4 px-6 pb-2">
|
|
62
|
+
<TextField
|
|
63
|
+
isRequired
|
|
64
|
+
name="title"
|
|
65
|
+
defaultValue={post?.title ?? ""}
|
|
66
|
+
variant="secondary"
|
|
67
|
+
className="grid gap-2"
|
|
68
|
+
>
|
|
69
|
+
<Label className="text-sm font-medium">{t("posts.editor.fields.title")}</Label>
|
|
70
|
+
<Input className="rounded-lg" />
|
|
71
|
+
<FieldError />
|
|
72
|
+
</TextField>
|
|
73
|
+
<TextField
|
|
74
|
+
name="slug"
|
|
75
|
+
defaultValue={post?.slug ?? ""}
|
|
76
|
+
variant="secondary"
|
|
77
|
+
className="grid gap-2"
|
|
78
|
+
>
|
|
79
|
+
<Label className="text-sm font-medium">{t("posts.editor.fields.slug")}</Label>
|
|
80
|
+
<Input className="rounded-lg" />
|
|
81
|
+
<FieldError />
|
|
82
|
+
</TextField>
|
|
83
|
+
<TextField
|
|
84
|
+
name="excerpt"
|
|
85
|
+
defaultValue={post?.excerpt ?? ""}
|
|
86
|
+
variant="secondary"
|
|
87
|
+
className="grid gap-2"
|
|
88
|
+
>
|
|
89
|
+
<Label className="text-sm font-medium">{t("posts.editor.fields.excerpt")}</Label>
|
|
90
|
+
<TextArea className="rounded-lg min-h-[5.5rem]" rows={3} />
|
|
91
|
+
<FieldError />
|
|
92
|
+
</TextField>
|
|
93
|
+
<TextField
|
|
94
|
+
isRequired
|
|
95
|
+
name="content"
|
|
96
|
+
defaultValue={post?.content ?? ""}
|
|
97
|
+
variant="secondary"
|
|
98
|
+
className="grid gap-2"
|
|
99
|
+
>
|
|
100
|
+
<Label className="text-sm font-medium">{t("posts.editor.fields.content")}</Label>
|
|
101
|
+
<TextArea className="rounded-lg min-h-[12rem]" rows={10} />
|
|
102
|
+
<FieldError />
|
|
103
|
+
</TextField>
|
|
104
|
+
</Modal.Body>
|
|
105
|
+
<Modal.Footer className="px-6 pb-6">
|
|
106
|
+
<Button className="rounded-full" variant="tertiary" type="button" onPress={onClose}>
|
|
107
|
+
{t("posts.editor.cancel")}
|
|
108
|
+
</Button>
|
|
109
|
+
<Button
|
|
110
|
+
className="rounded-full"
|
|
111
|
+
variant="primary"
|
|
112
|
+
type="submit"
|
|
113
|
+
isPending={isSaving}
|
|
114
|
+
>
|
|
115
|
+
{post ? t("posts.editor.save") : t("posts.editor.create")}
|
|
116
|
+
</Button>
|
|
117
|
+
</Modal.Footer>
|
|
118
|
+
</Form>
|
|
119
|
+
</Modal.Dialog>
|
|
120
|
+
</Modal.Container>
|
|
121
|
+
</Modal.Backdrop>
|
|
122
|
+
</Modal>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useDialog } from "@m5kdev/web-ui/components/DialogProvider";
|
|
2
|
+
import type {
|
|
3
|
+
PostCreateInputSchema,
|
|
4
|
+
PostPublishInputSchema,
|
|
5
|
+
PostSoftDeleteInputSchema,
|
|
6
|
+
PostUpdateInputSchema,
|
|
7
|
+
} from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.schema";
|
|
8
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
9
|
+
import { useTranslation } from "react-i18next";
|
|
10
|
+
import { toast } from "sonner";
|
|
11
|
+
import { useTRPC } from "@/utils/trpc";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Reusable post mutations: save (create/update), publish, and delete with a
|
|
15
|
+
* confirmation dialog. Toasts and list invalidation live here so any component
|
|
16
|
+
* can trigger the same action with the same behavior.
|
|
17
|
+
*/
|
|
18
|
+
export function usePostActions() {
|
|
19
|
+
const { t } = useTranslation("blog{{PACKAGE_SCOPE}}");
|
|
20
|
+
const trpc = useTRPC();
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
const showDialog = useDialog();
|
|
23
|
+
|
|
24
|
+
const invalidateList = async () => {
|
|
25
|
+
await queryClient.invalidateQueries(trpc.posts.list.queryFilter());
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const onError = (error: unknown) => {
|
|
29
|
+
toast.error(error instanceof Error ? error.message : String(error));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const createMutation = useMutation(
|
|
33
|
+
trpc.posts.create.mutationOptions({
|
|
34
|
+
onSuccess: async () => {
|
|
35
|
+
toast.success(t("posts.toast.created"));
|
|
36
|
+
await invalidateList();
|
|
37
|
+
},
|
|
38
|
+
onError,
|
|
39
|
+
})
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const updateMutation = useMutation(
|
|
43
|
+
trpc.posts.update.mutationOptions({
|
|
44
|
+
onSuccess: async () => {
|
|
45
|
+
toast.success(t("posts.toast.updated"));
|
|
46
|
+
await invalidateList();
|
|
47
|
+
},
|
|
48
|
+
onError,
|
|
49
|
+
})
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const publishMutation = useMutation(
|
|
53
|
+
trpc.posts.publish.mutationOptions({
|
|
54
|
+
onSuccess: async () => {
|
|
55
|
+
toast.success(t("posts.toast.published"));
|
|
56
|
+
await invalidateList();
|
|
57
|
+
},
|
|
58
|
+
onError,
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const deleteMutation = useMutation(
|
|
63
|
+
trpc.posts.softDelete.mutationOptions({
|
|
64
|
+
onSuccess: async () => {
|
|
65
|
+
toast.success(t("posts.toast.deleted"));
|
|
66
|
+
await invalidateList();
|
|
67
|
+
},
|
|
68
|
+
onError,
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
/** Create when `id` is missing, update otherwise. Resolves false on failure (toast already shown). */
|
|
73
|
+
const savePost = async (payload: PostCreateInputSchema, id?: string): Promise<boolean> => {
|
|
74
|
+
try {
|
|
75
|
+
if (id) {
|
|
76
|
+
await updateMutation.mutateAsync({ id, ...payload } as PostUpdateInputSchema);
|
|
77
|
+
} else {
|
|
78
|
+
await createMutation.mutateAsync(payload);
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const publishPost = (id: string) => {
|
|
87
|
+
void publishMutation
|
|
88
|
+
.mutateAsync({ id } satisfies PostPublishInputSchema)
|
|
89
|
+
.catch(() => undefined);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const deletePost = (id: string) => {
|
|
93
|
+
showDialog({
|
|
94
|
+
title: t("posts.deleteDialog.title"),
|
|
95
|
+
description: t("posts.deleteDialog.body"),
|
|
96
|
+
intent: "danger",
|
|
97
|
+
cancelable: true,
|
|
98
|
+
confirmLabel: t("posts.deleteDialog.confirm"),
|
|
99
|
+
cancelLabel: t("posts.deleteDialog.cancel"),
|
|
100
|
+
onConfirm: () => {
|
|
101
|
+
void deleteMutation
|
|
102
|
+
.mutateAsync({ id } satisfies PostSoftDeleteInputSchema)
|
|
103
|
+
.catch(() => undefined);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
savePost,
|
|
110
|
+
publishPost,
|
|
111
|
+
deletePost,
|
|
112
|
+
isSaving: createMutation.isPending || updateMutation.isPending,
|
|
113
|
+
publishingId: publishMutation.isPending ? publishMutation.variables?.id : undefined,
|
|
114
|
+
deletingId: deleteMutation.isPending ? deleteMutation.variables?.id : undefined,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import {
|
|
2
|
+
POST_FILTER_VALUES,
|
|
3
|
+
POSTS_PAGE_SIZE,
|
|
4
|
+
} from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.constants";
|
|
5
|
+
import type {
|
|
6
|
+
PostsListInputSchema,
|
|
7
|
+
PostsListOutputSchema,
|
|
8
|
+
} from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.schema";
|
|
9
|
+
import { type UseQueryOptions, useQuery } from "@tanstack/react-query";
|
|
10
|
+
import { parseAsInteger, parseAsString, parseAsStringLiteral, useQueryState } from "nuqs";
|
|
11
|
+
import { startTransition, useDeferredValue, useMemo } from "react";
|
|
12
|
+
import { useTRPC } from "@/utils/trpc";
|
|
13
|
+
|
|
14
|
+
export type PostStatusFilter = (typeof POST_FILTER_VALUES)[number];
|
|
15
|
+
export type PostRow = PostsListOutputSchema["rows"][number];
|
|
16
|
+
|
|
17
|
+
const STATUS_PARSER = parseAsStringLiteral(POST_FILTER_VALUES).withDefault("all");
|
|
18
|
+
|
|
19
|
+
/** List query plus its URL state (search, status filter, page) and derived stats. */
|
|
20
|
+
export function usePostsList() {
|
|
21
|
+
const trpc = useTRPC();
|
|
22
|
+
|
|
23
|
+
const [search, setSearchParam] = useQueryState("search", parseAsString.withDefault(""));
|
|
24
|
+
const [status, setStatusParam] = useQueryState<PostStatusFilter>("status", STATUS_PARSER);
|
|
25
|
+
const [page, setPageParam] = useQueryState("page", parseAsInteger.withDefault(1));
|
|
26
|
+
|
|
27
|
+
const deferredSearch = useDeferredValue(search);
|
|
28
|
+
|
|
29
|
+
const listInput = useMemo<PostsListInputSchema>(
|
|
30
|
+
() => ({
|
|
31
|
+
page,
|
|
32
|
+
limit: POSTS_PAGE_SIZE,
|
|
33
|
+
search: deferredSearch || undefined,
|
|
34
|
+
status: status === "all" ? undefined : status,
|
|
35
|
+
sort: "updatedAt",
|
|
36
|
+
order: "desc",
|
|
37
|
+
}),
|
|
38
|
+
[deferredSearch, page, status]
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const { data, isLoading, isFetching } = useQuery(
|
|
42
|
+
trpc.posts.list.queryOptions(listInput) as unknown as UseQueryOptions<PostsListOutputSchema>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const rows = data?.rows ?? [];
|
|
46
|
+
const total = data?.total ?? 0;
|
|
47
|
+
const pageCount = Math.max(1, Math.ceil(total / POSTS_PAGE_SIZE));
|
|
48
|
+
|
|
49
|
+
const stats = useMemo(
|
|
50
|
+
() => ({
|
|
51
|
+
published: rows.filter((row) => row.status === "published").length,
|
|
52
|
+
drafts: rows.filter((row) => row.status === "draft").length,
|
|
53
|
+
total,
|
|
54
|
+
}),
|
|
55
|
+
[rows, total]
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const setSearch = (value: string) => {
|
|
59
|
+
startTransition(() => {
|
|
60
|
+
void setSearchParam(value || null);
|
|
61
|
+
void setPageParam(1);
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const setStatus = (value: PostStatusFilter) => {
|
|
66
|
+
startTransition(() => {
|
|
67
|
+
void setStatusParam(value);
|
|
68
|
+
void setPageParam(1);
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const goToPage = (next: number) => {
|
|
73
|
+
startTransition(() => {
|
|
74
|
+
void setPageParam(Math.min(Math.max(1, next), pageCount));
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
rows,
|
|
80
|
+
total,
|
|
81
|
+
pageCount,
|
|
82
|
+
stats,
|
|
83
|
+
page,
|
|
84
|
+
search,
|
|
85
|
+
status,
|
|
86
|
+
isLoading,
|
|
87
|
+
isFetching,
|
|
88
|
+
setSearch,
|
|
89
|
+
setStatus,
|
|
90
|
+
goToPage,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { usePostActions } from "./usePostActions";
|
|
2
|
+
import { usePostsList } from "./usePostsList";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Screen-level hook for the posts route: composes the list query/URL state and
|
|
6
|
+
* the reusable post actions so the route component makes a single call.
|
|
7
|
+
*/
|
|
8
|
+
export function usePostsRoute() {
|
|
9
|
+
const list = usePostsList();
|
|
10
|
+
const actions = usePostActions();
|
|
11
|
+
|
|
12
|
+
return { ...list, ...actions };
|
|
13
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function formatDate(value: Date | null | undefined): string {
|
|
2
|
+
if (!value) {
|
|
3
|
+
return "Not scheduled";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
7
|
+
dateStyle: "medium",
|
|
8
|
+
timeStyle: "short",
|
|
9
|
+
}).format(new Date(value));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getReadingTime(content: string): string {
|
|
13
|
+
const words = content.trim().split(/\s+/).filter(Boolean).length;
|
|
14
|
+
const minutes = Math.max(1, Math.ceil(words / 180));
|
|
15
|
+
return `${minutes} min read`;
|
|
16
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { TFunction } from "i18next";
|
|
2
|
+
import {
|
|
3
|
+
BookTextIcon,
|
|
4
|
+
Building2Icon,
|
|
5
|
+
GiftIcon,
|
|
6
|
+
SettingsIcon,
|
|
7
|
+
ShieldCheckIcon,
|
|
8
|
+
UserCogIcon,
|
|
9
|
+
UsersIcon,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
|
|
12
|
+
/** Single source of truth for app paths — used by the Router and the sidebar. */
|
|
13
|
+
export const APP_ROUTES = {
|
|
14
|
+
home: "/",
|
|
15
|
+
posts: "/posts",
|
|
16
|
+
organization: {
|
|
17
|
+
members: "/organization/members",
|
|
18
|
+
manage: "/organization/manage",
|
|
19
|
+
preferences: "/organization/preferences",
|
|
20
|
+
},
|
|
21
|
+
user: {
|
|
22
|
+
preferences: "/user/preferences",
|
|
23
|
+
invite: "/user/invite",
|
|
24
|
+
},
|
|
25
|
+
admin: "/admin/users",
|
|
26
|
+
billing: "/billing",
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sidebar navigation for AppSidebarContent: top-level items plus collapsible
|
|
31
|
+
* groups. Labels are translated by the caller so the config stays a plain list.
|
|
32
|
+
*/
|
|
33
|
+
export function buildNavigationItems(t: TFunction) {
|
|
34
|
+
return [
|
|
35
|
+
{
|
|
36
|
+
label: t("layout.navigation.posts"),
|
|
37
|
+
icon: <BookTextIcon className="h-4 w-4" />,
|
|
38
|
+
link: APP_ROUTES.posts,
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
label: t("layout.navigation.organization"),
|
|
42
|
+
icon: <Building2Icon className="h-4 w-4" />,
|
|
43
|
+
link: APP_ROUTES.organization.members,
|
|
44
|
+
subItems: [
|
|
45
|
+
{
|
|
46
|
+
label: t("layout.navigation.members"),
|
|
47
|
+
icon: <UsersIcon className="h-4 w-4" />,
|
|
48
|
+
link: APP_ROUTES.organization.members,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
label: t("layout.navigation.childOrgs"),
|
|
52
|
+
icon: <Building2Icon className="h-4 w-4" />,
|
|
53
|
+
link: APP_ROUTES.organization.manage,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
label: t("layout.navigation.orgPreferences"),
|
|
57
|
+
icon: <SettingsIcon className="h-4 w-4" />,
|
|
58
|
+
link: APP_ROUTES.organization.preferences,
|
|
59
|
+
},
|
|
60
|
+
],
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
label: t("layout.navigation.account"),
|
|
64
|
+
icon: <UserCogIcon className="h-4 w-4" />,
|
|
65
|
+
link: APP_ROUTES.user.preferences,
|
|
66
|
+
subItems: [
|
|
67
|
+
{
|
|
68
|
+
label: t("layout.navigation.profile"),
|
|
69
|
+
icon: <UserCogIcon className="h-4 w-4" />,
|
|
70
|
+
link: APP_ROUTES.user.preferences,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
label: t("layout.navigation.invites"),
|
|
74
|
+
icon: <GiftIcon className="h-4 w-4" />,
|
|
75
|
+
link: APP_ROUTES.user.invite,
|
|
76
|
+
},
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
label: t("layout.navigation.admin"),
|
|
81
|
+
icon: <ShieldCheckIcon className="h-4 w-4" />,
|
|
82
|
+
link: APP_ROUTES.admin,
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
}
|
|
@@ -6,11 +6,12 @@ catalog:
|
|
|
6
6
|
'@heroui/react': 3.0.3
|
|
7
7
|
'@heroui/styles': 3.0.3
|
|
8
8
|
'@libsql/client': 0.17.0
|
|
9
|
-
'@m5kdev/backend': 0.
|
|
10
|
-
'@m5kdev/commons': 0.
|
|
11
|
-
'@m5kdev/config': 0.
|
|
12
|
-
'@m5kdev/
|
|
13
|
-
'@m5kdev/
|
|
9
|
+
'@m5kdev/backend': 0.25.1
|
|
10
|
+
'@m5kdev/commons': 0.25.1
|
|
11
|
+
'@m5kdev/config': 0.25.1
|
|
12
|
+
'@m5kdev/email': 0.25.1
|
|
13
|
+
'@m5kdev/frontend': 0.25.1
|
|
14
|
+
'@m5kdev/web-ui': 0.25.1
|
|
14
15
|
'@react-email/components': 1.0.1
|
|
15
16
|
'@tailwindcss/vite': 4.1.11
|
|
16
17
|
'@tanstack/react-query': 5.83.0
|