@carlonicora/nextjs-jsonapi 4.1.2 → 4.2.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 (229) hide show
  1. package/dist/{AssistantMessageInterface-CubxuBIJ.d.ts → AssistantMessageInterface-DHJJO_Z_.d.ts} +1 -1
  2. package/dist/{AssistantMessageInterface-sHjI2zPN.d.mts → AssistantMessageInterface-S7oyERTT.d.mts} +1 -1
  3. package/dist/{BlockNoteEditor-VOYKZOP5.mjs → BlockNoteEditor-HXAHDJA7.mjs} +34 -27
  4. package/dist/BlockNoteEditor-HXAHDJA7.mjs.map +1 -0
  5. package/dist/{BlockNoteEditor-BENYTTCZ.js → BlockNoteEditor-LLCZDFLZ.js} +50 -43
  6. package/dist/BlockNoteEditor-LLCZDFLZ.js.map +1 -0
  7. package/dist/{BlockNoteViewer-USLR4Y7C.js → BlockNoteViewer-77CCTC2U.js} +3 -3
  8. package/dist/{BlockNoteViewer-USLR4Y7C.js.map → BlockNoteViewer-77CCTC2U.js.map} +1 -1
  9. package/dist/{BlockNoteViewer-Y2ZM4ESA.mjs → BlockNoteViewer-MDPO6BXN.mjs} +2 -2
  10. package/dist/{auth.interface-Df4vXbNz.d.mts → auth.interface-CeFCz9NU.d.mts} +1 -1
  11. package/dist/{auth.interface-BzI4onI_.d.ts → auth.interface-D1R7VVky.d.ts} +1 -1
  12. package/dist/billing/index.d.mts +40 -6
  13. package/dist/billing/index.d.ts +40 -6
  14. package/dist/billing/index.js +806 -774
  15. package/dist/billing/index.js.map +1 -1
  16. package/dist/billing/index.mjs +494 -462
  17. package/dist/billing/index.mjs.map +1 -1
  18. package/dist/{chunk-6ISF5KNQ.mjs → chunk-2VXDZRYY.mjs} +1103 -816
  19. package/dist/chunk-2VXDZRYY.mjs.map +1 -0
  20. package/dist/{chunk-4BZJ4LAB.js → chunk-4ORLPEK7.js} +136 -104
  21. package/dist/chunk-4ORLPEK7.js.map +1 -0
  22. package/dist/{chunk-ZZ6E33FO.mjs → chunk-6DRSSW5O.mjs} +36 -4
  23. package/dist/chunk-6DRSSW5O.mjs.map +1 -0
  24. package/dist/{chunk-KS6WZP2E.js → chunk-6SMJRAE3.js} +4 -4
  25. package/dist/{chunk-KS6WZP2E.js.map → chunk-6SMJRAE3.js.map} +1 -1
  26. package/dist/{chunk-WQ77CUUZ.js → chunk-GPXYQMOZ.js} +7 -7
  27. package/dist/{chunk-WQ77CUUZ.js.map → chunk-GPXYQMOZ.js.map} +1 -1
  28. package/dist/{chunk-46S3KPLU.mjs → chunk-ILFFC6BG.mjs} +2 -2
  29. package/dist/{chunk-HZVVRIJQ.mjs → chunk-IUS2RHCX.mjs} +2 -2
  30. package/dist/{chunk-F7RNBGHS.js → chunk-KJMMBUOA.js} +3 -3
  31. package/dist/{chunk-F7RNBGHS.js.map → chunk-KJMMBUOA.js.map} +1 -1
  32. package/dist/{chunk-ZO3QQ5CQ.mjs → chunk-MQDR6MEE.mjs} +2 -2
  33. package/dist/{chunk-ORU7KLCY.js → chunk-VDVIUDY5.js} +1445 -1158
  34. package/dist/chunk-VDVIUDY5.js.map +1 -0
  35. package/dist/client/index.d.mts +20 -6
  36. package/dist/client/index.d.ts +20 -6
  37. package/dist/client/index.js +6 -4
  38. package/dist/client/index.js.map +1 -1
  39. package/dist/client/index.mjs +5 -3
  40. package/dist/components/index.d.mts +59 -21
  41. package/dist/components/index.d.ts +59 -21
  42. package/dist/components/index.js +4 -4
  43. package/dist/components/index.mjs +3 -3
  44. package/dist/{config-CEAq4Ozy.d.mts → config-BP2vyddw.d.mts} +5 -0
  45. package/dist/{config-DydDHCfb.d.ts → config-BhW2QrRD.d.ts} +5 -0
  46. package/dist/contexts/index.d.mts +4 -4
  47. package/dist/contexts/index.d.ts +4 -4
  48. package/dist/contexts/index.js +4 -4
  49. package/dist/contexts/index.mjs +3 -3
  50. package/dist/core/index.d.mts +17 -9
  51. package/dist/core/index.d.ts +17 -9
  52. package/dist/core/index.js +4 -2
  53. package/dist/core/index.js.map +1 -1
  54. package/dist/core/index.mjs +3 -1
  55. package/dist/features/analytics/index.d.mts +1 -1
  56. package/dist/features/analytics/index.d.ts +1 -1
  57. package/dist/features/analytics/index.js +122 -122
  58. package/dist/features/analytics/index.mjs +4 -4
  59. package/dist/features/analytics/server.js +4 -4
  60. package/dist/features/analytics/server.mjs +2 -2
  61. package/dist/features/help/index.d.mts +1 -1
  62. package/dist/features/help/index.d.ts +1 -1
  63. package/dist/features/help/index.js +42 -40
  64. package/dist/features/help/index.js.map +1 -1
  65. package/dist/features/help/index.mjs +8 -6
  66. package/dist/features/help/index.mjs.map +1 -1
  67. package/dist/features/tokenusage/index.js +125 -125
  68. package/dist/features/tokenusage/index.mjs +4 -4
  69. package/dist/index.d.mts +7 -7
  70. package/dist/index.d.ts +7 -7
  71. package/dist/index.js +5 -3
  72. package/dist/index.js.map +1 -1
  73. package/dist/index.mjs +4 -2
  74. package/dist/{notification.interface-P_pNfzZQ.d.mts → notification.interface-BB6GPpZY.d.mts} +1 -1
  75. package/dist/{notification.interface-BNCvnVCW.d.ts → notification.interface-CmAGrijJ.d.ts} +1 -1
  76. package/dist/{s3.service-CbUa6a-8.d.ts → s3.service-C-4YpEMu.d.ts} +7 -3
  77. package/dist/{s3.service-DAp8FbuG.d.mts → s3.service-dVj-nxMl.d.mts} +7 -3
  78. package/dist/server/index.d.mts +4 -4
  79. package/dist/server/index.d.ts +4 -4
  80. package/dist/server/index.js +3 -3
  81. package/dist/server/index.mjs +2 -2
  82. package/dist/{useSocket-BHVIyigI.d.ts → useSocket-B0H9pmmH.d.ts} +1 -1
  83. package/dist/{useSocket-B9gTbuCj.d.mts → useSocket-B5RQb6v2.d.mts} +1 -1
  84. package/dist/{user.interface-CrmwJLYW.d.ts → user.interface-C0ZdsBZj.d.ts} +2 -0
  85. package/dist/{user.interface-CSaGWFbg.d.mts → user.interface-CIWGlbqM.d.mts} +2 -0
  86. package/package.json +1 -1
  87. package/src/components/containers/RoundPageContainer.tsx +3 -1
  88. package/src/components/containers/RoundPageContainerTitle.tsx +5 -2
  89. package/src/components/editors/BlockNoteEditor.tsx +31 -15
  90. package/src/components/editors/BlockNoteEditorAiActions.tsx +11 -7
  91. package/src/components/editors/__tests__/BlockNoteEditorAiActions.test.ts +10 -3
  92. package/src/components/errors/ErrorDetails.tsx +5 -2
  93. package/src/components/forms/CommonAssociationForm.tsx +1 -1
  94. package/src/components/forms/EntityMultiSelector.tsx +6 -2
  95. package/src/components/forms/EntitySelector.tsx +4 -1
  96. package/src/components/forms/FileUploader.tsx +3 -2
  97. package/src/components/forms/MultiFileUploader.tsx +3 -1
  98. package/src/components/navigations/Breadcrumb.tsx +3 -2
  99. package/src/components/navigations/Header.tsx +3 -1
  100. package/src/components/navigations/MobileNavigationBar.tsx +3 -1
  101. package/src/components/navigations/__tests__/Header.spec.tsx +2 -2
  102. package/src/components/tables/cells/cell.id.tsx +13 -2
  103. package/src/core/abstracts/AbstractService.ts +3 -3
  104. package/src/core/abstracts/ClientAbstractService.ts +3 -2
  105. package/src/features/assistant-action/components/ApprovalActionCard.tsx +74 -5
  106. package/src/features/assistant-action/components/__tests__/ApprovalActionCard.spec.tsx +81 -0
  107. package/src/features/assistant-message/components/parts/RelevanceMeter.tsx +4 -1
  108. package/src/features/assistant-message/components/parts/tabs/UsersTab.tsx +2 -1
  109. package/src/features/auth/components/buttons/GoogleSignInButton.tsx +4 -1
  110. package/src/features/auth/components/details/LandingComponent.tsx +1 -1
  111. package/src/features/auth/components/forms/ForgotPassword.tsx +1 -1
  112. package/src/features/auth/components/forms/Login.tsx +1 -1
  113. package/src/features/auth/components/forms/Register.tsx +1 -1
  114. package/src/features/auth/components/forms/ResetPassword.tsx +2 -2
  115. package/src/features/auth/components/two-factor/BackupCodesDialog.tsx +1 -1
  116. package/src/features/auth/components/two-factor/PasskeyButton.tsx +1 -1
  117. package/src/features/billing/components/cards/BillingUsageSummaryCard.tsx +21 -11
  118. package/src/features/billing/components/cards/CustomerInfoCard.tsx +16 -13
  119. package/src/features/billing/components/cards/InvoicesSummaryCard.tsx +33 -21
  120. package/src/features/billing/components/cards/PaymentMethodSummaryCard.tsx +20 -10
  121. package/src/features/billing/components/cards/SubscriptionSummaryCard.tsx +35 -22
  122. package/src/features/billing/components/containers/BillingDashboardContainer.tsx +24 -24
  123. package/src/features/billing/components/utils/__tests__/billing-formatters.spec.ts +63 -0
  124. package/src/features/billing/components/utils/currency.ts +67 -10
  125. package/src/features/billing/components/utils/date.ts +9 -4
  126. package/src/features/billing/components/utils/format-options.ts +20 -0
  127. package/src/features/billing/components/utils/index.ts +3 -1
  128. package/src/features/billing/components/utils/useBillingFormatOptions.ts +11 -0
  129. package/src/features/billing/components/widgets/BillingAlertBanner.tsx +8 -9
  130. package/src/features/billing/contexts/PriceContext.tsx +18 -6
  131. package/src/features/billing/i18n-keys.ts +212 -0
  132. package/src/features/billing/stripe-customer/components/containers/PaymentMethodsContainer.tsx +8 -8
  133. package/src/features/billing/stripe-customer/components/details/PaymentMethodCard.tsx +11 -9
  134. package/src/features/billing/stripe-customer/components/forms/PaymentMethodEditor.tsx +4 -4
  135. package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +9 -7
  136. package/src/features/billing/stripe-invoice/components/containers/InvoicesContainer.tsx +10 -8
  137. package/src/features/billing/stripe-invoice/components/details/InvoiceDetails.tsx +34 -29
  138. package/src/features/billing/stripe-invoice/components/lists/InvoicesList.tsx +12 -9
  139. package/src/features/billing/stripe-invoice/components/widgets/InvoiceStatusBadge.tsx +9 -6
  140. package/src/features/billing/stripe-price/components/details/PriceDetails.tsx +4 -3
  141. package/src/features/billing/stripe-price/components/forms/PriceArchiver.tsx +3 -1
  142. package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +3 -1
  143. package/src/features/billing/stripe-price/hooks/useStripePriceTableStructure.tsx +6 -5
  144. package/src/features/billing/stripe-promotion-code/components/PromoCodeInput.tsx +12 -7
  145. package/src/features/billing/stripe-promotion-code/data/stripe-promotion-code.service.ts +3 -1
  146. package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +10 -6
  147. package/src/features/billing/stripe-subscription/components/details/SubscriptionDetails.tsx +44 -29
  148. package/src/features/billing/stripe-subscription/components/forms/CancelSubscriptionDialog.tsx +14 -11
  149. package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +20 -17
  150. package/src/features/billing/stripe-subscription/components/widgets/IntervalToggle.tsx +4 -2
  151. package/src/features/billing/stripe-subscription/components/widgets/PricingCard.tsx +16 -7
  152. package/src/features/billing/stripe-subscription/components/widgets/ProductPricingList.tsx +3 -1
  153. package/src/features/billing/stripe-subscription/components/widgets/ProductPricingRow.tsx +3 -1
  154. package/src/features/billing/stripe-subscription/components/widgets/ProrationPreview.tsx +12 -7
  155. package/src/features/billing/stripe-subscription/components/widgets/SubscriptionConfirmation.tsx +11 -8
  156. package/src/features/billing/stripe-subscription/components/widgets/SubscriptionStatusBadge.tsx +13 -10
  157. package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +8 -6
  158. package/src/features/billing/stripe-subscription/components/wizards/WizardProgressIndicator.tsx +7 -4
  159. package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx +4 -2
  160. package/src/features/billing/stripe-subscription/components/wizards/WizardStepPlanSelection.tsx +3 -1
  161. package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +37 -28
  162. package/src/features/billing/stripe-subscription/hooks/useConfirmSubscriptionPayment.ts +9 -7
  163. package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +20 -9
  164. package/src/features/billing/stripe-usage/components/containers/UsageContainer.tsx +6 -6
  165. package/src/features/billing/stripe-usage/components/details/UsageSummaryCard.tsx +24 -9
  166. package/src/features/billing/stripe-usage/components/lists/UsageHistoryTable.tsx +16 -12
  167. package/src/features/company/components/details/TokenStatusIndicator.tsx +7 -5
  168. package/src/features/company/components/forms/CompanyEditor.tsx +2 -2
  169. package/src/features/help/components/HelpArticleBody.tsx +1 -1
  170. package/src/features/help/components/HelpHeader.tsx +1 -1
  171. package/src/features/help/components/HelpSideNav.tsx +1 -1
  172. package/src/features/help/i18n-keys.ts +2 -0
  173. package/src/features/notification/components/common/NotificationErrorBoundary.tsx +17 -12
  174. package/src/features/notification/components/containers/NotificationsListContainer.tsx +4 -2
  175. package/src/features/notification/components/notifications/Notification.tsx +6 -3
  176. package/src/features/notification/contexts/NotificationContext.tsx +9 -4
  177. package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +13 -0
  178. package/src/features/notification/hooks/useNotificationTableStructure.tsx +4 -2
  179. package/src/features/oauth/components/OAuthClientCard.tsx +13 -7
  180. package/src/features/oauth/components/OAuthClientDetail.tsx +34 -30
  181. package/src/features/oauth/components/OAuthClientForm.tsx +24 -24
  182. package/src/features/oauth/components/OAuthClientList.tsx +10 -6
  183. package/src/features/oauth/components/OAuthClientSecretDisplay.tsx +10 -8
  184. package/src/features/oauth/components/OAuthRedirectUriInput.tsx +9 -13
  185. package/src/features/oauth/components/OAuthScopeSelector.tsx +15 -6
  186. package/src/features/oauth/components/__tests__/OAuthClientForm.spec.tsx +11 -9
  187. package/src/features/oauth/components/consent/OAuthScopeList.tsx +4 -2
  188. package/src/features/oauth/hooks/__tests__/useOAuthScopeText.spec.ts +30 -0
  189. package/src/features/oauth/hooks/index.ts +1 -0
  190. package/src/features/oauth/hooks/useOAuthClient.ts +11 -5
  191. package/src/features/oauth/hooks/useOAuthClients.ts +9 -3
  192. package/src/features/oauth/hooks/useOAuthConsent.ts +11 -5
  193. package/src/features/oauth/hooks/useOAuthScopeText.ts +33 -0
  194. package/src/features/rbac/components/RbacByRoleContainer.tsx +7 -6
  195. package/src/features/rbac/components/RbacContainer.tsx +7 -6
  196. package/src/features/rbac/contexts/RbacContext.tsx +3 -3
  197. package/src/features/user/components/containers/UserContainer.tsx +3 -1
  198. package/src/features/user/data/__tests__/user.test.ts +66 -0
  199. package/src/features/user/data/user.interface.ts +2 -0
  200. package/src/features/user/data/user.service.ts +9 -0
  201. package/src/features/user/data/user.ts +7 -0
  202. package/src/features/waitlist/components/forms/WaitlistForm.tsx +1 -1
  203. package/src/features/waitlist/components/lists/WaitlistList.tsx +1 -1
  204. package/src/i18n/__tests__/config.test.ts +21 -1
  205. package/src/i18n/config.ts +13 -0
  206. package/src/i18n/index.ts +1 -0
  207. package/src/shadcnui/custom/multi-select.tsx +47 -14
  208. package/src/shadcnui/custom/multiple-selector.tsx +14 -2
  209. package/src/shadcnui/ui/__tests__/use-ui-text.test.tsx +76 -0
  210. package/src/shadcnui/ui/breadcrumb.tsx +9 -2
  211. package/src/shadcnui/ui/carousel.tsx +15 -4
  212. package/src/shadcnui/ui/dialog.tsx +15 -2
  213. package/src/shadcnui/ui/sheet.tsx +6 -1
  214. package/src/shadcnui/ui/sidebar.tsx +33 -7
  215. package/src/shadcnui/ui/spinner.tsx +6 -1
  216. package/src/shadcnui/ui/use-ui-text.ts +49 -0
  217. package/src/utils/__tests__/date-formatter.test.ts +24 -1
  218. package/src/utils/date-formatter.ts +16 -0
  219. package/src/utils/index.ts +1 -1
  220. package/dist/BlockNoteEditor-BENYTTCZ.js.map +0 -1
  221. package/dist/BlockNoteEditor-VOYKZOP5.mjs.map +0 -1
  222. package/dist/chunk-4BZJ4LAB.js.map +0 -1
  223. package/dist/chunk-6ISF5KNQ.mjs.map +0 -1
  224. package/dist/chunk-ORU7KLCY.js.map +0 -1
  225. package/dist/chunk-ZZ6E33FO.mjs.map +0 -1
  226. /package/dist/{BlockNoteViewer-Y2ZM4ESA.mjs.map → BlockNoteViewer-MDPO6BXN.mjs.map} +0 -0
  227. /package/dist/{chunk-46S3KPLU.mjs.map → chunk-ILFFC6BG.mjs.map} +0 -0
  228. /package/dist/{chunk-HZVVRIJQ.mjs.map → chunk-IUS2RHCX.mjs.map} +0 -0
  229. /package/dist/{chunk-ZO3QQ5CQ.mjs.map → chunk-MQDR6MEE.mjs.map} +0 -0
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { BlockNoteSchema, defaultInlineContentSpecs, filterSuggestionItems, PartialBlock } from "@blocknote/core";
4
- import { en as coreEn } from "@blocknote/core/locales";
4
+ import * as coreLocales from "@blocknote/core/locales";
5
5
  import {
6
6
  createReactInlineContentSpec,
7
7
  DefaultReactSuggestionItem,
@@ -23,7 +23,7 @@ import {
23
23
  PromptSuggestionMenu,
24
24
  useAIDictionary,
25
25
  } from "@blocknote/xl-ai";
26
- import { en as aiEn } from "@blocknote/xl-ai/locales";
26
+ import * as aiLocales from "@blocknote/xl-ai/locales";
27
27
  import "@blocknote/xl-ai/style.css";
28
28
  import { DefaultChatTransport } from "ai";
29
29
  import { CheckIcon, LanguagesIcon, SparklesIcon, TypeIcon, WandSparklesIcon, XIcon } from "lucide-react";
@@ -137,6 +137,7 @@ function isDocumentEmpty(blocks: any[]): boolean {
137
137
  const createDiffActionsInlineContentSpec = (
138
138
  handleAcceptChange: (diffId: string) => void,
139
139
  handleRejectChange: (diffId: string) => void,
140
+ labels: { accept: string; reject: string },
140
141
  ) => {
141
142
  return createReactInlineContentSpec(
142
143
  {
@@ -155,7 +156,7 @@ const createDiffActionsInlineContentSpec = (
155
156
  return (
156
157
  <span className="diff-actions-container mx-2 inline-flex items-center gap-1 align-middle">
157
158
  <Button
158
- title="Accept change"
159
+ title={labels.accept}
159
160
  onClick={(e) => {
160
161
  e.preventDefault();
161
162
  e.stopPropagation();
@@ -165,7 +166,7 @@ const createDiffActionsInlineContentSpec = (
165
166
  <CheckIcon className="text-success h-3 w-3" />
166
167
  </Button>
167
168
  <Button
168
- title="Reject change"
169
+ title={labels.reject}
169
170
  className="mx-2 p-0"
170
171
  onClick={(e) => {
171
172
  e.preventDefault();
@@ -204,6 +205,7 @@ const createDiffActionsInlineContentSpec = (
204
205
  * with its own canonical prompt. NO prompt text lives in this file.
205
206
  */
206
207
  function NarrAIMenu({ actions }: { actions?: BlockNoteAiGenerateAction[] }) {
208
+ const t = useTranslations();
207
209
  const editor = useBlockNoteEditor();
208
210
  const ai = useExtension(AIExtension);
209
211
  const dict = useAIDictionary();
@@ -269,7 +271,7 @@ function NarrAIMenu({ actions }: { actions?: BlockNoteAiGenerateAction[] }) {
269
271
  // selection) because they operate on the whole document — they ignore any
270
272
  // active selection. Listed first so the first one is the
271
273
  // default-highlighted item.
272
- const generateItems = generateActionItems(actions, invokeGenerate);
274
+ const generateItems = generateActionItems(actions, invokeGenerate, t);
273
275
 
274
276
  if (hasSelection) {
275
277
  // Selection-edit items + the always-available whole-document items.
@@ -280,7 +282,7 @@ function NarrAIMenu({ actions }: { actions?: BlockNoteAiGenerateAction[] }) {
280
282
  ...generateItems,
281
283
  {
282
284
  key: "improve_writing",
283
- title: "Improve Writing",
285
+ title: t("ui.editor.ai.improve_writing"),
284
286
  aliases: ["improve", "rewrite", "polish"],
285
287
  icon: <SparklesIcon size={18} />,
286
288
  size: "small" as const,
@@ -295,7 +297,7 @@ function NarrAIMenu({ actions }: { actions?: BlockNoteAiGenerateAction[] }) {
295
297
  },
296
298
  {
297
299
  key: "fix_spelling",
298
- title: "Fix Spelling",
300
+ title: t("ui.editor.ai.fix_spelling"),
299
301
  aliases: ["spelling", "grammar", "typo"],
300
302
  icon: <TypeIcon size={18} />,
301
303
  size: "small" as const,
@@ -310,7 +312,7 @@ function NarrAIMenu({ actions }: { actions?: BlockNoteAiGenerateAction[] }) {
310
312
  },
311
313
  {
312
314
  key: "translate",
313
- title: "Translate…",
315
+ title: t("ui.editor.ai.translate"),
314
316
  aliases: ["translate", "language"],
315
317
  icon: <LanguagesIcon size={18} />,
316
318
  size: "small" as const,
@@ -322,12 +324,12 @@ function NarrAIMenu({ actions }: { actions?: BlockNoteAiGenerateAction[] }) {
322
324
  onItemClick: () => {
323
325
  expandSelectionToBlocks();
324
326
  pendingTypeRef.current = "translate";
325
- setPrompt("Translate to ");
327
+ setPrompt(t("ui.editor.ai.translate_prefill"));
326
328
  },
327
329
  },
328
330
  {
329
331
  key: "simplify",
330
- title: "Simplify",
332
+ title: t("ui.editor.ai.simplify"),
331
333
  aliases: ["simplify", "easier", "plain"],
332
334
  icon: <WandSparklesIcon size={18} />,
333
335
  size: "small" as const,
@@ -347,7 +349,7 @@ function NarrAIMenu({ actions }: { actions?: BlockNoteAiGenerateAction[] }) {
347
349
  // Free-form typing still works — once the user types, items hide and
348
350
  // Enter submits with no type (backend defaults to fill-template).
349
351
  return generateItems;
350
- }, [editor, status, prompt, ai, expandSelectionToBlocks, actions]);
352
+ }, [editor, status, prompt, ai, expandSelectionToBlocks, actions, t]);
351
353
 
352
354
  const handleSubmit = useCallback(
353
355
  async (userPrompt: string) => {
@@ -466,8 +468,12 @@ export default function BlockNoteEditor({
466
468
  }, []);
467
469
 
468
470
  const DiffActionsInlineContent = useMemo(
469
- () => createDiffActionsInlineContentSpec(handleAcceptChange, handleRejectChange),
470
- [handleAcceptChange, handleRejectChange],
471
+ () =>
472
+ createDiffActionsInlineContentSpec(handleAcceptChange, handleRejectChange, {
473
+ accept: t("ui.editor.accept_change"),
474
+ reject: t("ui.editor.reject_change"),
475
+ }),
476
+ [handleAcceptChange, handleRejectChange, t],
471
477
  );
472
478
 
473
479
  const mentionSpec = useMemo(
@@ -657,6 +663,12 @@ export default function BlockNoteEditor({
657
663
  return undefined;
658
664
  }, [processedContent]);
659
665
 
666
+ // BlockNote's own UI text (slash menu, toolbar, AI menu) in the active
667
+ // language; English when BlockNote has no dictionary for it.
668
+ const language = locale.split("-")[0];
669
+ const coreDictionary = (coreLocales as Record<string, typeof coreLocales.en>)[language] ?? coreLocales.en;
670
+ const aiDictionary = (aiLocales as Record<string, typeof aiLocales.en>)[language] ?? aiLocales.en;
671
+
660
672
  const editor = useCreateBlockNote(
661
673
  useMemo(
662
674
  () => ({
@@ -667,9 +679,13 @@ export default function BlockNoteEditor({
667
679
  initialContent: validatedInitialContent,
668
680
  uploadFile: uploadImage,
669
681
  extensions: aiExtension ? [aiExtension] : undefined,
670
- dictionary: aiExtension ? { ...coreEn, ai: aiEn } : undefined,
682
+ dictionary: aiExtension
683
+ ? { ...coreDictionary, ai: aiDictionary }
684
+ : coreDictionary === coreLocales.en
685
+ ? undefined
686
+ : coreDictionary,
671
687
  }),
672
- [placeholder, t, schema, validatedInitialContent, uploadImage, aiExtension],
688
+ [placeholder, t, schema, validatedInitialContent, uploadImage, aiExtension, coreDictionary, aiDictionary],
673
689
  ),
674
690
  );
675
691
 
@@ -21,17 +21,20 @@ export type AiGenerateMenuItem = {
21
21
  onItemClick: () => void;
22
22
  };
23
23
 
24
- const ITEMS: Record<BlockNoteAiGenerateAction, Omit<AiGenerateMenuItem, "onItemClick">> = {
24
+ /** `titleKey` is a message key; the caller's translator resolves it to the shown title. */
25
+ type AiGenerateItemDefinition = Omit<AiGenerateMenuItem, "onItemClick" | "title"> & { titleKey: string };
26
+
27
+ const ITEMS: Record<BlockNoteAiGenerateAction, AiGenerateItemDefinition> = {
25
28
  "fill-template": {
26
29
  key: "generate_from_template",
27
- title: "Generate from Template",
30
+ titleKey: "ui.editor.ai.generate_from_template",
28
31
  aliases: ["generate", "template", "fill"],
29
32
  icon: <LayoutTemplateIcon size={18} />,
30
33
  size: "small",
31
34
  },
32
35
  "draft-impression": {
33
36
  key: "suggest_impression",
34
- title: "Suggest impression",
37
+ titleKey: "ui.editor.ai.suggest_impression",
35
38
  aliases: ["suggest", "impression"],
36
39
  icon: <SparklesIcon size={18} />,
37
40
  size: "small",
@@ -41,9 +44,10 @@ const ITEMS: Record<BlockNoteAiGenerateAction, Omit<AiGenerateMenuItem, "onItemC
41
44
  export function generateActionItems(
42
45
  actions: BlockNoteAiGenerateAction[] | undefined,
43
46
  invoke: (type: BlockNoteAiGenerateAction) => void,
47
+ translate: (key: string) => string,
44
48
  ): AiGenerateMenuItem[] {
45
- return (actions ?? DEFAULT_AI_GENERATE_ACTIONS).map((type) => ({
46
- ...ITEMS[type],
47
- onItemClick: () => invoke(type),
48
- }));
49
+ return (actions ?? DEFAULT_AI_GENERATE_ACTIONS).map((type) => {
50
+ const { titleKey, ...item } = ITEMS[type];
51
+ return { ...item, title: translate(titleKey), onItemClick: () => invoke(type) };
52
+ });
49
53
  }
@@ -1,9 +1,16 @@
1
1
  import { describe, expect, it, vi } from "vitest";
2
2
  import { DEFAULT_AI_GENERATE_ACTIONS, generateActionItems } from "../BlockNoteEditorAiActions";
3
3
 
4
+ /** Stands in for next-intl's translator: returns the English message for each key. */
5
+ const english: Record<string, string> = {
6
+ "ui.editor.ai.generate_from_template": "Generate from Template",
7
+ "ui.editor.ai.suggest_impression": "Suggest impression",
8
+ };
9
+ const translate = (key: string) => english[key] ?? key;
10
+
4
11
  describe("generateActionItems", () => {
5
12
  it("defaults to Generate from Template only, so existing editors are unchanged", () => {
6
- const items = generateActionItems(undefined, vi.fn());
13
+ const items = generateActionItems(undefined, vi.fn(), translate);
7
14
  expect(DEFAULT_AI_GENERATE_ACTIONS).toEqual(["fill-template"]);
8
15
  expect(items.map((i) => i.key)).toEqual(["generate_from_template"]);
9
16
  expect(items[0].title).toBe("Generate from Template");
@@ -11,7 +18,7 @@ describe("generateActionItems", () => {
11
18
 
12
19
  it("offers Suggest impression when asked, and invokes with that type", () => {
13
20
  const invoke = vi.fn();
14
- const items = generateActionItems(["draft-impression"], invoke);
21
+ const items = generateActionItems(["draft-impression"], invoke, translate);
15
22
  expect(items.map((i) => i.key)).toEqual(["suggest_impression"]);
16
23
  expect(items[0].title).toBe("Suggest impression");
17
24
  items[0].onItemClick();
@@ -19,7 +26,7 @@ describe("generateActionItems", () => {
19
26
  });
20
27
 
21
28
  it("keeps the caller's order when both are requested", () => {
22
- const items = generateActionItems(["draft-impression", "fill-template"], vi.fn());
29
+ const items = generateActionItems(["draft-impression", "fill-template"], vi.fn(), translate);
23
30
  expect(items.map((i) => i.key)).toEqual(["suggest_impression", "generate_from_template"]);
24
31
  });
25
32
  });
@@ -1,9 +1,12 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
3
4
  import Image from "next/image";
4
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../../shadcnui";
5
6
 
6
7
  export function ErrorDetails({ title, message, code }: { title?: string; message: string; code: number }) {
8
+ const t = useTranslations();
9
+
7
10
  if (code === 403)
8
11
  return (
9
12
  <div className="w-xl max-w-xl">
@@ -12,10 +15,10 @@ export function ErrorDetails({ title, message, code }: { title?: string; message
12
15
  <CardTitle className="text-foreground flex flex-col items-center gap-y-4 pb-10 text-4xl">
13
16
  <Image src="/logo.webp" alt="Logo" width={100} height={100} priority />
14
17
  {/* <div>{code}</div> */}
15
- <div>Unauthorised</div>
18
+ <div>{t("common.errors.unauthorised")}</div>
16
19
  </CardTitle>
17
20
  <CardDescription className="text-center text-lg">
18
- We are sorry, but you are not allowed to access this content.
21
+ {t("common.errors.unauthorised_description")}
19
22
  </CardDescription>
20
23
  </CardHeader>
21
24
  <CardContent></CardContent>
@@ -34,7 +34,7 @@ export function CommonAssociationTrigger({
34
34
  if (hasDestination)
35
35
  return (
36
36
  <div className="hover:text-accent cursor-pointer" onClick={onTrigger}>
37
- Join
37
+ {t(`common.association.join`)}
38
38
  </div>
39
39
  );
40
40
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useWatch } from "react-hook-form";
5
+ import { useTranslations } from "next-intl";
5
6
  import { CheckIcon, ChevronDownIcon, SearchIcon, XIcon } from "lucide-react";
6
7
  import { Badge } from "../../shadcnui/ui/badge";
7
8
  import { Input } from "../../shadcnui/ui/input";
@@ -43,8 +44,8 @@ export function EntityMultiSelector<T extends { id: string }>({
43
44
  id,
44
45
  form,
45
46
  label,
46
- placeholder = "Search...",
47
- emptyText = "No results found.",
47
+ placeholder: placeholderProp,
48
+ emptyText: emptyTextProp,
48
49
  isRequired = false,
49
50
  retriever,
50
51
  retrieverParams = {},
@@ -59,6 +60,9 @@ export function EntityMultiSelector<T extends { id: string }>({
59
60
  description,
60
61
  disabled = false,
61
62
  }: EntityMultiSelectorProps<T>) {
63
+ const t = useTranslations();
64
+ const placeholder = placeholderProp ?? t("ui.search.placeholder_global");
65
+ const emptyText = emptyTextProp ?? t("ui.search.no_results_found");
62
66
  const [open, setOpen] = useState(false);
63
67
  const [searchTerm, setSearchTerm] = useState("");
64
68
  const [options, setOptions] = useState<OptionData<T>[]>([]);
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
4
4
  import { CircleX, Plus, RefreshCwIcon, SearchIcon, XIcon } from "lucide-react";
5
+ import { useTranslations } from "next-intl";
5
6
  import { Command, CommandItem, CommandList } from "../../shadcnui/ui/command";
6
7
  import { Input } from "../../shadcnui/ui/input";
7
8
  import { Popover, PopoverContent, PopoverTrigger } from "../../shadcnui/ui/popover";
@@ -46,7 +47,7 @@ export default function EntitySelector<T extends BaseEntity>({
46
47
  ready = true,
47
48
  getLabel = (entity: T) => (entity as any).name,
48
49
  label,
49
- placeholder = "Search...",
50
+ placeholder: placeholderProp,
50
51
  emptyText,
51
52
  isRequired = false,
52
53
  disabled = false,
@@ -62,6 +63,8 @@ export default function EntitySelector<T extends BaseEntity>({
62
63
  autoSelectSingle = false,
63
64
  useFormWatch: shouldUseFormWatch = false,
64
65
  }: EntitySelectorProps<T>) {
66
+ const t = useTranslations();
67
+ const placeholder = placeholderProp ?? t("ui.search.placeholder_global");
65
68
  const [open, setOpen] = useState<boolean>(false);
66
69
  const searchTermRef = useRef<string>("");
67
70
  const [searchTerm, setSearchTerm] = useState<string>("");
@@ -201,7 +201,7 @@ export const FileUploader = forwardRef<HTMLDivElement, FileUploaderProps & React
201
201
  if (onFilesRejected) {
202
202
  const overflowRejections: FileRejection[] = overflow.map((file) => ({
203
203
  file: file as FileWithPath,
204
- errors: [{ code: ErrorCode.TooManyFiles, message: `Too many files. Only ${maxFiles} allowed.` }],
204
+ errors: [{ code: ErrorCode.TooManyFiles, message: t("common.errors.file_too_many", { max: maxFiles }) }],
205
205
  }));
206
206
  const allRejections = [...rejectedFiles, ...overflowRejections];
207
207
  if (allRejections.length > 0) onFilesRejected(allRejections);
@@ -290,9 +290,10 @@ export const FileUploaderContent = forwardRef<HTMLDivElement, React.HTMLAttribut
290
290
  ({ children, className, ...props }, ref) => {
291
291
  const { orientation } = useFileUpload();
292
292
  const containerRef = useRef<HTMLDivElement>(null);
293
+ const t = useTranslations();
293
294
 
294
295
  return (
295
- <div className={cn("w-full px-1")} ref={containerRef} aria-description="content file holder">
296
+ <div className={cn("w-full px-1")} ref={containerRef} aria-description={t("ui.labels.file_holder")}>
296
297
  <div
297
298
  {...props}
298
299
  ref={ref}
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { FileIcon, FileImageIcon, FileSpreadsheetIcon, FileTextIcon, UploadIcon, XIcon } from "lucide-react";
4
+ import { useTranslations } from "next-intl";
4
5
  import Image from "next/image";
5
6
  import { useEffect, useState } from "react";
6
7
  import { DropzoneOptions } from "react-dropzone";
@@ -70,6 +71,7 @@ export default function MultiFileUploader({ files, setFiles, multiple = false, c
70
71
  }
71
72
 
72
73
  const FilePreviewItem = ({ file, onRemoveClick }: { file: File; onRemoveClick: () => void }) => {
74
+ const t = useTranslations();
73
75
  const [objectUrl, setObjectUrl] = useState<string | null>(null);
74
76
 
75
77
  useEffect(() => {
@@ -106,7 +108,7 @@ const FilePreviewItem = ({ file, onRemoveClick }: { file: File; onRemoveClick: (
106
108
  <button
107
109
  onClick={onRemoveClick}
108
110
  className="absolute top-1.5 end-1.5 z-20 rounded-full bg-black/60 p-1 text-white opacity-0 transition-all duration-200 ease-in-out group-hover:opacity-100 hover:bg-red-600 focus:outline-none"
109
- aria-label="Remove file"
111
+ aria-label={t("ui.labels.remove_file")}
110
112
  >
111
113
  <XIcon className="h-3.5 w-3.5" />
112
114
  </button>
@@ -34,6 +34,7 @@ function BreadcrumbDesktop({
34
34
  rootLabel: string;
35
35
  showRoot: boolean;
36
36
  }) {
37
+ const t = useTranslations();
37
38
  const [open, setOpen] = useState<boolean>(false);
38
39
 
39
40
  return (
@@ -62,8 +63,8 @@ function BreadcrumbDesktop({
62
63
  <BreadcrumbSeparator />
63
64
  <BreadcrumbItem>
64
65
  <DropdownMenu open={open} onOpenChange={setOpen}>
65
- <DropdownMenuTrigger className="flex items-center gap-1" aria-label="Toggle menu">
66
- <BreadcrumbEllipsis className="h-4 w-4" />
66
+ <DropdownMenuTrigger className="flex items-center gap-1" aria-label={t("ui.labels.toggle_menu")}>
67
+ <BreadcrumbEllipsis className="h-4 w-4" label={t("ui.labels.more")} />
67
68
  </DropdownMenuTrigger>
68
69
  <DropdownMenuContent align="start">
69
70
  {items.slice(1, -ITEMS_TO_DISPLAY + 2).map((item, index) => (
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
3
4
  import { useIsMobile } from "@/utils";
4
5
  import { useIsBreadcrumbRootHidden } from "../../contexts/BreadcrumbRootContext";
5
6
  import { useHeaderRootLabel } from "../../contexts/HeaderChildrenContext";
@@ -31,6 +32,7 @@ export function Header({
31
32
  className,
32
33
  hideSidebarTriggerOnMobile,
33
34
  }: HeaderProps) {
35
+ const t = useTranslations();
34
36
  const { breadcrumbs } = useSharedContext();
35
37
  const rootLabel = useHeaderRootLabel();
36
38
  const isRootHidden = useIsBreadcrumbRootHidden();
@@ -56,7 +58,7 @@ export function Header({
56
58
  <div className="bg-sidebar flex h-12 w-full flex-row items-center justify-between ps-2 pe-4">
57
59
  {isMobile && logo && <div className="flex shrink-0 flex-row items-center pe-1">{logo}</div>}
58
60
  {sidebar && !(isMobile && hideSidebarTriggerOnMobile) && (
59
- <SidebarTrigger aria-label="Toggle sidebar" id="sidebar-trigger" />
61
+ <SidebarTrigger aria-label={t("ui.labels.toggle_sidebar")} id="sidebar-trigger" />
60
62
  )}
61
63
  {leftContent}
62
64
  <div className="flex w-full flex-row items-center justify-start max-md:min-w-0">
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useTranslations } from "next-intl";
3
4
  import { usePathname } from "next/navigation";
4
5
  import { useMobileNavigationItems, type MobileNavigationItem } from "../../contexts/MobileNavigationContext";
5
6
  import { Link } from "../../shadcnui";
@@ -50,6 +51,7 @@ function SlotContent({ item }: { item: MobileNavigationItem }) {
50
51
  * the end of the page.
51
52
  */
52
53
  export function MobileNavigationBar({ className }: { className?: string } = {}) {
54
+ const t = useTranslations();
53
55
  const items = useMobileNavigationItems();
54
56
  const isMobile = useIsMobile();
55
57
  const pathname = usePathname();
@@ -68,7 +70,7 @@ export function MobileNavigationBar({ className }: { className?: string } = {})
68
70
  // bar when held by the shell instead.
69
71
  return (
70
72
  <nav
71
- aria-label="Primary"
73
+ aria-label={t("ui.labels.primary_navigation")}
72
74
  data-testid="mobile-navigation-bar"
73
75
  data-help="shell.mobile-nav"
74
76
  className={cn("bg-sidebar flex w-full shrink-0 flex-row items-stretch rounded-lg border", className)}
@@ -50,11 +50,11 @@ function withShared(node: React.ReactNode) {
50
50
  describe("Header", () => {
51
51
  it("renders the sidebar toggle inside a SidebarProvider", () => {
52
52
  render(withShared(<SidebarProvider>{<Header />}</SidebarProvider>));
53
- expect(screen.queryByRole("button", { name: "Toggle sidebar" })).not.toBeNull();
53
+ expect(screen.queryByRole("button", { name: "ui.labels.toggle_sidebar" })).not.toBeNull();
54
54
  });
55
55
 
56
56
  it("renders without a SidebarProvider and shows no sidebar toggle", () => {
57
57
  expect(() => render(withShared(<Header />))).not.toThrow();
58
- expect(screen.queryByRole("button", { name: "Toggle sidebar" })).toBeNull();
58
+ expect(screen.queryByRole("button", { name: "ui.labels.toggle_sidebar" })).toBeNull();
59
59
  });
60
60
  });
@@ -1,6 +1,18 @@
1
1
  import { ColumnDef } from "@tanstack/react-table";
2
+ import { useTranslations } from "next-intl";
2
3
  import { Checkbox } from "../../../shadcnui";
3
4
 
5
+ function SelectRowCheckbox({
6
+ checked,
7
+ onCheckedChange,
8
+ }: {
9
+ checked: boolean;
10
+ onCheckedChange: (value: boolean) => void;
11
+ }) {
12
+ const t = useTranslations();
13
+ return <Checkbox checked={checked} onCheckedChange={onCheckedChange} aria-label={t("ui.labels.select_row")} />;
14
+ }
15
+
4
16
  export const cellId = (params: {
5
17
  name: string;
6
18
  checkedIds?: string[];
@@ -12,13 +24,12 @@ export const cellId = (params: {
12
24
  header: "",
13
25
  cell: ({ row }) =>
14
26
  params.toggleId ? (
15
- <Checkbox
27
+ <SelectRowCheckbox
16
28
  checked={params.checkedIds?.includes(row.getValue(params.name)) || false}
17
29
  onCheckedChange={(value) => {
18
30
  row.toggleSelected(!!value);
19
31
  params.toggleId?.(row.getValue(params.name));
20
32
  }}
21
- aria-label="Select row"
22
33
  />
23
34
  ) : null,
24
35
  enableSorting: false,
@@ -1,7 +1,7 @@
1
1
  import { ApiRequestDataTypeInterface } from "../interfaces/ApiRequestDataTypeInterface";
2
2
  import { ApiResponseInterface } from "../interfaces/ApiResponseInterface";
3
3
  import { setLastApiResponse } from "./lastApiResponse";
4
- import { getI18nServerLocale } from "../../i18n/config";
4
+ import { getI18nServerLocale, getI18nSupportedLocales } from "../../i18n/config";
5
5
 
6
6
  export enum HttpMethod {
7
7
  GET = "GET",
@@ -68,8 +68,8 @@ export abstract class AbstractService {
68
68
  const pathSegments = window.location.pathname.split("/").filter(Boolean);
69
69
  const urlLocale = pathSegments[0];
70
70
 
71
- // Validate against supported locales (currently only "en")
72
- const supportedLocales = ["en"];
71
+ // Validate against the app's supported locales (default ["en"])
72
+ const supportedLocales = getI18nSupportedLocales();
73
73
  if (urlLocale && supportedLocales.includes(urlLocale)) {
74
74
  return urlLocale;
75
75
  }
@@ -10,6 +10,7 @@ import {
10
10
  ClientJsonApiDelete,
11
11
  } from "../../client/JsonApiClient";
12
12
  import { setLastApiResponse } from "./lastApiResponse";
13
+ import { getI18nSupportedLocales } from "../../i18n/config";
13
14
  // Duplicated to avoid importing from AbstractService which pulls in server code
14
15
  // These are exported so client services can import them from here instead of core
15
16
  export enum ClientHttpMethod {
@@ -72,8 +73,8 @@ export abstract class ClientAbstractService {
72
73
  const pathSegments = window.location.pathname.split("/").filter(Boolean);
73
74
  const urlLocale = pathSegments[0];
74
75
 
75
- // Validate against supported locales (currently only "en")
76
- const supportedLocales = ["en"];
76
+ // Validate against the app's supported locales (default ["en"])
77
+ const supportedLocales = getI18nSupportedLocales();
77
78
  if (urlLocale && supportedLocales.includes(urlLocale)) {
78
79
  return urlLocale;
79
80
  }
@@ -108,6 +108,69 @@ function humaniseKey(key: string): string {
108
108
  return words.charAt(0).toUpperCase() + words.slice(1);
109
109
  }
110
110
 
111
+ /**
112
+ * The untyped call signature of next-intl's `t`. Every key below is computed
113
+ * from wire identifiers, so the generated message-key union cannot describe it.
114
+ */
115
+ type LooseTranslate = ((key: string, values?: Record<string, unknown>) => string) & {
116
+ has?: (key: string) => boolean;
117
+ };
118
+
119
+ /**
120
+ * Translates `key` when the host app defines it, else `undefined`. `t.has` is
121
+ * the only non-throwing probe: a plain `t()` on a missing key either throws or
122
+ * returns the key path, depending on the app's next-intl error handler.
123
+ */
124
+ function translateIfPresent(t: LooseTranslate, key: string, values?: Record<string, unknown>): string | undefined {
125
+ if (typeof t.has !== "function" || !t.has(key)) return undefined;
126
+ try {
127
+ const text = t(key, values);
128
+ return text && text !== key ? text : undefined;
129
+ } catch {
130
+ return undefined;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Message-key segments a JSON:API type may be filed under in the app's
136
+ * `features.<feature>` namespace: `npcs` → `npc`, `knives` → `knife`,
137
+ * `stories` → `story`, `scene-cornerstones` → `scene-cornerstone` and
138
+ * `sceneCornerstone`.
139
+ */
140
+ function featureKeyCandidates(type: string): string[] {
141
+ if (!type) return [];
142
+ const singulars = new Set<string>();
143
+ if (type.endsWith("ives")) singulars.add(`${type.slice(0, -4)}ife`);
144
+ if (type.endsWith("ies")) singulars.add(`${type.slice(0, -3)}y`);
145
+ if (type.endsWith("s")) singulars.add(type.slice(0, -1));
146
+ singulars.add(type);
147
+ const candidates = new Set<string>();
148
+ for (const singular of singulars) {
149
+ candidates.add(singular);
150
+ candidates.add(singular.replace(/[-_]+([a-z0-9])/g, (_, letter: string) => letter.toUpperCase()));
151
+ }
152
+ return [...candidates];
153
+ }
154
+
155
+ /**
156
+ * Label of an attribute or relationship, in the active language. Tries, in
157
+ * order: the entity editor's own label (`features.<feature>.fields.<key>.label`),
158
+ * the generic `ui.fields.<key>`, then the humanised key — so a field no app
159
+ * catalogue knows still renders exactly as it did before translation existed.
160
+ */
161
+ function fieldLabel(t: LooseTranslate, type: string, key: string): string {
162
+ for (const feature of featureKeyCandidates(type)) {
163
+ const label = translateIfPresent(t, `features.${feature}.fields.${key}.label`);
164
+ if (label !== undefined) return label;
165
+ }
166
+ return translateIfPresent(t, `ui.fields.${key}`) ?? humaniseKey(key);
167
+ }
168
+
169
+ /** Singular entity name from the app's `entities.<type>` plural message, else the humanised type. */
170
+ function entityName(t: LooseTranslate, type: string): string {
171
+ return translateIfPresent(t, `entities.${type}`, { count: 1 }) ?? humaniseType(type);
172
+ }
173
+
111
174
  /**
112
175
  * Plain text of a BlockNote document, without importing BlockNote: walk the
113
176
  * blocks, concatenate the text runs of each block's `content`, recurse into
@@ -336,7 +399,9 @@ export function ApprovalActionCard({ actionId, summary, onResolved, hiddenRelati
336
399
  const presented = useMemo(() => parseOperatorProposal(actionProposal), [actionProposal]);
337
400
  const legacy = useMemo(() => (presented ? undefined : parseLegacyProposal(toolArgs)), [presented, toolArgs]);
338
401
  const isUpdate = toolName === "update_entity";
339
- const entityLabel = humaniseType(presented?.type ?? legacy?.type ?? "");
402
+ const entityType = presented?.type ?? legacy?.type ?? "";
403
+ const loose = t as unknown as LooseTranslate;
404
+ const entityLabel = entityName(loose, entityType);
340
405
  const targetLabel = presented?.target?.label ?? "";
341
406
 
342
407
  const rows = useMemo<ProposalRow[]>(() => {
@@ -345,7 +410,9 @@ export function ApprovalActionCard({ actionId, summary, onResolved, hiddenRelati
345
410
  const attributes = presented?.attributes ?? legacy?.attributes;
346
411
  const attributeRows = Object.entries(attributes ?? {}).flatMap<ProposalRow>(([key, value]) => {
347
412
  const text = formatAttribute(value, yes, no);
348
- return text === undefined ? [] : [{ key: `attribute:${key}`, label: humaniseKey(key), value: text }];
413
+ return text === undefined
414
+ ? []
415
+ : [{ key: `attribute:${key}`, label: fieldLabel(loose, entityType, key), value: text }];
349
416
  });
350
417
  if (!presented) return attributeRows;
351
418
 
@@ -353,7 +420,9 @@ export function ApprovalActionCard({ actionId, summary, onResolved, hiddenRelati
353
420
  const relationshipRows = Object.entries(presented.relationships ?? {}).flatMap<ProposalRow>(([key, refs]) => {
354
421
  if (hidden.has(key)) return [];
355
422
  const text = formatRefs(refs);
356
- return text === undefined ? [] : [{ key: `relationship:${key}`, label: humaniseKey(key), value: text }];
423
+ return text === undefined
424
+ ? []
425
+ : [{ key: `relationship:${key}`, label: fieldLabel(loose, entityType, key), value: text }];
357
426
  });
358
427
 
359
428
  const linkRows: ProposalRow[] = [];
@@ -361,7 +430,7 @@ export function ApprovalActionCard({ actionId, summary, onResolved, hiddenRelati
361
430
  linkRows.push({
362
431
  key: "link:relationship",
363
432
  label: t("features.assistant.approval.proposal.relationship"),
364
- value: humaniseKey(presented.relationship),
433
+ value: fieldLabel(loose, entityType, presented.relationship),
365
434
  });
366
435
  }
367
436
  const targetsText = formatRefs(presented.targets ?? []);
@@ -374,7 +443,7 @@ export function ApprovalActionCard({ actionId, summary, onResolved, hiddenRelati
374
443
  }
375
444
 
376
445
  return [...attributeRows, ...relationshipRows, ...linkRows];
377
- }, [presented, legacy, t, hiddenRelationshipKeys]);
446
+ }, [presented, legacy, t, loose, entityType, hiddenRelationshipKeys]);
378
447
 
379
448
  const hasProposal = rows.length > 0;
380
449