@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
package/messages/it.json
CHANGED
|
@@ -736,6 +736,99 @@
|
|
|
736
736
|
}
|
|
737
737
|
}
|
|
738
738
|
},
|
|
739
|
+
"blog": {
|
|
740
|
+
"admin": {
|
|
741
|
+
"title": "Blog",
|
|
742
|
+
"newPost": "Nuovo articolo",
|
|
743
|
+
"editPost": "Modifica articolo",
|
|
744
|
+
"newCategory": "Nuova categoria",
|
|
745
|
+
"editCategory": "Modifica categoria",
|
|
746
|
+
"empty": "Nessun articolo. Scrivi il primo.",
|
|
747
|
+
"emptyCategories": "Nessuna categoria."
|
|
748
|
+
},
|
|
749
|
+
"tabs": {
|
|
750
|
+
"posts": "Articoli",
|
|
751
|
+
"categories": "Categorie",
|
|
752
|
+
"layout": "Impaginazione"
|
|
753
|
+
},
|
|
754
|
+
"fields": {
|
|
755
|
+
"title": "Titolo",
|
|
756
|
+
"slug": "Slug",
|
|
757
|
+
"slugHelp": "L'ultima parte dell'indirizzo dell'articolo. Cambiarlo rompe i link che puntano già all'articolo.",
|
|
758
|
+
"summary": "Sommario",
|
|
759
|
+
"summaryHelp": "Lascialo vuoto e lo scriverà l'AI a partire dal testo.",
|
|
760
|
+
"body": "Testo",
|
|
761
|
+
"coverImage": "Immagine di copertina",
|
|
762
|
+
"coverImageAlt": "Descrizione dell'immagine di copertina",
|
|
763
|
+
"categories": "Categorie",
|
|
764
|
+
"originalLanguage": "Lingua originale",
|
|
765
|
+
"name": "Nome",
|
|
766
|
+
"description": "Descrizione",
|
|
767
|
+
"image": "Immagine",
|
|
768
|
+
"status": "Stato",
|
|
769
|
+
"publishedAt": "Pubblicato",
|
|
770
|
+
"languages": "Lingue pronte"
|
|
771
|
+
},
|
|
772
|
+
"status": {
|
|
773
|
+
"draft": "Bozza",
|
|
774
|
+
"published": "Pubblicato",
|
|
775
|
+
"unpublished": "Ritirato"
|
|
776
|
+
},
|
|
777
|
+
"actions": {
|
|
778
|
+
"publish": "Pubblica",
|
|
779
|
+
"unpublish": "Ritira",
|
|
780
|
+
"edit": "Modifica",
|
|
781
|
+
"translateAgain": "Traduci di nuovo",
|
|
782
|
+
"compareWithOriginal": "Confronta con l'originale",
|
|
783
|
+
"hideComparison": "Nascondi confronto"
|
|
784
|
+
},
|
|
785
|
+
"translation": {
|
|
786
|
+
"original": "Originale",
|
|
787
|
+
"translatedFrom": "Lingua originale: {language}, tradotto il {date}",
|
|
788
|
+
"editedByHand": "Modificato a mano",
|
|
789
|
+
"overwriteWarning": "Modificare l'originale lo farà tradurre di nuovo e sostituirà queste modifiche.",
|
|
790
|
+
"translating": "Traduzione in corso…",
|
|
791
|
+
"failed": "Traduzione non riuscita",
|
|
792
|
+
"pending": "In attesa di traduzione"
|
|
793
|
+
},
|
|
794
|
+
"layout": {
|
|
795
|
+
"title": "Impaginazione dell'elenco pubblico",
|
|
796
|
+
"featured": "In evidenza",
|
|
797
|
+
"featuredDescription": "L'articolo più recente in grande, poi una griglia di schede su tre colonne.",
|
|
798
|
+
"column": "Colonna",
|
|
799
|
+
"columnDescription": "Una colonna di lettura stretta, una riga per articolo con l'immagine accanto al testo.",
|
|
800
|
+
"rail": "Barra laterale",
|
|
801
|
+
"railDescription": "Ricerca e categorie in una barra laterale, l'articolo più recente in grande, gli altri su due colonne.",
|
|
802
|
+
"saved": "Impaginazione salvata"
|
|
803
|
+
},
|
|
804
|
+
"public": {
|
|
805
|
+
"eyebrow": "Blog",
|
|
806
|
+
"title": "Note e guide",
|
|
807
|
+
"subtitle": "Idee, guide e novità dal team.",
|
|
808
|
+
"all": "Tutti",
|
|
809
|
+
"search": "Cerca",
|
|
810
|
+
"searchPlaceholder": "Cerca negli articoli",
|
|
811
|
+
"loadMore": "Carica altri",
|
|
812
|
+
"readingTime": "{minutes} min di lettura",
|
|
813
|
+
"updated": "Aggiornato il {date}",
|
|
814
|
+
"by": "Di {author}",
|
|
815
|
+
"back": "Tutti gli articoli",
|
|
816
|
+
"moreIn": "Altro in {category}",
|
|
817
|
+
"empty": "Ancora nessun articolo.",
|
|
818
|
+
"noResults": "Nessun articolo corrisponde alla ricerca."
|
|
819
|
+
},
|
|
820
|
+
"entities": {
|
|
821
|
+
"post": "Articolo",
|
|
822
|
+
"posts": "Articoli",
|
|
823
|
+
"category": "Categoria",
|
|
824
|
+
"categories": "Categorie"
|
|
825
|
+
},
|
|
826
|
+
"errors": {
|
|
827
|
+
"title": "Il titolo è obbligatorio",
|
|
828
|
+
"name": "Il nome è obbligatorio",
|
|
829
|
+
"language": "Scegli la lingua originale"
|
|
830
|
+
}
|
|
831
|
+
},
|
|
739
832
|
"canvas": {
|
|
740
833
|
"toolbar": {
|
|
741
834
|
"fitView": "Adatta alla vista",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@carlonicora/nextjs-jsonapi",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.4.0",
|
|
4
4
|
"description": "Next.js JSON:API client with server/client support and caching",
|
|
5
5
|
"author": "Carlo Nicora",
|
|
6
6
|
"license": "GPL-3.0-or-later",
|
|
@@ -74,6 +74,16 @@
|
|
|
74
74
|
"import": "./dist/features/help/server.mjs",
|
|
75
75
|
"require": "./dist/features/help/server.js"
|
|
76
76
|
},
|
|
77
|
+
"./blog": {
|
|
78
|
+
"types": "./dist/features/blog/index.d.ts",
|
|
79
|
+
"import": "./dist/features/blog/index.mjs",
|
|
80
|
+
"require": "./dist/features/blog/index.js"
|
|
81
|
+
},
|
|
82
|
+
"./blog/server": {
|
|
83
|
+
"types": "./dist/features/blog/server.d.ts",
|
|
84
|
+
"import": "./dist/features/blog/server.mjs",
|
|
85
|
+
"require": "./dist/features/blog/server.js"
|
|
86
|
+
},
|
|
77
87
|
"./tokenusage": {
|
|
78
88
|
"types": "./dist/features/tokenusage/index.d.ts",
|
|
79
89
|
"import": "./dist/features/tokenusage/index.mjs",
|
|
@@ -78,6 +78,13 @@ export type BlockNoteEditorProps = {
|
|
|
78
78
|
diffContent?: PartialBlock[];
|
|
79
79
|
placeholder?: string;
|
|
80
80
|
bordered?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Storage folder for pasted images, used instead of the current company's
|
|
83
|
+
* `companies/<companyId>/<type>` folder: the image key becomes
|
|
84
|
+
* `<uploadKeyPrefix>/<id>/<file name>`. For platform content edited by users
|
|
85
|
+
* who belong to no company (the blog). Omitted: the company folder, as before.
|
|
86
|
+
*/
|
|
87
|
+
uploadKeyPrefix?: string;
|
|
81
88
|
inlineContentSpecs?: Record<string, any>;
|
|
82
89
|
renderOverlays?: (editor: any) => React.ReactNode;
|
|
83
90
|
enableMentions?: boolean;
|
|
@@ -405,6 +412,7 @@ export default function BlockNoteEditor({
|
|
|
405
412
|
diffContent,
|
|
406
413
|
placeholder,
|
|
407
414
|
bordered,
|
|
415
|
+
uploadKeyPrefix,
|
|
408
416
|
inlineContentSpecs,
|
|
409
417
|
renderOverlays,
|
|
410
418
|
enableMentions,
|
|
@@ -547,7 +555,7 @@ export default function BlockNoteEditor({
|
|
|
547
555
|
|
|
548
556
|
const uploadImage = useCallback(
|
|
549
557
|
async (file: File): Promise<string> => {
|
|
550
|
-
if (!company) {
|
|
558
|
+
if (!company && !uploadKeyPrefix) {
|
|
551
559
|
errorToast({
|
|
552
560
|
title: t(`common.errors.upload`),
|
|
553
561
|
error: t(`common.errors.upload_description`),
|
|
@@ -556,7 +564,9 @@ export default function BlockNoteEditor({
|
|
|
556
564
|
}
|
|
557
565
|
|
|
558
566
|
const fileType = file.type;
|
|
559
|
-
const key =
|
|
567
|
+
const key = uploadKeyPrefix
|
|
568
|
+
? `${uploadKeyPrefix}/${id}/${file.name}`
|
|
569
|
+
: `companies/${company!.id}/${type}/${id}/${file.name}`;
|
|
560
570
|
|
|
561
571
|
const s3: S3Interface = await S3Service.getPreSignedUrl({
|
|
562
572
|
key: key,
|
|
@@ -577,7 +587,7 @@ export default function BlockNoteEditor({
|
|
|
577
587
|
|
|
578
588
|
return signedImage.url;
|
|
579
589
|
},
|
|
580
|
-
[company, id, t],
|
|
590
|
+
[company, id, t, type, uploadKeyPrefix],
|
|
581
591
|
);
|
|
582
592
|
|
|
583
593
|
// Utility: Remove trailing empty blocks for read-only display
|
|
@@ -41,6 +41,7 @@ export function FormBlockNote({
|
|
|
41
41
|
aiConfig,
|
|
42
42
|
aiHandleRef,
|
|
43
43
|
headerAction,
|
|
44
|
+
uploadKeyPrefix,
|
|
44
45
|
}: {
|
|
45
46
|
form: any;
|
|
46
47
|
id: string;
|
|
@@ -74,6 +75,8 @@ export function FormBlockNote({
|
|
|
74
75
|
aiHandleRef?: React.MutableRefObject<import("../editors/BlockNoteEditor").BlockNoteAiHandle | null>;
|
|
75
76
|
/** Rendered on the label row, right-aligned — e.g. a "Suggest impression" button. */
|
|
76
77
|
headerAction?: React.ReactNode;
|
|
78
|
+
/** See `BlockNoteEditorProps.uploadKeyPrefix`. */
|
|
79
|
+
uploadKeyPrefix?: string;
|
|
77
80
|
}) {
|
|
78
81
|
const initialContentRef = useRef<any>(null);
|
|
79
82
|
const lastEditorContentRef = useRef<any>(undefined);
|
|
@@ -133,6 +136,7 @@ export function FormBlockNote({
|
|
|
133
136
|
bordered
|
|
134
137
|
inlineContentSpecs={inlineContentSpecs}
|
|
135
138
|
renderOverlays={renderOverlays}
|
|
139
|
+
uploadKeyPrefix={uploadKeyPrefix}
|
|
136
140
|
enableMentions={enableMentions}
|
|
137
141
|
mentionSearchFn={mentionSearchFn}
|
|
138
142
|
mentionSearchParams={mentionSearchParams}
|
|
@@ -11,9 +11,20 @@ type TitleProps = {
|
|
|
11
11
|
className?: string;
|
|
12
12
|
module?: ModuleWithPermissions;
|
|
13
13
|
prioritizeFunctions?: boolean;
|
|
14
|
+
/** Renders the title line as a real heading (public SEO pages). Omitted: a plain div, as before. */
|
|
15
|
+
headingLevel?: 1 | 2;
|
|
14
16
|
};
|
|
15
17
|
|
|
16
|
-
export function ContentTitle({
|
|
18
|
+
export function ContentTitle({
|
|
19
|
+
module,
|
|
20
|
+
type,
|
|
21
|
+
element,
|
|
22
|
+
functions,
|
|
23
|
+
className,
|
|
24
|
+
prioritizeFunctions,
|
|
25
|
+
headingLevel,
|
|
26
|
+
}: TitleProps) {
|
|
27
|
+
const ElementTag = headingLevel === 1 ? "h1" : headingLevel === 2 ? "h2" : "div";
|
|
17
28
|
const [clientFunctions, setClientFunctions] = useState<ReactNode>(null);
|
|
18
29
|
const [isClient, setIsClient] = useState(false);
|
|
19
30
|
|
|
@@ -43,9 +54,9 @@ export function ContentTitle({ module, type, element, functions, className, prio
|
|
|
43
54
|
</div>
|
|
44
55
|
)}
|
|
45
56
|
{/* Element (name) - truncates with ellipsis when space is limited */}
|
|
46
|
-
<
|
|
57
|
+
<ElementTag className={cn("text-primary text-3xl font-semibold", prioritizeFunctions ? "truncate" : "w-full")}>
|
|
47
58
|
{element}
|
|
48
|
-
</
|
|
59
|
+
</ElementTag>
|
|
49
60
|
</div>
|
|
50
61
|
{/* Functions section - doesn't shrink when prioritizeFunctions is true */}
|
|
51
62
|
{isClient && clientFunctions && (
|
package/src/core/index.ts
CHANGED
|
@@ -75,6 +75,10 @@ export * from "../features/email-automation/EmailAutomationTriggerModule";
|
|
|
75
75
|
// server page can wrap EmailAutomationService.findOne in cache(). The client-marked
|
|
76
76
|
// ./email-automations entry carries only components, contexts and hooks.
|
|
77
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";
|
|
78
82
|
export * from "../features/handbook/HandbookPageModule";
|
|
79
83
|
export * from "../features/handbook/HandbookSectionModule";
|
|
80
84
|
export * from "../features/handbook/HandbookThreadMessageModule";
|
|
@@ -46,6 +46,12 @@ export interface FoundationModuleDefinitions {
|
|
|
46
46
|
EmailAutomationStep: ModuleWithPermissions;
|
|
47
47
|
EmailAutomationEnrollment: ModuleWithPermissions;
|
|
48
48
|
EmailAutomationTrigger: ModuleWithPermissions;
|
|
49
|
+
// Blog modules
|
|
50
|
+
BlogPost: ModuleWithPermissions;
|
|
51
|
+
BlogPostTranslation: ModuleWithPermissions;
|
|
52
|
+
BlogCategory: ModuleWithPermissions;
|
|
53
|
+
BlogCategoryTranslation: ModuleWithPermissions;
|
|
54
|
+
BlogSettings: ModuleWithPermissions;
|
|
49
55
|
// Referral modules
|
|
50
56
|
Referral: ModuleWithPermissions;
|
|
51
57
|
ReferralStats: ModuleWithPermissions;
|
|
@@ -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
|
+
}
|