@carlonicora/nextjs-jsonapi 5.2.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-EGY4ZAAM.js → BlockNoteEditor-4YCAIEPT.js} +23 -22
- package/dist/BlockNoteEditor-4YCAIEPT.js.map +1 -0
- package/dist/{BlockNoteEditor-2QJVK2HH.mjs → BlockNoteEditor-YQL2SGFR.mjs} +8 -7
- package/dist/BlockNoteEditor-YQL2SGFR.mjs.map +1 -0
- package/dist/{BlockNoteViewer-6BQXJ4TB.mjs → BlockNoteViewer-AEJKJ27Q.mjs} +2 -2
- package/dist/{BlockNoteViewer-I2MMFH3S.js → BlockNoteViewer-YL3J7J2Y.js} +3 -3
- package/dist/{BlockNoteViewer-I2MMFH3S.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-3VEQ5JZJ.js → chunk-3NLAXVIN.js} +860 -849
- package/dist/{chunk-3VEQ5JZJ.js.map → chunk-3NLAXVIN.js.map} +1 -1
- package/dist/{chunk-YMUPTXOT.mjs → chunk-3YW35YHQ.mjs} +1 -101
- package/dist/chunk-3YW35YHQ.mjs.map +1 -0
- package/dist/{chunk-EXICQC4A.js → chunk-5PPG6PL7.js} +3 -3
- package/dist/{chunk-EXICQC4A.js.map → chunk-5PPG6PL7.js.map} +1 -1
- package/dist/{chunk-NPKFMRH5.mjs → chunk-6DC2WVVB.mjs} +2 -2
- package/dist/{chunk-2Z3ZCYXX.mjs → chunk-EUKTBOI2.mjs} +19 -8
- package/dist/chunk-EUKTBOI2.mjs.map +1 -0
- package/dist/{chunk-THKYH65N.js → chunk-KSYQEKJX.js} +11 -111
- package/dist/chunk-KSYQEKJX.js.map +1 -0
- package/dist/{chunk-S5MQDWWC.js → chunk-NFBOSPNJ.js} +1451 -176
- package/dist/chunk-NFBOSPNJ.js.map +1 -0
- package/dist/{chunk-JEZQTZQ4.mjs → chunk-QMYQRPQC.mjs} +2 -2
- package/dist/{chunk-HNOWSFM5.mjs → chunk-WC63PJSC.mjs} +1278 -3
- 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-75MLT74B.js → chunk-YNCQSWQW.js} +8 -8
- package/dist/{chunk-75MLT74B.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.d.mts +6 -39
- package/dist/client/index.d.ts +6 -39
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +15 -4
- package/dist/components/index.d.ts +15 -4
- 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 +400 -3
- package/dist/core/index.d.ts +400 -3
- package/dist/core/index.js +64 -2
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +63 -1
- package/dist/email-automation-enrollment.fields-BZvF14qx.d.mts +82 -0
- package/dist/email-automation-enrollment.fields-Cp3VMzFq.d.ts +82 -0
- package/dist/email-automations/index.css +30 -0
- package/dist/email-automations/index.css.map +1 -0
- package/dist/email-automations/index.d.mts +303 -0
- package/dist/email-automations/index.d.ts +303 -0
- package/dist/email-automations/index.js +1705 -0
- package/dist/email-automations/index.js.map +1 -0
- package/dist/email-automations/index.mjs +1705 -0
- package/dist/email-automations/index.mjs.map +1 -0
- 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 +5 -2
- package/dist/index.d.ts +5 -2
- package/dist/index.js +65 -3
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +64 -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/dist/types-70acd-8C.d.mts +39 -0
- package/dist/types-l8SFleXi.d.ts +39 -0
- package/messages/en.json +210 -0
- package/messages/it.json +210 -0
- package/package.json +21 -1
- package/src/components/editors/BlockNoteEditor.tsx +13 -3
- package/src/components/forms/FormBlockNote.tsx +4 -0
- package/src/components/forms/FormFieldWrapper.tsx +1 -1
- package/src/components/forms/FormTextarea.tsx +2 -1
- package/src/components/navigations/ContentTitle.tsx +14 -3
- package/src/core/index.ts +12 -0
- package/src/core/registry/ModuleRegistry.ts +11 -0
- package/src/email-automations/index.ts +5 -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/email-automation/EmailAutomationEnrollmentModule.ts +11 -0
- package/src/features/email-automation/EmailAutomationModule.ts +30 -0
- package/src/features/email-automation/EmailAutomationStepModule.ts +11 -0
- package/src/features/email-automation/EmailAutomationTriggerModule.ts +11 -0
- package/src/features/email-automation/components/__tests__/EmailAutomationEditor.spec.tsx +104 -0
- package/src/features/email-automation/components/canvas/EmailAutomationActiveSwitch.tsx +49 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvas.tsx +474 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvasInspector.tsx +89 -0
- package/src/features/email-automation/components/canvas/EmailAutomationCanvasTab.tsx +32 -0
- package/src/features/email-automation/components/canvas/__tests__/buildCanvasGraph.spec.ts +67 -0
- package/src/features/email-automation/components/canvas/buildCanvasGraph.ts +141 -0
- package/src/features/email-automation/components/canvas/edges/DelayEdge.tsx +68 -0
- package/src/features/email-automation/components/canvas/nodes/EmailStepNode.tsx +43 -0
- package/src/features/email-automation/components/canvas/nodes/TriggerNode.tsx +23 -0
- package/src/features/email-automation/components/canvas/shared/CanvasToolbar.tsx +53 -0
- package/src/features/email-automation/components/canvas/shared/EmptyCanvas.tsx +11 -0
- package/src/features/email-automation/components/canvas/shared/__tests__/autoLayout.spec.ts +42 -0
- package/src/features/email-automation/components/canvas/shared/__tests__/longPress.spec.ts +51 -0
- package/src/features/email-automation/components/canvas/shared/autoLayout.ts +55 -0
- package/src/features/email-automation/components/canvas/shared/index.ts +4 -0
- package/src/features/email-automation/components/canvas/shared/longPress.ts +57 -0
- package/src/features/email-automation/components/containers/EmailAutomationContainer.tsx +80 -0
- package/src/features/email-automation/components/containers/EmailAutomationsListContainer.tsx +27 -0
- package/src/features/email-automation/components/forms/EmailAutomationEditor.tsx +153 -0
- package/src/features/email-automation/components/forms/EmailStepEditor.tsx +151 -0
- package/src/features/email-automation/components/index.ts +15 -0
- package/src/features/email-automation/components/lists/EmailAutomationRecipientsList.tsx +90 -0
- package/src/features/email-automation/components/lists/EmailAutomationsList.tsx +76 -0
- package/src/features/email-automation/components/unsubscribe/EmailUnsubscribe.tsx +99 -0
- package/src/features/email-automation/components/unsubscribe/__tests__/EmailUnsubscribe.spec.tsx +55 -0
- package/src/features/email-automation/contexts/EmailAutomationContext.tsx +175 -0
- package/src/features/email-automation/data/EmailAutomation.ts +87 -0
- package/src/features/email-automation/data/EmailAutomationEnrollment.ts +105 -0
- package/src/features/email-automation/data/EmailAutomationEnrollmentInterface.ts +18 -0
- package/src/features/email-automation/data/EmailAutomationEnrollmentService.ts +31 -0
- package/src/features/email-automation/data/EmailAutomationInterface.ts +22 -0
- package/src/features/email-automation/data/EmailAutomationService.ts +106 -0
- package/src/features/email-automation/data/EmailAutomationStep.ts +104 -0
- package/src/features/email-automation/data/EmailAutomationStepInterface.ts +23 -0
- package/src/features/email-automation/data/EmailAutomationStepService.ts +80 -0
- package/src/features/email-automation/data/EmailAutomationTrigger.ts +28 -0
- package/src/features/email-automation/data/EmailAutomationTriggerInterface.ts +6 -0
- package/src/features/email-automation/data/__tests__/email-automation-models.spec.ts +103 -0
- package/src/features/email-automation/data/email-automation-enrollment.fields.ts +8 -0
- package/src/features/email-automation/data/email-automation.fields.ts +9 -0
- package/src/features/email-automation/data/index.ts +13 -0
- package/src/features/email-automation/hooks/index.ts +2 -0
- package/src/features/email-automation/hooks/useEmailAutomationEnrollmentTableStructure.tsx +126 -0
- package/src/features/email-automation/hooks/useEmailAutomationTableStructure.tsx +119 -0
- package/src/features/email-automation/index.ts +5 -0
- package/src/features/s3/data/s3.service.ts +17 -0
- package/dist/BlockNoteEditor-2QJVK2HH.mjs.map +0 -1
- package/dist/BlockNoteEditor-EGY4ZAAM.js.map +0 -1
- package/dist/chunk-2Z3ZCYXX.mjs.map +0 -1
- package/dist/chunk-HNOWSFM5.mjs.map +0 -1
- package/dist/chunk-S5MQDWWC.js.map +0 -1
- package/dist/chunk-THKYH65N.js.map +0 -1
- package/dist/chunk-YMUPTXOT.mjs.map +0 -1
- /package/dist/{BlockNoteViewer-6BQXJ4TB.mjs.map → BlockNoteViewer-AEJKJ27Q.mjs.map} +0 -0
- /package/dist/{chunk-NPKFMRH5.mjs.map → chunk-6DC2WVVB.mjs.map} +0 -0
- /package/dist/{chunk-JEZQTZQ4.mjs.map → chunk-QMYQRPQC.mjs.map} +0 -0
package/src/core/index.ts
CHANGED
|
@@ -67,6 +67,18 @@ export * from "../features/content/content.module";
|
|
|
67
67
|
export * from "../features/content/data";
|
|
68
68
|
export * from "../features/how-to/HowToModule";
|
|
69
69
|
export * from "../features/how-to/data";
|
|
70
|
+
export * from "../features/email-automation/EmailAutomationModule";
|
|
71
|
+
export * from "../features/email-automation/EmailAutomationStepModule";
|
|
72
|
+
export * from "../features/email-automation/EmailAutomationEnrollmentModule";
|
|
73
|
+
export * from "../features/email-automation/EmailAutomationTriggerModule";
|
|
74
|
+
// Email automation data (models, services, interfaces, fields): server-safe, so a
|
|
75
|
+
// server page can wrap EmailAutomationService.findOne in cache(). The client-marked
|
|
76
|
+
// ./email-automations entry carries only components, contexts and hooks.
|
|
77
|
+
export * from "../features/email-automation/data";
|
|
78
|
+
// Blog modules and data: server-safe, same placement as email automation above.
|
|
79
|
+
// The client-marked ./blog entry carries only components, contexts and hooks.
|
|
80
|
+
export * from "../features/blog/BlogModules";
|
|
81
|
+
export * from "../features/blog/data";
|
|
70
82
|
export * from "../features/handbook/HandbookPageModule";
|
|
71
83
|
export * from "../features/handbook/HandbookSectionModule";
|
|
72
84
|
export * from "../features/handbook/HandbookThreadMessageModule";
|
|
@@ -41,6 +41,17 @@ export interface FoundationModuleDefinitions {
|
|
|
41
41
|
// Waitlist modules
|
|
42
42
|
Waitlist: ModuleWithPermissions;
|
|
43
43
|
WaitlistStats: ModuleWithPermissions;
|
|
44
|
+
// Email automation modules
|
|
45
|
+
EmailAutomation: ModuleWithPermissions;
|
|
46
|
+
EmailAutomationStep: ModuleWithPermissions;
|
|
47
|
+
EmailAutomationEnrollment: ModuleWithPermissions;
|
|
48
|
+
EmailAutomationTrigger: ModuleWithPermissions;
|
|
49
|
+
// Blog modules
|
|
50
|
+
BlogPost: ModuleWithPermissions;
|
|
51
|
+
BlogPostTranslation: ModuleWithPermissions;
|
|
52
|
+
BlogCategory: ModuleWithPermissions;
|
|
53
|
+
BlogCategoryTranslation: ModuleWithPermissions;
|
|
54
|
+
BlogSettings: ModuleWithPermissions;
|
|
44
55
|
// Referral modules
|
|
45
56
|
Referral: ModuleWithPermissions;
|
|
46
57
|
ReferralStats: ModuleWithPermissions;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Email automation components - separate entry point to avoid loading @xyflow/react globally
|
|
2
|
+
export * from "../features/email-automation/components";
|
|
3
|
+
export * from "../features/email-automation/contexts/EmailAutomationContext";
|
|
4
|
+
export * from "../features/email-automation/components/canvas/shared";
|
|
5
|
+
export * from "../features/email-automation/hooks";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { NewspaperIcon } from "lucide-react";
|
|
2
|
+
import { ModuleFactory } from "../../permissions";
|
|
3
|
+
import { BlogCategory } from "./data/BlogCategory";
|
|
4
|
+
import { BlogCategoryTranslation } from "./data/BlogCategoryTranslation";
|
|
5
|
+
import { BlogPost } from "./data/BlogPost";
|
|
6
|
+
import { BlogPostTranslation } from "./data/BlogPostTranslation";
|
|
7
|
+
import { BlogSettings } from "./data/BlogSettings";
|
|
8
|
+
import { BLOG_MODULE_ID } from "./types/blog.constants";
|
|
9
|
+
|
|
10
|
+
export const BlogPostModule = (factory: ModuleFactory) =>
|
|
11
|
+
factory({
|
|
12
|
+
moduleId: BLOG_MODULE_ID,
|
|
13
|
+
pageUrl: "/administration/blog",
|
|
14
|
+
name: "blog-posts",
|
|
15
|
+
model: BlogPost,
|
|
16
|
+
icon: NewspaperIcon,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const BlogPostTranslationModule = (factory: ModuleFactory) =>
|
|
20
|
+
factory({
|
|
21
|
+
moduleId: BLOG_MODULE_ID,
|
|
22
|
+
name: "blog-post-translations",
|
|
23
|
+
model: BlogPostTranslation,
|
|
24
|
+
icon: NewspaperIcon,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export const BlogCategoryModule = (factory: ModuleFactory) =>
|
|
28
|
+
factory({
|
|
29
|
+
moduleId: BLOG_MODULE_ID,
|
|
30
|
+
name: "blog-categories",
|
|
31
|
+
model: BlogCategory,
|
|
32
|
+
icon: NewspaperIcon,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const BlogCategoryTranslationModule = (factory: ModuleFactory) =>
|
|
36
|
+
factory({
|
|
37
|
+
moduleId: BLOG_MODULE_ID,
|
|
38
|
+
name: "blog-category-translations",
|
|
39
|
+
model: BlogCategoryTranslation,
|
|
40
|
+
icon: NewspaperIcon,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const BlogSettingsModule = (factory: ModuleFactory) =>
|
|
44
|
+
factory({
|
|
45
|
+
moduleId: BLOG_MODULE_ID,
|
|
46
|
+
name: "blog-settings",
|
|
47
|
+
model: BlogSettings,
|
|
48
|
+
icon: NewspaperIcon,
|
|
49
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from "next-intl";
|
|
4
|
+
import { RoundPageContainer } from "../../../../components";
|
|
5
|
+
import { Modules } from "../../../../core";
|
|
6
|
+
import { AdministrationProvider } from "../../../administration/contexts/AdministrationContext";
|
|
7
|
+
import { BlogCategoriesList } from "./BlogCategoriesList";
|
|
8
|
+
import { BlogLayoutSetting } from "./BlogLayoutSetting";
|
|
9
|
+
import { BlogPostsList } from "./BlogPostsList";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Blog admin page: posts, categories and the public list layout, one tab each.
|
|
13
|
+
* Client component because `module={Modules.BlogPost}` carries an icon component.
|
|
14
|
+
* Mirrors features/email-automation/components/containers/EmailAutomationsListContainer.tsx.
|
|
15
|
+
*/
|
|
16
|
+
export function BlogAdminContainer() {
|
|
17
|
+
const t = useTranslations();
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<AdministrationProvider>
|
|
21
|
+
<RoundPageContainer
|
|
22
|
+
module={Modules.BlogPost}
|
|
23
|
+
fullWidth
|
|
24
|
+
tabs={[
|
|
25
|
+
{ sectionKey: "posts", label: t("blog.tabs.posts"), content: <BlogPostsList fullWidth /> },
|
|
26
|
+
{ sectionKey: "categories", label: t("blog.tabs.categories"), content: <BlogCategoriesList fullWidth /> },
|
|
27
|
+
{ sectionKey: "layout", label: t("blog.tabs.layout"), content: <BlogLayoutSetting /> },
|
|
28
|
+
]}
|
|
29
|
+
/>
|
|
30
|
+
</AdministrationProvider>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -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 when the list is the page's own content inside a `RoundPageContainer fullWidth`. */
|
|
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 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
|
+
}
|