@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,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
|
+
}
|