@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,101 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { useMemo } from "react";
|
|
5
|
+
import { RoundPageContainer } from "../../../../components";
|
|
6
|
+
import { SharedProvider } from "../../../../contexts/SharedContext";
|
|
7
|
+
import { Modules } from "../../../../core";
|
|
8
|
+
import { usePageUrlGenerator } from "../../../../hooks";
|
|
9
|
+
import { BreadcrumbItemData } from "../../../../interfaces";
|
|
10
|
+
import { Badge } from "../../../../shadcnui";
|
|
11
|
+
import { BlogPostProvider, useBlogPost } from "../../contexts/BlogPostContext";
|
|
12
|
+
import { BlogPostDeleter } from "./BlogPostDeleter";
|
|
13
|
+
import { BlogPostPublishButton } from "./BlogPostPublishButton";
|
|
14
|
+
import { BlogPostTranslationPanel } from "./BlogPostTranslationPanel";
|
|
15
|
+
import { BLOG_STATUS_BADGE, useBlogLanguageName } from "./BlogTranslationStatus";
|
|
16
|
+
|
|
17
|
+
export type BlogPostContainerProps = {
|
|
18
|
+
postId: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Admin post page: one tab per language, the original first and marked
|
|
23
|
+
* "Original" (approved mockup A). Mirrors
|
|
24
|
+
* features/email-automation/components/containers/EmailAutomationContainer.tsx.
|
|
25
|
+
*/
|
|
26
|
+
export function BlogPostContainer({ postId }: BlogPostContainerProps) {
|
|
27
|
+
return (
|
|
28
|
+
<BlogPostProvider postId={postId}>
|
|
29
|
+
<BlogPostShell />
|
|
30
|
+
</BlogPostProvider>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function BlogPostShell() {
|
|
35
|
+
const t = useTranslations();
|
|
36
|
+
const generateUrl = usePageUrlGenerator();
|
|
37
|
+
const languageName = useBlogLanguageName();
|
|
38
|
+
const { post, reload } = useBlogPost();
|
|
39
|
+
|
|
40
|
+
const translations = useMemo(() => {
|
|
41
|
+
const all = post?.translations ?? [];
|
|
42
|
+
return [
|
|
43
|
+
...all.filter((translation) => translation.isOriginal),
|
|
44
|
+
...all.filter((translation) => !translation.isOriginal),
|
|
45
|
+
];
|
|
46
|
+
}, [post]);
|
|
47
|
+
|
|
48
|
+
if (!post) return null;
|
|
49
|
+
|
|
50
|
+
const title = post.original?.title ?? post.title ?? "";
|
|
51
|
+
|
|
52
|
+
const breadcrumbs: BreadcrumbItemData[] = [
|
|
53
|
+
{ name: t("blog.admin.title"), href: generateUrl({ page: Modules.BlogPost }) },
|
|
54
|
+
{ name: title },
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const statusLabel = {
|
|
58
|
+
draft: t("blog.status.draft"),
|
|
59
|
+
published: t("blog.status.published"),
|
|
60
|
+
unpublished: t("blog.status.unpublished"),
|
|
61
|
+
}[post.status];
|
|
62
|
+
|
|
63
|
+
const tabLabel = (translation: (typeof translations)[number]) => {
|
|
64
|
+
const parts = [languageName(translation.language)];
|
|
65
|
+
if (translation.isOriginal) parts.push(t("blog.translation.original"));
|
|
66
|
+
if (translation.aiStatus === "pending" || translation.aiStatus === "in_progress")
|
|
67
|
+
parts.push(t("blog.translation.translating"));
|
|
68
|
+
if (translation.aiStatus === "failed") parts.push(t("blog.translation.failed"));
|
|
69
|
+
return parts.join(" · ");
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<SharedProvider
|
|
74
|
+
value={{
|
|
75
|
+
breadcrumbs,
|
|
76
|
+
title: {
|
|
77
|
+
type: t("blog.admin.title"),
|
|
78
|
+
element: title,
|
|
79
|
+
functions: [
|
|
80
|
+
<Badge key="blogPostStatus" variant={BLOG_STATUS_BADGE[post.status]}>
|
|
81
|
+
{statusLabel}
|
|
82
|
+
</Badge>,
|
|
83
|
+
<BlogPostPublishButton key="blogPostPublish" />,
|
|
84
|
+
<BlogPostDeleter key="blogPostDeleter" />,
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
<RoundPageContainer
|
|
90
|
+
module={Modules.BlogPost}
|
|
91
|
+
id={post.id}
|
|
92
|
+
forceHeader
|
|
93
|
+
tabs={translations.map((translation) => ({
|
|
94
|
+
sectionKey: translation.language,
|
|
95
|
+
label: tabLabel(translation),
|
|
96
|
+
content: <BlogPostTranslationPanel post={post} translation={translation} onChanged={reload} />,
|
|
97
|
+
}))}
|
|
98
|
+
/>
|
|
99
|
+
</SharedProvider>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { CommonDeleter } from "../../../../components";
|
|
5
|
+
import { Modules } from "../../../../core";
|
|
6
|
+
import { usePageUrlGenerator } from "../../../../hooks";
|
|
7
|
+
import { useBlogPost } from "../../contexts/BlogPostContext";
|
|
8
|
+
import { BlogPostService } from "../../data";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Deletes the post and returns to the blog admin page.
|
|
12
|
+
* Mirrors features/how-to/components/forms/HowToDeleter.tsx. The dialog texts
|
|
13
|
+
* are passed explicitly: CommonDeleter's default reads `entities.<type>`, a
|
|
14
|
+
* namespace only the app may fill, so the blog's own entity name is used.
|
|
15
|
+
*/
|
|
16
|
+
export function BlogPostDeleter() {
|
|
17
|
+
const t = useTranslations();
|
|
18
|
+
const generateUrl = usePageUrlGenerator();
|
|
19
|
+
const { post } = useBlogPost();
|
|
20
|
+
|
|
21
|
+
if (!post) return null;
|
|
22
|
+
|
|
23
|
+
const type = t("blog.entities.post");
|
|
24
|
+
|
|
25
|
+
return (
|
|
26
|
+
<CommonDeleter
|
|
27
|
+
title={t("common.delete.title", { type })}
|
|
28
|
+
subtitle={t("common.delete.subtitle", { type })}
|
|
29
|
+
description={<div className="text-destructive p-4 text-sm">{t("common.delete.description", { type })}</div>}
|
|
30
|
+
showLabel
|
|
31
|
+
deleteFunction={() => BlogPostService.delete({ id: post.id })}
|
|
32
|
+
redirectTo={generateUrl({ page: Modules.BlogPost })}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { ReactNode, useCallback, useMemo, useState } from "react";
|
|
6
|
+
import { useForm } from "react-hook-form";
|
|
7
|
+
import { v4 } from "uuid";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import {
|
|
10
|
+
EditorSheet,
|
|
11
|
+
EntityMultiSelector,
|
|
12
|
+
FormBlockNote,
|
|
13
|
+
FormInput,
|
|
14
|
+
FormSelect,
|
|
15
|
+
FormTextarea,
|
|
16
|
+
} from "../../../../components";
|
|
17
|
+
import { Modules } from "../../../../core";
|
|
18
|
+
import { getI18nSupportedLocales, useI18nLocale, useI18nRouter } from "../../../../i18n";
|
|
19
|
+
import {
|
|
20
|
+
BlogCategoryInterface,
|
|
21
|
+
BlogCategoryService,
|
|
22
|
+
BlogPostInput,
|
|
23
|
+
BlogPostInterface,
|
|
24
|
+
BlogPostService,
|
|
25
|
+
} from "../../data";
|
|
26
|
+
import { BlogCoverImageField, BlogImageSelection, uploadBlogImage } from "./BlogCoverImageField";
|
|
27
|
+
import { blogCategoryAdminName, useBlogLanguageName } from "./BlogTranslationStatus";
|
|
28
|
+
import { BLOG_POST_UPLOAD_PREFIX } from "../../types/blog.constants";
|
|
29
|
+
import { useBlogCategoryNames } from "../../hooks/useBlogCategoryNames";
|
|
30
|
+
|
|
31
|
+
type BlogPostEditorProps = {
|
|
32
|
+
/** Edit mode when present: the post's original translation and shared fields. */
|
|
33
|
+
post?: BlogPostInterface;
|
|
34
|
+
trigger?: ReactNode;
|
|
35
|
+
onSuccess?: () => void | Promise<void>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const isBodyEmpty = (body: any) => !body || (Array.isArray(body) && body.length === 0);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates a post, or edits its original-language text plus the fields every
|
|
42
|
+
* language shares (cover image, categories). Saving the original makes the
|
|
43
|
+
* worker translate it again. Mirrors features/how-to/components/forms/HowToEditor.tsx.
|
|
44
|
+
*/
|
|
45
|
+
export function BlogPostEditor({ post, trigger, onSuccess }: BlogPostEditorProps) {
|
|
46
|
+
const t = useTranslations();
|
|
47
|
+
const categoryName = useBlogCategoryNames();
|
|
48
|
+
const router = useI18nRouter();
|
|
49
|
+
const locale = useI18nLocale();
|
|
50
|
+
const languageName = useBlogLanguageName();
|
|
51
|
+
const original = post?.original;
|
|
52
|
+
const [cover, setCover] = useState<BlogImageSelection>(null);
|
|
53
|
+
const [isBodyEmptyState, setIsBodyEmptyState] = useState<boolean>(isBodyEmpty(original?.body));
|
|
54
|
+
|
|
55
|
+
const languages = useMemo(() => getI18nSupportedLocales(), []);
|
|
56
|
+
|
|
57
|
+
const formSchema = useMemo(
|
|
58
|
+
() =>
|
|
59
|
+
z.object({
|
|
60
|
+
id: z.string().uuid(),
|
|
61
|
+
originalLanguage: z.string().min(1, { message: t("blog.errors.language") }),
|
|
62
|
+
title: z.string().min(1, { message: t("blog.errors.title") }),
|
|
63
|
+
slug: z.string().optional(),
|
|
64
|
+
summary: z.string().optional(),
|
|
65
|
+
body: z.any(),
|
|
66
|
+
coverImage: z.string().optional(),
|
|
67
|
+
coverImageAlt: z.string().optional(),
|
|
68
|
+
categories: z.array(z.object({ id: z.string(), name: z.string() })),
|
|
69
|
+
}),
|
|
70
|
+
[],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
const getDefaultValues = useCallback(
|
|
74
|
+
() => ({
|
|
75
|
+
id: post?.id || v4(),
|
|
76
|
+
originalLanguage: post?.originalLanguage ?? (languages.includes(locale) ? locale : languages[0]),
|
|
77
|
+
title: original?.title ?? "",
|
|
78
|
+
slug: original?.slug ?? "",
|
|
79
|
+
summary: original?.summary ?? "",
|
|
80
|
+
body: original?.body || [],
|
|
81
|
+
// The stored value is a signed URL, not a key: only a newly picked file
|
|
82
|
+
// writes a key here, and only then is the field sent.
|
|
83
|
+
coverImage: "",
|
|
84
|
+
coverImageAlt: original?.coverImageAlt ?? "",
|
|
85
|
+
categories: (post?.categories ?? []).map((category) => ({
|
|
86
|
+
id: category.id,
|
|
87
|
+
name: blogCategoryAdminName(category),
|
|
88
|
+
})),
|
|
89
|
+
}),
|
|
90
|
+
[post, original, languages, locale],
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const form = useForm<z.infer<typeof formSchema>>({
|
|
94
|
+
resolver: zodResolver(formSchema),
|
|
95
|
+
defaultValues: getDefaultValues(),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const { dirtyFields } = form.formState;
|
|
99
|
+
const isFormDirty = useCallback(() => {
|
|
100
|
+
const dirty: Record<string, unknown> = { ...dirtyFields };
|
|
101
|
+
if (dirty.body && isBodyEmptyState) delete dirty.body;
|
|
102
|
+
return Object.keys(dirty).length > 0;
|
|
103
|
+
}, [dirtyFields, isBodyEmptyState]);
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<EditorSheet
|
|
107
|
+
form={form}
|
|
108
|
+
entityType={t("blog.entities.post")}
|
|
109
|
+
entityName={original?.title}
|
|
110
|
+
title={post ? t("blog.admin.editPost") : t("blog.admin.newPost")}
|
|
111
|
+
isEdit={!!post}
|
|
112
|
+
module={Modules.BlogPost}
|
|
113
|
+
size="lg"
|
|
114
|
+
isFormDirty={isFormDirty}
|
|
115
|
+
onSubmit={async (values) => {
|
|
116
|
+
if (values.coverImage && cover) await uploadBlogImage({ key: values.coverImage, selection: cover });
|
|
117
|
+
|
|
118
|
+
const payload: BlogPostInput = {
|
|
119
|
+
id: values.id,
|
|
120
|
+
title: values.title,
|
|
121
|
+
// An empty summary lets the worker write one; on edit the summary is
|
|
122
|
+
// sent only when changed here, so an untouched AI summary is regenerated.
|
|
123
|
+
summary: post
|
|
124
|
+
? form.getFieldState("summary").isDirty
|
|
125
|
+
? values.summary
|
|
126
|
+
: undefined
|
|
127
|
+
: values.summary || undefined,
|
|
128
|
+
body: values.body,
|
|
129
|
+
coverImage: values.coverImage && cover ? values.coverImage : undefined,
|
|
130
|
+
coverImageAlt: values.coverImageAlt,
|
|
131
|
+
categoryIds: values.categories.map((category) => category.id),
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (post) {
|
|
135
|
+
return BlogPostService.update({
|
|
136
|
+
...payload,
|
|
137
|
+
originalLanguage: post.originalLanguage,
|
|
138
|
+
slug: values.slug || undefined,
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return BlogPostService.create({ ...payload, originalLanguage: values.originalLanguage });
|
|
142
|
+
}}
|
|
143
|
+
onReset={() => {
|
|
144
|
+
setCover(null);
|
|
145
|
+
setIsBodyEmptyState(isBodyEmpty(original?.body));
|
|
146
|
+
return getDefaultValues();
|
|
147
|
+
}}
|
|
148
|
+
onSuccess={onSuccess}
|
|
149
|
+
onNavigate={(url) => router.push(url)}
|
|
150
|
+
trigger={trigger}
|
|
151
|
+
>
|
|
152
|
+
<div className="flex h-full min-h-0 w-full flex-col gap-y-4">
|
|
153
|
+
{!post && (
|
|
154
|
+
<FormSelect
|
|
155
|
+
form={form}
|
|
156
|
+
id="originalLanguage"
|
|
157
|
+
name={t("blog.fields.originalLanguage")}
|
|
158
|
+
values={languages.map((language) => ({ id: language, text: languageName(language) }))}
|
|
159
|
+
isRequired
|
|
160
|
+
/>
|
|
161
|
+
)}
|
|
162
|
+
<FormInput form={form} id="title" name={t("blog.fields.title")} isRequired />
|
|
163
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
164
|
+
<div className="flex flex-col gap-y-4">
|
|
165
|
+
{post && (
|
|
166
|
+
<FormInput form={form} id="slug" name={t("blog.fields.slug")} description={t("blog.fields.slugHelp")} />
|
|
167
|
+
)}
|
|
168
|
+
<EntityMultiSelector<BlogCategoryInterface>
|
|
169
|
+
id="categories"
|
|
170
|
+
form={form}
|
|
171
|
+
label={t("blog.fields.categories")}
|
|
172
|
+
retriever={(params) => BlogCategoryService.findMany(params)}
|
|
173
|
+
module={Modules.BlogCategory}
|
|
174
|
+
getLabel={blogCategoryAdminName}
|
|
175
|
+
// A post's included categories carry no translations, so the stored chip
|
|
176
|
+
// names come from the category list.
|
|
177
|
+
getFormValueLabel={(value: { id: string; name?: string }) => value.name || categoryName(value.id)}
|
|
178
|
+
/>
|
|
179
|
+
<FormTextarea
|
|
180
|
+
form={form}
|
|
181
|
+
id="summary"
|
|
182
|
+
name={t("blog.fields.summary")}
|
|
183
|
+
description={t("blog.fields.summaryHelp")}
|
|
184
|
+
className="min-h-24"
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
<div className="flex flex-col gap-y-4">
|
|
188
|
+
<BlogCoverImageField
|
|
189
|
+
form={form}
|
|
190
|
+
id="coverImage"
|
|
191
|
+
label={t("blog.fields.coverImage")}
|
|
192
|
+
keyPrefix={BLOG_POST_UPLOAD_PREFIX}
|
|
193
|
+
entityId={form.getValues("id")}
|
|
194
|
+
currentUrl={post?.coverImage}
|
|
195
|
+
alt={original?.coverImageAlt}
|
|
196
|
+
selection={cover}
|
|
197
|
+
onSelectionChange={setCover}
|
|
198
|
+
/>
|
|
199
|
+
<FormInput form={form} id="coverImageAlt" name={t("blog.fields.coverImageAlt")} />
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
<FormBlockNote
|
|
203
|
+
uploadKeyPrefix={BLOG_POST_UPLOAD_PREFIX}
|
|
204
|
+
form={form}
|
|
205
|
+
id="body"
|
|
206
|
+
name={t("blog.fields.body")}
|
|
207
|
+
type="blog-post"
|
|
208
|
+
isRequired
|
|
209
|
+
stretch
|
|
210
|
+
className="min-h-96"
|
|
211
|
+
onEmptyChange={setIsBodyEmptyState}
|
|
212
|
+
/>
|
|
213
|
+
</div>
|
|
214
|
+
</EditorSheet>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
import { errorToast } from "../../../../components";
|
|
7
|
+
import { Button } from "../../../../shadcnui";
|
|
8
|
+
import { useBlogPost } from "../../contexts/BlogPostContext";
|
|
9
|
+
import { BlogPostService } from "../../data";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Publish / Unpublish in the post page header. Publishing an unpublished post
|
|
13
|
+
* republishes it (the first publish date is kept server-side).
|
|
14
|
+
* Mirrors features/email-automation/components/canvas/EmailAutomationActiveSwitch.tsx.
|
|
15
|
+
*/
|
|
16
|
+
export function BlogPostPublishButton() {
|
|
17
|
+
const t = useTranslations();
|
|
18
|
+
const { post, reload } = useBlogPost();
|
|
19
|
+
const [saving, setSaving] = useState<boolean>(false);
|
|
20
|
+
|
|
21
|
+
if (!post) return null;
|
|
22
|
+
|
|
23
|
+
const isPublished = post.status === "published";
|
|
24
|
+
|
|
25
|
+
const toggle = async () => {
|
|
26
|
+
setSaving(true);
|
|
27
|
+
try {
|
|
28
|
+
if (isPublished) await BlogPostService.unpublish({ id: post.id });
|
|
29
|
+
else await BlogPostService.publish({ id: post.id });
|
|
30
|
+
await reload();
|
|
31
|
+
} catch (error) {
|
|
32
|
+
errorToast({ title: t("common.errors.error"), error });
|
|
33
|
+
} finally {
|
|
34
|
+
setSaving(false);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<Button
|
|
40
|
+
type="button"
|
|
41
|
+
size="sm"
|
|
42
|
+
variant={isPublished ? "outline" : "default"}
|
|
43
|
+
onClick={() => void toggle()}
|
|
44
|
+
disabled={saving}
|
|
45
|
+
>
|
|
46
|
+
{isPublished ? <EyeOffIcon /> : <EyeIcon />}
|
|
47
|
+
{isPublished ? t("blog.actions.unpublish") : t("blog.actions.publish")}
|
|
48
|
+
</Button>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from "@hookform/resolvers/zod";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { ReactNode, useCallback, useMemo, useState } from "react";
|
|
6
|
+
import { useForm } from "react-hook-form";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { EditorSheet, FormBlockNote, FormInput, FormTextarea } from "../../../../components";
|
|
9
|
+
import { Modules } from "../../../../core";
|
|
10
|
+
import { BlogPostTranslationInterface, BlogPostTranslationService } from "../../data";
|
|
11
|
+
import { useBlogLanguageName } from "./BlogTranslationStatus";
|
|
12
|
+
import { BLOG_POST_UPLOAD_PREFIX } from "../../types/blog.constants";
|
|
13
|
+
|
|
14
|
+
type BlogPostTranslationEditorProps = {
|
|
15
|
+
translation: BlogPostTranslationInterface;
|
|
16
|
+
trigger?: ReactNode;
|
|
17
|
+
onSuccess?: () => void | Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const isBodyEmpty = (body: any) => !body || (Array.isArray(body) && body.length === 0);
|
|
21
|
+
|
|
22
|
+
const buildFormSchema = (t: (key: string) => string) =>
|
|
23
|
+
z.object({
|
|
24
|
+
id: z.string().uuid(),
|
|
25
|
+
title: z.string().min(1, { message: t("blog.errors.title") }),
|
|
26
|
+
slug: z.string().optional(),
|
|
27
|
+
summary: z.string().optional(),
|
|
28
|
+
body: z.any(),
|
|
29
|
+
coverImageAlt: z.string().optional(),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Edits one non-original translation by hand. Saving marks it `manual` and
|
|
34
|
+
* re-runs nothing. Mirrors features/how-to/components/forms/HowToEditor.tsx.
|
|
35
|
+
*/
|
|
36
|
+
export function BlogPostTranslationEditor({ translation, trigger, onSuccess }: BlogPostTranslationEditorProps) {
|
|
37
|
+
const t = useTranslations();
|
|
38
|
+
const formSchema = useMemo(() => buildFormSchema(t), [t]);
|
|
39
|
+
const languageName = useBlogLanguageName();
|
|
40
|
+
const [isBodyEmptyState, setIsBodyEmptyState] = useState<boolean>(isBodyEmpty(translation.body));
|
|
41
|
+
|
|
42
|
+
const getDefaultValues = useCallback(
|
|
43
|
+
() => ({
|
|
44
|
+
id: translation.id,
|
|
45
|
+
title: translation.title ?? "",
|
|
46
|
+
slug: translation.slug ?? "",
|
|
47
|
+
summary: translation.summary ?? "",
|
|
48
|
+
body: translation.body || [],
|
|
49
|
+
coverImageAlt: translation.coverImageAlt ?? "",
|
|
50
|
+
}),
|
|
51
|
+
[translation],
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
const form = useForm<z.infer<typeof formSchema>>({
|
|
55
|
+
resolver: zodResolver(formSchema),
|
|
56
|
+
defaultValues: getDefaultValues(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const { dirtyFields } = form.formState;
|
|
60
|
+
const isFormDirty = useCallback(() => {
|
|
61
|
+
const dirty: Record<string, unknown> = { ...dirtyFields };
|
|
62
|
+
if (dirty.body && isBodyEmptyState) delete dirty.body;
|
|
63
|
+
return Object.keys(dirty).length > 0;
|
|
64
|
+
}, [dirtyFields, isBodyEmptyState]);
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<EditorSheet
|
|
68
|
+
form={form}
|
|
69
|
+
entityType={t("blog.entities.post")}
|
|
70
|
+
entityName={translation.title}
|
|
71
|
+
title={`${t("blog.admin.editPost")} · ${languageName(translation.language)}`}
|
|
72
|
+
isEdit
|
|
73
|
+
module={Modules.BlogPostTranslation}
|
|
74
|
+
size="lg"
|
|
75
|
+
isFormDirty={isFormDirty}
|
|
76
|
+
onSubmit={async (values) =>
|
|
77
|
+
BlogPostTranslationService.update({
|
|
78
|
+
id: values.id,
|
|
79
|
+
title: values.title,
|
|
80
|
+
slug: values.slug || undefined,
|
|
81
|
+
summary: values.summary,
|
|
82
|
+
body: values.body,
|
|
83
|
+
coverImageAlt: values.coverImageAlt,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
onReset={() => {
|
|
87
|
+
setIsBodyEmptyState(isBodyEmpty(translation.body));
|
|
88
|
+
return getDefaultValues();
|
|
89
|
+
}}
|
|
90
|
+
onSuccess={onSuccess ?? (() => {})}
|
|
91
|
+
trigger={trigger}
|
|
92
|
+
>
|
|
93
|
+
<div className="flex h-full min-h-0 w-full flex-col gap-y-4">
|
|
94
|
+
<FormInput form={form} id="title" name={t("blog.fields.title")} isRequired />
|
|
95
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
96
|
+
<div className="flex flex-col gap-y-4">
|
|
97
|
+
<FormInput form={form} id="slug" name={t("blog.fields.slug")} description={t("blog.fields.slugHelp")} />
|
|
98
|
+
<FormTextarea form={form} id="summary" name={t("blog.fields.summary")} className="min-h-24" />
|
|
99
|
+
</div>
|
|
100
|
+
<div className="flex flex-col gap-y-4">
|
|
101
|
+
<FormInput form={form} id="coverImageAlt" name={t("blog.fields.coverImageAlt")} />
|
|
102
|
+
</div>
|
|
103
|
+
</div>
|
|
104
|
+
<FormBlockNote
|
|
105
|
+
uploadKeyPrefix={BLOG_POST_UPLOAD_PREFIX}
|
|
106
|
+
form={form}
|
|
107
|
+
id="body"
|
|
108
|
+
name={t("blog.fields.body")}
|
|
109
|
+
type="blog-post"
|
|
110
|
+
isRequired
|
|
111
|
+
stretch
|
|
112
|
+
className="min-h-96"
|
|
113
|
+
onEmptyChange={setIsBodyEmptyState}
|
|
114
|
+
/>
|
|
115
|
+
</div>
|
|
116
|
+
</EditorSheet>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { PencilIcon } from "lucide-react";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import Image from "next/image";
|
|
6
|
+
import { useState } from "react";
|
|
7
|
+
import { DetailField } from "../../../../components";
|
|
8
|
+
import { Alert, AlertDescription, Badge, Button } from "../../../../shadcnui";
|
|
9
|
+
import { isRemoteImageSrc } from "../../../../utils";
|
|
10
|
+
import { BlogPostInterface, BlogPostTranslationInterface, BlogPostTranslationService } from "../../data";
|
|
11
|
+
import { BlogPostComparison, BlogPostTranslationFields } from "./BlogPostComparison";
|
|
12
|
+
import { BlogPostEditor } from "./BlogPostEditor";
|
|
13
|
+
import { BlogPostTranslationEditor } from "./BlogPostTranslationEditor";
|
|
14
|
+
import { useBlogCategoryNames } from "../../hooks/useBlogCategoryNames";
|
|
15
|
+
import { BlogTranslationStatus, useBlogDateFormatter, useBlogLanguageName } from "./BlogTranslationStatus";
|
|
16
|
+
|
|
17
|
+
type BlogPostTranslationPanelProps = {
|
|
18
|
+
post: BlogPostInterface;
|
|
19
|
+
translation: BlogPostTranslationInterface;
|
|
20
|
+
/** Re-reads the post after an edit or a re-translation request. */
|
|
21
|
+
onChanged?: () => Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One language tab of the admin post page (approved mockup A).
|
|
26
|
+
* - Original tab: cover image, categories, status, the text fields and an Edit
|
|
27
|
+
* that opens BlogPostEditor (which re-translates every other language).
|
|
28
|
+
* - Translation tab: where the text came from, whether it was edited by hand,
|
|
29
|
+
* the overwrite warning, "Compare with original", the job status, the text
|
|
30
|
+
* fields and an Edit that opens BlogPostTranslationEditor.
|
|
31
|
+
*/
|
|
32
|
+
export function BlogPostTranslationPanel({ post, translation, onChanged }: BlogPostTranslationPanelProps) {
|
|
33
|
+
const t = useTranslations();
|
|
34
|
+
const categoryName = useBlogCategoryNames();
|
|
35
|
+
const languageName = useBlogLanguageName();
|
|
36
|
+
const formatDate = useBlogDateFormatter();
|
|
37
|
+
const [isComparing, setIsComparing] = useState<boolean>(false);
|
|
38
|
+
|
|
39
|
+
const original = post.original;
|
|
40
|
+
const reload = onChanged ?? (async () => {});
|
|
41
|
+
|
|
42
|
+
const editTrigger = (
|
|
43
|
+
<Button render={<div />} nativeButton={false} size="sm" variant="outline">
|
|
44
|
+
<PencilIcon />
|
|
45
|
+
{t("blog.actions.edit")}
|
|
46
|
+
</Button>
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
if (translation.isOriginal) {
|
|
50
|
+
const statusLabel = {
|
|
51
|
+
draft: t("blog.status.draft"),
|
|
52
|
+
published: t("blog.status.published"),
|
|
53
|
+
unpublished: t("blog.status.unpublished"),
|
|
54
|
+
}[post.status];
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div className="flex w-full flex-col gap-y-6 p-4">
|
|
58
|
+
<div className="flex items-center justify-between gap-x-4">
|
|
59
|
+
<Badge variant="softGray">{t("blog.translation.original")}</Badge>
|
|
60
|
+
<BlogPostEditor post={post} trigger={editTrigger} onSuccess={reload} />
|
|
61
|
+
</div>
|
|
62
|
+
{post.coverImage && (
|
|
63
|
+
<Image
|
|
64
|
+
src={post.coverImage}
|
|
65
|
+
alt={translation.coverImageAlt ?? translation.title}
|
|
66
|
+
width={640}
|
|
67
|
+
height={360}
|
|
68
|
+
className="h-auto max-h-72 w-auto rounded-lg object-cover"
|
|
69
|
+
unoptimized={isRemoteImageSrc(post.coverImage)}
|
|
70
|
+
/>
|
|
71
|
+
)}
|
|
72
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
73
|
+
<DetailField label={t("blog.fields.status")} value={statusLabel} />
|
|
74
|
+
<DetailField label={t("blog.fields.publishedAt")} value={formatDate(post.publishedAt) || "—"} />
|
|
75
|
+
<DetailField
|
|
76
|
+
label={t("blog.fields.categories")}
|
|
77
|
+
value={
|
|
78
|
+
post.categories
|
|
79
|
+
.map((category) => categoryName(category.id))
|
|
80
|
+
.filter(Boolean)
|
|
81
|
+
.join(", ") || "—"
|
|
82
|
+
}
|
|
83
|
+
/>
|
|
84
|
+
</div>
|
|
85
|
+
<BlogPostTranslationFields translation={translation} />
|
|
86
|
+
</div>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const isWorking = translation.aiStatus === "pending" || translation.aiStatus === "in_progress";
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<div className="flex w-full flex-col gap-y-6 p-4">
|
|
94
|
+
<Alert>
|
|
95
|
+
<AlertDescription className="flex flex-col gap-y-2">
|
|
96
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
97
|
+
{translation.source === "manual" && <Badge variant="softAmber">{t("blog.translation.editedByHand")}</Badge>}
|
|
98
|
+
{original && translation.translatedAt && (
|
|
99
|
+
<span>
|
|
100
|
+
{t("blog.translation.translatedFrom", {
|
|
101
|
+
language: languageName(original.language),
|
|
102
|
+
date: formatDate(translation.translatedAt),
|
|
103
|
+
})}
|
|
104
|
+
</span>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
<span>{t("blog.translation.overwriteWarning")}</span>
|
|
108
|
+
{original && (
|
|
109
|
+
<div>
|
|
110
|
+
<Button type="button" size="sm" variant="ghost" onClick={() => setIsComparing((value) => !value)}>
|
|
111
|
+
{isComparing ? t("blog.actions.hideComparison") : t("blog.actions.compareWithOriginal")}
|
|
112
|
+
</Button>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
</AlertDescription>
|
|
116
|
+
</Alert>
|
|
117
|
+
|
|
118
|
+
<div className="flex items-center justify-between gap-x-4">
|
|
119
|
+
<BlogTranslationStatus
|
|
120
|
+
aiStatus={translation.aiStatus}
|
|
121
|
+
onRetranslate={async () => {
|
|
122
|
+
await BlogPostTranslationService.retranslate({ id: translation.id });
|
|
123
|
+
await reload();
|
|
124
|
+
}}
|
|
125
|
+
/>
|
|
126
|
+
{!isWorking && <BlogPostTranslationEditor translation={translation} trigger={editTrigger} onSuccess={reload} />}
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{isComparing && original ? (
|
|
130
|
+
<BlogPostComparison original={original} translation={translation} />
|
|
131
|
+
) : (
|
|
132
|
+
<BlogPostTranslationFields translation={translation} />
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|