@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.
Files changed (173) hide show
  1. package/dist/{BlockNoteEditor-UJ6363LC.js → BlockNoteEditor-4YCAIEPT.js} +23 -22
  2. package/dist/BlockNoteEditor-4YCAIEPT.js.map +1 -0
  3. package/dist/{BlockNoteEditor-WTSQANKI.mjs → BlockNoteEditor-YQL2SGFR.mjs} +8 -7
  4. package/dist/BlockNoteEditor-YQL2SGFR.mjs.map +1 -0
  5. package/dist/{BlockNoteViewer-JVKWJZ6J.mjs → BlockNoteViewer-AEJKJ27Q.mjs} +2 -2
  6. package/dist/{BlockNoteViewer-EL4JFB6P.js → BlockNoteViewer-YL3J7J2Y.js} +3 -3
  7. package/dist/{BlockNoteViewer-EL4JFB6P.js.map → BlockNoteViewer-YL3J7J2Y.js.map} +1 -1
  8. package/dist/BlogCategoryFields-DO48vOTE.d.mts +14 -0
  9. package/dist/BlogCategoryFields-DO48vOTE.d.ts +14 -0
  10. package/dist/BlogPostInterface-BB_4wcDR.d.mts +124 -0
  11. package/dist/BlogPostInterface-bWAT1rfR.d.ts +124 -0
  12. package/dist/billing/index.js +441 -441
  13. package/dist/billing/index.mjs +3 -3
  14. package/dist/blog-public.types-BbrzsC1c.d.mts +30 -0
  15. package/dist/blog-public.types-BbrzsC1c.d.ts +30 -0
  16. package/dist/{chunk-3BAM23VA.js → chunk-3NLAXVIN.js} +860 -849
  17. package/dist/chunk-3NLAXVIN.js.map +1 -0
  18. package/dist/{chunk-QKZUE5OF.mjs → chunk-3YW35YHQ.mjs} +1 -101
  19. package/dist/chunk-3YW35YHQ.mjs.map +1 -0
  20. package/dist/{chunk-QQQE3N2X.js → chunk-5PPG6PL7.js} +3 -3
  21. package/dist/{chunk-QQQE3N2X.js.map → chunk-5PPG6PL7.js.map} +1 -1
  22. package/dist/{chunk-FNTQQFLH.mjs → chunk-6DC2WVVB.mjs} +2 -2
  23. package/dist/{chunk-MIZTWBPX.mjs → chunk-EUKTBOI2.mjs} +19 -8
  24. package/dist/chunk-EUKTBOI2.mjs.map +1 -0
  25. package/dist/{chunk-XGTQWBDJ.js → chunk-KSYQEKJX.js} +11 -111
  26. package/dist/chunk-KSYQEKJX.js.map +1 -0
  27. package/dist/{chunk-CRI4T47Y.js → chunk-NFBOSPNJ.js} +756 -41
  28. package/dist/chunk-NFBOSPNJ.js.map +1 -0
  29. package/dist/{chunk-6WU74VFU.mjs → chunk-QMYQRPQC.mjs} +2 -2
  30. package/dist/{chunk-JQWI62NV.mjs → chunk-WC63PJSC.mjs} +716 -1
  31. package/dist/chunk-WC63PJSC.mjs.map +1 -0
  32. package/dist/chunk-XCHSX3VC.mjs +107 -0
  33. package/dist/chunk-XCHSX3VC.mjs.map +1 -0
  34. package/dist/{chunk-EO75CV76.js → chunk-YNCQSWQW.js} +8 -8
  35. package/dist/{chunk-EO75CV76.js.map → chunk-YNCQSWQW.js.map} +1 -1
  36. package/dist/chunk-YW4ZCEWB.js +107 -0
  37. package/dist/chunk-YW4ZCEWB.js.map +1 -0
  38. package/dist/client/index.js +4 -4
  39. package/dist/client/index.mjs +3 -3
  40. package/dist/components/index.d.mts +13 -2
  41. package/dist/components/index.d.ts +13 -2
  42. package/dist/components/index.js +4 -4
  43. package/dist/components/index.mjs +3 -3
  44. package/dist/contexts/index.js +4 -4
  45. package/dist/contexts/index.mjs +3 -3
  46. package/dist/core/index.d.mts +214 -3
  47. package/dist/core/index.d.ts +214 -3
  48. package/dist/core/index.js +36 -2
  49. package/dist/core/index.js.map +1 -1
  50. package/dist/core/index.mjs +35 -1
  51. package/dist/email-automations/index.js +92 -92
  52. package/dist/email-automations/index.mjs +3 -3
  53. package/dist/features/analytics/index.js +122 -122
  54. package/dist/features/analytics/index.mjs +4 -4
  55. package/dist/features/analytics/server.js +6 -5
  56. package/dist/features/analytics/server.js.map +1 -1
  57. package/dist/features/analytics/server.mjs +4 -3
  58. package/dist/features/analytics/server.mjs.map +1 -1
  59. package/dist/features/blog/index.css +30 -0
  60. package/dist/features/blog/index.css.map +1 -0
  61. package/dist/features/blog/index.d.mts +266 -0
  62. package/dist/features/blog/index.d.ts +266 -0
  63. package/dist/features/blog/index.js +1799 -0
  64. package/dist/features/blog/index.js.map +1 -0
  65. package/dist/features/blog/index.mjs +1799 -0
  66. package/dist/features/blog/index.mjs.map +1 -0
  67. package/dist/features/blog/server.d.mts +33 -0
  68. package/dist/features/blog/server.d.ts +33 -0
  69. package/dist/features/blog/server.js +139 -0
  70. package/dist/features/blog/server.js.map +1 -0
  71. package/dist/features/blog/server.mjs +139 -0
  72. package/dist/features/blog/server.mjs.map +1 -0
  73. package/dist/features/help/index.js +37 -37
  74. package/dist/features/help/index.mjs +3 -3
  75. package/dist/features/tokenusage/index.js +125 -125
  76. package/dist/features/tokenusage/index.mjs +4 -4
  77. package/dist/index.d.mts +4 -2
  78. package/dist/index.d.ts +4 -2
  79. package/dist/index.js +37 -3
  80. package/dist/index.js.map +1 -1
  81. package/dist/index.mjs +36 -2
  82. package/dist/{s3.service-CY-jgOAM.d.ts → s3.service-Bz_K-tjz.d.ts} +11 -0
  83. package/dist/{s3.service-Sri0QP7Z.d.mts → s3.service-D5zNCEW3.d.mts} +11 -0
  84. package/dist/server/index.d.mts +1 -1
  85. package/dist/server/index.d.ts +1 -1
  86. package/dist/server/index.js +8 -6
  87. package/dist/server/index.js.map +1 -1
  88. package/dist/server/index.mjs +12 -10
  89. package/messages/en.json +93 -0
  90. package/messages/it.json +93 -0
  91. package/package.json +11 -1
  92. package/src/components/editors/BlockNoteEditor.tsx +13 -3
  93. package/src/components/forms/FormBlockNote.tsx +4 -0
  94. package/src/components/navigations/ContentTitle.tsx +14 -3
  95. package/src/core/index.ts +4 -0
  96. package/src/core/registry/ModuleRegistry.ts +6 -0
  97. package/src/features/blog/BlogModules.ts +49 -0
  98. package/src/features/blog/components/admin/BlogAdminContainer.tsx +32 -0
  99. package/src/features/blog/components/admin/BlogCategoriesList.tsx +56 -0
  100. package/src/features/blog/components/admin/BlogCategoryEditor.tsx +230 -0
  101. package/src/features/blog/components/admin/BlogCoverImageField.tsx +123 -0
  102. package/src/features/blog/components/admin/BlogLayoutSetting.tsx +86 -0
  103. package/src/features/blog/components/admin/BlogPostComparison.tsx +54 -0
  104. package/src/features/blog/components/admin/BlogPostContainer.tsx +102 -0
  105. package/src/features/blog/components/admin/BlogPostDeleter.tsx +35 -0
  106. package/src/features/blog/components/admin/BlogPostEditor.tsx +207 -0
  107. package/src/features/blog/components/admin/BlogPostPublishButton.tsx +50 -0
  108. package/src/features/blog/components/admin/BlogPostTranslationEditor.tsx +110 -0
  109. package/src/features/blog/components/admin/BlogPostTranslationPanel.tsx +136 -0
  110. package/src/features/blog/components/admin/BlogPostsList.tsx +58 -0
  111. package/src/features/blog/components/admin/BlogTranslationStatus.tsx +104 -0
  112. package/src/features/blog/components/admin/__tests__/BlogPostTranslationPanel.spec.tsx +100 -0
  113. package/src/features/blog/components/admin/__tests__/BlogPostsList.spec.tsx +30 -0
  114. package/src/features/blog/components/admin/index.ts +14 -0
  115. package/src/features/blog/components/public/BlogCategoryChips.tsx +59 -0
  116. package/src/features/blog/components/public/BlogList.tsx +21 -0
  117. package/src/features/blog/components/public/BlogListColumn.tsx +53 -0
  118. package/src/features/blog/components/public/BlogListFeatured.tsx +57 -0
  119. package/src/features/blog/components/public/BlogListParts.tsx +74 -0
  120. package/src/features/blog/components/public/BlogListRail.tsx +62 -0
  121. package/src/features/blog/components/public/BlogPostCard.tsx +132 -0
  122. package/src/features/blog/components/public/BlogPostMeta.tsx +49 -0
  123. package/src/features/blog/components/public/BlogPostView.tsx +74 -0
  124. package/src/features/blog/components/public/BlogSearchBox.tsx +52 -0
  125. package/src/features/blog/components/public/__tests__/BlogList.spec.tsx +173 -0
  126. package/src/features/blog/components/public/__tests__/BlogPostView.spec.tsx +128 -0
  127. package/src/features/blog/components/public/blogPublicHelpers.ts +30 -0
  128. package/src/features/blog/components/public/index.ts +3 -0
  129. package/src/features/blog/contexts/BlogPostContext.tsx +66 -0
  130. package/src/features/blog/contexts/index.ts +1 -0
  131. package/src/features/blog/data/BlogCategory.ts +93 -0
  132. package/src/features/blog/data/BlogCategoryFields.ts +4 -0
  133. package/src/features/blog/data/BlogCategoryInterface.ts +24 -0
  134. package/src/features/blog/data/BlogCategoryService.ts +54 -0
  135. package/src/features/blog/data/BlogCategoryTranslation.ts +90 -0
  136. package/src/features/blog/data/BlogCategoryTranslationInterface.ts +22 -0
  137. package/src/features/blog/data/BlogCategoryTranslationService.ts +30 -0
  138. package/src/features/blog/data/BlogPost.spec.ts +123 -0
  139. package/src/features/blog/data/BlogPost.ts +159 -0
  140. package/src/features/blog/data/BlogPostFields.ts +7 -0
  141. package/src/features/blog/data/BlogPostInterface.ts +39 -0
  142. package/src/features/blog/data/BlogPostService.ts +82 -0
  143. package/src/features/blog/data/BlogPostTranslation.spec.ts +73 -0
  144. package/src/features/blog/data/BlogPostTranslation.ts +110 -0
  145. package/src/features/blog/data/BlogPostTranslationInterface.ts +27 -0
  146. package/src/features/blog/data/BlogPostTranslationService.ts +30 -0
  147. package/src/features/blog/data/BlogSettings.ts +37 -0
  148. package/src/features/blog/data/BlogSettingsInterface.ts +11 -0
  149. package/src/features/blog/data/BlogSettingsService.ts +24 -0
  150. package/src/features/blog/data/index.ts +17 -0
  151. package/src/features/blog/hooks/index.ts +3 -0
  152. package/src/features/blog/hooks/useBlogCategoryNames.ts +34 -0
  153. package/src/features/blog/hooks/useBlogCategoryTableStructure.tsx +84 -0
  154. package/src/features/blog/hooks/useBlogPostTableStructure.tsx +126 -0
  155. package/src/features/blog/index.ts +15 -0
  156. package/src/features/blog/server/fetchers.ts +120 -0
  157. package/src/features/blog/server/toPublic.spec.ts +91 -0
  158. package/src/features/blog/server/toPublic.ts +36 -0
  159. package/src/features/blog/server-entry.ts +17 -0
  160. package/src/features/blog/types/blog-public.types.ts +34 -0
  161. package/src/features/blog/types/blog.constants.ts +40 -0
  162. package/src/features/s3/data/s3.service.ts +17 -0
  163. package/dist/BlockNoteEditor-UJ6363LC.js.map +0 -1
  164. package/dist/BlockNoteEditor-WTSQANKI.mjs.map +0 -1
  165. package/dist/chunk-3BAM23VA.js.map +0 -1
  166. package/dist/chunk-CRI4T47Y.js.map +0 -1
  167. package/dist/chunk-JQWI62NV.mjs.map +0 -1
  168. package/dist/chunk-MIZTWBPX.mjs.map +0 -1
  169. package/dist/chunk-QKZUE5OF.mjs.map +0 -1
  170. package/dist/chunk-XGTQWBDJ.js.map +0 -1
  171. /package/dist/{BlockNoteViewer-JVKWJZ6J.mjs.map → BlockNoteViewer-AEJKJ27Q.mjs.map} +0 -0
  172. /package/dist/{chunk-FNTQQFLH.mjs.map → chunk-6DC2WVVB.mjs.map} +0 -0
  173. /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.0",
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 = `companies/${company.id}/${type}/${id}/${file.name}`;
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({ module, type, element, functions, className, prioritizeFunctions }: TitleProps) {
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
- <div className={cn("text-primary text-3xl font-semibold", prioritizeFunctions ? "truncate" : "w-full")}>
57
+ <ElementTag className={cn("text-primary text-3xl font-semibold", prioritizeFunctions ? "truncate" : "w-full")}>
47
58
  {element}
48
- </div>
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
+ }