@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.
Files changed (233) hide show
  1. package/dist/{BlockNoteEditor-WTSQANKI.mjs → BlockNoteEditor-GVQCKRMU.mjs} +9 -8
  2. package/dist/BlockNoteEditor-GVQCKRMU.mjs.map +1 -0
  3. package/dist/{BlockNoteEditor-UJ6363LC.js → BlockNoteEditor-THBJH2UR.js} +24 -23
  4. package/dist/BlockNoteEditor-THBJH2UR.js.map +1 -0
  5. package/dist/{BlockNoteViewer-JVKWJZ6J.mjs → BlockNoteViewer-U6E3DS4F.mjs} +3 -3
  6. package/dist/{BlockNoteViewer-EL4JFB6P.js → BlockNoteViewer-VZOVKTFO.js} +4 -4
  7. package/dist/{BlockNoteViewer-EL4JFB6P.js.map → BlockNoteViewer-VZOVKTFO.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 +442 -442
  13. package/dist/billing/index.mjs +4 -4
  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-3CGROPF3.mjs +107 -0
  17. package/dist/chunk-3CGROPF3.mjs.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-6WU74VFU.mjs → chunk-6SQYXUVP.mjs} +2 -2
  21. package/dist/{chunk-CRI4T47Y.js → chunk-G6UE6MEP.js} +814 -59
  22. package/dist/chunk-G6UE6MEP.js.map +1 -0
  23. package/dist/{chunk-XGTQWBDJ.js → chunk-KSYQEKJX.js} +11 -111
  24. package/dist/chunk-KSYQEKJX.js.map +1 -0
  25. package/dist/{chunk-EO75CV76.js → chunk-S4YJ5WCW.js} +8 -8
  26. package/dist/{chunk-EO75CV76.js.map → chunk-S4YJ5WCW.js.map} +1 -1
  27. package/dist/{chunk-3BAM23VA.js → chunk-TY5FYP3G.js} +930 -883
  28. package/dist/chunk-TY5FYP3G.js.map +1 -0
  29. package/dist/{chunk-JQWI62NV.mjs → chunk-VC5A3AUZ.mjs} +776 -21
  30. package/dist/chunk-VC5A3AUZ.mjs.map +1 -0
  31. package/dist/{chunk-FNTQQFLH.mjs → chunk-W7F5BDSV.mjs} +2 -2
  32. package/dist/{chunk-QQQE3N2X.js → chunk-WPYT4UKJ.js} +3 -3
  33. package/dist/{chunk-QQQE3N2X.js.map → chunk-WPYT4UKJ.js.map} +1 -1
  34. package/dist/{chunk-MIZTWBPX.mjs → chunk-X5WIUUNG.mjs} +1485 -1438
  35. package/dist/chunk-X5WIUUNG.mjs.map +1 -0
  36. package/dist/chunk-YDYVJM4I.js +107 -0
  37. package/dist/chunk-YDYVJM4I.js.map +1 -0
  38. package/dist/client/index.js +5 -5
  39. package/dist/client/index.js.map +1 -1
  40. package/dist/client/index.mjs +4 -4
  41. package/dist/components/editors/server.d.mts +15 -0
  42. package/dist/components/editors/server.d.ts +15 -0
  43. package/dist/components/editors/server.js +38 -0
  44. package/dist/components/editors/server.js.map +1 -0
  45. package/dist/components/editors/server.mjs +38 -0
  46. package/dist/components/editors/server.mjs.map +1 -0
  47. package/dist/components/index.d.mts +46 -12
  48. package/dist/components/index.d.ts +46 -12
  49. package/dist/components/index.js +7 -5
  50. package/dist/components/index.js.map +1 -1
  51. package/dist/components/index.mjs +8 -6
  52. package/dist/contexts/index.d.mts +9 -1
  53. package/dist/contexts/index.d.ts +9 -1
  54. package/dist/contexts/index.js +5 -5
  55. package/dist/contexts/index.mjs +4 -4
  56. package/dist/core/index.d.mts +214 -3
  57. package/dist/core/index.d.ts +214 -3
  58. package/dist/core/index.js +38 -4
  59. package/dist/core/index.js.map +1 -1
  60. package/dist/core/index.mjs +37 -3
  61. package/dist/email-automations/index.d.mts +3 -0
  62. package/dist/email-automations/index.d.ts +3 -0
  63. package/dist/email-automations/index.js +99 -95
  64. package/dist/email-automations/index.js.map +1 -1
  65. package/dist/email-automations/index.mjs +30 -26
  66. package/dist/email-automations/index.mjs.map +1 -1
  67. package/dist/features/analytics/index.d.mts +11 -4
  68. package/dist/features/analytics/index.d.ts +11 -4
  69. package/dist/features/analytics/index.js +127 -135
  70. package/dist/features/analytics/index.js.map +1 -1
  71. package/dist/features/analytics/index.mjs +16 -24
  72. package/dist/features/analytics/index.mjs.map +1 -1
  73. package/dist/features/analytics/server.d.mts +5 -2
  74. package/dist/features/analytics/server.d.ts +5 -2
  75. package/dist/features/analytics/server.js +9 -8
  76. package/dist/features/analytics/server.js.map +1 -1
  77. package/dist/features/analytics/server.mjs +7 -6
  78. package/dist/features/analytics/server.mjs.map +1 -1
  79. package/dist/features/blog/index.css +30 -0
  80. package/dist/features/blog/index.css.map +1 -0
  81. package/dist/features/blog/index.d.mts +276 -0
  82. package/dist/features/blog/index.d.ts +276 -0
  83. package/dist/features/blog/index.js +1810 -0
  84. package/dist/features/blog/index.js.map +1 -0
  85. package/dist/features/blog/index.mjs +1810 -0
  86. package/dist/features/blog/index.mjs.map +1 -0
  87. package/dist/features/blog/server.d.mts +36 -0
  88. package/dist/features/blog/server.d.ts +36 -0
  89. package/dist/features/blog/server.js +167 -0
  90. package/dist/features/blog/server.js.map +1 -0
  91. package/dist/features/blog/server.mjs +167 -0
  92. package/dist/features/blog/server.mjs.map +1 -0
  93. package/dist/features/help/index.js +38 -38
  94. package/dist/features/help/index.mjs +4 -4
  95. package/dist/features/tokenusage/index.d.mts +1 -3
  96. package/dist/features/tokenusage/index.d.ts +1 -3
  97. package/dist/features/tokenusage/index.js +127 -135
  98. package/dist/features/tokenusage/index.js.map +1 -1
  99. package/dist/features/tokenusage/index.mjs +10 -18
  100. package/dist/features/tokenusage/index.mjs.map +1 -1
  101. package/dist/index.d.mts +4 -2
  102. package/dist/index.d.ts +4 -2
  103. package/dist/index.js +39 -5
  104. package/dist/index.js.map +1 -1
  105. package/dist/index.mjs +38 -4
  106. package/dist/{s3.service-CY-jgOAM.d.ts → s3.service-Bz_K-tjz.d.ts} +11 -0
  107. package/dist/{s3.service-Sri0QP7Z.d.mts → s3.service-D5zNCEW3.d.mts} +11 -0
  108. package/dist/server/index.d.mts +1 -1
  109. package/dist/server/index.d.ts +1 -1
  110. package/dist/server/index.js +9 -7
  111. package/dist/server/index.js.map +1 -1
  112. package/dist/server/index.mjs +13 -11
  113. package/messages/en.json +93 -0
  114. package/messages/it.json +93 -0
  115. package/package.json +19 -1
  116. package/src/components/editors/BlockNoteEditor.tsx +13 -3
  117. package/src/components/editors/server/BlockNoteServerContent.tsx +28 -0
  118. package/src/components/editors/server/__tests__/renderBlockNoteHTML.spec.ts +44 -0
  119. package/src/components/editors/server/renderBlockNoteHTML.ts +26 -0
  120. package/src/components/editors/server-entry.ts +7 -0
  121. package/src/components/forms/FormBlockNote.tsx +4 -0
  122. package/src/components/index.ts +1 -0
  123. package/src/components/navigations/ContentTitle.tsx +14 -3
  124. package/src/components/viewers/BlockNoteStaticViewer.tsx +44 -0
  125. package/src/core/index.ts +4 -0
  126. package/src/core/registry/ModuleRegistry.ts +6 -0
  127. package/src/features/administration/components/AdminIndexContainer.tsx +61 -33
  128. package/src/features/administration/components/__tests__/AdminIndexContainer.spec.tsx +61 -1
  129. package/src/features/administration/components/index.ts +2 -1
  130. package/src/features/administration/contexts/AdministrationContext.tsx +11 -3
  131. package/src/features/administration/contexts/__tests__/AdministrationContext.spec.tsx +40 -0
  132. package/src/features/ai-connection/ai-connection.module.ts +2 -0
  133. package/src/features/ai-connection/contexts/AiConnectionsContext.tsx +3 -16
  134. package/src/features/ai-connection/contexts/__tests__/AiConnectionsContext.spec.tsx +33 -0
  135. package/src/features/analytics/__tests__/server-entry.spec.tsx +2 -1
  136. package/src/features/analytics/analytics.modules.ts +22 -5
  137. package/src/features/analytics/components/AnalyticsAdminContainer.tsx +4 -3
  138. package/src/features/analytics/components/AnalyticsAdminPageContainer.tsx +6 -3
  139. package/src/features/analytics/components/AnalyticsJourneyContainer.tsx +4 -3
  140. package/src/features/analytics/components/__tests__/AnalyticsJourneyContainer.spec.tsx +11 -0
  141. package/src/features/analytics/components/__tests__/AnalyticsPageContainers.spec.tsx +12 -1
  142. package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +6 -3
  143. package/src/features/analytics/contexts/AnalyticsAdminContext.tsx +8 -17
  144. package/src/features/analytics/contexts/__tests__/AnalyticsAdminContext.spec.tsx +17 -0
  145. package/src/features/billing/stripe-price/stripe-price.module.ts +2 -0
  146. package/src/features/billing/stripe-product/stripe-product.module.ts +2 -0
  147. package/src/features/blog/BlogModules.ts +49 -0
  148. package/src/features/blog/components/admin/BlogAdminContainer.tsx +39 -0
  149. package/src/features/blog/components/admin/BlogCategoriesList.tsx +56 -0
  150. package/src/features/blog/components/admin/BlogCategoryEditor.tsx +230 -0
  151. package/src/features/blog/components/admin/BlogCoverImageField.tsx +123 -0
  152. package/src/features/blog/components/admin/BlogLayoutSetting.tsx +86 -0
  153. package/src/features/blog/components/admin/BlogPostComparison.tsx +54 -0
  154. package/src/features/blog/components/admin/BlogPostContainer.tsx +101 -0
  155. package/src/features/blog/components/admin/BlogPostDeleter.tsx +35 -0
  156. package/src/features/blog/components/admin/BlogPostEditor.tsx +216 -0
  157. package/src/features/blog/components/admin/BlogPostPublishButton.tsx +50 -0
  158. package/src/features/blog/components/admin/BlogPostTranslationEditor.tsx +118 -0
  159. package/src/features/blog/components/admin/BlogPostTranslationPanel.tsx +136 -0
  160. package/src/features/blog/components/admin/BlogPostsList.tsx +58 -0
  161. package/src/features/blog/components/admin/BlogTranslationStatus.tsx +104 -0
  162. package/src/features/blog/components/admin/__tests__/BlogPostTranslationPanel.spec.tsx +100 -0
  163. package/src/features/blog/components/admin/__tests__/BlogPostsList.spec.tsx +30 -0
  164. package/src/features/blog/components/admin/index.ts +14 -0
  165. package/src/features/blog/components/public/BlogCategoryChips.tsx +59 -0
  166. package/src/features/blog/components/public/BlogList.tsx +21 -0
  167. package/src/features/blog/components/public/BlogListColumn.tsx +53 -0
  168. package/src/features/blog/components/public/BlogListFeatured.tsx +57 -0
  169. package/src/features/blog/components/public/BlogListParts.tsx +74 -0
  170. package/src/features/blog/components/public/BlogListRail.tsx +62 -0
  171. package/src/features/blog/components/public/BlogPostCard.tsx +125 -0
  172. package/src/features/blog/components/public/BlogPostMeta.tsx +49 -0
  173. package/src/features/blog/components/public/BlogPostView.tsx +80 -0
  174. package/src/features/blog/components/public/BlogSearchBox.tsx +52 -0
  175. package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +175 -0
  176. package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +134 -0
  177. package/src/features/blog/components/public/blogPublicHelpers.ts +30 -0
  178. package/src/features/blog/components/public/index.ts +3 -0
  179. package/src/features/blog/contexts/BlogPostContext.tsx +66 -0
  180. package/src/features/blog/contexts/index.ts +1 -0
  181. package/src/features/blog/data/BlogCategory.ts +93 -0
  182. package/src/features/blog/data/BlogCategoryFields.ts +4 -0
  183. package/src/features/blog/data/BlogCategoryInterface.ts +24 -0
  184. package/src/features/blog/data/BlogCategoryService.ts +54 -0
  185. package/src/features/blog/data/BlogCategoryTranslation.ts +90 -0
  186. package/src/features/blog/data/BlogCategoryTranslationInterface.ts +22 -0
  187. package/src/features/blog/data/BlogCategoryTranslationService.ts +30 -0
  188. package/src/features/blog/data/BlogPost.spec.ts +123 -0
  189. package/src/features/blog/data/BlogPost.ts +159 -0
  190. package/src/features/blog/data/BlogPostFields.ts +7 -0
  191. package/src/features/blog/data/BlogPostInterface.ts +39 -0
  192. package/src/features/blog/data/BlogPostService.ts +82 -0
  193. package/src/features/blog/data/BlogPostTranslation.spec.ts +73 -0
  194. package/src/features/blog/data/BlogPostTranslation.ts +110 -0
  195. package/src/features/blog/data/BlogPostTranslationInterface.ts +27 -0
  196. package/src/features/blog/data/BlogPostTranslationService.ts +30 -0
  197. package/src/features/blog/data/BlogSettings.ts +37 -0
  198. package/src/features/blog/data/BlogSettingsInterface.ts +11 -0
  199. package/src/features/blog/data/BlogSettingsService.ts +24 -0
  200. package/src/features/blog/data/index.ts +17 -0
  201. package/src/features/blog/hooks/index.ts +3 -0
  202. package/src/features/blog/hooks/useBlogCategoryNames.ts +34 -0
  203. package/src/features/blog/hooks/useBlogCategoryTableStructure.tsx +84 -0
  204. package/src/features/blog/hooks/useBlogPostTableStructure.tsx +126 -0
  205. package/src/features/blog/index.ts +15 -0
  206. package/src/features/blog/server/fetchers.ts +169 -0
  207. package/src/features/blog/server/toPublic.spec.ts +91 -0
  208. package/src/features/blog/server/toPublic.ts +36 -0
  209. package/src/features/blog/server-entry.ts +17 -0
  210. package/src/features/blog/types/blog-public.types.ts +34 -0
  211. package/src/features/blog/types/blog.constants.ts +40 -0
  212. package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +12 -3
  213. package/src/features/rbac/contexts/RbacContext.tsx +4 -7
  214. package/src/features/rbac/contexts/__tests__/RbacContext.spec.tsx +42 -0
  215. package/src/features/s3/data/s3.service.ts +17 -0
  216. package/src/features/tokenusage/components/TokenUsageAdminContainer.tsx +4 -3
  217. package/src/features/tokenusage/contexts/TokenUsageAdminContext.tsx +3 -18
  218. package/src/features/tokenusage/contexts/__tests__/TokenUsageAdminContext.spec.tsx +17 -0
  219. package/src/features/tokenusage/tokenusage-admin.module.ts +4 -3
  220. package/src/features/tokenusage/tokenusage.modules.ts +12 -2
  221. package/src/features/user/contexts/UserContext.tsx +18 -3
  222. package/src/features/user/contexts/__tests__/UserContext.spec.tsx +95 -0
  223. package/dist/BlockNoteEditor-UJ6363LC.js.map +0 -1
  224. package/dist/BlockNoteEditor-WTSQANKI.mjs.map +0 -1
  225. package/dist/chunk-3BAM23VA.js.map +0 -1
  226. package/dist/chunk-CRI4T47Y.js.map +0 -1
  227. package/dist/chunk-JQWI62NV.mjs.map +0 -1
  228. package/dist/chunk-MIZTWBPX.mjs.map +0 -1
  229. package/dist/chunk-QKZUE5OF.mjs.map +0 -1
  230. package/dist/chunk-XGTQWBDJ.js.map +0 -1
  231. /package/dist/{BlockNoteViewer-JVKWJZ6J.mjs.map → BlockNoteViewer-U6E3DS4F.mjs.map} +0 -0
  232. /package/dist/{chunk-6WU74VFU.mjs.map → chunk-6SQYXUVP.mjs.map} +0 -0
  233. /package/dist/{chunk-FNTQQFLH.mjs.map → chunk-W7F5BDSV.mjs.map} +0 -0
@@ -0,0 +1,125 @@
1
+ "use client";
2
+
3
+ import Image from "next/image";
4
+ import { MicroLabel, SectionHeader } from "../../../../components/typography";
5
+ import { Link } from "../../../../shadcnui";
6
+ import { cn } from "../../../../utils/cn";
7
+ import { isRemoteImageSrc } from "../../../../utils/isRemoteImageSrc";
8
+ import type { PublicBlogCategory, PublicBlogPost } from "../../types/blog-public.types";
9
+ import { blogPostHref, resolvePostCategory } from "./blogPublicHelpers";
10
+ import { BlogPostMeta } from "./BlogPostMeta";
11
+
12
+ /**
13
+ * Cover image in a fixed-ratio frame. With no image it renders a
14
+ * token-coloured placeholder of the same footprint, so rows stay aligned.
15
+ */
16
+ export function BlogPostCover(props: {
17
+ src?: string;
18
+ alt?: string;
19
+ sizes: string;
20
+ priority?: boolean;
21
+ className?: string;
22
+ }) {
23
+ const { src, alt, sizes, priority, className } = props;
24
+ return (
25
+ <div className={cn("border-border bg-primary/10 relative overflow-hidden rounded-lg border", className)}>
26
+ {src ? (
27
+ <Image
28
+ src={src}
29
+ alt={alt ?? ""}
30
+ fill
31
+ sizes={sizes}
32
+ priority={priority}
33
+ className="object-cover"
34
+ unoptimized={isRemoteImageSrc(src)}
35
+ />
36
+ ) : null}
37
+ </div>
38
+ );
39
+ }
40
+
41
+ type BlogPostCardVariant = "hero" | "large" | "card" | "row";
42
+
43
+ /**
44
+ * One post in a public list.
45
+ * - hero: image and text side by side, author and date (featured layout)
46
+ * - large: image above text, author and date (rail layout)
47
+ * - card: small grid card (featured grid, rail columns, related posts)
48
+ * - row: image at the start, text at the end (column layout)
49
+ * The title link is stretched over the whole card, so the card is one target
50
+ * whose accessible name is the title alone.
51
+ */
52
+ export function BlogPostCard(props: {
53
+ post: PublicBlogPost;
54
+ categories: PublicBlogCategory[];
55
+ basePath: string;
56
+ variant?: BlogPostCardVariant;
57
+ priority?: boolean;
58
+ headingLevel?: "h2" | "h3";
59
+ }) {
60
+ const { post, categories, basePath, variant = "card", priority, headingLevel = "h2" } = props;
61
+ const category = resolvePostCategory(post, categories);
62
+ const isProminent = variant === "hero" || variant === "large";
63
+
64
+ const eyebrow = category?.name;
65
+
66
+ const text = (
67
+ <div className="flex min-w-0 flex-col">
68
+ {eyebrow ? <MicroLabel as="span">{eyebrow}</MicroLabel> : null}
69
+ <SectionHeader level={headingLevel === "h3" ? 3 : 2} className="mt-1.5">
70
+ <Link href={blogPostHref(basePath, post.slug)} className="after:absolute after:inset-0">
71
+ {post.title}
72
+ </Link>
73
+ </SectionHeader>
74
+ {post.summary ? <p className="text-muted-foreground mt-2 text-sm leading-relaxed">{post.summary}</p> : null}
75
+ <BlogPostMeta post={post} showAuthor={isProminent} className="mt-3" />
76
+ </div>
77
+ );
78
+
79
+ if (variant === "hero") {
80
+ return (
81
+ <article className="group relative grid items-center gap-6 md:grid-cols-[1.4fr_1fr] md:gap-10">
82
+ <BlogPostCover
83
+ src={post.coverImage}
84
+ alt={post.coverImageAlt}
85
+ sizes="(min-width: 1024px) 560px, (min-width: 768px) 58vw, 100vw"
86
+ priority={priority}
87
+ className="aspect-[16/10]"
88
+ />
89
+ {text}
90
+ </article>
91
+ );
92
+ }
93
+
94
+ if (variant === "row") {
95
+ return (
96
+ <article className="group relative grid items-center gap-4 sm:grid-cols-[10rem_1fr] sm:gap-6">
97
+ <BlogPostCover
98
+ src={post.coverImage}
99
+ alt={post.coverImageAlt}
100
+ sizes="(min-width: 640px) 160px, 100vw"
101
+ priority={priority}
102
+ className="aspect-[16/10]"
103
+ />
104
+ {text}
105
+ </article>
106
+ );
107
+ }
108
+
109
+ return (
110
+ <article className="group relative flex flex-col gap-4">
111
+ <BlogPostCover
112
+ src={post.coverImage}
113
+ alt={post.coverImageAlt}
114
+ sizes={
115
+ variant === "large"
116
+ ? "(min-width: 1024px) 760px, 100vw"
117
+ : "(min-width: 1024px) 320px, (min-width: 640px) 50vw, 100vw"
118
+ }
119
+ priority={priority}
120
+ className={variant === "large" ? "aspect-[16/8]" : "aspect-[16/10]"}
121
+ />
122
+ {text}
123
+ </article>
124
+ );
125
+ }
@@ -0,0 +1,49 @@
1
+ "use client";
2
+
3
+ import { useFormatter, useTranslations } from "next-intl";
4
+ import { cn } from "../../../../utils/cn";
5
+ import type { PublicBlogPost } from "../../types/blog-public.types";
6
+
7
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
8
+
9
+ /**
10
+ * "author · date · updated · reading time". Each part except reading time is
11
+ * opt-in. The public blog shows no dates, so its callers pass only showAuthor.
12
+ */
13
+ export function BlogPostMeta(props: {
14
+ post: PublicBlogPost;
15
+ showAuthor?: boolean;
16
+ showDate?: boolean;
17
+ showUpdated?: boolean;
18
+ className?: string;
19
+ }) {
20
+ const { post, showAuthor, showDate, showUpdated, className } = props;
21
+ const t = useTranslations();
22
+ const format = useFormatter();
23
+
24
+ const parts: string[] = [];
25
+ if (showAuthor && post.authorName) parts.push(t("blog.public.by", { author: post.authorName }));
26
+ if (showDate && post.publishedAt) {
27
+ parts.push(format.dateTime(new Date(post.publishedAt), { dateStyle: "medium" }));
28
+ }
29
+ if (
30
+ showUpdated &&
31
+ post.publishedAt &&
32
+ post.updatedAt &&
33
+ new Date(post.updatedAt).getTime() - new Date(post.publishedAt).getTime() > ONE_DAY_MS
34
+ ) {
35
+ parts.push(t("blog.public.updated", { date: format.dateTime(new Date(post.updatedAt), { dateStyle: "medium" }) }));
36
+ }
37
+ parts.push(t("blog.public.readingTime", { minutes: post.readingMinutes }));
38
+
39
+ return (
40
+ <p className={cn("text-muted-foreground text-xs", className)}>
41
+ {parts.map((part, index) => (
42
+ <span key={index}>
43
+ {index > 0 ? " · " : null}
44
+ {part}
45
+ </span>
46
+ ))}
47
+ </p>
48
+ );
49
+ }
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import { ArrowLeftIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import type { ReactNode } from "react";
6
+ import { BlockNoteEditorContainer } from "../../../../components/editors/BlockNoteEditorContainer";
7
+ import { ContentTitle } from "../../../../components/navigations/ContentTitle";
8
+ import { SectionHeader } from "../../../../components/typography";
9
+ import { Link } from "../../../../shadcnui";
10
+ import type { PublicBlogCategory, PublicBlogPost } from "../../types/blog-public.types";
11
+ import { resolvePostCategory } from "./blogPublicHelpers";
12
+ import { BlogPostCard, BlogPostCover } from "./BlogPostCard";
13
+ import { BlogPostMeta } from "./BlogPostMeta";
14
+
15
+ /**
16
+ * Public post page: back link, category, title, summary, meta, cover, body,
17
+ * then up to three posts from the same category.
18
+ *
19
+ * `body` replaces the client-only editor with server-rendered content (the app
20
+ * passes BlockNoteServerContent from `@carlonicora/nextjs-jsonapi/blocknote/server`),
21
+ * so the post text is in the HTML crawlers read.
22
+ */
23
+ export function BlogPostView(props: {
24
+ post: PublicBlogPost;
25
+ categories: PublicBlogCategory[];
26
+ related: PublicBlogPost[];
27
+ basePath: string;
28
+ body?: ReactNode;
29
+ }) {
30
+ const { post, categories, related, basePath, body } = props;
31
+ const t = useTranslations();
32
+ const category = resolvePostCategory(post, categories);
33
+ const more = related.slice(0, 3);
34
+
35
+ return (
36
+ <article className="px-4 pt-12 pb-20 md:pt-16">
37
+ <div className="mx-auto max-w-3xl">
38
+ <Link href={basePath} className="inline-flex items-center gap-1.5">
39
+ <ArrowLeftIcon className="size-4 rtl:rotate-180" />
40
+ {t("blog.public.back")}
41
+ </Link>
42
+ <header className="mt-8">
43
+ <ContentTitle type={category?.name} element={post.title} headingLevel={1} className="mb-0" />
44
+ {post.summary ? <p className="text-muted-foreground mt-3 text-sm leading-relaxed">{post.summary}</p> : null}
45
+ <BlogPostMeta post={post} showAuthor className="mt-4" />
46
+ </header>
47
+ {post.coverImage ? (
48
+ <BlogPostCover
49
+ src={post.coverImage}
50
+ alt={post.coverImageAlt}
51
+ sizes="(min-width: 768px) 768px, 100vw"
52
+ priority
53
+ className="mt-8 aspect-[16/9]"
54
+ />
55
+ ) : null}
56
+ <div className="mt-8">
57
+ {body ?? <BlockNoteEditorContainer id={post.id} type="blog-post" initialContent={post.body} />}
58
+ </div>
59
+ </div>
60
+ {more.length > 0 ? (
61
+ <section className="mx-auto mt-16 max-w-5xl">
62
+ <div className="border-border border-t pt-10">
63
+ <SectionHeader level={2}>{t("blog.public.moreIn", { category: category?.name ?? "" })}</SectionHeader>
64
+ <div className="mt-6 grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
65
+ {more.map((relatedPost) => (
66
+ <BlogPostCard
67
+ key={relatedPost.id}
68
+ post={relatedPost}
69
+ categories={categories}
70
+ basePath={basePath}
71
+ headingLevel="h3"
72
+ />
73
+ ))}
74
+ </div>
75
+ </div>
76
+ </section>
77
+ ) : null}
78
+ </article>
79
+ );
80
+ }
@@ -0,0 +1,52 @@
1
+ "use client";
2
+
3
+ import { SearchIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
5
+ import { FormEvent, useEffect, useState } from "react";
6
+ import { useI18nRouter } from "../../../../i18n";
7
+ import { InputGroup, InputGroupAddon, InputGroupInput } from "../../../../shadcnui";
8
+ import { blogListHref } from "./blogPublicHelpers";
9
+
10
+ /**
11
+ * Search form for the public list. The current query and category come in as
12
+ * props (the page already parsed the search params), so this component never
13
+ * reads `useSearchParams` and needs no Suspense boundary of its own.
14
+ * Submitting keeps the category and resets to page one.
15
+ */
16
+ export function BlogSearchBox(props: {
17
+ basePath: string;
18
+ query?: string;
19
+ activeCategorySlug?: string;
20
+ className?: string;
21
+ }) {
22
+ const { basePath, query, activeCategorySlug, className } = props;
23
+ const t = useTranslations();
24
+ const router = useI18nRouter();
25
+ const [value, setValue] = useState(query ?? "");
26
+
27
+ useEffect(() => setValue(query ?? ""), [query]);
28
+
29
+ const onSubmit = (event: FormEvent<HTMLFormElement>) => {
30
+ event.preventDefault();
31
+ const q = value.trim();
32
+ router.push(blogListHref(basePath, { category: activeCategorySlug, q: q || undefined }));
33
+ };
34
+
35
+ return (
36
+ <form role="search" onSubmit={onSubmit} className={className}>
37
+ <InputGroup>
38
+ <InputGroupAddon>
39
+ <SearchIcon />
40
+ </InputGroupAddon>
41
+ <InputGroupInput
42
+ type="search"
43
+ name="q"
44
+ value={value}
45
+ onChange={(event) => setValue(event.target.value)}
46
+ placeholder={t("blog.public.searchPlaceholder")}
47
+ aria-label={t("blog.public.search")}
48
+ />
49
+ </InputGroup>
50
+ </form>
51
+ );
52
+ }
@@ -0,0 +1,175 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { configureI18n } from "../../../../../i18n";
5
+ import type { PublicBlogCategory, PublicBlogPost } from "../../../types/blog-public.types";
6
+ import { BlogList } from "../BlogList";
7
+
8
+ // t interpolates its values after the key so assertions can see them.
9
+ vi.mock("next-intl", () => ({
10
+ useTranslations: () => (key: string, values?: Record<string, unknown>) =>
11
+ values ? `${key} ${Object.values(values).join(" ")}` : key,
12
+ useFormatter: () => ({ dateTime: (date: Date) => date.toISOString().slice(0, 10) }),
13
+ }));
14
+ vi.mock("next/image", () => ({
15
+ default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
16
+ }));
17
+
18
+ const cats: PublicBlogCategory[] = [
19
+ { id: "c1", name: "Game prep", slug: "game-prep" },
20
+ { id: "c2", name: "Product", slug: "product" },
21
+ ];
22
+
23
+ const post = (overrides: Partial<PublicBlogPost>): PublicBlogPost => ({
24
+ id: "p",
25
+ language: "en",
26
+ title: "Title",
27
+ slug: "title",
28
+ summary: "Summary",
29
+ readingMinutes: 4,
30
+ publishedAt: "2026-10-12T09:00:00.000Z",
31
+ updatedAt: "2026-10-12T09:00:00.000Z",
32
+ authorName: "Carlo Nicora",
33
+ categoryIds: ["c1"],
34
+ ...overrides,
35
+ });
36
+
37
+ const p1 = post({
38
+ id: "p1",
39
+ title: "Prep situations, not plots",
40
+ slug: "prep-situations",
41
+ summary: "Why a scene that can go three ways beats a scene that must go one way.",
42
+ coverImage: "https://cdn.test/p1.webp",
43
+ });
44
+ const p2 = post({
45
+ id: "p2",
46
+ title: "Voice attribution, explained",
47
+ slug: "voice",
48
+ summary: "How it knows who spoke.",
49
+ categoryIds: ["c2"],
50
+ });
51
+ const p3 = post({
52
+ id: "p3",
53
+ title: "Clues that point, not explain",
54
+ slug: "clues",
55
+ summary: "Make every clue a pointer.",
56
+ });
57
+
58
+ beforeEach(() => {
59
+ configureI18n({
60
+ useRouter: () => ({
61
+ push: () => {},
62
+ replace: () => {},
63
+ back: () => {},
64
+ forward: () => {},
65
+ refresh: () => {},
66
+ prefetch: () => {},
67
+ }),
68
+ useTranslations: () => (key: string) => key,
69
+ usePathname: () => "/blog",
70
+ getLocale: async () => "en",
71
+ Link: ({ href, children, prefetch: _prefetch, scroll: _scroll, ...props }: any) => (
72
+ <a href={href} {...props}>
73
+ {children}
74
+ </a>
75
+ ),
76
+ });
77
+ });
78
+
79
+ describe("BlogList", () => {
80
+ it.each(["featured", "column", "rail"] as const)("%s layout renders every post title and a summary", (layout) => {
81
+ render(
82
+ <BlogList
83
+ layout={layout}
84
+ list={{ posts: [p1, p2, p3], hasMore: false }}
85
+ categories={cats}
86
+ page={1}
87
+ basePath="/blog"
88
+ />,
89
+ );
90
+
91
+ expect(screen.getAllByRole("link", { name: /Prep situations/ })[0]).toHaveAttribute(
92
+ "href",
93
+ "/blog/prep-situations",
94
+ );
95
+ expect(screen.getAllByRole("link", { name: /Voice attribution/ })[0]).toHaveAttribute("href", "/blog/voice");
96
+ expect(screen.getAllByRole("link", { name: /Clues that point/ })[0]).toHaveAttribute("href", "/blog/clues");
97
+ expect(screen.getByText(p3.summary!)).toBeInTheDocument();
98
+ expect(screen.getAllByText(/blog\.public\.readingTime 4/).length).toBeGreaterThanOrEqual(3);
99
+ // One real h1 per page (public SEO page), carried by ContentTitle; the eyebrow is not part of it.
100
+ expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
101
+ expect(screen.getByRole("heading", { level: 1, name: "blog.public.title" })).toBeInTheDocument();
102
+ expect(screen.getByText("blog.public.eyebrow")).toBeInTheDocument();
103
+ // The public blog shows no dates in any layout.
104
+ expect(screen.queryByText(/2026-10-12/)).not.toBeInTheDocument();
105
+ });
106
+
107
+ it("active category chip is marked and links back to all", () => {
108
+ render(
109
+ <BlogList
110
+ layout="featured"
111
+ list={{ posts: [p1], hasMore: false }}
112
+ categories={cats}
113
+ activeCategorySlug="game-prep"
114
+ page={1}
115
+ basePath="/blog"
116
+ />,
117
+ );
118
+
119
+ const active = screen.getByRole("link", { name: "Game prep" });
120
+ expect(active).toHaveAttribute("aria-current", "page");
121
+ expect(active).toHaveAttribute("href", "/blog?category=game-prep");
122
+ expect(screen.getByRole("link", { name: "Product" })).not.toHaveAttribute("aria-current");
123
+ const all = screen.getByRole("link", { name: "blog.public.all" });
124
+ expect(all).toHaveAttribute("href", "/blog");
125
+ expect(all).not.toHaveAttribute("aria-current");
126
+ });
127
+
128
+ it("load more links to the next page keeping filters", () => {
129
+ render(
130
+ <BlogList
131
+ layout="column"
132
+ list={{ posts: [p1], hasMore: true }}
133
+ categories={cats}
134
+ activeCategorySlug="game-prep"
135
+ query="x"
136
+ page={1}
137
+ basePath="/blog"
138
+ />,
139
+ );
140
+
141
+ expect(screen.getByRole("link", { name: "blog.public.loadMore" })).toHaveAttribute(
142
+ "href",
143
+ "/blog?category=game-prep&q=x&page=2",
144
+ );
145
+ });
146
+
147
+ it("hides load more when there is nothing more", () => {
148
+ render(
149
+ <BlogList layout="rail" list={{ posts: [p1], hasMore: false }} categories={cats} page={1} basePath="/blog" />,
150
+ );
151
+ expect(screen.queryByRole("link", { name: "blog.public.loadMore" })).not.toBeInTheDocument();
152
+ });
153
+
154
+ it("empty list with a query shows noResults", () => {
155
+ render(
156
+ <BlogList
157
+ layout="featured"
158
+ list={{ posts: [], hasMore: false }}
159
+ categories={cats}
160
+ query="zzz"
161
+ page={1}
162
+ basePath="/blog"
163
+ />,
164
+ );
165
+ expect(screen.getByText("blog.public.noResults")).toBeInTheDocument();
166
+ expect(screen.queryByText("blog.public.empty")).not.toBeInTheDocument();
167
+ });
168
+
169
+ it("empty list without a query shows empty", () => {
170
+ render(
171
+ <BlogList layout="column" list={{ posts: [], hasMore: false }} categories={cats} page={1} basePath="/blog" />,
172
+ );
173
+ expect(screen.getByText("blog.public.empty")).toBeInTheDocument();
174
+ });
175
+ });
@@ -0,0 +1,134 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ import { configureI18n } from "../../../../../i18n";
5
+ import type { PublicBlogCategory, PublicBlogPost } from "../../../types/blog-public.types";
6
+ import { BlogPostView } from "../BlogPostView";
7
+
8
+ // t interpolates its values after the key so assertions can see them.
9
+ vi.mock("next-intl", () => ({
10
+ useTranslations: () => (key: string, values?: Record<string, unknown>) =>
11
+ values ? `${key} ${Object.values(values).join(" ")}` : key,
12
+ useFormatter: () => ({ dateTime: (date: Date) => date.toISOString().slice(0, 10) }),
13
+ }));
14
+ vi.mock("next/image", () => ({
15
+ default: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
16
+ }));
17
+ // The real editor is a next/dynamic ssr:false import; assert on the props it receives.
18
+ vi.mock("../../../../../components/editors/BlockNoteEditorContainer", () => ({
19
+ BlockNoteEditorContainer: ({ id, type, initialContent }: { id: string; type: string; initialContent: unknown }) => (
20
+ <div data-testid="post-body" data-id={id} data-type={type}>
21
+ {JSON.stringify(initialContent)}
22
+ </div>
23
+ ),
24
+ }));
25
+
26
+ const cats: PublicBlogCategory[] = [{ id: "c1", name: "Game prep", slug: "game-prep" }];
27
+
28
+ const post = (overrides: Partial<PublicBlogPost>): PublicBlogPost => ({
29
+ id: "p",
30
+ language: "en",
31
+ title: "Title",
32
+ slug: "title",
33
+ readingMinutes: 4,
34
+ publishedAt: "2026-10-12T09:00:00.000Z",
35
+ updatedAt: "2026-10-12T09:00:00.000Z",
36
+ categoryIds: ["c1"],
37
+ ...overrides,
38
+ });
39
+
40
+ const main = post({
41
+ id: "p1",
42
+ title: "Prep situations, not plots",
43
+ slug: "prep-situations",
44
+ summary: "Why a scene that can go three ways beats a scene that must go one way.",
45
+ body: [{ type: "paragraph", content: [{ type: "text", text: "Every GM has lived that moment" }] }],
46
+ coverImage: "https://cdn.test/p1.webp",
47
+ coverImageAlt: "A table with dice",
48
+ readingMinutes: 6,
49
+ authorName: "Carlo Nicora",
50
+ updatedAt: "2026-10-15T09:00:00.000Z",
51
+ });
52
+ const r1 = post({
53
+ id: "r1",
54
+ title: "Clues that point, not explain",
55
+ slug: "clues",
56
+ summary: "Make every clue a pointer.",
57
+ });
58
+ const r2 = post({ id: "r2", title: "Three-way scenes", slug: "three-way" });
59
+
60
+ beforeEach(() => {
61
+ configureI18n({
62
+ useRouter: () => ({
63
+ push: () => {},
64
+ replace: () => {},
65
+ back: () => {},
66
+ forward: () => {},
67
+ refresh: () => {},
68
+ prefetch: () => {},
69
+ }),
70
+ useTranslations: () => (key: string) => key,
71
+ usePathname: () => "/blog/prep-situations",
72
+ getLocale: async () => "en",
73
+ Link: ({ href, children, prefetch: _prefetch, scroll: _scroll, ...props }: any) => (
74
+ <a href={href} {...props}>
75
+ {children}
76
+ </a>
77
+ ),
78
+ });
79
+ });
80
+
81
+ describe("BlogPostView", () => {
82
+ it("shows category, title, author, reading time and more-in links", () => {
83
+ render(<BlogPostView post={main} categories={cats} related={[r1, r2]} basePath="/blog" />);
84
+
85
+ expect(screen.getByRole("link", { name: /blog\.public\.back/ })).toHaveAttribute("href", "/blog");
86
+ expect(screen.getAllByText("Game prep")[0]).toBeInTheDocument();
87
+ expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
88
+ expect(screen.getByRole("heading", { level: 1, name: "Prep situations, not plots" })).toBeInTheDocument();
89
+ expect(screen.getByText(main.summary!)).toBeInTheDocument();
90
+ expect(screen.getByText("blog.public.by Carlo Nicora")).toBeInTheDocument();
91
+ expect(screen.getByText(/blog\.public\.readingTime 6/)).toBeInTheDocument();
92
+ expect(screen.getByAltText("A table with dice")).toHaveAttribute("src", "https://cdn.test/p1.webp");
93
+ const body = screen.getByTestId("post-body");
94
+ expect(body).toHaveTextContent("Every GM has lived that moment");
95
+ expect(body).toHaveAttribute("data-id", "p1");
96
+ expect(body).toHaveAttribute("data-type", "blog-post");
97
+
98
+ expect(screen.getByRole("heading", { level: 2, name: "blog.public.moreIn Game prep" })).toBeInTheDocument();
99
+ expect(screen.getByRole("link", { name: "Clues that point, not explain" })).toHaveAttribute("href", "/blog/clues");
100
+ expect(screen.getByRole("link", { name: "Three-way scenes" })).toHaveAttribute("href", "/blog/three-way");
101
+ });
102
+
103
+ it("shows neither the publish date nor the updated date", () => {
104
+ render(<BlogPostView post={main} categories={cats} related={[]} basePath="/blog" />);
105
+ expect(screen.queryByText(/2026-10-12/)).not.toBeInTheDocument();
106
+ expect(screen.queryByText(/2026-10-15/)).not.toBeInTheDocument();
107
+ expect(screen.queryByText(/blog\.public\.updated/)).not.toBeInTheDocument();
108
+ });
109
+
110
+ it("omits the more-in section when there are no related posts", () => {
111
+ render(<BlogPostView post={main} categories={cats} related={[]} basePath="/blog" />);
112
+ expect(screen.queryByText(/blog\.public\.moreIn/)).not.toBeInTheDocument();
113
+ });
114
+
115
+ it("renders the body prop in place of the editor when given", () => {
116
+ render(
117
+ <BlogPostView
118
+ post={main}
119
+ categories={cats}
120
+ related={[]}
121
+ basePath="/blog"
122
+ body={<div data-testid="server-body">Server-rendered text</div>}
123
+ />,
124
+ );
125
+ expect(screen.getByTestId("server-body")).toHaveTextContent("Server-rendered text");
126
+ expect(screen.queryByTestId("post-body")).not.toBeInTheDocument();
127
+ });
128
+
129
+ it("caps related posts at three", () => {
130
+ const many = [1, 2, 3, 4].map((n) => post({ id: `r${n}`, title: `Related ${n}`, slug: `related-${n}` }));
131
+ render(<BlogPostView post={main} categories={cats} related={many} basePath="/blog" />);
132
+ expect(screen.getAllByRole("heading", { level: 3 })).toHaveLength(3);
133
+ });
134
+ });
@@ -0,0 +1,30 @@
1
+ import type { PublicBlogCategory, PublicBlogPost } from "../../types/blog-public.types";
2
+
3
+ /**
4
+ * Locale-free href to the public list, keeping the active filters.
5
+ * Parameter order is fixed (category, q, page) so links are stable.
6
+ */
7
+ export function blogListHref(basePath: string, params: { category?: string; q?: string; page?: number }): string {
8
+ const search = new URLSearchParams();
9
+ if (params.category) search.set("category", params.category);
10
+ if (params.q) search.set("q", params.q);
11
+ if (params.page && params.page > 1) search.set("page", String(params.page));
12
+ const query = search.toString();
13
+ return query ? `${basePath}?${query}` : basePath;
14
+ }
15
+
16
+ export function blogPostHref(basePath: string, slug: string): string {
17
+ return `${basePath}/${encodeURIComponent(slug)}`;
18
+ }
19
+
20
+ /** The first of the post's categories that the visitor's category list knows. */
21
+ export function resolvePostCategory(
22
+ post: PublicBlogPost,
23
+ categories: PublicBlogCategory[],
24
+ ): PublicBlogCategory | undefined {
25
+ for (const id of post.categoryIds) {
26
+ const category = categories.find((c) => c.id === id);
27
+ if (category) return category;
28
+ }
29
+ return undefined;
30
+ }
@@ -0,0 +1,3 @@
1
+ export { BlogList } from "./BlogList";
2
+ export type { BlogListProps } from "./BlogList";
3
+ export { BlogPostView } from "./BlogPostView";