@carlonicora/nextjs-jsonapi 5.3.0 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/dist/{BlockNoteEditor-UJ6363LC.js → BlockNoteEditor-4YCAIEPT.js} +23 -22
  2. package/dist/BlockNoteEditor-4YCAIEPT.js.map +1 -0
  3. package/dist/{BlockNoteEditor-WTSQANKI.mjs → BlockNoteEditor-YQL2SGFR.mjs} +8 -7
  4. package/dist/BlockNoteEditor-YQL2SGFR.mjs.map +1 -0
  5. package/dist/{BlockNoteViewer-JVKWJZ6J.mjs → BlockNoteViewer-AEJKJ27Q.mjs} +2 -2
  6. package/dist/{BlockNoteViewer-EL4JFB6P.js → BlockNoteViewer-YL3J7J2Y.js} +3 -3
  7. package/dist/{BlockNoteViewer-EL4JFB6P.js.map → BlockNoteViewer-YL3J7J2Y.js.map} +1 -1
  8. package/dist/BlogCategoryFields-DO48vOTE.d.mts +14 -0
  9. package/dist/BlogCategoryFields-DO48vOTE.d.ts +14 -0
  10. package/dist/BlogPostInterface-BB_4wcDR.d.mts +124 -0
  11. package/dist/BlogPostInterface-bWAT1rfR.d.ts +124 -0
  12. package/dist/billing/index.js +441 -441
  13. package/dist/billing/index.mjs +3 -3
  14. package/dist/blog-public.types-BbrzsC1c.d.mts +30 -0
  15. package/dist/blog-public.types-BbrzsC1c.d.ts +30 -0
  16. package/dist/{chunk-3BAM23VA.js → chunk-3NLAXVIN.js} +860 -849
  17. package/dist/chunk-3NLAXVIN.js.map +1 -0
  18. package/dist/{chunk-QKZUE5OF.mjs → chunk-3YW35YHQ.mjs} +1 -101
  19. package/dist/chunk-3YW35YHQ.mjs.map +1 -0
  20. package/dist/{chunk-QQQE3N2X.js → chunk-5PPG6PL7.js} +3 -3
  21. package/dist/{chunk-QQQE3N2X.js.map → chunk-5PPG6PL7.js.map} +1 -1
  22. package/dist/{chunk-FNTQQFLH.mjs → chunk-6DC2WVVB.mjs} +2 -2
  23. package/dist/{chunk-MIZTWBPX.mjs → chunk-EUKTBOI2.mjs} +19 -8
  24. package/dist/chunk-EUKTBOI2.mjs.map +1 -0
  25. package/dist/{chunk-XGTQWBDJ.js → chunk-KSYQEKJX.js} +11 -111
  26. package/dist/chunk-KSYQEKJX.js.map +1 -0
  27. package/dist/{chunk-CRI4T47Y.js → chunk-NFBOSPNJ.js} +756 -41
  28. package/dist/chunk-NFBOSPNJ.js.map +1 -0
  29. package/dist/{chunk-6WU74VFU.mjs → chunk-QMYQRPQC.mjs} +2 -2
  30. package/dist/{chunk-JQWI62NV.mjs → chunk-WC63PJSC.mjs} +716 -1
  31. package/dist/chunk-WC63PJSC.mjs.map +1 -0
  32. package/dist/chunk-XCHSX3VC.mjs +107 -0
  33. package/dist/chunk-XCHSX3VC.mjs.map +1 -0
  34. package/dist/{chunk-EO75CV76.js → chunk-YNCQSWQW.js} +8 -8
  35. package/dist/{chunk-EO75CV76.js.map → chunk-YNCQSWQW.js.map} +1 -1
  36. package/dist/chunk-YW4ZCEWB.js +107 -0
  37. package/dist/chunk-YW4ZCEWB.js.map +1 -0
  38. package/dist/client/index.js +4 -4
  39. package/dist/client/index.mjs +3 -3
  40. package/dist/components/index.d.mts +13 -2
  41. package/dist/components/index.d.ts +13 -2
  42. package/dist/components/index.js +4 -4
  43. package/dist/components/index.mjs +3 -3
  44. package/dist/contexts/index.js +4 -4
  45. package/dist/contexts/index.mjs +3 -3
  46. package/dist/core/index.d.mts +214 -3
  47. package/dist/core/index.d.ts +214 -3
  48. package/dist/core/index.js +36 -2
  49. package/dist/core/index.js.map +1 -1
  50. package/dist/core/index.mjs +35 -1
  51. package/dist/email-automations/index.js +92 -92
  52. package/dist/email-automations/index.mjs +3 -3
  53. package/dist/features/analytics/index.js +122 -122
  54. package/dist/features/analytics/index.mjs +4 -4
  55. package/dist/features/analytics/server.js +6 -5
  56. package/dist/features/analytics/server.js.map +1 -1
  57. package/dist/features/analytics/server.mjs +4 -3
  58. package/dist/features/analytics/server.mjs.map +1 -1
  59. package/dist/features/blog/index.css +30 -0
  60. package/dist/features/blog/index.css.map +1 -0
  61. package/dist/features/blog/index.d.mts +266 -0
  62. package/dist/features/blog/index.d.ts +266 -0
  63. package/dist/features/blog/index.js +1799 -0
  64. package/dist/features/blog/index.js.map +1 -0
  65. package/dist/features/blog/index.mjs +1799 -0
  66. package/dist/features/blog/index.mjs.map +1 -0
  67. package/dist/features/blog/server.d.mts +33 -0
  68. package/dist/features/blog/server.d.ts +33 -0
  69. package/dist/features/blog/server.js +139 -0
  70. package/dist/features/blog/server.js.map +1 -0
  71. package/dist/features/blog/server.mjs +139 -0
  72. package/dist/features/blog/server.mjs.map +1 -0
  73. package/dist/features/help/index.js +37 -37
  74. package/dist/features/help/index.mjs +3 -3
  75. package/dist/features/tokenusage/index.js +125 -125
  76. package/dist/features/tokenusage/index.mjs +4 -4
  77. package/dist/index.d.mts +4 -2
  78. package/dist/index.d.ts +4 -2
  79. package/dist/index.js +37 -3
  80. package/dist/index.js.map +1 -1
  81. package/dist/index.mjs +36 -2
  82. package/dist/{s3.service-CY-jgOAM.d.ts → s3.service-Bz_K-tjz.d.ts} +11 -0
  83. package/dist/{s3.service-Sri0QP7Z.d.mts → s3.service-D5zNCEW3.d.mts} +11 -0
  84. package/dist/server/index.d.mts +1 -1
  85. package/dist/server/index.d.ts +1 -1
  86. package/dist/server/index.js +8 -6
  87. package/dist/server/index.js.map +1 -1
  88. package/dist/server/index.mjs +12 -10
  89. package/messages/en.json +93 -0
  90. package/messages/it.json +93 -0
  91. package/package.json +11 -1
  92. package/src/components/editors/BlockNoteEditor.tsx +13 -3
  93. package/src/components/forms/FormBlockNote.tsx +4 -0
  94. package/src/components/navigations/ContentTitle.tsx +14 -3
  95. package/src/core/index.ts +4 -0
  96. package/src/core/registry/ModuleRegistry.ts +6 -0
  97. package/src/features/blog/BlogModules.ts +49 -0
  98. package/src/features/blog/components/admin/BlogAdminContainer.tsx +32 -0
  99. package/src/features/blog/components/admin/BlogCategoriesList.tsx +56 -0
  100. package/src/features/blog/components/admin/BlogCategoryEditor.tsx +230 -0
  101. package/src/features/blog/components/admin/BlogCoverImageField.tsx +123 -0
  102. package/src/features/blog/components/admin/BlogLayoutSetting.tsx +86 -0
  103. package/src/features/blog/components/admin/BlogPostComparison.tsx +54 -0
  104. package/src/features/blog/components/admin/BlogPostContainer.tsx +102 -0
  105. package/src/features/blog/components/admin/BlogPostDeleter.tsx +35 -0
  106. package/src/features/blog/components/admin/BlogPostEditor.tsx +207 -0
  107. package/src/features/blog/components/admin/BlogPostPublishButton.tsx +50 -0
  108. package/src/features/blog/components/admin/BlogPostTranslationEditor.tsx +110 -0
  109. package/src/features/blog/components/admin/BlogPostTranslationPanel.tsx +136 -0
  110. package/src/features/blog/components/admin/BlogPostsList.tsx +58 -0
  111. package/src/features/blog/components/admin/BlogTranslationStatus.tsx +104 -0
  112. package/src/features/blog/components/admin/__tests__/BlogPostTranslationPanel.spec.tsx +100 -0
  113. package/src/features/blog/components/admin/__tests__/BlogPostsList.spec.tsx +30 -0
  114. package/src/features/blog/components/admin/index.ts +14 -0
  115. package/src/features/blog/components/public/BlogCategoryChips.tsx +59 -0
  116. package/src/features/blog/components/public/BlogList.tsx +21 -0
  117. package/src/features/blog/components/public/BlogListColumn.tsx +53 -0
  118. package/src/features/blog/components/public/BlogListFeatured.tsx +57 -0
  119. package/src/features/blog/components/public/BlogListParts.tsx +74 -0
  120. package/src/features/blog/components/public/BlogListRail.tsx +62 -0
  121. package/src/features/blog/components/public/BlogPostCard.tsx +132 -0
  122. package/src/features/blog/components/public/BlogPostMeta.tsx +49 -0
  123. package/src/features/blog/components/public/BlogPostView.tsx +74 -0
  124. package/src/features/blog/components/public/BlogSearchBox.tsx +52 -0
  125. package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +173 -0
  126. package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +128 -0
  127. package/src/features/blog/components/public/blogPublicHelpers.ts +30 -0
  128. package/src/features/blog/components/public/index.ts +3 -0
  129. package/src/features/blog/contexts/BlogPostContext.tsx +66 -0
  130. package/src/features/blog/contexts/index.ts +1 -0
  131. package/src/features/blog/data/BlogCategory.ts +93 -0
  132. package/src/features/blog/data/BlogCategoryFields.ts +4 -0
  133. package/src/features/blog/data/BlogCategoryInterface.ts +24 -0
  134. package/src/features/blog/data/BlogCategoryService.ts +54 -0
  135. package/src/features/blog/data/BlogCategoryTranslation.ts +90 -0
  136. package/src/features/blog/data/BlogCategoryTranslationInterface.ts +22 -0
  137. package/src/features/blog/data/BlogCategoryTranslationService.ts +30 -0
  138. package/src/features/blog/data/BlogPost.spec.ts +123 -0
  139. package/src/features/blog/data/BlogPost.ts +159 -0
  140. package/src/features/blog/data/BlogPostFields.ts +7 -0
  141. package/src/features/blog/data/BlogPostInterface.ts +39 -0
  142. package/src/features/blog/data/BlogPostService.ts +82 -0
  143. package/src/features/blog/data/BlogPostTranslation.spec.ts +73 -0
  144. package/src/features/blog/data/BlogPostTranslation.ts +110 -0
  145. package/src/features/blog/data/BlogPostTranslationInterface.ts +27 -0
  146. package/src/features/blog/data/BlogPostTranslationService.ts +30 -0
  147. package/src/features/blog/data/BlogSettings.ts +37 -0
  148. package/src/features/blog/data/BlogSettingsInterface.ts +11 -0
  149. package/src/features/blog/data/BlogSettingsService.ts +24 -0
  150. package/src/features/blog/data/index.ts +17 -0
  151. package/src/features/blog/hooks/index.ts +3 -0
  152. package/src/features/blog/hooks/useBlogCategoryNames.ts +34 -0
  153. package/src/features/blog/hooks/useBlogCategoryTableStructure.tsx +84 -0
  154. package/src/features/blog/hooks/useBlogPostTableStructure.tsx +126 -0
  155. package/src/features/blog/index.ts +15 -0
  156. package/src/features/blog/server/fetchers.ts +120 -0
  157. package/src/features/blog/server/toPublic.spec.ts +91 -0
  158. package/src/features/blog/server/toPublic.ts +36 -0
  159. package/src/features/blog/server-entry.ts +17 -0
  160. package/src/features/blog/types/blog-public.types.ts +34 -0
  161. package/src/features/blog/types/blog.constants.ts +40 -0
  162. package/src/features/s3/data/s3.service.ts +17 -0
  163. package/dist/BlockNoteEditor-UJ6363LC.js.map +0 -1
  164. package/dist/BlockNoteEditor-WTSQANKI.mjs.map +0 -1
  165. package/dist/chunk-3BAM23VA.js.map +0 -1
  166. package/dist/chunk-CRI4T47Y.js.map +0 -1
  167. package/dist/chunk-JQWI62NV.mjs.map +0 -1
  168. package/dist/chunk-MIZTWBPX.mjs.map +0 -1
  169. package/dist/chunk-QKZUE5OF.mjs.map +0 -1
  170. package/dist/chunk-XGTQWBDJ.js.map +0 -1
  171. /package/dist/{BlockNoteViewer-JVKWJZ6J.mjs.map → BlockNoteViewer-AEJKJ27Q.mjs.map} +0 -0
  172. /package/dist/{chunk-FNTQQFLH.mjs.map → chunk-6DC2WVVB.mjs.map} +0 -0
  173. /package/dist/{chunk-6WU74VFU.mjs.map → chunk-QMYQRPQC.mjs.map} +0 -0
@@ -0,0 +1,30 @@
1
+ import { AbstractService, EndpointCreator, HttpMethod, Modules } from "../../../core";
2
+ import { BLOG_ACTION } from "../types/blog.constants";
3
+ import { BlogPostTranslationInput, BlogPostTranslationInterface } from "./BlogPostTranslationInterface";
4
+
5
+ export class BlogPostTranslationService extends AbstractService {
6
+ static async update(params: BlogPostTranslationInput): Promise<BlogPostTranslationInterface> {
7
+ return this.callApi<BlogPostTranslationInterface>({
8
+ type: Modules.BlogPostTranslation,
9
+ method: HttpMethod.PUT,
10
+ endpoint: new EndpointCreator({ endpoint: Modules.BlogPostTranslation, id: params.id }).generate(),
11
+ input: params,
12
+ });
13
+ }
14
+
15
+ /**
16
+ * POST blog-post-translations/:id/retranslate. Re-queues this one language
17
+ * from the current original. Replies 204.
18
+ */
19
+ static async retranslate(params: { id: string }): Promise<void> {
20
+ await this.callApi({
21
+ type: Modules.BlogPostTranslation,
22
+ method: HttpMethod.POST,
23
+ endpoint: new EndpointCreator({
24
+ endpoint: Modules.BlogPostTranslation,
25
+ id: params.id,
26
+ childEndpoint: BLOG_ACTION.retranslate,
27
+ }).generate(),
28
+ });
29
+ }
30
+ }
@@ -0,0 +1,37 @@
1
+ import { AbstractApiData, JsonApiHydratedDataInterface, Modules } from "../../../core";
2
+ import { BLOG_LIST_LAYOUTS, BlogListLayout } from "../types/blog.constants";
3
+ import { BlogSettingsInput, BlogSettingsInterface } from "./BlogSettingsInterface";
4
+
5
+ export class BlogSettings extends AbstractApiData implements BlogSettingsInterface {
6
+ private _listLayout?: BlogListLayout;
7
+
8
+ get listLayout(): BlogListLayout {
9
+ return this._listLayout ?? "featured";
10
+ }
11
+
12
+ rehydrate(data: JsonApiHydratedDataInterface): this {
13
+ super.rehydrate(data);
14
+
15
+ const listLayout = data.jsonApi.attributes?.listLayout;
16
+ this._listLayout = BLOG_LIST_LAYOUTS.includes(listLayout) ? listLayout : undefined;
17
+
18
+ return this;
19
+ }
20
+
21
+ createJsonApi(data: BlogSettingsInput) {
22
+ const response: any = {
23
+ data: {
24
+ type: Modules.BlogSettings.name,
25
+ id: data.id,
26
+ attributes: {},
27
+ meta: {},
28
+ relationships: {},
29
+ },
30
+ included: [],
31
+ };
32
+
33
+ response.data.attributes.listLayout = data.listLayout;
34
+
35
+ return response;
36
+ }
37
+ }
@@ -0,0 +1,11 @@
1
+ import { ApiDataInterface } from "../../../core";
2
+ import { BlogListLayout } from "../types/blog.constants";
3
+
4
+ export type BlogSettingsInput = {
5
+ id: string;
6
+ listLayout: BlogListLayout;
7
+ };
8
+
9
+ export interface BlogSettingsInterface extends ApiDataInterface {
10
+ get listLayout(): BlogListLayout;
11
+ }
@@ -0,0 +1,24 @@
1
+ import { AbstractService, EndpointCreator, HttpMethod, Modules } from "../../../core";
2
+ import { BlogSettingsInput, BlogSettingsInterface } from "./BlogSettingsInterface";
3
+
4
+ export class BlogSettingsService extends AbstractService {
5
+ /**
6
+ * GET blog-settings: the stored singleton, or the API's default.
7
+ */
8
+ static async get(): Promise<BlogSettingsInterface> {
9
+ return this.callApi<BlogSettingsInterface>({
10
+ type: Modules.BlogSettings,
11
+ method: HttpMethod.GET,
12
+ endpoint: new EndpointCreator({ endpoint: Modules.BlogSettings }).generate(),
13
+ });
14
+ }
15
+
16
+ static async save(params: BlogSettingsInput): Promise<BlogSettingsInterface> {
17
+ return this.callApi<BlogSettingsInterface>({
18
+ type: Modules.BlogSettings,
19
+ method: HttpMethod.PUT,
20
+ endpoint: new EndpointCreator({ endpoint: Modules.BlogSettings, id: params.id }).generate(),
21
+ input: params,
22
+ });
23
+ }
24
+ }
@@ -0,0 +1,17 @@
1
+ export * from "./BlogPostInterface";
2
+ export * from "./BlogPost";
3
+ export * from "./BlogPostService";
4
+ export * from "./BlogPostTranslationInterface";
5
+ export * from "./BlogPostTranslation";
6
+ export * from "./BlogPostTranslationService";
7
+ export * from "./BlogCategoryInterface";
8
+ export * from "./BlogCategory";
9
+ export * from "./BlogCategoryService";
10
+ export * from "./BlogCategoryTranslationInterface";
11
+ export * from "./BlogCategoryTranslation";
12
+ export * from "./BlogCategoryTranslationService";
13
+ export * from "./BlogSettingsInterface";
14
+ export * from "./BlogSettings";
15
+ export * from "./BlogSettingsService";
16
+ export * from "./BlogPostFields";
17
+ export * from "./BlogCategoryFields";
@@ -0,0 +1,3 @@
1
+ export * from "./useBlogPostTableStructure";
2
+ export * from "./useBlogCategoryTableStructure";
3
+ export * from "./useBlogCategoryNames";
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { BlogCategoryService } from "../data";
5
+ import { blogCategoryAdminName } from "../components/admin/BlogTranslationStatus";
6
+
7
+ /**
8
+ * Category names for the admin views, in each category's original language.
9
+ *
10
+ * A post response includes its categories without their translations (the
11
+ * framework includes one level), so the name is not on the included record.
12
+ * The category list does include the translations: it is read once and the
13
+ * returned lookup maps a category id to its name ("" while loading).
14
+ */
15
+ export function useBlogCategoryNames(): (categoryId: string) => string {
16
+ const [names, setNames] = useState<Record<string, string>>({});
17
+
18
+ useEffect(() => {
19
+ let cancelled = false;
20
+ BlogCategoryService.findMany({ fetchAll: true })
21
+ .then((categories) => {
22
+ if (cancelled) return;
23
+ setNames(Object.fromEntries(categories.map((category) => [category.id, blogCategoryAdminName(category)])));
24
+ })
25
+ .catch(() => {
26
+ // The names are decoration on an admin page: a failed read leaves the ids unnamed.
27
+ });
28
+ return () => {
29
+ cancelled = true;
30
+ };
31
+ }, []);
32
+
33
+ return useCallback((categoryId: string) => names[categoryId] ?? "", [names]);
34
+ }
@@ -0,0 +1,84 @@
1
+ "use client";
2
+
3
+ import { ColumnDef } from "@tanstack/react-table";
4
+ import { useTranslations } from "next-intl";
5
+ import { useMemo } from "react";
6
+ import { registerTableGenerator, TableContent, UseTableStructureHook } from "../../../hooks";
7
+ import { BlogCategoryEditor } from "../components/admin/BlogCategoryEditor";
8
+ import { blogCategoryAdminName } from "../components/admin/BlogTranslationStatus";
9
+ import { BlogCategoryFields, BlogCategoryInterface } from "../data";
10
+
11
+ /**
12
+ * Admin categories table: the name (original language) opens BlogCategoryEditor
13
+ * for that row; "languages ready" counts completed translations. Categories have
14
+ * no page of their own. Mirrors hooks/useEmailAutomationTableStructure.tsx in
15
+ * features/email-automation.
16
+ */
17
+ export const useBlogCategoryTableStructure: UseTableStructureHook<BlogCategoryInterface, BlogCategoryFields> = (
18
+ params,
19
+ ) => {
20
+ const t = useTranslations();
21
+ const refresh = params.dataRetriever?.refresh;
22
+
23
+ const tableData = useMemo(() => {
24
+ return params.data.map((category: BlogCategoryInterface) => {
25
+ const entry: TableContent<BlogCategoryInterface> = { jsonApiData: category };
26
+ params.fields.forEach((field) => {
27
+ entry[field] = (category as any)[field as keyof BlogCategoryInterface];
28
+ });
29
+ return entry;
30
+ });
31
+ }, [params.data, params.fields]);
32
+
33
+ const fieldColumnMap: Partial<Record<BlogCategoryFields, () => any>> = {
34
+ [BlogCategoryFields.name]: () => ({
35
+ id: BlogCategoryFields.name,
36
+ accessorKey: BlogCategoryFields.name,
37
+ header: t("blog.fields.name"),
38
+ cell: ({ row }: { row: TableContent<BlogCategoryInterface> }) => {
39
+ const category: BlogCategoryInterface = row.original.jsonApiData;
40
+ return (
41
+ <BlogCategoryEditor
42
+ category={category}
43
+ onSuccess={refresh}
44
+ trigger={
45
+ <button type="button" className="text-primary text-start font-medium">
46
+ {blogCategoryAdminName(category) || category.id}
47
+ </button>
48
+ }
49
+ />
50
+ );
51
+ },
52
+ enableSorting: false,
53
+ enableHiding: false,
54
+ }),
55
+ [BlogCategoryFields.languages]: () => ({
56
+ id: BlogCategoryFields.languages,
57
+ accessorKey: BlogCategoryFields.languages,
58
+ header: t("blog.fields.languages"),
59
+ cell: ({ row }: { row: TableContent<BlogCategoryInterface> }) => {
60
+ const category: BlogCategoryInterface = row.original.jsonApiData;
61
+ const total = category.translations.length;
62
+ if (total === 0) return <span className="text-muted-foreground text-xs">—</span>;
63
+ const ready = category.translations.filter((translation) => translation.aiStatus === "completed").length;
64
+ return (
65
+ <span className="block text-xs tabular-nums text-end">
66
+ {ready}/{total}
67
+ </span>
68
+ );
69
+ },
70
+ enableSorting: false,
71
+ enableHiding: false,
72
+ }),
73
+ };
74
+
75
+ const columns = useMemo(() => {
76
+ return params.fields.map((field) => fieldColumnMap[field]?.()).filter((col) => col !== undefined) as ColumnDef<
77
+ TableContent<BlogCategoryInterface>
78
+ >[];
79
+ }, [params.fields, fieldColumnMap, t, refresh]);
80
+
81
+ return useMemo(() => ({ data: tableData, columns: columns }), [tableData, columns]);
82
+ };
83
+
84
+ registerTableGenerator("blog-categories", useBlogCategoryTableStructure);
@@ -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,120 @@
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
+ page: number;
32
+ }): Promise<PublicBlogList> {
33
+ // Matches the API cap (120 = 10 pages of 12); a larger ?page= is served as the last allowed one.
34
+ const page = Math.min(10, Math.max(1, Math.floor(params.page || 1)));
35
+ const size = BLOG_PAGE_SIZE * page;
36
+
37
+ const endpoint = new EndpointCreator({ endpoint: publicPostsEndpoint() });
38
+ endpoint.addAdditionalParam("language", params.language);
39
+ if (params.categorySlug) endpoint.addAdditionalParam("category", encodeURIComponent(params.categorySlug));
40
+ if (params.search) endpoint.addAdditionalParam("search", encodeURIComponent(params.search));
41
+ endpoint.addAdditionalParam("page[size]", String(size));
42
+
43
+ const res = await ServerJsonApiGet({
44
+ classKey: { ...Modules.BlogPost, cache: "minutes" },
45
+ endpoint: endpoint.generate(),
46
+ language: params.language,
47
+ });
48
+ if (!res.ok) return { posts: [], hasMore: false };
49
+
50
+ const posts = asArray<BlogPostInterface>(res.data).map(toPublicBlogPost);
51
+ const total = Number(res.meta?.total);
52
+ const hasMore = Number.isFinite(total) ? total > posts.length : false;
53
+
54
+ return { posts, hasMore };
55
+ }
56
+
57
+ export async function fetchPublicBlogPost(params: { language: string; slug: string }): Promise<PublicBlogPost | null> {
58
+ const endpoint = new EndpointCreator({ endpoint: publicPostsEndpoint(), id: encodeURIComponent(params.slug) });
59
+ endpoint.addAdditionalParam("language", params.language);
60
+
61
+ const res = await ServerJsonApiGet({
62
+ classKey: { ...Modules.BlogPost, cache: "minutes" },
63
+ endpoint: endpoint.generate(),
64
+ language: params.language,
65
+ });
66
+ if (!res.ok) return null;
67
+
68
+ const post = asArray<BlogPostInterface>(res.data)[0];
69
+ return post ? toPublicBlogPost(post) : null;
70
+ }
71
+
72
+ export async function fetchRelatedBlogPosts(params: { language: string; slug: string }): Promise<PublicBlogPost[]> {
73
+ const endpoint = new EndpointCreator({
74
+ endpoint: publicPostsEndpoint(),
75
+ id: encodeURIComponent(params.slug),
76
+ childEndpoint: BLOG_ACTION.related,
77
+ });
78
+ endpoint.addAdditionalParam("language", params.language);
79
+
80
+ const res = await ServerJsonApiGet({
81
+ classKey: { ...Modules.BlogPost, cache: "minutes" },
82
+ endpoint: endpoint.generate(),
83
+ language: params.language,
84
+ });
85
+ if (!res.ok) return [];
86
+
87
+ return asArray<BlogPostInterface>(res.data).map(toPublicBlogPost);
88
+ }
89
+
90
+ export async function fetchPublicBlogCategories(params: { language: string }): Promise<PublicBlogCategory[]> {
91
+ const endpoint = new EndpointCreator({ endpoint: `${BLOG_PUBLIC_PREFIX}/${Modules.BlogCategory.name}` });
92
+ endpoint.addAdditionalParam("language", params.language);
93
+
94
+ const res = await ServerJsonApiGet({
95
+ classKey: { ...Modules.BlogCategory, cache: "minutes" },
96
+ endpoint: endpoint.generate(),
97
+ language: params.language,
98
+ });
99
+ if (!res.ok) return [];
100
+
101
+ return asArray<BlogCategoryInterface>(res.data).map(toPublicBlogCategory);
102
+ }
103
+
104
+ export async function fetchBlogSettings(): Promise<{ listLayout: BlogListLayout }> {
105
+ const fallback: { listLayout: BlogListLayout } = { listLayout: "featured" };
106
+
107
+ // The settings are language-neutral; the language only fills the required
108
+ // request header, so one fixed value keeps a single cache entry.
109
+ const res = await ServerJsonApiGet({
110
+ classKey: { ...Modules.BlogSettings, cache: "minutes" },
111
+ endpoint: new EndpointCreator({ endpoint: `${BLOG_PUBLIC_PREFIX}/${Modules.BlogSettings.name}` }).generate(),
112
+ language: "en",
113
+ });
114
+ if (!res.ok) return fallback;
115
+
116
+ const settings = asArray<BlogSettingsInterface>(res.data)[0];
117
+ if (!settings || !BLOG_LIST_LAYOUTS.includes(settings.listLayout)) return fallback;
118
+
119
+ return { listLayout: settings.listLayout };
120
+ }
@@ -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";