@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.
- package/dist/{BlockNoteEditor-UJ6363LC.js → BlockNoteEditor-4YCAIEPT.js} +23 -22
- package/dist/BlockNoteEditor-4YCAIEPT.js.map +1 -0
- package/dist/{BlockNoteEditor-WTSQANKI.mjs → BlockNoteEditor-YQL2SGFR.mjs} +8 -7
- package/dist/BlockNoteEditor-YQL2SGFR.mjs.map +1 -0
- package/dist/{BlockNoteViewer-JVKWJZ6J.mjs → BlockNoteViewer-AEJKJ27Q.mjs} +2 -2
- package/dist/{BlockNoteViewer-EL4JFB6P.js → BlockNoteViewer-YL3J7J2Y.js} +3 -3
- package/dist/{BlockNoteViewer-EL4JFB6P.js.map → BlockNoteViewer-YL3J7J2Y.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 +441 -441
- package/dist/billing/index.mjs +3 -3
- package/dist/blog-public.types-BbrzsC1c.d.mts +30 -0
- package/dist/blog-public.types-BbrzsC1c.d.ts +30 -0
- package/dist/{chunk-3BAM23VA.js → chunk-3NLAXVIN.js} +860 -849
- package/dist/chunk-3NLAXVIN.js.map +1 -0
- package/dist/{chunk-QKZUE5OF.mjs → chunk-3YW35YHQ.mjs} +1 -101
- package/dist/chunk-3YW35YHQ.mjs.map +1 -0
- package/dist/{chunk-QQQE3N2X.js → chunk-5PPG6PL7.js} +3 -3
- package/dist/{chunk-QQQE3N2X.js.map → chunk-5PPG6PL7.js.map} +1 -1
- package/dist/{chunk-FNTQQFLH.mjs → chunk-6DC2WVVB.mjs} +2 -2
- package/dist/{chunk-MIZTWBPX.mjs → chunk-EUKTBOI2.mjs} +19 -8
- package/dist/chunk-EUKTBOI2.mjs.map +1 -0
- package/dist/{chunk-XGTQWBDJ.js → chunk-KSYQEKJX.js} +11 -111
- package/dist/chunk-KSYQEKJX.js.map +1 -0
- package/dist/{chunk-CRI4T47Y.js → chunk-NFBOSPNJ.js} +756 -41
- package/dist/chunk-NFBOSPNJ.js.map +1 -0
- package/dist/{chunk-6WU74VFU.mjs → chunk-QMYQRPQC.mjs} +2 -2
- package/dist/{chunk-JQWI62NV.mjs → chunk-WC63PJSC.mjs} +716 -1
- package/dist/chunk-WC63PJSC.mjs.map +1 -0
- package/dist/chunk-XCHSX3VC.mjs +107 -0
- package/dist/chunk-XCHSX3VC.mjs.map +1 -0
- package/dist/{chunk-EO75CV76.js → chunk-YNCQSWQW.js} +8 -8
- package/dist/{chunk-EO75CV76.js.map → chunk-YNCQSWQW.js.map} +1 -1
- package/dist/chunk-YW4ZCEWB.js +107 -0
- package/dist/chunk-YW4ZCEWB.js.map +1 -0
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +13 -2
- package/dist/components/index.d.ts +13 -2
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +214 -3
- package/dist/core/index.d.ts +214 -3
- package/dist/core/index.js +36 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +35 -1
- package/dist/email-automations/index.js +92 -92
- package/dist/email-automations/index.mjs +3 -3
- package/dist/features/analytics/index.js +122 -122
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +6 -5
- package/dist/features/analytics/server.js.map +1 -1
- package/dist/features/analytics/server.mjs +4 -3
- 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 +266 -0
- package/dist/features/blog/index.d.ts +266 -0
- package/dist/features/blog/index.js +1799 -0
- package/dist/features/blog/index.js.map +1 -0
- package/dist/features/blog/index.mjs +1799 -0
- package/dist/features/blog/index.mjs.map +1 -0
- package/dist/features/blog/server.d.mts +33 -0
- package/dist/features/blog/server.d.ts +33 -0
- package/dist/features/blog/server.js +139 -0
- package/dist/features/blog/server.js.map +1 -0
- package/dist/features/blog/server.mjs +139 -0
- package/dist/features/blog/server.mjs.map +1 -0
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +125 -125
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +37 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +36 -2
- 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 +8 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +12 -10
- package/messages/en.json +93 -0
- package/messages/it.json +93 -0
- package/package.json +11 -1
- package/src/components/editors/BlockNoteEditor.tsx +13 -3
- package/src/components/forms/FormBlockNote.tsx +4 -0
- package/src/components/navigations/ContentTitle.tsx +14 -3
- package/src/core/index.ts +4 -0
- package/src/core/registry/ModuleRegistry.ts +6 -0
- package/src/features/blog/BlogModules.ts +49 -0
- package/src/features/blog/components/admin/BlogAdminContainer.tsx +32 -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 +102 -0
- package/src/features/blog/components/admin/BlogPostDeleter.tsx +35 -0
- package/src/features/blog/components/admin/BlogPostEditor.tsx +207 -0
- package/src/features/blog/components/admin/BlogPostPublishButton.tsx +50 -0
- package/src/features/blog/components/admin/BlogPostTranslationEditor.tsx +110 -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 +132 -0
- package/src/features/blog/components/public/BlogPostMeta.tsx +49 -0
- package/src/features/blog/components/public/BlogPostView.tsx +74 -0
- package/src/features/blog/components/public/BlogSearchBox.tsx +52 -0
- package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +173 -0
- package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +128 -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 +120 -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/s3/data/s3.service.ts +17 -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-AEJKJ27Q.mjs.map} +0 -0
- /package/dist/{chunk-FNTQQFLH.mjs.map → chunk-6DC2WVVB.mjs.map} +0 -0
- /package/dist/{chunk-6WU74VFU.mjs.map → chunk-QMYQRPQC.mjs.map} +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { BlogCategoryChips } from "./BlogCategoryChips";
|
|
4
|
+
import { BlogListEmpty, BlogListHeader, type BlogListProps, BlogLoadMore } from "./BlogListParts";
|
|
5
|
+
import { BlogPostCard } from "./BlogPostCard";
|
|
6
|
+
import { BlogSearchBox } from "./BlogSearchBox";
|
|
7
|
+
|
|
8
|
+
/** Layout B: one narrow reading column, every post a row (image at the start, text at the end). */
|
|
9
|
+
export function BlogListColumn(props: BlogListProps) {
|
|
10
|
+
const { list, categories, activeCategorySlug, query, page, basePath } = props;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<section className="px-4 pt-16 pb-20 md:pt-20">
|
|
14
|
+
<div className="mx-auto max-w-2xl">
|
|
15
|
+
<BlogListHeader align="center" />
|
|
16
|
+
<BlogCategoryChips
|
|
17
|
+
categories={categories}
|
|
18
|
+
basePath={basePath}
|
|
19
|
+
activeCategorySlug={activeCategorySlug}
|
|
20
|
+
query={query}
|
|
21
|
+
className="mt-8 [&>ul]:justify-center"
|
|
22
|
+
/>
|
|
23
|
+
<BlogSearchBox basePath={basePath} query={query} activeCategorySlug={activeCategorySlug} className="mt-4" />
|
|
24
|
+
{list.posts.length === 0 ? (
|
|
25
|
+
<BlogListEmpty query={query} />
|
|
26
|
+
) : (
|
|
27
|
+
<div className="divide-border mt-10 flex flex-col divide-y">
|
|
28
|
+
{list.posts.map((post, index) => (
|
|
29
|
+
<div key={post.id} className="py-6 first:pt-0">
|
|
30
|
+
<BlogPostCard
|
|
31
|
+
post={post}
|
|
32
|
+
categories={categories}
|
|
33
|
+
basePath={basePath}
|
|
34
|
+
variant="row"
|
|
35
|
+
priority={index === 0}
|
|
36
|
+
/>
|
|
37
|
+
</div>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
)}
|
|
41
|
+
{list.hasMore ? (
|
|
42
|
+
<BlogLoadMore
|
|
43
|
+
basePath={basePath}
|
|
44
|
+
page={page}
|
|
45
|
+
activeCategorySlug={activeCategorySlug}
|
|
46
|
+
query={query}
|
|
47
|
+
className="mt-10"
|
|
48
|
+
/>
|
|
49
|
+
) : null}
|
|
50
|
+
</div>
|
|
51
|
+
</section>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { BlogCategoryChips } from "./BlogCategoryChips";
|
|
4
|
+
import { BlogListEmpty, BlogListHeader, type BlogListProps, BlogLoadMore } from "./BlogListParts";
|
|
5
|
+
import { BlogPostCard } from "./BlogPostCard";
|
|
6
|
+
import { BlogSearchBox } from "./BlogSearchBox";
|
|
7
|
+
|
|
8
|
+
/** Layout A: newest post as a hero, then filters, then a three-column grid. */
|
|
9
|
+
export function BlogListFeatured(props: BlogListProps) {
|
|
10
|
+
const { list, categories, activeCategorySlug, query, page, basePath } = props;
|
|
11
|
+
const [hero, ...rest] = list.posts;
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<section className="px-4 pt-16 pb-20 md:pt-20">
|
|
15
|
+
<div className="mx-auto max-w-5xl">
|
|
16
|
+
<BlogListHeader />
|
|
17
|
+
{hero ? (
|
|
18
|
+
<div className="mt-10">
|
|
19
|
+
<BlogPostCard post={hero} categories={categories} basePath={basePath} variant="hero" priority />
|
|
20
|
+
</div>
|
|
21
|
+
) : null}
|
|
22
|
+
<div className="mt-12 flex flex-col gap-3 md:flex-row md:items-center">
|
|
23
|
+
<BlogCategoryChips
|
|
24
|
+
categories={categories}
|
|
25
|
+
basePath={basePath}
|
|
26
|
+
activeCategorySlug={activeCategorySlug}
|
|
27
|
+
query={query}
|
|
28
|
+
className="flex-1"
|
|
29
|
+
/>
|
|
30
|
+
<BlogSearchBox
|
|
31
|
+
basePath={basePath}
|
|
32
|
+
query={query}
|
|
33
|
+
activeCategorySlug={activeCategorySlug}
|
|
34
|
+
className="md:w-64"
|
|
35
|
+
/>
|
|
36
|
+
</div>
|
|
37
|
+
{list.posts.length === 0 ? <BlogListEmpty query={query} /> : null}
|
|
38
|
+
{rest.length > 0 ? (
|
|
39
|
+
<div className="mt-8 grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
|
40
|
+
{rest.map((post) => (
|
|
41
|
+
<BlogPostCard key={post.id} post={post} categories={categories} basePath={basePath} />
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
) : null}
|
|
45
|
+
{list.hasMore ? (
|
|
46
|
+
<BlogLoadMore
|
|
47
|
+
basePath={basePath}
|
|
48
|
+
page={page}
|
|
49
|
+
activeCategorySlug={activeCategorySlug}
|
|
50
|
+
query={query}
|
|
51
|
+
className="mt-12"
|
|
52
|
+
/>
|
|
53
|
+
) : null}
|
|
54
|
+
</div>
|
|
55
|
+
</section>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { ContentTitle } from "../../../../components/navigations/ContentTitle";
|
|
5
|
+
import { buttonVariants, Link } from "../../../../shadcnui";
|
|
6
|
+
import { cn } from "../../../../utils/cn";
|
|
7
|
+
import type { PublicBlogCategory, PublicBlogList } from "../../types/blog-public.types";
|
|
8
|
+
import type { BlogListLayout } from "../../types/blog.constants";
|
|
9
|
+
import { blogListHref } from "./blogPublicHelpers";
|
|
10
|
+
|
|
11
|
+
export type BlogListProps = {
|
|
12
|
+
layout: BlogListLayout;
|
|
13
|
+
list: PublicBlogList;
|
|
14
|
+
categories: PublicBlogCategory[];
|
|
15
|
+
activeCategorySlug?: string;
|
|
16
|
+
query?: string;
|
|
17
|
+
page: number;
|
|
18
|
+
basePath: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Eyebrow, page title and (optionally) the subtitle. */
|
|
22
|
+
export function BlogListHeader(props: { align?: "start" | "center"; showSubtitle?: boolean }) {
|
|
23
|
+
const { align = "start", showSubtitle = true } = props;
|
|
24
|
+
const t = useTranslations();
|
|
25
|
+
return (
|
|
26
|
+
<header className={align === "center" ? "text-center" : undefined}>
|
|
27
|
+
<ContentTitle
|
|
28
|
+
type={t("blog.public.eyebrow")}
|
|
29
|
+
element={t("blog.public.title")}
|
|
30
|
+
headingLevel={1}
|
|
31
|
+
className={cn("mb-0", align === "center" && "[&_div]:justify-center")}
|
|
32
|
+
/>
|
|
33
|
+
{showSubtitle ? (
|
|
34
|
+
<p className="text-muted-foreground mt-3 text-sm leading-relaxed">{t("blog.public.subtitle")}</p>
|
|
35
|
+
) : null}
|
|
36
|
+
</header>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Shown in place of the posts: "no results" when searching, "no posts yet" otherwise. */
|
|
41
|
+
export function BlogListEmpty(props: { query?: string; className?: string }) {
|
|
42
|
+
const t = useTranslations();
|
|
43
|
+
return (
|
|
44
|
+
<p className={cn("text-muted-foreground py-12 text-center text-sm", props.className)}>
|
|
45
|
+
{props.query ? t("blog.public.noResults") : t("blog.public.empty")}
|
|
46
|
+
</p>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** "Load more": a plain link to the next page, keeping category and query. */
|
|
51
|
+
export function BlogLoadMore(props: {
|
|
52
|
+
basePath: string;
|
|
53
|
+
page: number;
|
|
54
|
+
activeCategorySlug?: string;
|
|
55
|
+
query?: string;
|
|
56
|
+
className?: string;
|
|
57
|
+
}) {
|
|
58
|
+
const { basePath, page, activeCategorySlug, query, className } = props;
|
|
59
|
+
const t = useTranslations();
|
|
60
|
+
return (
|
|
61
|
+
<div className={cn("flex justify-center", className)}>
|
|
62
|
+
{/* A styled Link, not <Button render={<Link />} nativeButton={false}>: Base UI then
|
|
63
|
+
stamps role="button" on the anchor, so a control that navigates would announce
|
|
64
|
+
as a button (same reasoning as HelpHeader). */}
|
|
65
|
+
<Link
|
|
66
|
+
href={blogListHref(basePath, { category: activeCategorySlug, q: query, page: page + 1 })}
|
|
67
|
+
scroll={false}
|
|
68
|
+
className={buttonVariants({ variant: "outline" })}
|
|
69
|
+
>
|
|
70
|
+
{t("blog.public.loadMore")}
|
|
71
|
+
</Link>
|
|
72
|
+
</div>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { MicroLabel } from "../../../../components/typography";
|
|
5
|
+
import { BlogCategoryChips } from "./BlogCategoryChips";
|
|
6
|
+
import { BlogListEmpty, BlogListHeader, type BlogListProps, BlogLoadMore } from "./BlogListParts";
|
|
7
|
+
import { BlogPostCard } from "./BlogPostCard";
|
|
8
|
+
import { BlogSearchBox } from "./BlogSearchBox";
|
|
9
|
+
|
|
10
|
+
/** Layout C: search and categories in a side rail; newest post large, the rest in two columns. */
|
|
11
|
+
export function BlogListRail(props: BlogListProps) {
|
|
12
|
+
const { list, categories, activeCategorySlug, query, page, basePath } = props;
|
|
13
|
+
const t = useTranslations();
|
|
14
|
+
const [first, ...rest] = list.posts;
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<section className="px-4 pt-16 pb-20 md:pt-20">
|
|
18
|
+
<div className="mx-auto grid max-w-5xl gap-10 md:grid-cols-[13rem_1fr] md:gap-12">
|
|
19
|
+
<aside className="flex flex-col gap-6">
|
|
20
|
+
<BlogListHeader showSubtitle={false} />
|
|
21
|
+
<BlogSearchBox basePath={basePath} query={query} activeCategorySlug={activeCategorySlug} />
|
|
22
|
+
<div>
|
|
23
|
+
<MicroLabel as="span" className="block">
|
|
24
|
+
{t("blog.entities.categories")}
|
|
25
|
+
</MicroLabel>
|
|
26
|
+
<BlogCategoryChips
|
|
27
|
+
categories={categories}
|
|
28
|
+
basePath={basePath}
|
|
29
|
+
activeCategorySlug={activeCategorySlug}
|
|
30
|
+
query={query}
|
|
31
|
+
variant="list"
|
|
32
|
+
className="mt-3"
|
|
33
|
+
/>
|
|
34
|
+
</div>
|
|
35
|
+
</aside>
|
|
36
|
+
<div className="min-w-0">
|
|
37
|
+
{first ? (
|
|
38
|
+
<BlogPostCard post={first} categories={categories} basePath={basePath} variant="large" priority />
|
|
39
|
+
) : (
|
|
40
|
+
<BlogListEmpty query={query} />
|
|
41
|
+
)}
|
|
42
|
+
{rest.length > 0 ? (
|
|
43
|
+
<div className="border-border mt-10 grid gap-x-6 gap-y-10 border-t pt-10 sm:grid-cols-2">
|
|
44
|
+
{rest.map((post) => (
|
|
45
|
+
<BlogPostCard key={post.id} post={post} categories={categories} basePath={basePath} />
|
|
46
|
+
))}
|
|
47
|
+
</div>
|
|
48
|
+
) : null}
|
|
49
|
+
{list.hasMore ? (
|
|
50
|
+
<BlogLoadMore
|
|
51
|
+
basePath={basePath}
|
|
52
|
+
page={page}
|
|
53
|
+
activeCategorySlug={activeCategorySlug}
|
|
54
|
+
query={query}
|
|
55
|
+
className="mt-12"
|
|
56
|
+
/>
|
|
57
|
+
) : null}
|
|
58
|
+
</div>
|
|
59
|
+
</div>
|
|
60
|
+
</section>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useFormatter } from "next-intl";
|
|
4
|
+
import Image from "next/image";
|
|
5
|
+
import { MicroLabel, SectionHeader } from "../../../../components/typography";
|
|
6
|
+
import { Link } from "../../../../shadcnui";
|
|
7
|
+
import { cn } from "../../../../utils/cn";
|
|
8
|
+
import { isRemoteImageSrc } from "../../../../utils/isRemoteImageSrc";
|
|
9
|
+
import type { PublicBlogCategory, PublicBlogPost } from "../../types/blog-public.types";
|
|
10
|
+
import { blogPostHref, resolvePostCategory } from "./blogPublicHelpers";
|
|
11
|
+
import { BlogPostMeta } from "./BlogPostMeta";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Cover image in a fixed-ratio frame. With no image it renders a
|
|
15
|
+
* token-coloured placeholder of the same footprint, so rows stay aligned.
|
|
16
|
+
*/
|
|
17
|
+
export function BlogPostCover(props: {
|
|
18
|
+
src?: string;
|
|
19
|
+
alt?: string;
|
|
20
|
+
sizes: string;
|
|
21
|
+
priority?: boolean;
|
|
22
|
+
className?: string;
|
|
23
|
+
}) {
|
|
24
|
+
const { src, alt, sizes, priority, className } = props;
|
|
25
|
+
return (
|
|
26
|
+
<div className={cn("border-border bg-primary/10 relative overflow-hidden rounded-lg border", className)}>
|
|
27
|
+
{src ? (
|
|
28
|
+
<Image
|
|
29
|
+
src={src}
|
|
30
|
+
alt={alt ?? ""}
|
|
31
|
+
fill
|
|
32
|
+
sizes={sizes}
|
|
33
|
+
priority={priority}
|
|
34
|
+
className="object-cover"
|
|
35
|
+
unoptimized={isRemoteImageSrc(src)}
|
|
36
|
+
/>
|
|
37
|
+
) : null}
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
type BlogPostCardVariant = "hero" | "large" | "card" | "row";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One post in a public list.
|
|
46
|
+
* - hero: image and text side by side, author and date (featured layout)
|
|
47
|
+
* - large: image above text, author and date (rail layout)
|
|
48
|
+
* - card: small grid card (featured grid, rail columns, related posts)
|
|
49
|
+
* - row: image at the start, text at the end (column layout)
|
|
50
|
+
* The title link is stretched over the whole card, so the card is one target
|
|
51
|
+
* whose accessible name is the title alone.
|
|
52
|
+
*/
|
|
53
|
+
export function BlogPostCard(props: {
|
|
54
|
+
post: PublicBlogPost;
|
|
55
|
+
categories: PublicBlogCategory[];
|
|
56
|
+
basePath: string;
|
|
57
|
+
variant?: BlogPostCardVariant;
|
|
58
|
+
priority?: boolean;
|
|
59
|
+
headingLevel?: "h2" | "h3";
|
|
60
|
+
}) {
|
|
61
|
+
const { post, categories, basePath, variant = "card", priority, headingLevel = "h2" } = props;
|
|
62
|
+
const format = useFormatter();
|
|
63
|
+
const category = resolvePostCategory(post, categories);
|
|
64
|
+
const isProminent = variant === "hero" || variant === "large";
|
|
65
|
+
|
|
66
|
+
const eyebrow =
|
|
67
|
+
variant === "row" && post.publishedAt
|
|
68
|
+
? [category?.name, format.dateTime(new Date(post.publishedAt), { day: "numeric", month: "short" })]
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join(" · ")
|
|
71
|
+
: category?.name;
|
|
72
|
+
|
|
73
|
+
const text = (
|
|
74
|
+
<div className="flex min-w-0 flex-col">
|
|
75
|
+
{eyebrow ? <MicroLabel as="span">{eyebrow}</MicroLabel> : null}
|
|
76
|
+
<SectionHeader level={headingLevel === "h3" ? 3 : 2} className="mt-1.5">
|
|
77
|
+
<Link href={blogPostHref(basePath, post.slug)} className="after:absolute after:inset-0">
|
|
78
|
+
{post.title}
|
|
79
|
+
</Link>
|
|
80
|
+
</SectionHeader>
|
|
81
|
+
{post.summary ? <p className="text-muted-foreground mt-2 text-sm leading-relaxed">{post.summary}</p> : null}
|
|
82
|
+
<BlogPostMeta post={post} showAuthor={isProminent} showDate={isProminent} className="mt-3" />
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
if (variant === "hero") {
|
|
87
|
+
return (
|
|
88
|
+
<article className="group relative grid items-center gap-6 md:grid-cols-[1.4fr_1fr] md:gap-10">
|
|
89
|
+
<BlogPostCover
|
|
90
|
+
src={post.coverImage}
|
|
91
|
+
alt={post.coverImageAlt}
|
|
92
|
+
sizes="(min-width: 1024px) 560px, (min-width: 768px) 58vw, 100vw"
|
|
93
|
+
priority={priority}
|
|
94
|
+
className="aspect-[16/10]"
|
|
95
|
+
/>
|
|
96
|
+
{text}
|
|
97
|
+
</article>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (variant === "row") {
|
|
102
|
+
return (
|
|
103
|
+
<article className="group relative grid items-center gap-4 sm:grid-cols-[10rem_1fr] sm:gap-6">
|
|
104
|
+
<BlogPostCover
|
|
105
|
+
src={post.coverImage}
|
|
106
|
+
alt={post.coverImageAlt}
|
|
107
|
+
sizes="(min-width: 640px) 160px, 100vw"
|
|
108
|
+
priority={priority}
|
|
109
|
+
className="aspect-[16/10]"
|
|
110
|
+
/>
|
|
111
|
+
{text}
|
|
112
|
+
</article>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<article className="group relative flex flex-col gap-4">
|
|
118
|
+
<BlogPostCover
|
|
119
|
+
src={post.coverImage}
|
|
120
|
+
alt={post.coverImageAlt}
|
|
121
|
+
sizes={
|
|
122
|
+
variant === "large"
|
|
123
|
+
? "(min-width: 1024px) 760px, 100vw"
|
|
124
|
+
: "(min-width: 1024px) 320px, (min-width: 640px) 50vw, 100vw"
|
|
125
|
+
}
|
|
126
|
+
priority={priority}
|
|
127
|
+
className={variant === "large" ? "aspect-[16/8]" : "aspect-[16/10]"}
|
|
128
|
+
/>
|
|
129
|
+
{text}
|
|
130
|
+
</article>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -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 is opt-in so cards show
|
|
11
|
+
* only the reading time while the hero and the post page show more.
|
|
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,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ArrowLeftIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { BlockNoteEditorContainer } from "../../../../components/editors/BlockNoteEditorContainer";
|
|
6
|
+
import { ContentTitle } from "../../../../components/navigations/ContentTitle";
|
|
7
|
+
import { SectionHeader } from "../../../../components/typography";
|
|
8
|
+
import { Link } from "../../../../shadcnui";
|
|
9
|
+
import type { PublicBlogCategory, PublicBlogPost } from "../../types/blog-public.types";
|
|
10
|
+
import { resolvePostCategory } from "./blogPublicHelpers";
|
|
11
|
+
import { BlogPostCard, BlogPostCover } from "./BlogPostCard";
|
|
12
|
+
import { BlogPostMeta } from "./BlogPostMeta";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Public post page: back link, category, title, summary, meta, cover, body,
|
|
16
|
+
* then up to three posts from the same category.
|
|
17
|
+
*/
|
|
18
|
+
export function BlogPostView(props: {
|
|
19
|
+
post: PublicBlogPost;
|
|
20
|
+
categories: PublicBlogCategory[];
|
|
21
|
+
related: PublicBlogPost[];
|
|
22
|
+
basePath: string;
|
|
23
|
+
}) {
|
|
24
|
+
const { post, categories, related, basePath } = props;
|
|
25
|
+
const t = useTranslations();
|
|
26
|
+
const category = resolvePostCategory(post, categories);
|
|
27
|
+
const more = related.slice(0, 3);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<article className="px-4 pt-12 pb-20 md:pt-16">
|
|
31
|
+
<div className="mx-auto max-w-3xl">
|
|
32
|
+
<Link href={basePath} className="inline-flex items-center gap-1.5">
|
|
33
|
+
<ArrowLeftIcon className="size-4 rtl:rotate-180" />
|
|
34
|
+
{t("blog.public.back")}
|
|
35
|
+
</Link>
|
|
36
|
+
<header className="mt-8">
|
|
37
|
+
<ContentTitle type={category?.name} element={post.title} headingLevel={1} className="mb-0" />
|
|
38
|
+
{post.summary ? <p className="text-muted-foreground mt-3 text-sm leading-relaxed">{post.summary}</p> : null}
|
|
39
|
+
<BlogPostMeta post={post} showAuthor showDate showUpdated className="mt-4" />
|
|
40
|
+
</header>
|
|
41
|
+
{post.coverImage ? (
|
|
42
|
+
<BlogPostCover
|
|
43
|
+
src={post.coverImage}
|
|
44
|
+
alt={post.coverImageAlt}
|
|
45
|
+
sizes="(min-width: 768px) 768px, 100vw"
|
|
46
|
+
priority
|
|
47
|
+
className="mt-8 aspect-[16/9]"
|
|
48
|
+
/>
|
|
49
|
+
) : null}
|
|
50
|
+
<div className="mt-8">
|
|
51
|
+
<BlockNoteEditorContainer id={post.id} type="blog-post" initialContent={post.body} />
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
{more.length > 0 ? (
|
|
55
|
+
<section className="mx-auto mt-16 max-w-5xl">
|
|
56
|
+
<div className="border-border border-t pt-10">
|
|
57
|
+
<SectionHeader level={2}>{t("blog.public.moreIn", { category: category?.name ?? "" })}</SectionHeader>
|
|
58
|
+
<div className="mt-6 grid gap-x-6 gap-y-10 sm:grid-cols-2 lg:grid-cols-3">
|
|
59
|
+
{more.map((relatedPost) => (
|
|
60
|
+
<BlogPostCard
|
|
61
|
+
key={relatedPost.id}
|
|
62
|
+
post={relatedPost}
|
|
63
|
+
categories={categories}
|
|
64
|
+
basePath={basePath}
|
|
65
|
+
headingLevel="h3"
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
</section>
|
|
71
|
+
) : null}
|
|
72
|
+
</article>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -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
|
+
}
|