@byline/host-tanstack-start 2.5.2 → 2.6.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 (123) hide show
  1. package/dist/admin-shell/admin-roles/container.js +38 -24
  2. package/dist/admin-shell/admin-roles/delete.js +9 -7
  3. package/dist/admin-shell/admin-roles/list.js +20 -16
  4. package/dist/admin-shell/admin-users/container.js +79 -56
  5. package/dist/admin-shell/admin-users/delete.js +10 -8
  6. package/dist/admin-shell/admin-users/list.js +27 -18
  7. package/dist/admin-shell/chrome/admin-app-bar.js +5 -2
  8. package/dist/admin-shell/chrome/breadcrumbs/breadcrumbs.js +3 -1
  9. package/dist/admin-shell/chrome/dashboard.js +13 -11
  10. package/dist/admin-shell/chrome/hamburger.js +3 -1
  11. package/dist/admin-shell/chrome/menu-drawer.js +7 -5
  12. package/dist/admin-shell/chrome/preview-toggle.js +5 -3
  13. package/dist/admin-shell/chrome/route-error.d.ts +3 -2
  14. package/dist/admin-shell/chrome/route-error.js +29 -22
  15. package/dist/admin-shell/chrome/sign-in-page.d.ts +16 -4
  16. package/dist/admin-shell/chrome/sign-in-page.js +38 -13
  17. package/dist/admin-shell/chrome/sign-in-page.module.js +1 -0
  18. package/dist/admin-shell/chrome/sign-in-page_module.css +8 -1
  19. package/dist/admin-shell/collections/api.js +6 -5
  20. package/dist/admin-shell/collections/create.js +12 -4
  21. package/dist/admin-shell/collections/edit.js +112 -37
  22. package/dist/admin-shell/collections/history.js +17 -12
  23. package/dist/admin-shell/collections/list.js +18 -13
  24. package/dist/admin-shell/collections/preview-link.d.ts +1 -10
  25. package/dist/admin-shell/collections/preview-link.js +9 -11
  26. package/dist/admin-shell/collections/resolve-preview-url.d.ts +34 -0
  27. package/dist/admin-shell/collections/resolve-preview-url.js +17 -0
  28. package/dist/admin-shell/collections/restore-version-modal.js +13 -14
  29. package/dist/admin-shell/collections/tanstack-navigation-guard.d.ts +1 -1
  30. package/dist/admin-shell/collections/view-menu.js +7 -5
  31. package/dist/i18n/index.d.ts +19 -0
  32. package/dist/i18n/index.js +4 -0
  33. package/dist/i18n/locale-cookie.d.ts +17 -0
  34. package/dist/i18n/locale-cookie.js +26 -0
  35. package/dist/i18n/locale-definitions.d.ts +29 -0
  36. package/dist/i18n/locale-definitions.js +27 -0
  37. package/dist/i18n/resolve-locale.d.ts +20 -0
  38. package/dist/i18n/resolve-locale.js +43 -0
  39. package/dist/i18n/server-translator.d.ts +33 -0
  40. package/dist/i18n/server-translator.js +19 -0
  41. package/dist/integrations/byline-admin-services.js +2 -0
  42. package/dist/integrations/byline-field-services.d.ts +3 -3
  43. package/dist/routes/create-admin-account-route.js +6 -3
  44. package/dist/routes/create-admin-dashboard-route.js +3 -1
  45. package/dist/routes/create-admin-layout-route.js +48 -25
  46. package/dist/routes/create-admin-permissions-route.js +4 -2
  47. package/dist/routes/create-admin-role-edit-route.js +5 -3
  48. package/dist/routes/create-admin-roles-list-route.js +4 -2
  49. package/dist/routes/create-admin-user-edit-route.js +5 -3
  50. package/dist/routes/create-admin-users-list-route.js +4 -2
  51. package/dist/routes/create-collection-api-route.js +5 -3
  52. package/dist/routes/create-collection-create-route.js +4 -2
  53. package/dist/routes/create-collection-edit-route.js +4 -2
  54. package/dist/routes/create-collection-history-route.js +5 -3
  55. package/dist/routes/create-collection-list-route.js +11 -5
  56. package/dist/routes/create-sign-in-route.js +10 -1
  57. package/dist/server-fns/admin-account/change-password.d.ts +1 -0
  58. package/dist/server-fns/admin-account/get.d.ts +1 -0
  59. package/dist/server-fns/admin-account/update.d.ts +1 -0
  60. package/dist/server-fns/admin-users/create.d.ts +1 -0
  61. package/dist/server-fns/admin-users/get.d.ts +1 -0
  62. package/dist/server-fns/admin-users/list.d.ts +1 -0
  63. package/dist/server-fns/admin-users/set-password.d.ts +1 -0
  64. package/dist/server-fns/admin-users/update.d.ts +1 -0
  65. package/dist/server-fns/auth/sign-in.js +18 -0
  66. package/dist/server-fns/i18n/get-active-locale.d.ts +8 -0
  67. package/dist/server-fns/i18n/get-active-locale.js +6 -0
  68. package/dist/server-fns/i18n/index.d.ts +10 -0
  69. package/dist/server-fns/i18n/index.js +2 -0
  70. package/dist/server-fns/i18n/set-locale.d.ts +25 -0
  71. package/dist/server-fns/i18n/set-locale.js +42 -0
  72. package/package.json +16 -7
  73. package/src/admin-shell/admin-roles/container.tsx +41 -31
  74. package/src/admin-shell/admin-roles/delete.tsx +10 -11
  75. package/src/admin-shell/admin-roles/list.tsx +29 -16
  76. package/src/admin-shell/admin-users/container.tsx +77 -50
  77. package/src/admin-shell/admin-users/delete.tsx +11 -12
  78. package/src/admin-shell/admin-users/list.tsx +39 -18
  79. package/src/admin-shell/chrome/admin-app-bar.tsx +5 -2
  80. package/src/admin-shell/chrome/breadcrumbs/breadcrumbs.tsx +3 -1
  81. package/src/admin-shell/chrome/dashboard.tsx +9 -3
  82. package/src/admin-shell/chrome/hamburger.tsx +3 -1
  83. package/src/admin-shell/chrome/menu-drawer.tsx +7 -5
  84. package/src/admin-shell/chrome/preview-toggle.tsx +6 -4
  85. package/src/admin-shell/chrome/route-error.tsx +39 -26
  86. package/src/admin-shell/chrome/sign-in-page.module.css +10 -1
  87. package/src/admin-shell/chrome/sign-in-page.tsx +46 -12
  88. package/src/admin-shell/collections/api.tsx +5 -1
  89. package/src/admin-shell/collections/create.tsx +10 -4
  90. package/src/admin-shell/collections/edit.tsx +79 -72
  91. package/src/admin-shell/collections/history.tsx +18 -12
  92. package/src/admin-shell/collections/list.tsx +25 -14
  93. package/src/admin-shell/collections/preview-link.tsx +20 -33
  94. package/src/admin-shell/collections/resolve-preview-url.test.node.ts +167 -0
  95. package/src/admin-shell/collections/resolve-preview-url.ts +67 -0
  96. package/src/admin-shell/collections/restore-version-modal.tsx +11 -12
  97. package/src/admin-shell/collections/tanstack-navigation-guard.ts +1 -1
  98. package/src/admin-shell/collections/view-menu.tsx +9 -5
  99. package/src/i18n/index.ts +26 -0
  100. package/src/i18n/locale-cookie.ts +68 -0
  101. package/src/i18n/locale-definitions.ts +48 -0
  102. package/src/i18n/resolve-locale.ts +96 -0
  103. package/src/i18n/server-translator.ts +60 -0
  104. package/src/integrations/byline-admin-services.ts +2 -0
  105. package/src/integrations/byline-field-services.ts +7 -3
  106. package/src/routes/create-admin-account-route.tsx +6 -4
  107. package/src/routes/create-admin-dashboard-route.tsx +5 -1
  108. package/src/routes/create-admin-layout-route.tsx +53 -20
  109. package/src/routes/create-admin-permissions-route.tsx +4 -2
  110. package/src/routes/create-admin-role-edit-route.tsx +5 -3
  111. package/src/routes/create-admin-roles-list-route.tsx +5 -2
  112. package/src/routes/create-admin-user-edit-route.tsx +5 -3
  113. package/src/routes/create-admin-users-list-route.tsx +4 -2
  114. package/src/routes/create-collection-api-route.tsx +5 -3
  115. package/src/routes/create-collection-create-route.tsx +7 -2
  116. package/src/routes/create-collection-edit-route.tsx +4 -2
  117. package/src/routes/create-collection-history-route.tsx +5 -3
  118. package/src/routes/create-collection-list-route.tsx +8 -10
  119. package/src/routes/create-sign-in-route.tsx +14 -1
  120. package/src/server-fns/auth/sign-in.ts +45 -0
  121. package/src/server-fns/i18n/get-active-locale.ts +26 -0
  122. package/src/server-fns/i18n/index.ts +11 -0
  123. package/src/server-fns/i18n/set-locale.ts +103 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Tests for the preview-URL resolver's three-level cascade:
11
+ *
12
+ * 1. `adminConfig.preview.url` — wins outright
13
+ * 2. `definition.buildDocumentPath` — schema-side fallback
14
+ * 3. Generic `/${collectionPath}/${path}` — last-resort compose
15
+ *
16
+ * Also covers the branch-A posture (hook throws → fall through) and the
17
+ * "missing path means hide affordance" contract.
18
+ */
19
+
20
+ import {
21
+ type CollectionAdminConfig,
22
+ type CollectionDefinition,
23
+ defineServerConfig,
24
+ type PreviewDocument,
25
+ } from '@byline/core'
26
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
27
+
28
+ import { resolvePreviewUrl } from './resolve-preview-url.js'
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Harness
32
+ // ---------------------------------------------------------------------------
33
+
34
+ function clearConfig(): void {
35
+ ;(globalThis as any)[Symbol.for('__byline_server_config__')] = null
36
+ ;(globalThis as any)[Symbol.for('__byline_client_config__')] = null
37
+ }
38
+
39
+ function registerCollection(definition: Partial<CollectionDefinition> & { path: string }): void {
40
+ defineServerConfig({
41
+ collections: [
42
+ {
43
+ labels: { singular: 'Page', plural: 'Pages' },
44
+ fields: [{ name: 'title', type: 'text', label: 'Title' }],
45
+ useAsTitle: 'title',
46
+ ...definition,
47
+ } as CollectionDefinition,
48
+ ],
49
+ } as Parameters<typeof defineServerConfig>[0])
50
+ }
51
+
52
+ const doc: PreviewDocument = {
53
+ id: 'doc-1',
54
+ path: 'about',
55
+ status: 'published',
56
+ fields: { title: 'About' },
57
+ }
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Suite
61
+ // ---------------------------------------------------------------------------
62
+
63
+ describe('resolvePreviewUrl', () => {
64
+ beforeEach(clearConfig)
65
+ afterEach(clearConfig)
66
+
67
+ // -------------------------------------------------------------------------
68
+ // Tier 1 — adminConfig.preview.url
69
+ // -------------------------------------------------------------------------
70
+
71
+ describe('adminConfig.preview.url', () => {
72
+ it('takes precedence over the schema hook', () => {
73
+ registerCollection({
74
+ path: 'pages',
75
+ buildDocumentPath: () => '/schema-path',
76
+ })
77
+ const adminConfig = {
78
+ preview: { url: () => '/from-admin' },
79
+ } as unknown as CollectionAdminConfig
80
+ expect(resolvePreviewUrl(doc, 'pages', adminConfig, undefined)).toBe('/from-admin')
81
+ })
82
+
83
+ it('forwards the locale to the configured url builder', () => {
84
+ const calls: Array<{ locale: string | undefined }> = []
85
+ const adminConfig = {
86
+ preview: {
87
+ url: (_d: PreviewDocument, ctx: { locale?: string }) => {
88
+ calls.push({ locale: ctx.locale })
89
+ return '/anything'
90
+ },
91
+ },
92
+ } as unknown as CollectionAdminConfig
93
+ resolvePreviewUrl(doc, 'pages', adminConfig, 'fr')
94
+ expect(calls).toEqual([{ locale: 'fr' }])
95
+ })
96
+
97
+ it('returns null when the configured url builder returns null', () => {
98
+ const adminConfig = {
99
+ preview: { url: () => null },
100
+ } as unknown as CollectionAdminConfig
101
+ expect(resolvePreviewUrl(doc, 'pages', adminConfig, undefined)).toBeNull()
102
+ })
103
+ })
104
+
105
+ // -------------------------------------------------------------------------
106
+ // Tier 2 — schema-side buildDocumentPath
107
+ // -------------------------------------------------------------------------
108
+
109
+ describe('buildDocumentPath fallback', () => {
110
+ it('uses the schema hook when no adminConfig.preview is set', () => {
111
+ registerCollection({
112
+ path: 'pages',
113
+ buildDocumentPath: (d) => `/marketing/${d.path}`,
114
+ })
115
+ expect(resolvePreviewUrl(doc, 'pages', undefined, undefined)).toBe('/marketing/about')
116
+ })
117
+
118
+ it('passes through null to hide the preview affordance', () => {
119
+ registerCollection({
120
+ path: 'pages',
121
+ buildDocumentPath: () => null,
122
+ })
123
+ expect(resolvePreviewUrl(doc, 'pages', undefined, undefined)).toBeNull()
124
+ })
125
+
126
+ it('falls through to generic compose when the hook throws (branch-A posture)', () => {
127
+ registerCollection({
128
+ path: 'pages',
129
+ buildDocumentPath: () => {
130
+ throw new Error('boom')
131
+ },
132
+ })
133
+ expect(resolvePreviewUrl(doc, 'pages', undefined, undefined)).toBe('/pages/about')
134
+ })
135
+
136
+ it('falls through to generic compose when the hook returns a non-string non-null', () => {
137
+ registerCollection({
138
+ path: 'pages',
139
+ // @ts-expect-error — exercising defensive handling of malformed return values
140
+ buildDocumentPath: () => 42,
141
+ })
142
+ expect(resolvePreviewUrl(doc, 'pages', undefined, undefined)).toBe('/pages/about')
143
+ })
144
+ })
145
+
146
+ // -------------------------------------------------------------------------
147
+ // Tier 3 — generic compose
148
+ // -------------------------------------------------------------------------
149
+
150
+ describe('generic compose', () => {
151
+ it('uses /${collectionPath}/${path} when no schema hook is defined', () => {
152
+ registerCollection({ path: 'pages' })
153
+ expect(resolvePreviewUrl(doc, 'pages', undefined, undefined)).toBe('/pages/about')
154
+ })
155
+
156
+ it('works without any registered collection (fully unconfigured host)', () => {
157
+ // No registerCollection — getCollectionDefinition returns null,
158
+ // the cascade falls straight through to the generic compose.
159
+ expect(resolvePreviewUrl(doc, 'pages', undefined, undefined)).toBe('/pages/about')
160
+ })
161
+
162
+ it('returns null when the doc has no path yet', () => {
163
+ registerCollection({ path: 'pages' })
164
+ expect(resolvePreviewUrl({ ...doc, path: '' }, 'pages', undefined, undefined)).toBeNull()
165
+ })
166
+ })
167
+ })
@@ -0,0 +1,67 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Pure (no React, no DOM) resolver for the admin Preview button's URL.
11
+ * Lives in its own module so it can be unit-tested without dragging the
12
+ * React UI graph of `preview-link.tsx` into a node-mode test run.
13
+ */
14
+
15
+ import {
16
+ type CollectionAdminConfig,
17
+ getCollectionDefinition,
18
+ type PreviewDocument,
19
+ } from '@byline/core'
20
+
21
+ /**
22
+ * Resolve the preview URL for a doc against an admin config. Exported so
23
+ * other surfaces (list-row preview links in the future) can share the
24
+ * same fallback logic.
25
+ *
26
+ * Cascade:
27
+ * 1. `adminConfig.preview.url(doc, { locale })` — wins outright when
28
+ * configured. The host can do anything (locale prefix, query
29
+ * string, conditional return-null).
30
+ * 2. `CollectionDefinition.buildDocumentPath(doc, { collectionPath })`
31
+ * — the schema-side hook the richtext embed walker also reads.
32
+ * Locale-agnostic by contract; hosts that need a locale prefix
33
+ * keep their own `preview.url`.
34
+ * 3. Generic compose `/${collectionPath}/${doc.path}` — last-resort
35
+ * convention for collections with no schema-side hook.
36
+ *
37
+ * Returns:
38
+ * - `string` → URL to open
39
+ * - `null` → no preview URL meaningful for this doc; hide affordance
40
+ */
41
+ export function resolvePreviewUrl(
42
+ doc: PreviewDocument,
43
+ collectionPath: string,
44
+ adminConfig: CollectionAdminConfig | undefined,
45
+ locale: string | undefined
46
+ ): string | null {
47
+ if (adminConfig?.preview) {
48
+ return adminConfig.preview.url(doc, { locale })
49
+ }
50
+ // Schema-side default — same hook the richtext embed walker reads, so
51
+ // the public path and the admin Preview button agree by construction.
52
+ const definition = getCollectionDefinition(collectionPath)
53
+ if (definition?.buildDocumentPath != null) {
54
+ try {
55
+ const built = definition.buildDocumentPath(doc, { collectionPath })
56
+ if (typeof built === 'string') return built
57
+ if (built === null) return null
58
+ } catch {
59
+ // Fall through to generic compose — matches the embed walker's
60
+ // branch-A posture (don't take the render path down with the hook).
61
+ }
62
+ }
63
+ // Generic compose: collection lives at `/${collectionPath}/${path}`.
64
+ // Returns null when the doc has no path yet (unsaved, awaiting slug).
65
+ if (!doc.path) return null
66
+ return `/${collectionPath}/${doc.path}`
67
+ }
@@ -20,6 +20,7 @@
20
20
  import { useState } from 'react'
21
21
  import { useRouter } from '@tanstack/react-router'
22
22
 
23
+ import { useTranslation } from '@byline/i18n/react'
23
24
  import { Alert, Button, LoaderEllipsis, Modal } from '@byline/ui/react'
24
25
  import cx from 'classnames'
25
26
 
@@ -46,6 +47,7 @@ export function RestoreVersionModal({
46
47
  }: RestoreVersionModalProps) {
47
48
  const navigate = useNavigate()
48
49
  const router = useRouter()
50
+ const { t } = useTranslation('byline-admin')
49
51
  const [error, setError] = useState<string | null>(null)
50
52
  const [pending, setPending] = useState(false)
51
53
 
@@ -66,13 +68,13 @@ export function RestoreVersionModal({
66
68
  } catch (err) {
67
69
  const code = getErrorCode(err)
68
70
  if (code === 'ERR_INVALID_TRANSITION') {
69
- setError('This version is already the current version of the document.')
71
+ setError(t('collections.restore.errors.alreadyCurrent'))
70
72
  } else if (code === 'ERR_NOT_FOUND') {
71
- setError('The selected version could not be found. The history may be out of date.')
73
+ setError(t('collections.restore.errors.notFound'))
72
74
  } else if (code === 'ERR_FORBIDDEN' || code === 'ERR_UNAUTHENTICATED') {
73
- setError('You do not have permission to restore versions for this collection.')
75
+ setError(t('collections.restore.errors.forbidden'))
74
76
  } else {
75
- setError('Could not restore this version. Please try again.')
77
+ setError(t('collections.restore.errors.fallback'))
76
78
  }
77
79
  setPending(false)
78
80
  }
@@ -87,16 +89,13 @@ export function RestoreVersionModal({
87
89
  </Alert>
88
90
  ) : null}
89
91
  <p className={cx('byline-coll-restore-row', styles.row)}>
90
- <span className="muted">Version:</span> {versionNumber}
92
+ <span className="muted">{t('collections.restore.versionLabel')}</span> {versionNumber}
91
93
  </p>
92
94
  <p className={cx('byline-coll-restore-row', styles.row)}>
93
- <span className="muted">Created:</span> {versionLabel}
95
+ <span className="muted">{t('collections.restore.createdLabel')}</span> {versionLabel}
94
96
  </p>
95
97
  <p className={cx('byline-coll-restore-warning', styles.warning)}>
96
- This will create a new draft version of this document with the content from version{' '}
97
- {versionNumber}, and that draft will become the current version. The existing versions
98
- (including any published version) are preserved in history. The restored draft will need
99
- to be published through the normal workflow.
98
+ {t('collections.restore.warning', { version: versionNumber })}
100
99
  </p>
101
100
  </div>
102
101
  <div className={cx('byline-coll-restore-actions', styles.actions)}>
@@ -117,7 +116,7 @@ export function RestoreVersionModal({
117
116
  disabled={pending}
118
117
  className={cx('byline-coll-restore-button', styles.button)}
119
118
  >
120
- Cancel
119
+ {t('common.actions.cancel')}
121
120
  </Button>
122
121
  <Button
123
122
  size="sm"
@@ -127,7 +126,7 @@ export function RestoreVersionModal({
127
126
  disabled={pending}
128
127
  className={cx('byline-coll-restore-button', styles.button)}
129
128
  >
130
- {pending === true ? <LoaderEllipsis size={42} /> : 'Restore as Draft'}
129
+ {pending === true ? <LoaderEllipsis size={42} /> : t('collections.restore.confirmButton')}
131
130
  </Button>
132
131
  </div>
133
132
  </Modal.Content>
@@ -16,7 +16,7 @@
16
16
  import { useCallback } from 'react'
17
17
  import { useBlocker } from '@tanstack/react-router'
18
18
 
19
- import type { NavigationGuardResult, UseNavigationGuard } from '@byline/ui/react'
19
+ import type { NavigationGuardResult, UseNavigationGuard } from '@byline/admin/react'
20
20
 
21
21
  /**
22
22
  * Navigation guard backed by TanStack Router's `useBlocker`.
@@ -9,6 +9,7 @@
9
9
  import { useEffect } from 'react'
10
10
 
11
11
  import type { CollectionAdminConfig, PreviewDocument } from '@byline/core'
12
+ import { useTranslation } from '@byline/i18n/react'
12
13
  import { Button, HistoryIcon, IconButton, Label, Select } from '@byline/ui/react'
13
14
  import cx from 'classnames'
14
15
 
@@ -78,6 +79,7 @@ export const ViewMenu = ({
78
79
  doc?: PreviewDocument
79
80
  }) => {
80
81
  const navigate = useNavigate()
82
+ const { t } = useTranslation('byline-admin')
81
83
 
82
84
  // Edit view must never use 'all' locale — strip it from the URL and fall
83
85
  // back to the default locale if it somehow arrives via navigation.
@@ -142,7 +144,7 @@ export const ViewMenu = ({
142
144
  className={cx('muted byline-view-menu-label', styles.label)}
143
145
  id="contentLocaleLabel"
144
146
  htmlFor="contentLocale"
145
- label="Content Locale:"
147
+ label={t('collections.viewMenu.contentLocaleLabel')}
146
148
  />
147
149
  <Select<string>
148
150
  name="contentLocale"
@@ -152,7 +154,9 @@ export const ViewMenu = ({
152
154
  variant="outlined"
153
155
  value={locale ?? defaultContentLocale}
154
156
  items={[
155
- ...(activeView !== 'edit' ? [{ value: 'all', label: 'All' }] : []),
157
+ ...(activeView !== 'edit'
158
+ ? [{ value: 'all', label: t('collections.viewMenu.localeAll') }]
159
+ : []),
156
160
  ...contentLocales.map((loc) => ({ value: loc.code, label: loc.label })),
157
161
  ]}
158
162
  onValueChange={handleLocaleChange}
@@ -163,7 +167,7 @@ export const ViewMenu = ({
163
167
  className={cx('muted byline-view-menu-label', styles.label)}
164
168
  id="populateDepthLabel"
165
169
  htmlFor="populateDepth"
166
- label="Depth:"
170
+ label={t('collections.viewMenu.depthLabel')}
167
171
  />
168
172
  <Select<string>
169
173
  name="populateDepth"
@@ -217,7 +221,7 @@ export const ViewMenu = ({
217
221
  })
218
222
  }
219
223
  >
220
- Edit
224
+ {t('common.actions.edit')}
221
225
  </Button>
222
226
  <Button
223
227
  size="xs"
@@ -231,7 +235,7 @@ export const ViewMenu = ({
231
235
  })
232
236
  }
233
237
  >
234
- API
238
+ {t('collections.viewMenu.apiButton')}
235
239
  </Button>
236
240
  </div>
237
241
  )
@@ -0,0 +1,26 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * `@byline/host-tanstack-start/i18n` — host-side glue between the
11
+ * admin interface translation registry and TanStack Start's request
12
+ * model. Cookie helpers, the per-request locale resolver, and the
13
+ * server-side `resolveServerTranslator` companion to the client
14
+ * `useTranslation` hook.
15
+ */
16
+
17
+ export {
18
+ ADMIN_LOCALE_COOKIE,
19
+ clearAdminLocaleCookie,
20
+ readAdminLocaleCookie,
21
+ setAdminLocaleCookie,
22
+ } from './locale-cookie.js'
23
+ export { buildLocaleDefinitions } from './locale-definitions.js'
24
+ export { resolveRequestLocale } from './resolve-locale.js'
25
+ export { resolveServerTranslator } from './server-translator.js'
26
+ export type { ServerTranslator } from './server-translator.js'
@@ -0,0 +1,68 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Cookie helpers for the admin interface locale preference.
11
+ *
12
+ * The cookie name is `byline_admin_lng` — follows the `byline_*` prefix
13
+ * convention shared with the other admin cookies (`byline_access_token`,
14
+ * `byline_refresh_token`, `byline_preview`). Deliberately distinct from
15
+ * any host-side front-end-site i18n cookie (the example webapp uses
16
+ * `lng` for its public-site switcher). The two systems are independent:
17
+ * an editor in a Spanish-language admin chrome routinely edits English /
18
+ * French / German content, and the host's public site may carry its own
19
+ * locale switcher in its own cookie. Sharing one cookie across both
20
+ * would cause cross-talk.
21
+ *
22
+ * Cookie attributes:
23
+ * - `httpOnly: false` — the client-side `<LanguageMenu>` reads the
24
+ * cookie to render the active row indicator; setting it httpOnly
25
+ * would force a server roundtrip on every page load just to know
26
+ * which locale the user chose.
27
+ * - `sameSite: 'lax'`
28
+ * - `secure: true` in production — https-only.
29
+ * - `path: '/'`
30
+ * - `maxAge: 365 days` — language preference is a long-lived choice.
31
+ */
32
+
33
+ import { getCookie, setCookie } from '@tanstack/react-start/server'
34
+
35
+ export const ADMIN_LOCALE_COOKIE = 'byline_admin_lng'
36
+ const ADMIN_LOCALE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 // 365 days
37
+
38
+ const IS_PROD = process.env.NODE_ENV === 'production'
39
+
40
+ /** Read the current admin locale cookie, if any. */
41
+ export function readAdminLocaleCookie(): string | null {
42
+ return getCookie(ADMIN_LOCALE_COOKIE) ?? null
43
+ }
44
+
45
+ /**
46
+ * Write the admin locale cookie. Caller is expected to have validated
47
+ * `locale` against the permitted `i18n.interface.locales` set first.
48
+ */
49
+ export function setAdminLocaleCookie(locale: string): void {
50
+ setCookie(ADMIN_LOCALE_COOKIE, locale, {
51
+ httpOnly: false,
52
+ sameSite: 'lax',
53
+ secure: IS_PROD,
54
+ path: '/',
55
+ maxAge: ADMIN_LOCALE_MAX_AGE_SECONDS,
56
+ })
57
+ }
58
+
59
+ /** Clear the cookie — used when a user opts back into browser-default detection. */
60
+ export function clearAdminLocaleCookie(): void {
61
+ setCookie(ADMIN_LOCALE_COOKIE, '', {
62
+ httpOnly: false,
63
+ sameSite: 'lax',
64
+ secure: IS_PROD,
65
+ path: '/',
66
+ maxAge: 0,
67
+ })
68
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ import type { LocaleDefinition } from '@byline/i18n'
10
+
11
+ /**
12
+ * Build a `LocaleDefinition[]` for the language switcher. Per-code
13
+ * resolution order:
14
+ *
15
+ * 1. An entry from the host's `i18n.interface.localeDefinitions`
16
+ * (matched by code). Wins outright — this is the path that lets a
17
+ * host author write `Français` instead of the lowercase
18
+ * `français` that CLDR's `Intl.DisplayNames` returns for romance
19
+ * languages.
20
+ * 2. `Intl.DisplayNames(code).of(code)` — produces a display name in
21
+ * each locale's own language using CLDR's data.
22
+ * 3. The raw code, as a last-resort fallback for exotic tags or
23
+ * runtimes that lack `Intl.DisplayNames`.
24
+ *
25
+ * Used by both the admin layout (post-auth) and the sign-in page
26
+ * (pre-auth) — anywhere `<LanguageMenu>` mounts.
27
+ */
28
+ export function buildLocaleDefinitions(
29
+ codes: readonly string[],
30
+ configured: ReadonlyArray<{ code: string; nativeName: string }> | undefined
31
+ ): LocaleDefinition[] {
32
+ const explicit = new Map((configured ?? []).map((d) => [d.code, d.nativeName]))
33
+ return codes.map((code) => {
34
+ const explicitName = explicit.get(code)
35
+ if (explicitName != null) {
36
+ return { code, nativeName: explicitName }
37
+ }
38
+ let nativeName = code
39
+ try {
40
+ const dn = new Intl.DisplayNames([code], { type: 'language' })
41
+ nativeName = dn.of(code) ?? code
42
+ } catch {
43
+ // Intl.DisplayNames is available in Node 18+ and every modern
44
+ // browser. Defensive catch covers exotic codes or sandbox quirks.
45
+ }
46
+ return { code, nativeName }
47
+ })
48
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * Server-side locale resolution for the admin interface.
11
+ *
12
+ * Combines `@byline/i18n`'s pure `resolveInterfaceLocale` cascade with
13
+ * the host's request-scoped signals — the `byline_admin_lng` cookie, the
14
+ * `Accept-Language` header, and (when an admin session is present)
15
+ * `admin_users.preferred_locale` on the authenticated actor.
16
+ *
17
+ * Idempotent: calling this twice in one request produces the same
18
+ * answer. SSR and the hydrated client therefore resolve to the same
19
+ * locale by construction — no "locale flicker" between server render
20
+ * and client takeover.
21
+ */
22
+
23
+ import { getRequestHeader } from '@tanstack/react-start/server'
24
+
25
+ import { AuthError, AuthErrorCodes } from '@byline/auth'
26
+ import type { LocaleCode } from '@byline/i18n'
27
+ import { resolveInterfaceLocale } from '@byline/i18n'
28
+
29
+ import { getAdminRequestContext } from '../auth/auth-context.js'
30
+ import { bylineCore } from '../integrations/byline-core.js'
31
+ import { readAdminLocaleCookie } from './locale-cookie.js'
32
+
33
+ /**
34
+ * Resolve the locale for the current request. Skips the
35
+ * `admin_users.preferred_locale` tier when no admin session is present —
36
+ * this is the right behaviour for pre-auth surfaces (sign-in page).
37
+ *
38
+ * Bypasses the auth resolver when `skipActorLookup` is true. Useful for
39
+ * very early-boot surfaces where the actor lookup itself is expensive
40
+ * or unwanted (rare; default to letting the cascade do its work).
41
+ */
42
+ export async function resolveRequestLocale(options?: {
43
+ skipActorLookup?: boolean
44
+ }): Promise<LocaleCode> {
45
+ const core = bylineCore()
46
+ const { interface: ifaceConfig } = core.config.i18n
47
+
48
+ let preferred: string | null = null
49
+ if (!options?.skipActorLookup) {
50
+ preferred = await readPreferredLocaleFromActor()
51
+ }
52
+
53
+ return resolveInterfaceLocale({
54
+ locales: ifaceConfig.locales,
55
+ defaultLocale: ifaceConfig.defaultLocale,
56
+ preferred,
57
+ cookie: readAdminLocaleCookie(),
58
+ acceptLanguage: readAcceptLanguage(),
59
+ })
60
+ }
61
+
62
+ async function readPreferredLocaleFromActor(): Promise<string | null> {
63
+ try {
64
+ const context = await getAdminRequestContext()
65
+ // The actor type isn't structurally typed for `preferred_locale`
66
+ // here — read it through the admin store to keep the resolver
67
+ // resilient to actor-shape evolution.
68
+ const adminStore = bylineCore().adminStore
69
+ if (adminStore == null) return null
70
+ const actor = context.actor
71
+ // Best-effort: the AdminAuth actor carries `id`. Reading the row
72
+ // directly from the repo costs one query per request that resolves
73
+ // a locale, which is fine for SSR; high-traffic paths can opt out
74
+ // via `skipActorLookup`.
75
+ if (actor == null || typeof actor !== 'object' || !('id' in actor)) return null
76
+ const id = (actor as { id: unknown }).id
77
+ if (typeof id !== 'string') return null
78
+ const row = await adminStore.adminUsers.getById(id)
79
+ return row?.preferred_locale ?? null
80
+ } catch (err) {
81
+ if (err instanceof AuthError && err.code === AuthErrorCodes.UNAUTHENTICATED) {
82
+ return null
83
+ }
84
+ // Any other failure (transport, refresh) → fall through to the
85
+ // cookie / Accept-Language tiers rather than crashing the SSR.
86
+ return null
87
+ }
88
+ }
89
+
90
+ function readAcceptLanguage(): string | null {
91
+ try {
92
+ return getRequestHeader('accept-language') ?? null
93
+ } catch {
94
+ return null
95
+ }
96
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * This Source Code is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
5
+ *
6
+ * Copyright (c) Infonomic Company Limited
7
+ */
8
+
9
+ /**
10
+ * `resolveServerTranslator(namespace)` — server-side companion to the
11
+ * client `useTranslation(namespace)` hook.
12
+ *
13
+ * Resolves the request locale via the same cascade the client hydrates
14
+ * against (preferred → cookie → Accept-Language → default), constructs
15
+ * a formatter bound to the registered translation bundle, and returns
16
+ * a `{ t, locale }` for the requested namespace. The shape mirrors
17
+ * `useTranslation` so callers can swap surfaces (loader vs component)
18
+ * without rethinking the API.
19
+ *
20
+ * Typical use:
21
+ *
22
+ * import { resolveServerTranslator } from '@byline/host-tanstack-start/i18n'
23
+ *
24
+ * export const sendInviteFn = createServerFn(...).handler(async () => {
25
+ * const { t } = await resolveServerTranslator('byline-admin')
26
+ * return { subject: t('email.invite.subject') }
27
+ * })
28
+ */
29
+
30
+ import { createFormatter, type LocaleCode } from '@byline/i18n'
31
+
32
+ import { bylineCore } from '../integrations/byline-core.js'
33
+ import { resolveRequestLocale } from './resolve-locale.js'
34
+
35
+ export interface ServerTranslator {
36
+ t: (key: string, values?: Record<string, string | number | boolean | Date | null>) => string
37
+ locale: LocaleCode
38
+ }
39
+
40
+ export async function resolveServerTranslator(namespace: string): Promise<ServerTranslator> {
41
+ const core = bylineCore()
42
+ const { interface: ifaceConfig, translations: bundle } = core.config.i18n
43
+ if (bundle == null) {
44
+ throw new Error(
45
+ '[resolveServerTranslator] no translation bundle is registered. ' +
46
+ 'Pass `translations: adminTranslations({ en: true })` (or a merged bundle) ' +
47
+ 'on `i18n` in your config.'
48
+ )
49
+ }
50
+ const activeLocale = await resolveRequestLocale()
51
+ const formatter = createFormatter({
52
+ bundle,
53
+ activeLocale,
54
+ defaultLocale: ifaceConfig.defaultLocale,
55
+ })
56
+ return {
57
+ t: (key, values) => formatter.t(namespace, key, values),
58
+ locale: activeLocale,
59
+ }
60
+ }