@carlonicora/nextjs-jsonapi 5.3.0 → 5.5.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/{BlockNoteEditor-WTSQANKI.mjs → BlockNoteEditor-GVQCKRMU.mjs} +9 -8
- package/dist/BlockNoteEditor-GVQCKRMU.mjs.map +1 -0
- package/dist/{BlockNoteEditor-UJ6363LC.js → BlockNoteEditor-THBJH2UR.js} +24 -23
- package/dist/BlockNoteEditor-THBJH2UR.js.map +1 -0
- package/dist/{BlockNoteViewer-JVKWJZ6J.mjs → BlockNoteViewer-U6E3DS4F.mjs} +3 -3
- package/dist/{BlockNoteViewer-EL4JFB6P.js → BlockNoteViewer-VZOVKTFO.js} +4 -4
- package/dist/{BlockNoteViewer-EL4JFB6P.js.map → BlockNoteViewer-VZOVKTFO.js.map} +1 -1
- package/dist/BlogCategoryFields-DO48vOTE.d.mts +14 -0
- package/dist/BlogCategoryFields-DO48vOTE.d.ts +14 -0
- package/dist/BlogPostInterface-BB_4wcDR.d.mts +124 -0
- package/dist/BlogPostInterface-bWAT1rfR.d.ts +124 -0
- package/dist/billing/index.js +442 -442
- package/dist/billing/index.mjs +4 -4
- package/dist/blog-public.types-BbrzsC1c.d.mts +30 -0
- package/dist/blog-public.types-BbrzsC1c.d.ts +30 -0
- package/dist/chunk-3CGROPF3.mjs +107 -0
- package/dist/chunk-3CGROPF3.mjs.map +1 -0
- package/dist/{chunk-QKZUE5OF.mjs → chunk-3YW35YHQ.mjs} +1 -101
- package/dist/chunk-3YW35YHQ.mjs.map +1 -0
- package/dist/{chunk-6WU74VFU.mjs → chunk-6SQYXUVP.mjs} +2 -2
- package/dist/{chunk-CRI4T47Y.js → chunk-G6UE6MEP.js} +814 -59
- package/dist/chunk-G6UE6MEP.js.map +1 -0
- package/dist/{chunk-XGTQWBDJ.js → chunk-KSYQEKJX.js} +11 -111
- package/dist/chunk-KSYQEKJX.js.map +1 -0
- package/dist/{chunk-EO75CV76.js → chunk-S4YJ5WCW.js} +8 -8
- package/dist/{chunk-EO75CV76.js.map → chunk-S4YJ5WCW.js.map} +1 -1
- package/dist/{chunk-3BAM23VA.js → chunk-TY5FYP3G.js} +930 -883
- package/dist/chunk-TY5FYP3G.js.map +1 -0
- package/dist/{chunk-JQWI62NV.mjs → chunk-VC5A3AUZ.mjs} +776 -21
- package/dist/chunk-VC5A3AUZ.mjs.map +1 -0
- package/dist/{chunk-FNTQQFLH.mjs → chunk-W7F5BDSV.mjs} +2 -2
- package/dist/{chunk-QQQE3N2X.js → chunk-WPYT4UKJ.js} +3 -3
- package/dist/{chunk-QQQE3N2X.js.map → chunk-WPYT4UKJ.js.map} +1 -1
- package/dist/{chunk-MIZTWBPX.mjs → chunk-X5WIUUNG.mjs} +1485 -1438
- package/dist/chunk-X5WIUUNG.mjs.map +1 -0
- package/dist/chunk-YDYVJM4I.js +107 -0
- package/dist/chunk-YDYVJM4I.js.map +1 -0
- package/dist/client/index.js +5 -5
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +4 -4
- package/dist/components/editors/server.d.mts +15 -0
- package/dist/components/editors/server.d.ts +15 -0
- package/dist/components/editors/server.js +38 -0
- package/dist/components/editors/server.js.map +1 -0
- package/dist/components/editors/server.mjs +38 -0
- package/dist/components/editors/server.mjs.map +1 -0
- package/dist/components/index.d.mts +46 -12
- package/dist/components/index.d.ts +46 -12
- package/dist/components/index.js +7 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +8 -6
- package/dist/contexts/index.d.mts +9 -1
- package/dist/contexts/index.d.ts +9 -1
- package/dist/contexts/index.js +5 -5
- package/dist/contexts/index.mjs +4 -4
- package/dist/core/index.d.mts +214 -3
- package/dist/core/index.d.ts +214 -3
- package/dist/core/index.js +38 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +37 -3
- package/dist/email-automations/index.d.mts +3 -0
- package/dist/email-automations/index.d.ts +3 -0
- package/dist/email-automations/index.js +99 -95
- package/dist/email-automations/index.js.map +1 -1
- package/dist/email-automations/index.mjs +30 -26
- package/dist/email-automations/index.mjs.map +1 -1
- package/dist/features/analytics/index.d.mts +11 -4
- package/dist/features/analytics/index.d.ts +11 -4
- package/dist/features/analytics/index.js +127 -135
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +16 -24
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.d.mts +5 -2
- package/dist/features/analytics/server.d.ts +5 -2
- package/dist/features/analytics/server.js +9 -8
- package/dist/features/analytics/server.js.map +1 -1
- package/dist/features/analytics/server.mjs +7 -6
- package/dist/features/analytics/server.mjs.map +1 -1
- package/dist/features/blog/index.css +30 -0
- package/dist/features/blog/index.css.map +1 -0
- package/dist/features/blog/index.d.mts +276 -0
- package/dist/features/blog/index.d.ts +276 -0
- package/dist/features/blog/index.js +1810 -0
- package/dist/features/blog/index.js.map +1 -0
- package/dist/features/blog/index.mjs +1810 -0
- package/dist/features/blog/index.mjs.map +1 -0
- package/dist/features/blog/server.d.mts +36 -0
- package/dist/features/blog/server.d.ts +36 -0
- package/dist/features/blog/server.js +167 -0
- package/dist/features/blog/server.js.map +1 -0
- package/dist/features/blog/server.mjs +167 -0
- package/dist/features/blog/server.mjs.map +1 -0
- package/dist/features/help/index.js +38 -38
- package/dist/features/help/index.mjs +4 -4
- package/dist/features/tokenusage/index.d.mts +1 -3
- package/dist/features/tokenusage/index.d.ts +1 -3
- package/dist/features/tokenusage/index.js +127 -135
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +10 -18
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.d.mts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +39 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +38 -4
- package/dist/{s3.service-CY-jgOAM.d.ts → s3.service-Bz_K-tjz.d.ts} +11 -0
- package/dist/{s3.service-Sri0QP7Z.d.mts → s3.service-D5zNCEW3.d.mts} +11 -0
- package/dist/server/index.d.mts +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +9 -7
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +13 -11
- package/messages/en.json +93 -0
- package/messages/it.json +93 -0
- package/package.json +19 -1
- package/src/components/editors/BlockNoteEditor.tsx +13 -3
- package/src/components/editors/server/BlockNoteServerContent.tsx +28 -0
- package/src/components/editors/server/__tests__/renderBlockNoteHTML.spec.ts +44 -0
- package/src/components/editors/server/renderBlockNoteHTML.ts +26 -0
- package/src/components/editors/server-entry.ts +7 -0
- package/src/components/forms/FormBlockNote.tsx +4 -0
- package/src/components/index.ts +1 -0
- package/src/components/navigations/ContentTitle.tsx +14 -3
- package/src/components/viewers/BlockNoteStaticViewer.tsx +44 -0
- package/src/core/index.ts +4 -0
- package/src/core/registry/ModuleRegistry.ts +6 -0
- package/src/features/administration/components/AdminIndexContainer.tsx +61 -33
- package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +61 -1
- package/src/features/administration/components/index.ts +2 -1
- package/src/features/administration/contexts/AdministrationContext.tsx +11 -3
- package/src/features/administration/contexts/__tests__/AdministrationContext.spec.tsx +40 -0
- package/src/features/ai-connection/ai-connection.module.ts +2 -0
- package/src/features/ai-connection/contexts/AiConnectionsContext.tsx +3 -16
- package/src/features/ai-connection/contexts/__tests__/AiConnectionsContext.spec.tsx +33 -0
- package/src/features/analytics/__tests__/server-entry.spec.tsx +2 -1
- package/src/features/analytics/analytics.modules.ts +22 -5
- package/src/features/analytics/components/AnalyticsAdminContainer.tsx +4 -3
- package/src/features/analytics/components/AnalyticsAdminPageContainer.tsx +6 -3
- package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +4 -3
- package/src/features/analytics/components/__tests__/AnalyticsJourneyContainer.spec.tsx +11 -0
- package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +12 -1
- package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +6 -3
- package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +8 -17
- package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +17 -0
- package/src/features/billing/stripe-price/stripe-price.module.ts +2 -0
- package/src/features/billing/stripe-product/stripe-product.module.ts +2 -0
- package/src/features/blog/BlogModules.ts +49 -0
- package/src/features/blog/components/admin/BlogAdminContainer.tsx +39 -0
- package/src/features/blog/components/admin/BlogCategoriesList.tsx +56 -0
- package/src/features/blog/components/admin/BlogCategoryEditor.tsx +230 -0
- package/src/features/blog/components/admin/BlogCoverImageField.tsx +123 -0
- package/src/features/blog/components/admin/BlogLayoutSetting.tsx +86 -0
- package/src/features/blog/components/admin/BlogPostComparison.tsx +54 -0
- package/src/features/blog/components/admin/BlogPostContainer.tsx +101 -0
- package/src/features/blog/components/admin/BlogPostDeleter.tsx +35 -0
- package/src/features/blog/components/admin/BlogPostEditor.tsx +216 -0
- package/src/features/blog/components/admin/BlogPostPublishButton.tsx +50 -0
- package/src/features/blog/components/admin/BlogPostTranslationEditor.tsx +118 -0
- package/src/features/blog/components/admin/BlogPostTranslationPanel.tsx +136 -0
- package/src/features/blog/components/admin/BlogPostsList.tsx +58 -0
- package/src/features/blog/components/admin/BlogTranslationStatus.tsx +104 -0
- package/src/features/blog/components/admin/__tests__/BlogPostTranslationPanel.spec.tsx +100 -0
- package/src/features/blog/components/admin/__tests__/BlogPostsList.spec.tsx +30 -0
- package/src/features/blog/components/admin/index.ts +14 -0
- package/src/features/blog/components/public/BlogCategoryChips.tsx +59 -0
- package/src/features/blog/components/public/BlogList.tsx +21 -0
- package/src/features/blog/components/public/BlogListColumn.tsx +53 -0
- package/src/features/blog/components/public/BlogListFeatured.tsx +57 -0
- package/src/features/blog/components/public/BlogListParts.tsx +74 -0
- package/src/features/blog/components/public/BlogListRail.tsx +62 -0
- package/src/features/blog/components/public/BlogPostCard.tsx +125 -0
- package/src/features/blog/components/public/BlogPostMeta.tsx +49 -0
- package/src/features/blog/components/public/BlogPostView.tsx +80 -0
- package/src/features/blog/components/public/BlogSearchBox.tsx +52 -0
- package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +175 -0
- package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +134 -0
- package/src/features/blog/components/public/blogPublicHelpers.ts +30 -0
- package/src/features/blog/components/public/index.ts +3 -0
- package/src/features/blog/contexts/BlogPostContext.tsx +66 -0
- package/src/features/blog/contexts/index.ts +1 -0
- package/src/features/blog/data/BlogCategory.ts +93 -0
- package/src/features/blog/data/BlogCategoryFields.ts +4 -0
- package/src/features/blog/data/BlogCategoryInterface.ts +24 -0
- package/src/features/blog/data/BlogCategoryService.ts +54 -0
- package/src/features/blog/data/BlogCategoryTranslation.ts +90 -0
- package/src/features/blog/data/BlogCategoryTranslationInterface.ts +22 -0
- package/src/features/blog/data/BlogCategoryTranslationService.ts +30 -0
- package/src/features/blog/data/BlogPost.spec.ts +123 -0
- package/src/features/blog/data/BlogPost.ts +159 -0
- package/src/features/blog/data/BlogPostFields.ts +7 -0
- package/src/features/blog/data/BlogPostInterface.ts +39 -0
- package/src/features/blog/data/BlogPostService.ts +82 -0
- package/src/features/blog/data/BlogPostTranslation.spec.ts +73 -0
- package/src/features/blog/data/BlogPostTranslation.ts +110 -0
- package/src/features/blog/data/BlogPostTranslationInterface.ts +27 -0
- package/src/features/blog/data/BlogPostTranslationService.ts +30 -0
- package/src/features/blog/data/BlogSettings.ts +37 -0
- package/src/features/blog/data/BlogSettingsInterface.ts +11 -0
- package/src/features/blog/data/BlogSettingsService.ts +24 -0
- package/src/features/blog/data/index.ts +17 -0
- package/src/features/blog/hooks/index.ts +3 -0
- package/src/features/blog/hooks/useBlogCategoryNames.ts +34 -0
- package/src/features/blog/hooks/useBlogCategoryTableStructure.tsx +84 -0
- package/src/features/blog/hooks/useBlogPostTableStructure.tsx +126 -0
- package/src/features/blog/index.ts +15 -0
- package/src/features/blog/server/fetchers.ts +169 -0
- package/src/features/blog/server/toPublic.spec.ts +91 -0
- package/src/features/blog/server/toPublic.ts +36 -0
- package/src/features/blog/server-entry.ts +17 -0
- package/src/features/blog/types/blog-public.types.ts +34 -0
- package/src/features/blog/types/blog.constants.ts +40 -0
- package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +12 -3
- package/src/features/rbac/contexts/RbacContext.tsx +4 -7
- package/src/features/rbac/contexts/__tests__/RbacContext.spec.tsx +42 -0
- package/src/features/s3/data/s3.service.ts +17 -0
- package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +4 -3
- package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +3 -18
- package/src/features/tokenusage/contexts/__tests__/TokenUsageAdminContext.spec.tsx +17 -0
- package/src/features/tokenusage/tokenusage-admin.module.ts +4 -3
- package/src/features/tokenusage/tokenusage.modules.ts +12 -2
- package/src/features/user/contexts/UserContext.tsx +18 -3
- package/src/features/user/contexts/__tests__/UserContext.spec.tsx +95 -0
- package/dist/BlockNoteEditor-UJ6363LC.js.map +0 -1
- package/dist/BlockNoteEditor-WTSQANKI.mjs.map +0 -1
- package/dist/chunk-3BAM23VA.js.map +0 -1
- package/dist/chunk-CRI4T47Y.js.map +0 -1
- package/dist/chunk-JQWI62NV.mjs.map +0 -1
- package/dist/chunk-MIZTWBPX.mjs.map +0 -1
- package/dist/chunk-QKZUE5OF.mjs.map +0 -1
- package/dist/chunk-XGTQWBDJ.js.map +0 -1
- /package/dist/{BlockNoteViewer-JVKWJZ6J.mjs.map → BlockNoteViewer-U6E3DS4F.mjs.map} +0 -0
- /package/dist/{chunk-6WU74VFU.mjs.map → chunk-6SQYXUVP.mjs.map} +0 -0
- /package/dist/{chunk-FNTQQFLH.mjs.map → chunk-W7F5BDSV.mjs.map} +0 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ColumnDef } from "@tanstack/react-table";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { Modules } from "../../../core";
|
|
7
|
+
import { registerTableGenerator, TableContent, usePageUrlGenerator, UseTableStructureHook } from "../../../hooks";
|
|
8
|
+
import { useI18nLocale } from "../../../i18n";
|
|
9
|
+
import { Badge, Link } from "../../../shadcnui";
|
|
10
|
+
import { BLOG_STATUS_BADGE } from "../components/admin/BlogTranslationStatus";
|
|
11
|
+
import { useBlogCategoryNames } from "./useBlogCategoryNames";
|
|
12
|
+
import { BlogPostFields, BlogPostInterface } from "../data";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Admin posts table: title (original language, links to the post page),
|
|
16
|
+
* status, categories, published date and "languages ready" (completed
|
|
17
|
+
* translations / all translations). Mirrors hooks/useEmailAutomationTableStructure.tsx
|
|
18
|
+
* in features/email-automation.
|
|
19
|
+
*/
|
|
20
|
+
export const useBlogPostTableStructure: UseTableStructureHook<BlogPostInterface, BlogPostFields> = (params) => {
|
|
21
|
+
const t = useTranslations();
|
|
22
|
+
const locale = useI18nLocale();
|
|
23
|
+
const generateUrl = usePageUrlGenerator();
|
|
24
|
+
const categoryName = useBlogCategoryNames();
|
|
25
|
+
|
|
26
|
+
const tableData = useMemo(() => {
|
|
27
|
+
return params.data.map((post: BlogPostInterface) => {
|
|
28
|
+
const entry: TableContent<BlogPostInterface> = { jsonApiData: post };
|
|
29
|
+
params.fields.forEach((field) => {
|
|
30
|
+
entry[field] = (post as any)[field as keyof BlogPostInterface];
|
|
31
|
+
});
|
|
32
|
+
return entry;
|
|
33
|
+
});
|
|
34
|
+
}, [params.data, params.fields]);
|
|
35
|
+
|
|
36
|
+
const fieldColumnMap: Partial<Record<BlogPostFields, () => any>> = {
|
|
37
|
+
[BlogPostFields.title]: () => ({
|
|
38
|
+
id: BlogPostFields.title,
|
|
39
|
+
accessorKey: BlogPostFields.title,
|
|
40
|
+
header: t("blog.fields.title"),
|
|
41
|
+
cell: ({ row }: { row: TableContent<BlogPostInterface> }) => {
|
|
42
|
+
const post: BlogPostInterface = row.original.jsonApiData;
|
|
43
|
+
return (
|
|
44
|
+
<Link href={generateUrl({ page: Modules.BlogPost, id: post.id })}>
|
|
45
|
+
{post.original?.title ?? post.title ?? post.id}
|
|
46
|
+
</Link>
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
enableSorting: false,
|
|
50
|
+
enableHiding: false,
|
|
51
|
+
}),
|
|
52
|
+
[BlogPostFields.status]: () => ({
|
|
53
|
+
id: BlogPostFields.status,
|
|
54
|
+
accessorKey: BlogPostFields.status,
|
|
55
|
+
header: t("blog.fields.status"),
|
|
56
|
+
cell: ({ row }: { row: TableContent<BlogPostInterface> }) => {
|
|
57
|
+
const post: BlogPostInterface = row.original.jsonApiData;
|
|
58
|
+
const label = {
|
|
59
|
+
draft: t("blog.status.draft"),
|
|
60
|
+
published: t("blog.status.published"),
|
|
61
|
+
unpublished: t("blog.status.unpublished"),
|
|
62
|
+
}[post.status];
|
|
63
|
+
return <Badge variant={BLOG_STATUS_BADGE[post.status] ?? "softGray"}>{label ?? post.status}</Badge>;
|
|
64
|
+
},
|
|
65
|
+
enableSorting: false,
|
|
66
|
+
enableHiding: false,
|
|
67
|
+
}),
|
|
68
|
+
[BlogPostFields.categories]: () => ({
|
|
69
|
+
id: BlogPostFields.categories,
|
|
70
|
+
accessorKey: BlogPostFields.categories,
|
|
71
|
+
header: t("blog.fields.categories"),
|
|
72
|
+
cell: ({ row }: { row: TableContent<BlogPostInterface> }) => {
|
|
73
|
+
const post: BlogPostInterface = row.original.jsonApiData;
|
|
74
|
+
const names = post.categories.map((category) => categoryName(category.id)).filter(Boolean);
|
|
75
|
+
if (names.length === 0) return <span className="text-muted-foreground text-xs">—</span>;
|
|
76
|
+
return <span className="text-xs">{names.join(", ")}</span>;
|
|
77
|
+
},
|
|
78
|
+
enableSorting: false,
|
|
79
|
+
enableHiding: false,
|
|
80
|
+
}),
|
|
81
|
+
[BlogPostFields.publishedAt]: () => ({
|
|
82
|
+
id: BlogPostFields.publishedAt,
|
|
83
|
+
accessorKey: BlogPostFields.publishedAt,
|
|
84
|
+
header: t("blog.fields.publishedAt"),
|
|
85
|
+
cell: ({ row }: { row: TableContent<BlogPostInterface> }) => {
|
|
86
|
+
const post: BlogPostInterface = row.original.jsonApiData;
|
|
87
|
+
if (!post.publishedAt) return <span className="text-muted-foreground text-xs">—</span>;
|
|
88
|
+
return (
|
|
89
|
+
<span className="text-xs tabular-nums">
|
|
90
|
+
{post.publishedAt.toLocaleDateString(locale, { dateStyle: "medium" })}
|
|
91
|
+
</span>
|
|
92
|
+
);
|
|
93
|
+
},
|
|
94
|
+
enableSorting: false,
|
|
95
|
+
enableHiding: false,
|
|
96
|
+
}),
|
|
97
|
+
[BlogPostFields.languages]: () => ({
|
|
98
|
+
id: BlogPostFields.languages,
|
|
99
|
+
accessorKey: BlogPostFields.languages,
|
|
100
|
+
header: t("blog.fields.languages"),
|
|
101
|
+
cell: ({ row }: { row: TableContent<BlogPostInterface> }) => {
|
|
102
|
+
const post: BlogPostInterface = row.original.jsonApiData;
|
|
103
|
+
const total = post.translations.length;
|
|
104
|
+
if (total === 0) return <span className="text-muted-foreground text-xs">—</span>;
|
|
105
|
+
const ready = post.translations.filter((translation) => translation.aiStatus === "completed").length;
|
|
106
|
+
return (
|
|
107
|
+
<span className="block text-xs tabular-nums text-end">
|
|
108
|
+
{ready}/{total}
|
|
109
|
+
</span>
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
enableSorting: false,
|
|
113
|
+
enableHiding: false,
|
|
114
|
+
}),
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const columns = useMemo(() => {
|
|
118
|
+
return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
|
|
119
|
+
TableContent<BlogPostInterface>
|
|
120
|
+
>[];
|
|
121
|
+
}, [params.fields, fieldColumnMap, t, generateUrl, locale, categoryName]);
|
|
122
|
+
|
|
123
|
+
return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
registerTableGenerator("blog-posts", useBlogPostTableStructure);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Client-only public barrel for the blog feature.
|
|
2
|
+
// Consumed via `@carlonicora/nextjs-jsonapi/blog`. Tsup adds a top-level
|
|
3
|
+
// "use client" directive to the bundled output via clientEntries.
|
|
4
|
+
//
|
|
5
|
+
// Models, services and modules are server-safe and ship from
|
|
6
|
+
// `@carlonicora/nextjs-jsonapi/core`. Server fetchers ship from
|
|
7
|
+
// `@carlonicora/nextjs-jsonapi/blog/server`.
|
|
8
|
+
|
|
9
|
+
export * from "./components/admin";
|
|
10
|
+
export * from "./components/public";
|
|
11
|
+
export * from "./contexts";
|
|
12
|
+
// Imported for its registerTableGenerator side effect as well as its exports.
|
|
13
|
+
export * from "./hooks";
|
|
14
|
+
export * from "./types/blog.constants";
|
|
15
|
+
export type { PublicBlogCategory, PublicBlogList, PublicBlogPost } from "./types/blog-public.types";
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { EndpointCreator, Modules } from "../../../core";
|
|
2
|
+
import { ServerJsonApiGet } from "../../../server/JsonApiServer";
|
|
3
|
+
import { BlogCategoryInterface } from "../data/BlogCategoryInterface";
|
|
4
|
+
import { BlogPostInterface } from "../data/BlogPostInterface";
|
|
5
|
+
import { BlogSettingsInterface } from "../data/BlogSettingsInterface";
|
|
6
|
+
import { BLOG_ACTION, BLOG_LIST_LAYOUTS, BLOG_PUBLIC_PREFIX, BlogListLayout } from "../types/blog.constants";
|
|
7
|
+
import { PublicBlogCategory, PublicBlogList, PublicBlogPost } from "../types/blog-public.types";
|
|
8
|
+
import { toPublicBlogCategory, toPublicBlogPost } from "./toPublic";
|
|
9
|
+
|
|
10
|
+
// Public, token-free reads for the marketing blog pages.
|
|
11
|
+
//
|
|
12
|
+
// `cache: "minutes"` is a cacheLife profile name: the GET goes through
|
|
13
|
+
// cachedServerGet ("use cache"), so an admin change shows up within about a
|
|
14
|
+
// minute. Nothing connects API or worker writes to the web cache, and
|
|
15
|
+
// translations finish asynchronously in the worker, so there is no tag to expire.
|
|
16
|
+
//
|
|
17
|
+
// Each classKey is built INSIDE the function, never at module scope: reading
|
|
18
|
+
// Modules.X while the module evaluates throws "Module X not registered" in build
|
|
19
|
+
// workers that never ran the app's bootstrapper (see the app's publicHowTo.ts).
|
|
20
|
+
|
|
21
|
+
const BLOG_PAGE_SIZE = 12;
|
|
22
|
+
|
|
23
|
+
const publicPostsEndpoint = () => `${BLOG_PUBLIC_PREFIX}/${Modules.BlogPost.name}`;
|
|
24
|
+
|
|
25
|
+
const asArray = <T>(data: unknown): T[] => (Array.isArray(data) ? data : data ? [data] : []) as T[];
|
|
26
|
+
|
|
27
|
+
export async function fetchPublicBlogPosts(params: {
|
|
28
|
+
language: string;
|
|
29
|
+
categorySlug?: string;
|
|
30
|
+
search?: string;
|
|
31
|
+
/** 1-based list page; ignored with `all`. */
|
|
32
|
+
page?: number;
|
|
33
|
+
/** Every matching post, read in API-sized pages (a sitemap). `hasMore` is then false. */
|
|
34
|
+
all?: boolean;
|
|
35
|
+
}): Promise<PublicBlogList> {
|
|
36
|
+
if (params.all) return { posts: await fetchEveryPublicBlogPost(params), hasMore: false };
|
|
37
|
+
|
|
38
|
+
// Matches the API cap (120 = 10 pages of 12); a larger ?page= is served as the last allowed one.
|
|
39
|
+
const page = Math.min(10, Math.max(1, Math.floor(params.page || 1)));
|
|
40
|
+
const size = BLOG_PAGE_SIZE * page;
|
|
41
|
+
|
|
42
|
+
const res = await ServerJsonApiGet({
|
|
43
|
+
classKey: { ...Modules.BlogPost, cache: "minutes" },
|
|
44
|
+
endpoint: publicPostsListEndpoint({ ...params, size }),
|
|
45
|
+
language: params.language,
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) return { posts: [], hasMore: false };
|
|
48
|
+
|
|
49
|
+
const posts = asArray<BlogPostInterface>(res.data).map(toPublicBlogPost);
|
|
50
|
+
const total = Number(res.meta?.total);
|
|
51
|
+
const hasMore = Number.isFinite(total) ? total > posts.length : false;
|
|
52
|
+
|
|
53
|
+
return { posts, hasMore };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function publicPostsListEndpoint(params: {
|
|
57
|
+
language: string;
|
|
58
|
+
categorySlug?: string;
|
|
59
|
+
search?: string;
|
|
60
|
+
size: number;
|
|
61
|
+
offset?: number;
|
|
62
|
+
}): string {
|
|
63
|
+
const endpoint = new EndpointCreator({ endpoint: publicPostsEndpoint() });
|
|
64
|
+
endpoint.addAdditionalParam("language", params.language);
|
|
65
|
+
if (params.categorySlug) endpoint.addAdditionalParam("category", encodeURIComponent(params.categorySlug));
|
|
66
|
+
if (params.search) endpoint.addAdditionalParam("search", encodeURIComponent(params.search));
|
|
67
|
+
endpoint.addAdditionalParam("page[size]", String(params.size));
|
|
68
|
+
if (params.offset) endpoint.addAdditionalParam("page[offset]", String(params.offset));
|
|
69
|
+
return endpoint.generate();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The API serves at most PUBLIC_MAX_PAGE_SIZE posts per request, so "all" walks
|
|
73
|
+
// page[offset] until a short page comes back. MAX_ALL_PAGES only stops a runaway loop.
|
|
74
|
+
const PUBLIC_MAX_PAGE_SIZE = 120;
|
|
75
|
+
const MAX_ALL_PAGES = 100;
|
|
76
|
+
|
|
77
|
+
async function fetchEveryPublicBlogPost(params: {
|
|
78
|
+
language: string;
|
|
79
|
+
categorySlug?: string;
|
|
80
|
+
search?: string;
|
|
81
|
+
}): Promise<PublicBlogPost[]> {
|
|
82
|
+
const posts: PublicBlogPost[] = [];
|
|
83
|
+
|
|
84
|
+
// In sequence: a failing cached call throws, and parallel failing cached calls
|
|
85
|
+
// never close the response.
|
|
86
|
+
for (let index = 0; index < MAX_ALL_PAGES; index++) {
|
|
87
|
+
const res = await ServerJsonApiGet({
|
|
88
|
+
classKey: { ...Modules.BlogPost, cache: "minutes" },
|
|
89
|
+
endpoint: publicPostsListEndpoint({
|
|
90
|
+
...params,
|
|
91
|
+
size: PUBLIC_MAX_PAGE_SIZE,
|
|
92
|
+
offset: index * PUBLIC_MAX_PAGE_SIZE,
|
|
93
|
+
}),
|
|
94
|
+
language: params.language,
|
|
95
|
+
});
|
|
96
|
+
if (!res.ok) break;
|
|
97
|
+
|
|
98
|
+
const batch = asArray<BlogPostInterface>(res.data);
|
|
99
|
+
posts.push(...batch.map(toPublicBlogPost));
|
|
100
|
+
if (batch.length < PUBLIC_MAX_PAGE_SIZE) break;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return posts;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function fetchPublicBlogPost(params: { language: string; slug: string }): Promise<PublicBlogPost | null> {
|
|
107
|
+
const endpoint = new EndpointCreator({ endpoint: publicPostsEndpoint(), id: encodeURIComponent(params.slug) });
|
|
108
|
+
endpoint.addAdditionalParam("language", params.language);
|
|
109
|
+
|
|
110
|
+
const res = await ServerJsonApiGet({
|
|
111
|
+
classKey: { ...Modules.BlogPost, cache: "minutes" },
|
|
112
|
+
endpoint: endpoint.generate(),
|
|
113
|
+
language: params.language,
|
|
114
|
+
});
|
|
115
|
+
if (!res.ok) return null;
|
|
116
|
+
|
|
117
|
+
const post = asArray<BlogPostInterface>(res.data)[0];
|
|
118
|
+
return post ? toPublicBlogPost(post) : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function fetchRelatedBlogPosts(params: { language: string; slug: string }): Promise<PublicBlogPost[]> {
|
|
122
|
+
const endpoint = new EndpointCreator({
|
|
123
|
+
endpoint: publicPostsEndpoint(),
|
|
124
|
+
id: encodeURIComponent(params.slug),
|
|
125
|
+
childEndpoint: BLOG_ACTION.related,
|
|
126
|
+
});
|
|
127
|
+
endpoint.addAdditionalParam("language", params.language);
|
|
128
|
+
|
|
129
|
+
const res = await ServerJsonApiGet({
|
|
130
|
+
classKey: { ...Modules.BlogPost, cache: "minutes" },
|
|
131
|
+
endpoint: endpoint.generate(),
|
|
132
|
+
language: params.language,
|
|
133
|
+
});
|
|
134
|
+
if (!res.ok) return [];
|
|
135
|
+
|
|
136
|
+
return asArray<BlogPostInterface>(res.data).map(toPublicBlogPost);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function fetchPublicBlogCategories(params: { language: string }): Promise<PublicBlogCategory[]> {
|
|
140
|
+
const endpoint = new EndpointCreator({ endpoint: `${BLOG_PUBLIC_PREFIX}/${Modules.BlogCategory.name}` });
|
|
141
|
+
endpoint.addAdditionalParam("language", params.language);
|
|
142
|
+
|
|
143
|
+
const res = await ServerJsonApiGet({
|
|
144
|
+
classKey: { ...Modules.BlogCategory, cache: "minutes" },
|
|
145
|
+
endpoint: endpoint.generate(),
|
|
146
|
+
language: params.language,
|
|
147
|
+
});
|
|
148
|
+
if (!res.ok) return [];
|
|
149
|
+
|
|
150
|
+
return asArray<BlogCategoryInterface>(res.data).map(toPublicBlogCategory);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function fetchBlogSettings(): Promise<{ listLayout: BlogListLayout }> {
|
|
154
|
+
const fallback: { listLayout: BlogListLayout } = { listLayout: "featured" };
|
|
155
|
+
|
|
156
|
+
// The settings are language-neutral; the language only fills the required
|
|
157
|
+
// request header, so one fixed value keeps a single cache entry.
|
|
158
|
+
const res = await ServerJsonApiGet({
|
|
159
|
+
classKey: { ...Modules.BlogSettings, cache: "minutes" },
|
|
160
|
+
endpoint: new EndpointCreator({ endpoint: `${BLOG_PUBLIC_PREFIX}/${Modules.BlogSettings.name}` }).generate(),
|
|
161
|
+
language: "en",
|
|
162
|
+
});
|
|
163
|
+
if (!res.ok) return fallback;
|
|
164
|
+
|
|
165
|
+
const settings = asArray<BlogSettingsInterface>(res.data)[0];
|
|
166
|
+
if (!settings || !BLOG_LIST_LAYOUTS.includes(settings.listLayout)) return fallback;
|
|
167
|
+
|
|
168
|
+
return { listLayout: settings.listLayout };
|
|
169
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { User } from "../../user/data/user";
|
|
3
|
+
import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
|
|
4
|
+
import { ModuleFactory, ModuleWithPermissions } from "../../../permissions/types";
|
|
5
|
+
import {
|
|
6
|
+
BlogCategoryModule,
|
|
7
|
+
BlogCategoryTranslationModule,
|
|
8
|
+
BlogPostModule,
|
|
9
|
+
BlogPostTranslationModule,
|
|
10
|
+
} from "../BlogModules";
|
|
11
|
+
import { BlogCategory } from "../data/BlogCategory";
|
|
12
|
+
import { BlogPost } from "../data/BlogPost";
|
|
13
|
+
import { toPublicBlogCategory, toPublicBlogPost } from "./toPublic";
|
|
14
|
+
|
|
15
|
+
const moduleFactory: ModuleFactory = (params) =>
|
|
16
|
+
({
|
|
17
|
+
pageUrl: params.pageUrl,
|
|
18
|
+
name: params.name,
|
|
19
|
+
model: params.model,
|
|
20
|
+
}) as ModuleWithPermissions;
|
|
21
|
+
|
|
22
|
+
beforeAll(() => {
|
|
23
|
+
ModuleRegistry.register("BlogPost", BlogPostModule(moduleFactory));
|
|
24
|
+
ModuleRegistry.register("BlogPostTranslation", BlogPostTranslationModule(moduleFactory));
|
|
25
|
+
ModuleRegistry.register("BlogCategory", BlogCategoryModule(moduleFactory));
|
|
26
|
+
ModuleRegistry.register("BlogCategoryTranslation", BlogCategoryTranslationModule(moduleFactory));
|
|
27
|
+
ModuleRegistry.register("User", { name: "users", model: User } as ModuleWithPermissions);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const blocks = [{ type: "paragraph", content: [{ type: "text", text: "Hello", styles: {} }] }];
|
|
31
|
+
|
|
32
|
+
const makePost = () =>
|
|
33
|
+
new BlogPost().rehydrate({
|
|
34
|
+
jsonApi: {
|
|
35
|
+
id: "p",
|
|
36
|
+
type: "blog-posts",
|
|
37
|
+
attributes: {
|
|
38
|
+
status: "published",
|
|
39
|
+
originalLanguage: "en",
|
|
40
|
+
publishedAt: "2026-10-12T09:00:00.000Z",
|
|
41
|
+
language: "en",
|
|
42
|
+
title: "Prep situations",
|
|
43
|
+
slug: "prep-situations",
|
|
44
|
+
summary: "What to prepare.",
|
|
45
|
+
body: JSON.stringify(blocks),
|
|
46
|
+
readingMinutes: 4,
|
|
47
|
+
authorName: "Ada",
|
|
48
|
+
categoryIds: ["c1"],
|
|
49
|
+
},
|
|
50
|
+
meta: { createdAt: "2026-10-10T09:00:00.000Z", updatedAt: "2026-10-14T09:00:00.000Z" },
|
|
51
|
+
},
|
|
52
|
+
included: [],
|
|
53
|
+
} as any);
|
|
54
|
+
|
|
55
|
+
describe("toPublicBlogPost", () => {
|
|
56
|
+
it("toPublicBlogPost maps a rehydrated post to plain serialisable data", () => {
|
|
57
|
+
expect(JSON.parse(JSON.stringify(toPublicBlogPost(makePost())))).toEqual(toPublicBlogPost(makePost()));
|
|
58
|
+
expect(toPublicBlogPost(makePost()).publishedAt).toBe("2026-10-12T09:00:00.000Z");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("carries the resolved fields", () => {
|
|
62
|
+
const result = toPublicBlogPost(makePost());
|
|
63
|
+
expect(result).toMatchObject({
|
|
64
|
+
id: "p",
|
|
65
|
+
language: "en",
|
|
66
|
+
title: "Prep situations",
|
|
67
|
+
slug: "prep-situations",
|
|
68
|
+
summary: "What to prepare.",
|
|
69
|
+
readingMinutes: 4,
|
|
70
|
+
updatedAt: "2026-10-14T09:00:00.000Z",
|
|
71
|
+
authorName: "Ada",
|
|
72
|
+
categoryIds: ["c1"],
|
|
73
|
+
});
|
|
74
|
+
expect(result.body).toEqual(blocks);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("toPublicBlogCategory", () => {
|
|
79
|
+
it("maps the resolved name and slug", () => {
|
|
80
|
+
const category = new BlogCategory().rehydrate({
|
|
81
|
+
jsonApi: {
|
|
82
|
+
id: "c1",
|
|
83
|
+
type: "blog-categories",
|
|
84
|
+
attributes: { originalLanguage: "en", name: "Game prep", slug: "game-prep" },
|
|
85
|
+
},
|
|
86
|
+
included: [],
|
|
87
|
+
} as any);
|
|
88
|
+
|
|
89
|
+
expect(toPublicBlogCategory(category)).toEqual({ id: "c1", name: "Game prep", slug: "game-prep" });
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { BlogCategoryInterface } from "../data/BlogCategoryInterface";
|
|
2
|
+
import { BlogPostInterface } from "../data/BlogPostInterface";
|
|
3
|
+
import { PublicBlogCategory, PublicBlogPost } from "../types/blog-public.types";
|
|
4
|
+
|
|
5
|
+
// Serialisation boundary: a rehydrated model (class instance, Date getters)
|
|
6
|
+
// becomes plain data a server page can pass to the public client components.
|
|
7
|
+
|
|
8
|
+
export function toPublicBlogPost(post: BlogPostInterface): PublicBlogPost {
|
|
9
|
+
const publishedAt = post.publishedAt ?? post.createdAt;
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
id: post.id,
|
|
13
|
+
language: post.language ?? post.originalLanguage,
|
|
14
|
+
title: post.title ?? "",
|
|
15
|
+
slug: post.slug ?? "",
|
|
16
|
+
summary: post.summary,
|
|
17
|
+
body: post.body,
|
|
18
|
+
coverImage: post.coverImage,
|
|
19
|
+
coverImageAlt: post.coverImageAlt,
|
|
20
|
+
readingMinutes: post.readingMinutes ?? 1,
|
|
21
|
+
publishedAt: publishedAt.toISOString(),
|
|
22
|
+
updatedAt: post.updatedAt.toISOString(),
|
|
23
|
+
authorName: post.authorName,
|
|
24
|
+
categoryIds: [...post.categoryIds],
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function toPublicBlogCategory(category: BlogCategoryInterface): PublicBlogCategory {
|
|
29
|
+
return {
|
|
30
|
+
id: category.id,
|
|
31
|
+
name: category.name ?? "",
|
|
32
|
+
slug: category.slug ?? "",
|
|
33
|
+
description: category.description,
|
|
34
|
+
image: category.image,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Server-safe public barrel for the blog feature.
|
|
2
|
+
// Consumed via `@carlonicora/nextjs-jsonapi/blog/server`. Must NOT receive a
|
|
3
|
+
// "use client" directive: server pages import the cached public fetchers and
|
|
4
|
+
// the plain view-model types from here without pulling the client bundle.
|
|
5
|
+
// Client components live in `@carlonicora/nextjs-jsonapi/blog`.
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
fetchBlogSettings,
|
|
9
|
+
fetchPublicBlogCategories,
|
|
10
|
+
fetchPublicBlogPost,
|
|
11
|
+
fetchPublicBlogPosts,
|
|
12
|
+
fetchRelatedBlogPosts,
|
|
13
|
+
} from "./server/fetchers";
|
|
14
|
+
export { toPublicBlogCategory, toPublicBlogPost } from "./server/toPublic";
|
|
15
|
+
export { BLOG_LIST_LAYOUTS } from "./types/blog.constants";
|
|
16
|
+
export type { BlogListLayout } from "./types/blog.constants";
|
|
17
|
+
export type { PublicBlogCategory, PublicBlogList, PublicBlogPost } from "./types/blog-public.types";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Plain, serialisable view models handed from server pages to the public
|
|
2
|
+
// components. No class instances and no Date objects: they cross the
|
|
3
|
+
// server → client boundary as props.
|
|
4
|
+
|
|
5
|
+
import type { PartialBlock } from "@blocknote/core";
|
|
6
|
+
|
|
7
|
+
export type PublicBlogCategory = {
|
|
8
|
+
id: string;
|
|
9
|
+
name: string;
|
|
10
|
+
slug: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
image?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type PublicBlogPost = {
|
|
16
|
+
id: string;
|
|
17
|
+
language: string;
|
|
18
|
+
title: string;
|
|
19
|
+
slug: string;
|
|
20
|
+
summary?: string;
|
|
21
|
+
body?: PartialBlock[];
|
|
22
|
+
coverImage?: string;
|
|
23
|
+
coverImageAlt?: string;
|
|
24
|
+
readingMinutes: number;
|
|
25
|
+
publishedAt: string;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
authorName?: string;
|
|
28
|
+
categoryIds: string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type PublicBlogList = {
|
|
32
|
+
posts: PublicBlogPost[];
|
|
33
|
+
hasMore: boolean;
|
|
34
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Mirrors packages/nestjs-neo4jsonapi/src/foundations/blog/blog.constants.ts and
|
|
2
|
+
// CoreModuleId.Blog. The two packages share no code, so the values are repeated.
|
|
3
|
+
|
|
4
|
+
export const BLOG_LIST_LAYOUTS = ["featured", "column", "rail"] as const;
|
|
5
|
+
export type BlogListLayout = (typeof BLOG_LIST_LAYOUTS)[number];
|
|
6
|
+
|
|
7
|
+
export const BLOG_POST_STATUSES = ["draft", "published", "unpublished"] as const;
|
|
8
|
+
export type BlogPostStatus = (typeof BLOG_POST_STATUSES)[number];
|
|
9
|
+
|
|
10
|
+
export const BLOG_TRANSLATION_SOURCES = ["original", "ai", "manual"] as const;
|
|
11
|
+
export type BlogTranslationSource = (typeof BLOG_TRANSLATION_SOURCES)[number];
|
|
12
|
+
|
|
13
|
+
// Same values as the backend AiStatus enum.
|
|
14
|
+
export type BlogAiStatus = "pending" | "in_progress" | "completed" | "failed";
|
|
15
|
+
|
|
16
|
+
export const BLOG_SETTINGS_ID = "5f3c2a9e-8d41-4b7a-9c2e-1a6b0d4e7f13";
|
|
17
|
+
|
|
18
|
+
export const BLOG_MODULE_ID = "c1d0e6a2-4b8f-4f3e-9a7d-2e5b8c6f1a90";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Storage root for blog images (covers, category images, images pasted into a
|
|
22
|
+
* post body). Blog content belongs to the platform, not to a company, and the
|
|
23
|
+
* administrators who write it may belong to no company, so the keys are
|
|
24
|
+
* `blog/<folder>/<id>/<file>` instead of `companies/<companyId>/...`. An app's
|
|
25
|
+
* S3 key access policy must allow Administrators on this root.
|
|
26
|
+
*/
|
|
27
|
+
export const BLOG_UPLOAD_ROOT = "blog";
|
|
28
|
+
export const BLOG_POST_UPLOAD_PREFIX = `${BLOG_UPLOAD_ROOT}/blog-posts`;
|
|
29
|
+
export const BLOG_CATEGORY_UPLOAD_PREFIX = `${BLOG_UPLOAD_ROOT}/blog-categories`;
|
|
30
|
+
|
|
31
|
+
/** Path prefix of the token-free public blog endpoints (`public/blog-posts`, ...). */
|
|
32
|
+
export const BLOG_PUBLIC_PREFIX = "public";
|
|
33
|
+
|
|
34
|
+
/** Action segments under a blog resource (`blog-posts/:id/publish`, ...). */
|
|
35
|
+
export const BLOG_ACTION = {
|
|
36
|
+
publish: "publish",
|
|
37
|
+
unpublish: "unpublish",
|
|
38
|
+
retranslate: "retranslate",
|
|
39
|
+
related: "related",
|
|
40
|
+
} as const;
|
package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
3
4
|
import { RoundPageContainer } from "../../../../components";
|
|
5
|
+
import { SharedProvider } from "../../../../contexts/SharedContext";
|
|
4
6
|
import { Modules } from "../../../../core";
|
|
5
|
-
import {
|
|
7
|
+
import { BreadcrumbItemData } from "../../../../interfaces";
|
|
6
8
|
import { EmailAutomationsList } from "../lists/EmailAutomationsList";
|
|
7
9
|
|
|
8
10
|
/**
|
|
@@ -15,13 +17,20 @@ import { EmailAutomationsList } from "../lists/EmailAutomationsList";
|
|
|
15
17
|
*
|
|
16
18
|
* No `forceHeader`: ContentListTable draws its own header row with the title
|
|
17
19
|
* and the create action, exactly as the company and user list pages do.
|
|
20
|
+
*
|
|
21
|
+
* Publishes its own breadcrumb, as CompanyProvider does for the companies list:
|
|
22
|
+
* the list root comes first and has no href because it is the current page.
|
|
18
23
|
*/
|
|
19
24
|
export function EmailAutomationsListContainer() {
|
|
25
|
+
const t = useTranslations();
|
|
26
|
+
|
|
27
|
+
const breadcrumbs: BreadcrumbItemData[] = [{ name: t("emailAutomations.admin.title") }];
|
|
28
|
+
|
|
20
29
|
return (
|
|
21
|
-
<
|
|
30
|
+
<SharedProvider value={{ breadcrumbs, title: { type: t("emailAutomations.admin.title") } }}>
|
|
22
31
|
<RoundPageContainer module={Modules.EmailAutomation} fullWidth>
|
|
23
32
|
<EmailAutomationsList fullWidth />
|
|
24
33
|
</RoundPageContainer>
|
|
25
|
-
</
|
|
34
|
+
</SharedProvider>
|
|
26
35
|
);
|
|
27
36
|
}
|
|
@@ -4,8 +4,6 @@ import { DownloadIcon, Loader2Icon } from "lucide-react";
|
|
|
4
4
|
import { useTranslations } from "next-intl";
|
|
5
5
|
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
6
6
|
import { SharedProvider } from "../../../contexts";
|
|
7
|
-
import { Modules } from "../../../core";
|
|
8
|
-
import { usePageUrlGenerator } from "../../../hooks";
|
|
9
7
|
import { BreadcrumbItemData } from "../../../interfaces";
|
|
10
8
|
import { Button } from "../../../shadcnui";
|
|
11
9
|
import { showError, showToast } from "../../../utils/toast";
|
|
@@ -70,7 +68,6 @@ export const RbacProvider = ({
|
|
|
70
68
|
moduleNames,
|
|
71
69
|
outputPath = DEFAULT_OUTPUT_PATH,
|
|
72
70
|
}: RbacProviderProps) => {
|
|
73
|
-
const generateUrl = usePageUrlGenerator();
|
|
74
71
|
const t = useTranslations();
|
|
75
72
|
|
|
76
73
|
const [matrix, setMatrix] = useState<RbacMatrix | null>(null);
|
|
@@ -149,10 +146,10 @@ export const RbacProvider = ({
|
|
|
149
146
|
|
|
150
147
|
const breadcrumb = (): BreadcrumbItemData[] => {
|
|
151
148
|
const response: BreadcrumbItemData[] = [];
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
});
|
|
149
|
+
// Unlinked: the provider only serves the RBAC page, so the root is always
|
|
150
|
+
// the current page. The dev matrix module has no pageUrl either, so a link
|
|
151
|
+
// built from it pointed at "undefined".
|
|
152
|
+
response.push({ name: t(`entities.rbac`, { count: 2 }) });
|
|
156
153
|
return response;
|
|
157
154
|
};
|
|
158
155
|
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { useSharedContext } from "../../../../contexts/SharedContext";
|
|
4
|
+
import { RbacService } from "../../data/RbacService";
|
|
5
|
+
import { RbacProvider } from "../RbacContext";
|
|
6
|
+
|
|
7
|
+
vi.mock("../../data/RbacService", () => ({
|
|
8
|
+
RbacService: {
|
|
9
|
+
fetchMatrix: vi.fn(async () => ({ matrix: {}, modulePaths: {} })),
|
|
10
|
+
saveMatrix: vi.fn(async () => undefined),
|
|
11
|
+
},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// The provider refetches whenever `t` changes identity, and the global next-intl
|
|
15
|
+
// mock in vitest.setup.ts builds a new `t` per call. A stable one keeps the fetch
|
|
16
|
+
// to a single run. Labels still render as their own key.
|
|
17
|
+
const { stableT } = vi.hoisted(() => ({ stableT: (key: string) => key }));
|
|
18
|
+
vi.mock("next-intl", () => ({
|
|
19
|
+
useTranslations: () => stableT,
|
|
20
|
+
useLocale: () => "en",
|
|
21
|
+
useNow: () => new Date(),
|
|
22
|
+
useTimeZone: () => "UTC",
|
|
23
|
+
useFormatter: () => ({ dateTime: vi.fn(), number: vi.fn(), relativeTime: vi.fn() }),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
function BreadcrumbProbe() {
|
|
27
|
+
const { breadcrumbs } = useSharedContext();
|
|
28
|
+
return <span data-testid="breadcrumbs">{JSON.stringify(breadcrumbs)}</span>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("RbacContext", () => {
|
|
32
|
+
it("publishes the page as an unlinked breadcrumb, since it is the current page", async () => {
|
|
33
|
+
render(
|
|
34
|
+
<RbacProvider>
|
|
35
|
+
<BreadcrumbProbe />
|
|
36
|
+
</RbacProvider>,
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
await waitFor(() => expect(RbacService.fetchMatrix).toHaveBeenCalled());
|
|
40
|
+
expect(screen.getByTestId("breadcrumbs")).toHaveTextContent(JSON.stringify([{ name: "entities.rbac" }]));
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -12,6 +12,23 @@ export class S3Service extends AbstractService {
|
|
|
12
12
|
return this.callApi<S3Interface>({ type: Modules.S3, method: HttpMethod.GET, endpoint: endpoint.generate() });
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Presigns `key` and PUTs `file` to the presigned S3 URL. The PUT goes straight
|
|
17
|
+
* to object storage (not the JSON:API backend), so it cannot use callApi.
|
|
18
|
+
* Throws when the upload is rejected.
|
|
19
|
+
*/
|
|
20
|
+
static async upload(params: { key: string; contentType: string; file: Blob; isPublic?: boolean }): Promise<void> {
|
|
21
|
+
const s3 = await this.getPreSignedUrl({
|
|
22
|
+
key: params.key,
|
|
23
|
+
contentType: params.contentType,
|
|
24
|
+
isPublic: params.isPublic,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const response = await fetch(s3.url, { method: "PUT", headers: s3.headers, body: params.file });
|
|
28
|
+
|
|
29
|
+
if (!response.ok) throw new Error(`S3 upload failed: ${response.status}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
15
32
|
static async getSignedUrl(params: { key: string; isPublic?: boolean }): Promise<S3Interface> {
|
|
16
33
|
const endpoint = new EndpointCreator({ endpoint: Modules.S3, id: "sign" }).addAdditionalParam("key", params.key);
|
|
17
34
|
|