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
@@ -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
+ }
@@ -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
+ }
@@ -13,6 +13,8 @@
13
13
  },
14
14
  "navigation": {
15
15
  "posts": "Posts",
16
+ "organization": "Organization",
17
+ "account": "Account",
16
18
  "members": "Members",
17
19
  "childOrgs": "Child orgs",
18
20
  "orgPreferences": "Org preferences",
@@ -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.21.3
10
- '@m5kdev/commons': 0.21.3
11
- '@m5kdev/config': 0.21.3
12
- '@m5kdev/frontend': 0.21.3
13
- '@m5kdev/web-ui': 0.21.3
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