@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,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { RoundPageContainer } from "../../../../components";
|
|
5
|
+
import { SharedProvider } from "../../../../contexts/SharedContext";
|
|
6
|
+
import { Modules } from "../../../../core";
|
|
7
|
+
import { BreadcrumbItemData } from "../../../../interfaces";
|
|
8
|
+
import { BlogCategoriesList } from "./BlogCategoriesList";
|
|
9
|
+
import { BlogLayoutSetting } from "./BlogLayoutSetting";
|
|
10
|
+
import { BlogPostsList } from "./BlogPostsList";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Blog admin page: posts, categories and the public list layout, one tab each.
|
|
14
|
+
* Client component because `module={Modules.BlogPost}` carries an icon component.
|
|
15
|
+
* Mirrors features/email-automation/components/containers/EmailAutomationsListContainer.tsx.
|
|
16
|
+
*
|
|
17
|
+
* Publishes its own breadcrumb, as CompanyProvider does for the companies list:
|
|
18
|
+
* the list root comes first and has no href because it is the current page.
|
|
19
|
+
* The lists inside the tabs omit `fullWidth` so they keep their bordered card,
|
|
20
|
+
* as PricesList does inside ProductContainer.
|
|
21
|
+
*/
|
|
22
|
+
export function BlogAdminContainer() {
|
|
23
|
+
const t = useTranslations();
|
|
24
|
+
|
|
25
|
+
const breadcrumbs: BreadcrumbItemData[] = [{ name: t("blog.admin.title") }];
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<SharedProvider value={{ breadcrumbs, title: { type: t("blog.admin.title") } }}>
|
|
29
|
+
<RoundPageContainer
|
|
30
|
+
module={Modules.BlogPost}
|
|
31
|
+
tabs={[
|
|
32
|
+
{ sectionKey: "posts", label: t("blog.tabs.posts"), content: <BlogPostsList /> },
|
|
33
|
+
{ sectionKey: "categories", label: t("blog.tabs.categories"), content: <BlogCategoriesList /> },
|
|
34
|
+
{ sectionKey: "layout", label: t("blog.tabs.layout"), content: <BlogLayoutSetting /> },
|
|
35
|
+
]}
|
|
36
|
+
/>
|
|
37
|
+
</SharedProvider>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { TagIcon } 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 { BlogCategoryFields, BlogCategoryInterface, BlogCategoryService } from "../../data";
|
|
11
|
+
import "../../hooks/useBlogCategoryTableStructure";
|
|
12
|
+
import { BlogCategoryEditor } from "./BlogCategoryEditor";
|
|
13
|
+
|
|
14
|
+
type BlogCategoriesListProps = {
|
|
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
|
+
/**
|
|
20
|
+
* Admin categories table. Categories have no page of their own: creating and
|
|
21
|
+
* editing both stay on this tab and refresh the list.
|
|
22
|
+
* Mirrors features/email-automation/components/lists/EmailAutomationsList.tsx.
|
|
23
|
+
*/
|
|
24
|
+
export function BlogCategoriesList({ fullWidth }: BlogCategoriesListProps = {}) {
|
|
25
|
+
const t = useTranslations();
|
|
26
|
+
|
|
27
|
+
const data: DataListRetriever<BlogCategoryInterface> = useDataListRetriever({
|
|
28
|
+
retriever: (params) => BlogCategoryService.findMany(params),
|
|
29
|
+
retrieverParams: {},
|
|
30
|
+
module: Modules.BlogCategory,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const functions: ReactNode[] = [
|
|
34
|
+
<BlogCategoryEditor
|
|
35
|
+
key="create-blog-category"
|
|
36
|
+
onSuccess={data.refresh}
|
|
37
|
+
trigger={
|
|
38
|
+
<Button render={<div />} nativeButton={false} size="sm" variant="outline">
|
|
39
|
+
{t("blog.admin.newCategory")}
|
|
40
|
+
</Button>
|
|
41
|
+
}
|
|
42
|
+
/>,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<ContentListTable
|
|
47
|
+
data={data}
|
|
48
|
+
fields={[BlogCategoryFields.name, BlogCategoryFields.languages]}
|
|
49
|
+
tableGeneratorType={Modules.BlogCategory}
|
|
50
|
+
functions={functions}
|
|
51
|
+
fullWidth={fullWidth}
|
|
52
|
+
title={t("blog.entities.categories")}
|
|
53
|
+
emptyState={<EmptyState icon={TagIcon} title={t("blog.admin.emptyCategories")} />}
|
|
54
|
+
/>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
+
import { ChevronDownIcon } from "lucide-react";
|
|
5
|
+
import { useTranslations } from "next-intl";
|
|
6
|
+
import { ReactNode, useCallback, useMemo, useState } from "react";
|
|
7
|
+
import { useForm } from "react-hook-form";
|
|
8
|
+
import { v4 } from "uuid";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { EditorSheet, FormInput, FormSelect, FormTextarea, SectionHeader } from "../../../../components";
|
|
11
|
+
import { Modules } from "../../../../core";
|
|
12
|
+
import { getI18nSupportedLocales, useI18nLocale } from "../../../../i18n";
|
|
13
|
+
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../shadcnui";
|
|
14
|
+
import {
|
|
15
|
+
BlogCategoryInterface,
|
|
16
|
+
BlogCategoryService,
|
|
17
|
+
BlogCategoryTranslationInterface,
|
|
18
|
+
BlogCategoryTranslationService,
|
|
19
|
+
} from "../../data";
|
|
20
|
+
import { BlogCoverImageField, BlogImageSelection, uploadBlogImage } from "./BlogCoverImageField";
|
|
21
|
+
import { BlogTranslationStatus, useBlogLanguageName } from "./BlogTranslationStatus";
|
|
22
|
+
import { BLOG_CATEGORY_UPLOAD_PREFIX } from "../../types/blog.constants";
|
|
23
|
+
|
|
24
|
+
type BlogCategoryEditorProps = {
|
|
25
|
+
/** Edit mode when present. */
|
|
26
|
+
category?: BlogCategoryInterface;
|
|
27
|
+
trigger?: ReactNode;
|
|
28
|
+
onSuccess?: () => void | Promise<void>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const buildTranslationSchema = (t: (key: string) => string) =>
|
|
32
|
+
z.object({
|
|
33
|
+
id: z.string(),
|
|
34
|
+
name: z.string().min(1, { message: t("blog.errors.name") }),
|
|
35
|
+
slug: z.string().optional(),
|
|
36
|
+
description: z.string().optional(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const buildFormSchema = (t: (key: string) => string) =>
|
|
40
|
+
z.object({
|
|
41
|
+
id: z.string().uuid(),
|
|
42
|
+
originalLanguage: z.string().min(1, { message: t("blog.errors.language") }),
|
|
43
|
+
name: z.string().min(1, { message: t("blog.errors.name") }),
|
|
44
|
+
slug: z.string().optional(),
|
|
45
|
+
description: z.string().optional(),
|
|
46
|
+
image: z.string().optional(),
|
|
47
|
+
translations: z.array(buildTranslationSchema(t)),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
type TranslationValues = z.infer<ReturnType<typeof buildTranslationSchema>>;
|
|
51
|
+
|
|
52
|
+
const sameTranslation = (a: TranslationValues, b?: TranslationValues) =>
|
|
53
|
+
!!b && a.name === b.name && (a.slug ?? "") === (b.slug ?? "") && (a.description ?? "") === (b.description ?? "");
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates a category, or edits its original-language text and image plus, in
|
|
57
|
+
* edit mode, each AI translation by hand (one collapsible section per language).
|
|
58
|
+
* Submit updates the category when the original changed (which re-translates
|
|
59
|
+
* every other language) and each translation that changed (marked `manual`).
|
|
60
|
+
* Mirrors features/how-to/components/forms/HowToEditor.tsx.
|
|
61
|
+
*/
|
|
62
|
+
export function BlogCategoryEditor({ category, trigger, onSuccess }: BlogCategoryEditorProps) {
|
|
63
|
+
const t = useTranslations();
|
|
64
|
+
const formSchema = useMemo(() => buildFormSchema(t), [t]);
|
|
65
|
+
const locale = useI18nLocale();
|
|
66
|
+
const languageName = useBlogLanguageName();
|
|
67
|
+
const [image, setImage] = useState<BlogImageSelection>(null);
|
|
68
|
+
|
|
69
|
+
const languages = useMemo(() => getI18nSupportedLocales(), []);
|
|
70
|
+
const original = category?.original;
|
|
71
|
+
const translations: BlogCategoryTranslationInterface[] = useMemo(
|
|
72
|
+
() => (category?.translations ?? []).filter((translation) => !translation.isOriginal),
|
|
73
|
+
[category],
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
const getDefaultValues = useCallback(
|
|
77
|
+
() => ({
|
|
78
|
+
id: category?.id || v4(),
|
|
79
|
+
originalLanguage: category?.originalLanguage ?? (languages.includes(locale) ? locale : languages[0]),
|
|
80
|
+
name: original?.name ?? category?.name ?? "",
|
|
81
|
+
slug: original?.slug ?? "",
|
|
82
|
+
description: original?.description ?? "",
|
|
83
|
+
// The stored value is a signed URL, not a key: only a newly picked file writes a key.
|
|
84
|
+
image: "",
|
|
85
|
+
translations: translations.map((translation) => ({
|
|
86
|
+
id: translation.id,
|
|
87
|
+
name: translation.name ?? "",
|
|
88
|
+
slug: translation.slug ?? "",
|
|
89
|
+
description: translation.description ?? "",
|
|
90
|
+
})),
|
|
91
|
+
}),
|
|
92
|
+
[category, original, translations, languages, locale],
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
const form = useForm<z.infer<typeof formSchema>>({
|
|
96
|
+
resolver: zodResolver(formSchema),
|
|
97
|
+
defaultValues: getDefaultValues(),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<EditorSheet
|
|
102
|
+
form={form}
|
|
103
|
+
entityType={t("blog.entities.category")}
|
|
104
|
+
entityName={original?.name ?? category?.name}
|
|
105
|
+
title={category ? t("blog.admin.editCategory") : t("blog.admin.newCategory")}
|
|
106
|
+
isEdit={!!category}
|
|
107
|
+
module={Modules.BlogCategory}
|
|
108
|
+
size="md"
|
|
109
|
+
onSubmit={async (values) => {
|
|
110
|
+
if (values.image && image) await uploadBlogImage({ key: values.image, selection: image });
|
|
111
|
+
const imageKey = values.image && image ? values.image : undefined;
|
|
112
|
+
|
|
113
|
+
if (!category) {
|
|
114
|
+
return BlogCategoryService.create({
|
|
115
|
+
id: values.id,
|
|
116
|
+
originalLanguage: values.originalLanguage,
|
|
117
|
+
name: values.name,
|
|
118
|
+
description: values.description,
|
|
119
|
+
image: imageKey,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const defaults = getDefaultValues();
|
|
124
|
+
const originalChanged =
|
|
125
|
+
!!imageKey ||
|
|
126
|
+
!sameTranslation(
|
|
127
|
+
{ id: values.id, name: values.name, slug: values.slug, description: values.description },
|
|
128
|
+
{ id: defaults.id, name: defaults.name, slug: defaults.slug, description: defaults.description },
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (originalChanged) {
|
|
132
|
+
await BlogCategoryService.update({
|
|
133
|
+
id: values.id,
|
|
134
|
+
originalLanguage: category?.originalLanguage,
|
|
135
|
+
name: values.name,
|
|
136
|
+
slug: values.slug || undefined,
|
|
137
|
+
description: values.description,
|
|
138
|
+
image: imageKey,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const translation of values.translations) {
|
|
143
|
+
const before = defaults.translations.find((candidate) => candidate.id === translation.id);
|
|
144
|
+
if (sameTranslation(translation, before)) continue;
|
|
145
|
+
await BlogCategoryTranslationService.update({
|
|
146
|
+
id: translation.id,
|
|
147
|
+
name: translation.name,
|
|
148
|
+
slug: translation.slug || undefined,
|
|
149
|
+
description: translation.description,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { id: values.id };
|
|
154
|
+
}}
|
|
155
|
+
onReset={() => {
|
|
156
|
+
setImage(null);
|
|
157
|
+
return getDefaultValues();
|
|
158
|
+
}}
|
|
159
|
+
onSuccess={onSuccess ?? (() => {})}
|
|
160
|
+
trigger={trigger}
|
|
161
|
+
>
|
|
162
|
+
<div className="flex w-full flex-col gap-y-4">
|
|
163
|
+
{!category && (
|
|
164
|
+
<FormSelect
|
|
165
|
+
form={form}
|
|
166
|
+
id="originalLanguage"
|
|
167
|
+
name={t("blog.fields.originalLanguage")}
|
|
168
|
+
values={languages.map((language) => ({ id: language, text: languageName(language) }))}
|
|
169
|
+
isRequired
|
|
170
|
+
/>
|
|
171
|
+
)}
|
|
172
|
+
<FormInput form={form} id="name" name={t("blog.fields.name")} isRequired />
|
|
173
|
+
{category && (
|
|
174
|
+
<FormInput form={form} id="slug" name={t("blog.fields.slug")} description={t("blog.fields.slugHelp")} />
|
|
175
|
+
)}
|
|
176
|
+
<FormTextarea form={form} id="description" name={t("blog.fields.description")} />
|
|
177
|
+
<BlogCoverImageField
|
|
178
|
+
form={form}
|
|
179
|
+
id="image"
|
|
180
|
+
label={t("blog.fields.image")}
|
|
181
|
+
keyPrefix={BLOG_CATEGORY_UPLOAD_PREFIX}
|
|
182
|
+
entityId={form.getValues("id")}
|
|
183
|
+
currentUrl={category?.image}
|
|
184
|
+
selection={image}
|
|
185
|
+
onSelectionChange={setImage}
|
|
186
|
+
/>
|
|
187
|
+
|
|
188
|
+
{translations.length > 0 && (
|
|
189
|
+
<div className="flex flex-col gap-y-2">
|
|
190
|
+
<SectionHeader className="mt-2">{t("blog.fields.languages")}</SectionHeader>
|
|
191
|
+
{translations.map((translation, index) => (
|
|
192
|
+
<Collapsible key={translation.id} className="rounded-md border">
|
|
193
|
+
<CollapsibleTrigger className="flex w-full items-center justify-between gap-x-2 px-3 py-2 text-start">
|
|
194
|
+
<span className="flex items-center gap-x-2 text-sm font-medium">
|
|
195
|
+
{languageName(translation.language)}
|
|
196
|
+
<BlogTranslationStatus aiStatus={translation.aiStatus} />
|
|
197
|
+
</span>
|
|
198
|
+
<ChevronDownIcon className="text-muted-foreground size-4" />
|
|
199
|
+
</CollapsibleTrigger>
|
|
200
|
+
<CollapsibleContent className="flex flex-col gap-y-4 border-t p-3">
|
|
201
|
+
{translation.aiStatus === "failed" && (
|
|
202
|
+
<BlogTranslationStatus
|
|
203
|
+
aiStatus={translation.aiStatus}
|
|
204
|
+
onRetranslate={async () => {
|
|
205
|
+
await BlogCategoryTranslationService.retranslate({ id: translation.id });
|
|
206
|
+
await onSuccess?.();
|
|
207
|
+
}}
|
|
208
|
+
/>
|
|
209
|
+
)}
|
|
210
|
+
<FormInput form={form} id={`translations.${index}.name`} name={t("blog.fields.name")} isRequired />
|
|
211
|
+
<FormInput
|
|
212
|
+
form={form}
|
|
213
|
+
id={`translations.${index}.slug`}
|
|
214
|
+
name={t("blog.fields.slug")}
|
|
215
|
+
description={t("blog.fields.slugHelp")}
|
|
216
|
+
/>
|
|
217
|
+
<FormTextarea
|
|
218
|
+
form={form}
|
|
219
|
+
id={`translations.${index}.description`}
|
|
220
|
+
name={t("blog.fields.description")}
|
|
221
|
+
/>
|
|
222
|
+
</CollapsibleContent>
|
|
223
|
+
</Collapsible>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
)}
|
|
227
|
+
</div>
|
|
228
|
+
</EditorSheet>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { UploadIcon } from "lucide-react";
|
|
4
|
+
import Image from "next/image";
|
|
5
|
+
import { useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { DropzoneOptions } from "react-dropzone";
|
|
7
|
+
import { FileInput, FileUploader, FormFieldWrapper } from "../../../../components";
|
|
8
|
+
import { isRemoteImageSrc } from "../../../../utils";
|
|
9
|
+
import { S3Service } from "../../../s3/data/s3.service";
|
|
10
|
+
|
|
11
|
+
/** A picked image waiting to be uploaded: the S3 key is already in the form field. */
|
|
12
|
+
export type BlogImageSelection = { file: File; contentType: string } | null;
|
|
13
|
+
|
|
14
|
+
const dropzone = {
|
|
15
|
+
multiple: false,
|
|
16
|
+
maxSize: 100 * 1024 * 1024,
|
|
17
|
+
preventDropOnDocument: false,
|
|
18
|
+
accept: {
|
|
19
|
+
"application/images": [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"],
|
|
20
|
+
},
|
|
21
|
+
} satisfies DropzoneOptions;
|
|
22
|
+
|
|
23
|
+
/** Uploads a picked image to its presigned S3 key. */
|
|
24
|
+
export async function uploadBlogImage(params: { key: string; selection: BlogImageSelection }): Promise<void> {
|
|
25
|
+
if (!params.selection || !params.key) return;
|
|
26
|
+
|
|
27
|
+
await S3Service.upload({
|
|
28
|
+
key: params.key,
|
|
29
|
+
contentType: params.selection.contentType,
|
|
30
|
+
file: params.selection.file,
|
|
31
|
+
isPublic: true,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type BlogCoverImageFieldProps = {
|
|
36
|
+
form: any;
|
|
37
|
+
/** The form field that receives the new S3 key ("coverImage" or "image"). */
|
|
38
|
+
id: string;
|
|
39
|
+
label: string;
|
|
40
|
+
/** BLOG_POST_UPLOAD_PREFIX or BLOG_CATEGORY_UPLOAD_PREFIX. */
|
|
41
|
+
keyPrefix: string;
|
|
42
|
+
/** The id of the post or category the image belongs to. */
|
|
43
|
+
entityId: string;
|
|
44
|
+
/** The signed URL of the image already stored, for the preview. */
|
|
45
|
+
currentUrl?: string;
|
|
46
|
+
alt?: string;
|
|
47
|
+
selection: BlogImageSelection;
|
|
48
|
+
onSelectionChange: (selection: BlogImageSelection) => void;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Dropzone for a post cover or a category image. Picking a file writes its S3
|
|
53
|
+
* key (`<keyPrefix>/<id>/<id>.<timestamp>.<ext>`, under the platform blog root,
|
|
54
|
+
* see BLOG_UPLOAD_ROOT) into the form field and hands
|
|
55
|
+
* the file to the editor, which uploads it with `uploadBlogImage` on submit.
|
|
56
|
+
*/
|
|
57
|
+
export function BlogCoverImageField({
|
|
58
|
+
form,
|
|
59
|
+
id,
|
|
60
|
+
label,
|
|
61
|
+
keyPrefix,
|
|
62
|
+
entityId,
|
|
63
|
+
currentUrl,
|
|
64
|
+
alt,
|
|
65
|
+
selection,
|
|
66
|
+
onSelectionChange,
|
|
67
|
+
}: BlogCoverImageFieldProps) {
|
|
68
|
+
const [files, setFiles] = useState<File[] | null>(null);
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
if (selection === null) setFiles(null);
|
|
72
|
+
}, [selection]);
|
|
73
|
+
|
|
74
|
+
const previewUrl = useMemo(() => (selection ? URL.createObjectURL(selection.file) : undefined), [selection]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
return () => {
|
|
78
|
+
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
79
|
+
};
|
|
80
|
+
}, [previewUrl]);
|
|
81
|
+
|
|
82
|
+
const src = previewUrl ?? currentUrl;
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<FormFieldWrapper form={form} name={id} label={label}>
|
|
86
|
+
{(field) => (
|
|
87
|
+
<FileUploader
|
|
88
|
+
value={files}
|
|
89
|
+
onValueChange={(value: File[] | null) => {
|
|
90
|
+
setFiles(value);
|
|
91
|
+
const file = value?.[0];
|
|
92
|
+
if (!file) return;
|
|
93
|
+
const extension = file.type.split("/").pop() ?? "";
|
|
94
|
+
field.onChange(`${keyPrefix}/${entityId}/${entityId}.${Date.now()}.${extension}`);
|
|
95
|
+
onSelectionChange({ file, contentType: file.type });
|
|
96
|
+
}}
|
|
97
|
+
dropzoneOptions={dropzone}
|
|
98
|
+
className="w-full"
|
|
99
|
+
>
|
|
100
|
+
<FileInput className="text-muted-foreground/50 rounded-lg outline-1 outline-dashed">
|
|
101
|
+
<div className="flex w-full flex-col items-center justify-center p-4">
|
|
102
|
+
{src ? (
|
|
103
|
+
<Image
|
|
104
|
+
src={src}
|
|
105
|
+
alt={alt ?? label}
|
|
106
|
+
width={320}
|
|
107
|
+
height={180}
|
|
108
|
+
className="h-auto max-h-48 w-auto max-w-full rounded-md object-cover"
|
|
109
|
+
unoptimized={isRemoteImageSrc(previewUrl ? undefined : currentUrl)}
|
|
110
|
+
/>
|
|
111
|
+
) : (
|
|
112
|
+
<>
|
|
113
|
+
<UploadIcon className="my-4 h-8 w-8" />
|
|
114
|
+
<span className="text-muted-foreground text-xs">{label}</span>
|
|
115
|
+
</>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
118
|
+
</FileInput>
|
|
119
|
+
</FileUploader>
|
|
120
|
+
)}
|
|
121
|
+
</FormFieldWrapper>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useEffect, useState } from "react";
|
|
5
|
+
import { errorToast, SectionHeader } from "../../../../components";
|
|
6
|
+
import { RadioGroup, RadioGroupItem } from "../../../../shadcnui";
|
|
7
|
+
import { showToast } from "../../../../utils/toast";
|
|
8
|
+
import { BlogSettingsService } from "../../data";
|
|
9
|
+
import { BLOG_LIST_LAYOUTS, BLOG_SETTINGS_ID, BlogListLayout } from "../../types/blog.constants";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Picks the public list layout (featured / column / rail). The choice is saved
|
|
13
|
+
* at once and the public blog uses it within the cache window.
|
|
14
|
+
*/
|
|
15
|
+
export function BlogLayoutSetting() {
|
|
16
|
+
const t = useTranslations();
|
|
17
|
+
const [layout, setLayout] = useState<BlogListLayout | undefined>(undefined);
|
|
18
|
+
const [saving, setSaving] = useState<boolean>(false);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
let active = true;
|
|
22
|
+
BlogSettingsService.get()
|
|
23
|
+
.then((settings) => {
|
|
24
|
+
if (active) setLayout(settings?.listLayout ?? "featured");
|
|
25
|
+
})
|
|
26
|
+
.catch(() => {
|
|
27
|
+
if (active) setLayout("featured");
|
|
28
|
+
});
|
|
29
|
+
return () => {
|
|
30
|
+
active = false;
|
|
31
|
+
};
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
const options: Record<BlogListLayout, { label: string; description: string }> = {
|
|
35
|
+
featured: { label: t("blog.layout.featured"), description: t("blog.layout.featuredDescription") },
|
|
36
|
+
column: { label: t("blog.layout.column"), description: t("blog.layout.columnDescription") },
|
|
37
|
+
rail: { label: t("blog.layout.rail"), description: t("blog.layout.railDescription") },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const save = async (next: BlogListLayout) => {
|
|
41
|
+
const previous = layout;
|
|
42
|
+
setLayout(next);
|
|
43
|
+
setSaving(true);
|
|
44
|
+
try {
|
|
45
|
+
await BlogSettingsService.save({ id: BLOG_SETTINGS_ID, listLayout: next });
|
|
46
|
+
showToast(t("blog.layout.saved"));
|
|
47
|
+
} catch (error) {
|
|
48
|
+
setLayout(previous);
|
|
49
|
+
errorToast({ title: t("common.errors.error"), error });
|
|
50
|
+
} finally {
|
|
51
|
+
setSaving(false);
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex w-full max-w-3xl flex-col gap-y-4 p-4">
|
|
57
|
+
<SectionHeader>{t("blog.layout.title")}</SectionHeader>
|
|
58
|
+
{layout && (
|
|
59
|
+
<RadioGroup
|
|
60
|
+
value={layout}
|
|
61
|
+
onValueChange={(value) => {
|
|
62
|
+
// Base UI can emit a null value; ignore it and an unchanged choice.
|
|
63
|
+
if (!value || value === layout) return;
|
|
64
|
+
void save(value as BlogListLayout);
|
|
65
|
+
}}
|
|
66
|
+
disabled={saving}
|
|
67
|
+
className="grid grid-cols-1 gap-3 md:grid-cols-3"
|
|
68
|
+
>
|
|
69
|
+
{BLOG_LIST_LAYOUTS.map((option) => (
|
|
70
|
+
<label
|
|
71
|
+
key={option}
|
|
72
|
+
htmlFor={`blog-layout-${option}`}
|
|
73
|
+
className="has-data-checked:border-primary flex cursor-pointer items-start gap-x-3 rounded-lg border p-4"
|
|
74
|
+
>
|
|
75
|
+
<RadioGroupItem value={option} id={`blog-layout-${option}`} className="mt-0.5" />
|
|
76
|
+
<span className="flex flex-col gap-y-1">
|
|
77
|
+
<span className="text-sm font-medium">{options[option].label}</span>
|
|
78
|
+
<span className="text-muted-foreground text-xs/relaxed">{options[option].description}</span>
|
|
79
|
+
</span>
|
|
80
|
+
</label>
|
|
81
|
+
))}
|
|
82
|
+
</RadioGroup>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { BlockNoteEditorContainer, DetailField, MicroLabel } from "../../../../components";
|
|
5
|
+
import { Badge } from "../../../../shadcnui";
|
|
6
|
+
import { BlogPostTranslationInterface } from "../../data";
|
|
7
|
+
import { useBlogLanguageName } from "./BlogTranslationStatus";
|
|
8
|
+
|
|
9
|
+
type BlogPostComparisonProps = {
|
|
10
|
+
original: BlogPostTranslationInterface;
|
|
11
|
+
translation: BlogPostTranslationInterface;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** One language's text fields, read-only. Shared by both sides of the comparison. */
|
|
15
|
+
export function BlogPostTranslationFields({ translation }: { translation: BlogPostTranslationInterface }) {
|
|
16
|
+
const t = useTranslations();
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<div className="flex flex-col gap-y-4">
|
|
20
|
+
<DetailField label={t("blog.fields.title")} value={translation.title} />
|
|
21
|
+
<DetailField label={t("blog.fields.slug")} value={translation.slug} />
|
|
22
|
+
<DetailField label={t("blog.fields.summary")} value={translation.summary} />
|
|
23
|
+
<DetailField label={t("blog.fields.coverImageAlt")} value={translation.coverImageAlt} />
|
|
24
|
+
<DetailField label={t("blog.fields.body")}>
|
|
25
|
+
<BlockNoteEditorContainer id={translation.id} type="blog-post" initialContent={translation.body} />
|
|
26
|
+
</DetailField>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Original on the inline start, translation on the inline end, same fields (mockup A → B's split view). */
|
|
32
|
+
export function BlogPostComparison({ original, translation }: BlogPostComparisonProps) {
|
|
33
|
+
const t = useTranslations();
|
|
34
|
+
const languageName = useBlogLanguageName();
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
38
|
+
<div className="bg-muted/40 flex flex-col gap-y-4 rounded-lg border p-4">
|
|
39
|
+
<div className="flex items-center gap-x-2">
|
|
40
|
+
<MicroLabel as="h3">{languageName(original.language)}</MicroLabel>
|
|
41
|
+
<Badge variant="softGray">{t("blog.translation.original")}</Badge>
|
|
42
|
+
</div>
|
|
43
|
+
<BlogPostTranslationFields translation={original} />
|
|
44
|
+
</div>
|
|
45
|
+
<div className="flex flex-col gap-y-4 rounded-lg border p-4">
|
|
46
|
+
<div className="flex items-center gap-x-2">
|
|
47
|
+
<MicroLabel as="h3">{languageName(translation.language)}</MicroLabel>
|
|
48
|
+
{translation.source === "manual" && <Badge variant="softAmber">{t("blog.translation.editedByHand")}</Badge>}
|
|
49
|
+
</div>
|
|
50
|
+
<BlogPostTranslationFields translation={translation} />
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|