@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,58 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { NewspaperIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { ReactNode } from "react";
|
|
6
|
+
import { ContentListTable } from "../../../../components";
|
|
7
|
+
import { Modules } from "../../../../core";
|
|
8
|
+
import { DataListRetriever, useDataListRetriever } from "../../../../hooks";
|
|
9
|
+
import { Button, EmptyState } from "../../../../shadcnui";
|
|
10
|
+
import { BlogPostFields, BlogPostInterface, BlogPostService } from "../../data";
|
|
11
|
+
import "../../hooks/useBlogPostTableStructure";
|
|
12
|
+
import { BlogPostEditor } from "./BlogPostEditor";
|
|
13
|
+
|
|
14
|
+
type BlogPostsListProps = {
|
|
15
|
+
/** Pass only when the list is the page's whole content inside a `RoundPageContainer fullWidth`; omit it in a tab so the list keeps its bordered card. */
|
|
16
|
+
fullWidth?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Admin posts table. Mirrors features/email-automation/components/lists/EmailAutomationsList.tsx. */
|
|
20
|
+
export function BlogPostsList({ fullWidth }: BlogPostsListProps = {}) {
|
|
21
|
+
const t = useTranslations();
|
|
22
|
+
|
|
23
|
+
const data: DataListRetriever<BlogPostInterface> = useDataListRetriever({
|
|
24
|
+
retriever: (params) => BlogPostService.findMany(params),
|
|
25
|
+
retrieverParams: {},
|
|
26
|
+
module: Modules.BlogPost,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Creating navigates to the new post's page (EditorSheet's default without onSuccess).
|
|
30
|
+
const functions: ReactNode[] = [
|
|
31
|
+
<BlogPostEditor
|
|
32
|
+
key="create-blog-post"
|
|
33
|
+
trigger={
|
|
34
|
+
<Button render={<div />} nativeButton={false} size="sm" variant="outline">
|
|
35
|
+
{t("blog.admin.newPost")}
|
|
36
|
+
</Button>
|
|
37
|
+
}
|
|
38
|
+
/>,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<ContentListTable
|
|
43
|
+
data={data}
|
|
44
|
+
fields={[
|
|
45
|
+
BlogPostFields.title,
|
|
46
|
+
BlogPostFields.status,
|
|
47
|
+
BlogPostFields.categories,
|
|
48
|
+
BlogPostFields.publishedAt,
|
|
49
|
+
BlogPostFields.languages,
|
|
50
|
+
]}
|
|
51
|
+
tableGeneratorType={Modules.BlogPost}
|
|
52
|
+
functions={functions}
|
|
53
|
+
fullWidth={fullWidth}
|
|
54
|
+
title={t("blog.entities.posts")}
|
|
55
|
+
emptyState={<EmptyState icon={NewspaperIcon} title={t("blog.admin.empty")} />}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { LoaderCircleIcon, RotateCcwIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useCallback, useMemo, useState } from "react";
|
|
6
|
+
import { errorToast } from "../../../../components";
|
|
7
|
+
import { useI18nLocale } from "../../../../i18n";
|
|
8
|
+
import { Badge, Button } from "../../../../shadcnui";
|
|
9
|
+
import { BlogCategoryInterface } from "../../data";
|
|
10
|
+
import { BlogAiStatus } from "../../types/blog.constants";
|
|
11
|
+
|
|
12
|
+
/** Status pill colours for a post, shared by the post header and the admin posts table. */
|
|
13
|
+
export const BLOG_STATUS_BADGE = {
|
|
14
|
+
draft: "softGray",
|
|
15
|
+
published: "softGreen",
|
|
16
|
+
unpublished: "softAmber",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
/** The category label in the admin: the category's own original-language name. */
|
|
20
|
+
export function blogCategoryAdminName(category: BlogCategoryInterface): string {
|
|
21
|
+
return category.original?.name ?? category.name ?? "";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Language names come from `Intl.DisplayNames` in the viewer's locale: there is
|
|
26
|
+
* no per-language message key. An unknown code falls back to the code itself.
|
|
27
|
+
*/
|
|
28
|
+
export function useBlogLanguageName(): (language: string) => string {
|
|
29
|
+
const locale = useI18nLocale();
|
|
30
|
+
const names = useMemo(() => {
|
|
31
|
+
try {
|
|
32
|
+
return new Intl.DisplayNames([locale], { type: "language" });
|
|
33
|
+
} catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}, [locale]);
|
|
37
|
+
|
|
38
|
+
return useCallback(
|
|
39
|
+
(language: string) => {
|
|
40
|
+
const name = names?.of(language);
|
|
41
|
+
if (!name) return language;
|
|
42
|
+
return name.charAt(0).toLocaleUpperCase(locale) + name.slice(1);
|
|
43
|
+
},
|
|
44
|
+
[names, locale],
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Formats a date for the admin pages in the viewer's locale. */
|
|
49
|
+
export function useBlogDateFormatter(): (date?: Date) => string {
|
|
50
|
+
const locale = useI18nLocale();
|
|
51
|
+
return useCallback((date?: Date) => (date ? date.toLocaleDateString(locale, { dateStyle: "medium" }) : ""), [locale]);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type BlogTranslationStatusProps = {
|
|
55
|
+
aiStatus: BlogAiStatus;
|
|
56
|
+
/** Re-queues the translation of this one language. Shown only when the job failed. */
|
|
57
|
+
onRetranslate?: () => Promise<void>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The translation state of one language. Nothing is rendered once the
|
|
62
|
+
* translation is `completed`; `failed` adds the "Translate again" action.
|
|
63
|
+
*/
|
|
64
|
+
export function BlogTranslationStatus({ aiStatus, onRetranslate }: BlogTranslationStatusProps) {
|
|
65
|
+
const t = useTranslations();
|
|
66
|
+
const [isRetrying, setIsRetrying] = useState<boolean>(false);
|
|
67
|
+
|
|
68
|
+
if (aiStatus === "completed") return null;
|
|
69
|
+
|
|
70
|
+
if (aiStatus === "pending" || aiStatus === "in_progress") {
|
|
71
|
+
return (
|
|
72
|
+
<div className="flex items-center gap-x-2">
|
|
73
|
+
<Badge variant="softBlue">
|
|
74
|
+
<LoaderCircleIcon className="animate-spin" />
|
|
75
|
+
{aiStatus === "pending" ? t("blog.translation.pending") : t("blog.translation.translating")}
|
|
76
|
+
</Badge>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const retranslate = async () => {
|
|
82
|
+
if (!onRetranslate) return;
|
|
83
|
+
setIsRetrying(true);
|
|
84
|
+
try {
|
|
85
|
+
await onRetranslate();
|
|
86
|
+
} catch (error) {
|
|
87
|
+
errorToast({ title: t("common.errors.error"), error });
|
|
88
|
+
} finally {
|
|
89
|
+
setIsRetrying(false);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex items-center gap-x-2">
|
|
95
|
+
<Badge variant="softRed">{t("blog.translation.failed")}</Badge>
|
|
96
|
+
{onRetranslate && (
|
|
97
|
+
<Button type="button" size="sm" variant="outline" onClick={() => void retranslate()} disabled={isRetrying}>
|
|
98
|
+
<RotateCcwIcon />
|
|
99
|
+
{t("blog.actions.translateAgain")}
|
|
100
|
+
</Button>
|
|
101
|
+
)}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import type { BlogPostInterface, BlogPostTranslationInterface } from "../../../data";
|
|
5
|
+
import { BlogPostTranslationPanel } from "../BlogPostTranslationPanel";
|
|
6
|
+
|
|
7
|
+
const { retranslate } = vi.hoisted(() => ({ retranslate: vi.fn() }));
|
|
8
|
+
|
|
9
|
+
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
|
10
|
+
vi.mock("../../../data", () => ({ BlogPostTranslationService: { retranslate } }));
|
|
11
|
+
vi.mock("../../../hooks/useBlogCategoryNames", () => ({ useBlogCategoryNames: () => () => "" }));
|
|
12
|
+
// Editors and the BlockNote field display are not under test.
|
|
13
|
+
vi.mock("../BlogPostEditor", () => ({ BlogPostEditor: () => <span>post-editor</span> }));
|
|
14
|
+
vi.mock("../BlogPostTranslationEditor", () => ({
|
|
15
|
+
BlogPostTranslationEditor: () => <span>translation-editor</span>,
|
|
16
|
+
}));
|
|
17
|
+
vi.mock("../BlogPostComparison", () => ({
|
|
18
|
+
BlogPostTranslationFields: () => <div data-testid="fields" />,
|
|
19
|
+
BlogPostComparison: () => <div data-testid="comparison" />,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
const english = {
|
|
23
|
+
id: "t-en",
|
|
24
|
+
language: "en",
|
|
25
|
+
isOriginal: true,
|
|
26
|
+
source: "original",
|
|
27
|
+
aiStatus: "completed",
|
|
28
|
+
title: "Prep situations, not plots",
|
|
29
|
+
slug: "prep-situations-not-plots",
|
|
30
|
+
summary: "Why a scene that can go three ways beats one that must go one way.",
|
|
31
|
+
body: [],
|
|
32
|
+
readingMinutes: 6,
|
|
33
|
+
} as unknown as BlogPostTranslationInterface;
|
|
34
|
+
|
|
35
|
+
const translation = (overrides: Record<string, unknown>) =>
|
|
36
|
+
({
|
|
37
|
+
id: "t-it",
|
|
38
|
+
language: "it",
|
|
39
|
+
isOriginal: false,
|
|
40
|
+
source: "ai",
|
|
41
|
+
aiStatus: "completed",
|
|
42
|
+
title: "Prepara situazioni, non trame",
|
|
43
|
+
slug: "prepara-situazioni-non-trame",
|
|
44
|
+
body: [],
|
|
45
|
+
readingMinutes: 6,
|
|
46
|
+
translatedAt: new Date("2026-10-12T09:00:00.000Z"),
|
|
47
|
+
...overrides,
|
|
48
|
+
}) as unknown as BlogPostTranslationInterface;
|
|
49
|
+
|
|
50
|
+
const manualItalian = translation({ source: "manual" });
|
|
51
|
+
|
|
52
|
+
const post = {
|
|
53
|
+
id: "p1",
|
|
54
|
+
status: "published",
|
|
55
|
+
originalLanguage: "en",
|
|
56
|
+
publishedAt: new Date("2026-10-12T09:00:00.000Z"),
|
|
57
|
+
categories: [],
|
|
58
|
+
translations: [english, manualItalian],
|
|
59
|
+
original: english,
|
|
60
|
+
categoryIds: [],
|
|
61
|
+
} as unknown as BlogPostInterface;
|
|
62
|
+
|
|
63
|
+
describe("BlogPostTranslationPanel", () => {
|
|
64
|
+
it("warns that editing the original replaces hand edits", () => {
|
|
65
|
+
render(<BlogPostTranslationPanel post={post} translation={manualItalian} />);
|
|
66
|
+
expect(screen.getByText("blog.translation.editedByHand")).toBeInTheDocument();
|
|
67
|
+
expect(screen.getByText("blog.translation.overwriteWarning")).toBeInTheDocument();
|
|
68
|
+
expect(screen.getByText("blog.translation.translatedFrom")).toBeInTheDocument();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("opens the comparison with the original on demand", () => {
|
|
72
|
+
render(<BlogPostTranslationPanel post={post} translation={manualItalian} />);
|
|
73
|
+
expect(screen.queryByTestId("comparison")).not.toBeInTheDocument();
|
|
74
|
+
fireEvent.click(screen.getByText("blog.actions.compareWithOriginal"));
|
|
75
|
+
expect(screen.getByTestId("comparison")).toBeInTheDocument();
|
|
76
|
+
expect(screen.getByText("blog.actions.hideComparison")).toBeInTheDocument();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("failed translation shows Translate again, which re-queues that language", async () => {
|
|
80
|
+
const onChanged = vi.fn().mockResolvedValue(undefined);
|
|
81
|
+
retranslate.mockResolvedValue(undefined);
|
|
82
|
+
render(
|
|
83
|
+
<BlogPostTranslationPanel post={post} translation={translation({ aiStatus: "failed" })} onChanged={onChanged} />,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
expect(screen.getByText("blog.translation.failed")).toBeInTheDocument();
|
|
87
|
+
fireEvent.click(screen.getByText("blog.actions.translateAgain"));
|
|
88
|
+
|
|
89
|
+
await waitFor(() => expect(retranslate).toHaveBeenCalledWith({ id: "t-it" }));
|
|
90
|
+
await waitFor(() => expect(onChanged).toHaveBeenCalled());
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("original tab shows the Original badge and no overwrite warning", () => {
|
|
94
|
+
render(<BlogPostTranslationPanel post={post} translation={english} />);
|
|
95
|
+
expect(screen.getByText("blog.translation.original")).toBeInTheDocument();
|
|
96
|
+
expect(screen.getByText("post-editor")).toBeInTheDocument();
|
|
97
|
+
expect(screen.queryByText("blog.translation.overwriteWarning")).not.toBeInTheDocument();
|
|
98
|
+
expect(screen.queryByText("blog.translation.editedByHand")).not.toBeInTheDocument();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { ReactNode } from "react";
|
|
3
|
+
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
|
|
6
|
+
import { BlogPostsList } from "../BlogPostsList";
|
|
7
|
+
|
|
8
|
+
vi.mock("../../../../../hooks", async (importOriginal) => ({
|
|
9
|
+
...(await importOriginal<typeof import("../../../../../hooks")>()),
|
|
10
|
+
useDataListRetriever: () => ({ data: [], refresh: vi.fn(), search: vi.fn() }),
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("../../../../../components/tables/ContentListTable", () => ({
|
|
13
|
+
ContentListTable: ({ functions }: { functions: ReactNode[] }) => <div data-testid="functions">{functions}</div>,
|
|
14
|
+
}));
|
|
15
|
+
vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
|
|
16
|
+
// The editor sheet itself is not under test: render only the trigger the list hands it.
|
|
17
|
+
vi.mock("../BlogPostEditor", () => ({
|
|
18
|
+
BlogPostEditor: ({ trigger }: { trigger?: ReactNode }) => <>{trigger}</>,
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
beforeAll(() => {
|
|
22
|
+
ModuleRegistry.register("BlogPost" as any, { name: "blog-posts" } as any);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("BlogPostsList", () => {
|
|
26
|
+
it("offers the new-post editor", () => {
|
|
27
|
+
render(<BlogPostsList />);
|
|
28
|
+
expect(screen.getByText("blog.admin.newPost")).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export * from "./BlogAdminContainer";
|
|
2
|
+
export * from "./BlogPostContainer";
|
|
3
|
+
export * from "./BlogPostsList";
|
|
4
|
+
export * from "./BlogCategoriesList";
|
|
5
|
+
export * from "./BlogLayoutSetting";
|
|
6
|
+
export * from "./BlogPostTranslationPanel";
|
|
7
|
+
export * from "./BlogPostComparison";
|
|
8
|
+
export * from "./BlogTranslationStatus";
|
|
9
|
+
export * from "./BlogPostEditor";
|
|
10
|
+
export * from "./BlogPostTranslationEditor";
|
|
11
|
+
export * from "./BlogCategoryEditor";
|
|
12
|
+
export * from "./BlogPostPublishButton";
|
|
13
|
+
export * from "./BlogPostDeleter";
|
|
14
|
+
export * from "./BlogCoverImageField";
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { Badge, Link } from "../../../../shadcnui";
|
|
5
|
+
import type { PublicBlogCategory } from "../../types/blog-public.types";
|
|
6
|
+
import { blogListHref } from "./blogPublicHelpers";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Category filter: "All" plus one link per category. Every link keeps the
|
|
10
|
+
* search query and drops the page, so a new filter starts from page one.
|
|
11
|
+
* `chips` is the horizontal pill row; `list` is the vertical rail list.
|
|
12
|
+
*/
|
|
13
|
+
export function BlogCategoryChips(props: {
|
|
14
|
+
categories: PublicBlogCategory[];
|
|
15
|
+
basePath: string;
|
|
16
|
+
activeCategorySlug?: string;
|
|
17
|
+
query?: string;
|
|
18
|
+
variant?: "chips" | "list";
|
|
19
|
+
className?: string;
|
|
20
|
+
}) {
|
|
21
|
+
const { categories, basePath, activeCategorySlug, query, variant = "chips", className } = props;
|
|
22
|
+
const t = useTranslations();
|
|
23
|
+
|
|
24
|
+
const items = [
|
|
25
|
+
{
|
|
26
|
+
key: "all",
|
|
27
|
+
label: t("blog.public.all"),
|
|
28
|
+
href: blogListHref(basePath, { q: query }),
|
|
29
|
+
active: !activeCategorySlug,
|
|
30
|
+
},
|
|
31
|
+
...categories.map((category) => ({
|
|
32
|
+
key: category.id,
|
|
33
|
+
label: category.name,
|
|
34
|
+
href: blogListHref(basePath, { category: category.slug, q: query }),
|
|
35
|
+
active: category.slug === activeCategorySlug,
|
|
36
|
+
})),
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
// Pills are Badges rendered as links: the active one is the filled default
|
|
40
|
+
// variant, the rest are outline (chips row) or ghost (rail list).
|
|
41
|
+
const inactiveVariant = variant === "chips" ? "outline" : "ghost";
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<nav aria-label={t("blog.entities.categories")} className={className}>
|
|
45
|
+
<ul className={variant === "chips" ? "flex flex-wrap items-center gap-2" : "flex flex-col items-start gap-2"}>
|
|
46
|
+
{items.map((item) => (
|
|
47
|
+
<li key={item.key}>
|
|
48
|
+
<Badge
|
|
49
|
+
variant={item.active ? "default" : inactiveVariant}
|
|
50
|
+
render={<Link href={item.href} aria-current={item.active ? "page" : undefined} />}
|
|
51
|
+
>
|
|
52
|
+
{item.label}
|
|
53
|
+
</Badge>
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
</nav>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { BlogListColumn } from "./BlogListColumn";
|
|
4
|
+
import { BlogListFeatured } from "./BlogListFeatured";
|
|
5
|
+
import type { BlogListProps } from "./BlogListParts";
|
|
6
|
+
import { BlogListRail } from "./BlogListRail";
|
|
7
|
+
|
|
8
|
+
/** Public blog list in the layout an administrator picked (BlogSettings.listLayout). */
|
|
9
|
+
export function BlogList(props: BlogListProps) {
|
|
10
|
+
switch (props.layout) {
|
|
11
|
+
case "column":
|
|
12
|
+
return <BlogListColumn {...props} />;
|
|
13
|
+
case "rail":
|
|
14
|
+
return <BlogListRail {...props} />;
|
|
15
|
+
case "featured":
|
|
16
|
+
default:
|
|
17
|
+
return <BlogListFeatured {...props} />;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type { BlogListProps };
|
|
@@ -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
|
+
}
|