@carlonicora/nextjs-jsonapi 5.3.0 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{BlockNoteEditor-UJ6363LC.js → BlockNoteEditor-4YCAIEPT.js} +23 -22
- package/dist/BlockNoteEditor-4YCAIEPT.js.map +1 -0
- package/dist/{BlockNoteEditor-WTSQANKI.mjs → BlockNoteEditor-YQL2SGFR.mjs} +8 -7
- package/dist/BlockNoteEditor-YQL2SGFR.mjs.map +1 -0
- package/dist/{BlockNoteViewer-JVKWJZ6J.mjs → BlockNoteViewer-AEJKJ27Q.mjs} +2 -2
- package/dist/{BlockNoteViewer-EL4JFB6P.js → BlockNoteViewer-YL3J7J2Y.js} +3 -3
- package/dist/{BlockNoteViewer-EL4JFB6P.js.map → BlockNoteViewer-YL3J7J2Y.js.map} +1 -1
- package/dist/BlogCategoryFields-DO48vOTE.d.mts +14 -0
- package/dist/BlogCategoryFields-DO48vOTE.d.ts +14 -0
- package/dist/BlogPostInterface-BB_4wcDR.d.mts +124 -0
- package/dist/BlogPostInterface-bWAT1rfR.d.ts +124 -0
- package/dist/billing/index.js +441 -441
- package/dist/billing/index.mjs +3 -3
- package/dist/blog-public.types-BbrzsC1c.d.mts +30 -0
- package/dist/blog-public.types-BbrzsC1c.d.ts +30 -0
- package/dist/{chunk-3BAM23VA.js → chunk-3NLAXVIN.js} +860 -849
- package/dist/chunk-3NLAXVIN.js.map +1 -0
- package/dist/{chunk-QKZUE5OF.mjs → chunk-3YW35YHQ.mjs} +1 -101
- package/dist/chunk-3YW35YHQ.mjs.map +1 -0
- package/dist/{chunk-QQQE3N2X.js → chunk-5PPG6PL7.js} +3 -3
- package/dist/{chunk-QQQE3N2X.js.map → chunk-5PPG6PL7.js.map} +1 -1
- package/dist/{chunk-FNTQQFLH.mjs → chunk-6DC2WVVB.mjs} +2 -2
- package/dist/{chunk-MIZTWBPX.mjs → chunk-EUKTBOI2.mjs} +19 -8
- package/dist/chunk-EUKTBOI2.mjs.map +1 -0
- package/dist/{chunk-XGTQWBDJ.js → chunk-KSYQEKJX.js} +11 -111
- package/dist/chunk-KSYQEKJX.js.map +1 -0
- package/dist/{chunk-CRI4T47Y.js → chunk-NFBOSPNJ.js} +756 -41
- package/dist/chunk-NFBOSPNJ.js.map +1 -0
- package/dist/{chunk-6WU74VFU.mjs → chunk-QMYQRPQC.mjs} +2 -2
- package/dist/{chunk-JQWI62NV.mjs → chunk-WC63PJSC.mjs} +716 -1
- package/dist/chunk-WC63PJSC.mjs.map +1 -0
- package/dist/chunk-XCHSX3VC.mjs +107 -0
- package/dist/chunk-XCHSX3VC.mjs.map +1 -0
- package/dist/{chunk-EO75CV76.js → chunk-YNCQSWQW.js} +8 -8
- package/dist/{chunk-EO75CV76.js.map → chunk-YNCQSWQW.js.map} +1 -1
- package/dist/chunk-YW4ZCEWB.js +107 -0
- package/dist/chunk-YW4ZCEWB.js.map +1 -0
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +13 -2
- package/dist/components/index.d.ts +13 -2
- package/dist/components/index.js +4 -4
- package/dist/components/index.mjs +3 -3
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +214 -3
- package/dist/core/index.d.ts +214 -3
- package/dist/core/index.js +36 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +35 -1
- package/dist/email-automations/index.js +92 -92
- package/dist/email-automations/index.mjs +3 -3
- package/dist/features/analytics/index.js +122 -122
- package/dist/features/analytics/index.mjs +4 -4
- package/dist/features/analytics/server.js +6 -5
- package/dist/features/analytics/server.js.map +1 -1
- package/dist/features/analytics/server.mjs +4 -3
- package/dist/features/analytics/server.mjs.map +1 -1
- package/dist/features/blog/index.css +30 -0
- package/dist/features/blog/index.css.map +1 -0
- package/dist/features/blog/index.d.mts +266 -0
- package/dist/features/blog/index.d.ts +266 -0
- package/dist/features/blog/index.js +1799 -0
- package/dist/features/blog/index.js.map +1 -0
- package/dist/features/blog/index.mjs +1799 -0
- package/dist/features/blog/index.mjs.map +1 -0
- package/dist/features/blog/server.d.mts +33 -0
- package/dist/features/blog/server.d.ts +33 -0
- package/dist/features/blog/server.js +139 -0
- package/dist/features/blog/server.js.map +1 -0
- package/dist/features/blog/server.mjs +139 -0
- package/dist/features/blog/server.mjs.map +1 -0
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +125 -125
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/index.d.mts +4 -2
- package/dist/index.d.ts +4 -2
- package/dist/index.js +37 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +36 -2
- package/dist/{s3.service-CY-jgOAM.d.ts → s3.service-Bz_K-tjz.d.ts} +11 -0
- package/dist/{s3.service-Sri0QP7Z.d.mts → s3.service-D5zNCEW3.d.mts} +11 -0
- package/dist/server/index.d.mts +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +8 -6
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +12 -10
- package/messages/en.json +93 -0
- package/messages/it.json +93 -0
- package/package.json +11 -1
- package/src/components/editors/BlockNoteEditor.tsx +13 -3
- package/src/components/forms/FormBlockNote.tsx +4 -0
- package/src/components/navigations/ContentTitle.tsx +14 -3
- package/src/core/index.ts +4 -0
- package/src/core/registry/ModuleRegistry.ts +6 -0
- package/src/features/blog/BlogModules.ts +49 -0
- package/src/features/blog/components/admin/BlogAdminContainer.tsx +32 -0
- package/src/features/blog/components/admin/BlogCategoriesList.tsx +56 -0
- package/src/features/blog/components/admin/BlogCategoryEditor.tsx +230 -0
- package/src/features/blog/components/admin/BlogCoverImageField.tsx +123 -0
- package/src/features/blog/components/admin/BlogLayoutSetting.tsx +86 -0
- package/src/features/blog/components/admin/BlogPostComparison.tsx +54 -0
- package/src/features/blog/components/admin/BlogPostContainer.tsx +102 -0
- package/src/features/blog/components/admin/BlogPostDeleter.tsx +35 -0
- package/src/features/blog/components/admin/BlogPostEditor.tsx +207 -0
- package/src/features/blog/components/admin/BlogPostPublishButton.tsx +50 -0
- package/src/features/blog/components/admin/BlogPostTranslationEditor.tsx +110 -0
- package/src/features/blog/components/admin/BlogPostTranslationPanel.tsx +136 -0
- package/src/features/blog/components/admin/BlogPostsList.tsx +58 -0
- package/src/features/blog/components/admin/BlogTranslationStatus.tsx +104 -0
- package/src/features/blog/components/admin/__tests__/BlogPostTranslationPanel.spec.tsx +100 -0
- package/src/features/blog/components/admin/__tests__/BlogPostsList.spec.tsx +30 -0
- package/src/features/blog/components/admin/index.ts +14 -0
- package/src/features/blog/components/public/BlogCategoryChips.tsx +59 -0
- package/src/features/blog/components/public/BlogList.tsx +21 -0
- package/src/features/blog/components/public/BlogListColumn.tsx +53 -0
- package/src/features/blog/components/public/BlogListFeatured.tsx +57 -0
- package/src/features/blog/components/public/BlogListParts.tsx +74 -0
- package/src/features/blog/components/public/BlogListRail.tsx +62 -0
- package/src/features/blog/components/public/BlogPostCard.tsx +132 -0
- package/src/features/blog/components/public/BlogPostMeta.tsx +49 -0
- package/src/features/blog/components/public/BlogPostView.tsx +74 -0
- package/src/features/blog/components/public/BlogSearchBox.tsx +52 -0
- package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +173 -0
- package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +128 -0
- package/src/features/blog/components/public/blogPublicHelpers.ts +30 -0
- package/src/features/blog/components/public/index.ts +3 -0
- package/src/features/blog/contexts/BlogPostContext.tsx +66 -0
- package/src/features/blog/contexts/index.ts +1 -0
- package/src/features/blog/data/BlogCategory.ts +93 -0
- package/src/features/blog/data/BlogCategoryFields.ts +4 -0
- package/src/features/blog/data/BlogCategoryInterface.ts +24 -0
- package/src/features/blog/data/BlogCategoryService.ts +54 -0
- package/src/features/blog/data/BlogCategoryTranslation.ts +90 -0
- package/src/features/blog/data/BlogCategoryTranslationInterface.ts +22 -0
- package/src/features/blog/data/BlogCategoryTranslationService.ts +30 -0
- package/src/features/blog/data/BlogPost.spec.ts +123 -0
- package/src/features/blog/data/BlogPost.ts +159 -0
- package/src/features/blog/data/BlogPostFields.ts +7 -0
- package/src/features/blog/data/BlogPostInterface.ts +39 -0
- package/src/features/blog/data/BlogPostService.ts +82 -0
- package/src/features/blog/data/BlogPostTranslation.spec.ts +73 -0
- package/src/features/blog/data/BlogPostTranslation.ts +110 -0
- package/src/features/blog/data/BlogPostTranslationInterface.ts +27 -0
- package/src/features/blog/data/BlogPostTranslationService.ts +30 -0
- package/src/features/blog/data/BlogSettings.ts +37 -0
- package/src/features/blog/data/BlogSettingsInterface.ts +11 -0
- package/src/features/blog/data/BlogSettingsService.ts +24 -0
- package/src/features/blog/data/index.ts +17 -0
- package/src/features/blog/hooks/index.ts +3 -0
- package/src/features/blog/hooks/useBlogCategoryNames.ts +34 -0
- package/src/features/blog/hooks/useBlogCategoryTableStructure.tsx +84 -0
- package/src/features/blog/hooks/useBlogPostTableStructure.tsx +126 -0
- package/src/features/blog/index.ts +15 -0
- package/src/features/blog/server/fetchers.ts +120 -0
- package/src/features/blog/server/toPublic.spec.ts +91 -0
- package/src/features/blog/server/toPublic.ts +36 -0
- package/src/features/blog/server-entry.ts +17 -0
- package/src/features/blog/types/blog-public.types.ts +34 -0
- package/src/features/blog/types/blog.constants.ts +40 -0
- package/src/features/s3/data/s3.service.ts +17 -0
- package/dist/BlockNoteEditor-UJ6363LC.js.map +0 -1
- package/dist/BlockNoteEditor-WTSQANKI.mjs.map +0 -1
- package/dist/chunk-3BAM23VA.js.map +0 -1
- package/dist/chunk-CRI4T47Y.js.map +0 -1
- package/dist/chunk-JQWI62NV.mjs.map +0 -1
- package/dist/chunk-MIZTWBPX.mjs.map +0 -1
- package/dist/chunk-QKZUE5OF.mjs.map +0 -1
- package/dist/chunk-XGTQWBDJ.js.map +0 -1
- /package/dist/{BlockNoteViewer-JVKWJZ6J.mjs.map → BlockNoteViewer-AEJKJ27Q.mjs.map} +0 -0
- /package/dist/{chunk-FNTQQFLH.mjs.map → chunk-6DC2WVVB.mjs.map} +0 -0
- /package/dist/{chunk-6WU74VFU.mjs.map → chunk-QMYQRPQC.mjs.map} +0 -0
|
@@ -0,0 +1,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 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
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
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
|
+
fullWidth
|
|
93
|
+
forceHeader
|
|
94
|
+
tabs={translations.map((translation) => ({
|
|
95
|
+
sectionKey: translation.language,
|
|
96
|
+
label: tabLabel(translation),
|
|
97
|
+
content: <BlogPostTranslationPanel post={post} translation={translation} onChanged={reload} />,
|
|
98
|
+
}))}
|
|
99
|
+
/>
|
|
100
|
+
</SharedProvider>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -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,207 @@
|
|
|
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 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
|
+
{post && (
|
|
164
|
+
<FormInput form={form} id="slug" name={t("blog.fields.slug")} description={t("blog.fields.slugHelp")} />
|
|
165
|
+
)}
|
|
166
|
+
<FormTextarea
|
|
167
|
+
form={form}
|
|
168
|
+
id="summary"
|
|
169
|
+
name={t("blog.fields.summary")}
|
|
170
|
+
description={t("blog.fields.summaryHelp")}
|
|
171
|
+
/>
|
|
172
|
+
<BlogCoverImageField
|
|
173
|
+
form={form}
|
|
174
|
+
id="coverImage"
|
|
175
|
+
label={t("blog.fields.coverImage")}
|
|
176
|
+
keyPrefix={BLOG_POST_UPLOAD_PREFIX}
|
|
177
|
+
entityId={form.getValues("id")}
|
|
178
|
+
currentUrl={post?.coverImage}
|
|
179
|
+
alt={original?.coverImageAlt}
|
|
180
|
+
selection={cover}
|
|
181
|
+
onSelectionChange={setCover}
|
|
182
|
+
/>
|
|
183
|
+
<FormInput form={form} id="coverImageAlt" name={t("blog.fields.coverImageAlt")} />
|
|
184
|
+
<EntityMultiSelector<BlogCategoryInterface>
|
|
185
|
+
id="categories"
|
|
186
|
+
form={form}
|
|
187
|
+
label={t("blog.fields.categories")}
|
|
188
|
+
retriever={(params) => BlogCategoryService.findMany(params)}
|
|
189
|
+
module={Modules.BlogCategory}
|
|
190
|
+
getLabel={blogCategoryAdminName}
|
|
191
|
+
// A post's included categories carry no translations, so the stored chip
|
|
192
|
+
// names come from the category list.
|
|
193
|
+
getFormValueLabel={(value: { id: string; name?: string }) => value.name || categoryName(value.id)}
|
|
194
|
+
/>
|
|
195
|
+
<FormBlockNote
|
|
196
|
+
uploadKeyPrefix={BLOG_POST_UPLOAD_PREFIX}
|
|
197
|
+
form={form}
|
|
198
|
+
id="body"
|
|
199
|
+
name={t("blog.fields.body")}
|
|
200
|
+
type="blog-post"
|
|
201
|
+
isRequired
|
|
202
|
+
onEmptyChange={setIsBodyEmptyState}
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
</EditorSheet>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
@@ -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
|
+
}
|