@byline/host-tanstack-start 6.7.2 → 6.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admin-shell/admin-roles/container_module.css +1 -1
- package/dist/admin-shell/admin-roles/delete.js +1 -1
- package/dist/admin-shell/admin-users/container.js +181 -159
- package/dist/admin-shell/admin-users/container.module.js +2 -1
- package/dist/admin-shell/admin-users/container_module.css +5 -1
- package/dist/admin-shell/admin-users/delete.js +1 -1
- package/dist/admin-shell/chrome/admin-app-bar.d.ts +0 -7
- package/dist/admin-shell/collections/api.d.ts +6 -2
- package/dist/admin-shell/collections/api.js +109 -3
- package/dist/admin-shell/collections/api.test.d.ts +8 -0
- package/dist/admin-shell/collections/edit.js +2 -0
- package/dist/admin-shell/collections/preview-link.js +17 -13
- package/dist/admin-shell/collections/view-menu.d.ts +8 -1
- package/dist/admin-shell/collections/view-menu.js +124 -107
- package/dist/admin-shell/collections/view-menu.module.js +1 -0
- package/dist/admin-shell/collections/view-menu_module.css +15 -1
- package/dist/admin-shell/scheduled-publications/list.js +2 -2
- package/dist/admin-shell/singletons/preview-link.js +17 -13
- package/dist/admin-shell/singletons/view-menu.js +78 -63
- package/dist/admin-shell/singletons/view.js +2 -0
- package/dist/integrations/byline-admin-services.js +2 -0
- package/dist/integrations/passkey-admin-services.d.ts +9 -0
- package/dist/integrations/passkey-admin-services.js +53 -0
- package/dist/integrations/session-coordination.d.ts +3 -1
- package/dist/integrations/session-coordination.js +3 -1
- package/dist/integrations/session-middleware.d.ts +3 -1
- package/dist/integrations/session-middleware.js +87 -79
- package/dist/integrations/sign-in-body.d.ts +3 -0
- package/dist/integrations/sign-in-body.js +19 -4
- package/dist/integrations/sign-in-middleware.js +30 -5
- package/dist/server-fns/auth/passkey-admission.d.ts +24 -0
- package/dist/server-fns/auth/passkey-admission.js +88 -0
- package/dist/server-fns/auth/passkeys.d.ts +96 -0
- package/dist/server-fns/auth/passkeys.js +238 -0
- package/dist/server-fns/auth/reconcile-locale.d.ts +20 -0
- package/dist/server-fns/auth/reconcile-locale.js +16 -0
- package/dist/server-fns/auth/sign-in.js +1 -15
- package/dist/server-fns/collections/get.d.ts +1 -0
- package/dist/server-fns/collections/get.js +4 -0
- package/package.json +11 -11
- package/src/admin-shell/admin-roles/container.module.css +1 -1
- package/src/admin-shell/admin-roles/delete.tsx +1 -1
- package/src/admin-shell/admin-users/container.module.css +7 -1
- package/src/admin-shell/admin-users/container.tsx +22 -2
- package/src/admin-shell/admin-users/delete.tsx +1 -1
- package/src/admin-shell/chrome/admin-app-bar.tsx +2 -2
- package/src/admin-shell/chrome/dashboard.test.tsx +2 -2
- package/src/admin-shell/collections/api.test.tsx +223 -0
- package/src/admin-shell/collections/api.tsx +98 -3
- package/src/admin-shell/collections/edit.tsx +9 -1
- package/src/admin-shell/collections/preview-link.tsx +13 -12
- package/src/admin-shell/collections/view-menu.module.css +29 -3
- package/src/admin-shell/collections/view-menu.tsx +114 -96
- package/src/admin-shell/scheduled-publications/list.tsx +3 -4
- package/src/admin-shell/singletons/history.test.tsx +1 -1
- package/src/admin-shell/singletons/preview-link.tsx +13 -12
- package/src/admin-shell/singletons/view-menu.tsx +65 -59
- package/src/admin-shell/singletons/view.test.tsx +4 -4
- package/src/admin-shell/singletons/view.tsx +3 -1
- package/src/integrations/byline-admin-services.ts +3 -0
- package/src/integrations/passkey-admin-services.ts +85 -0
- package/src/integrations/session-client.test.node.ts +73 -0
- package/src/integrations/session-coordination.ts +3 -1
- package/src/integrations/session-middleware.ts +86 -72
- package/src/integrations/sign-in-body.test.node.ts +31 -1
- package/src/integrations/sign-in-body.ts +27 -3
- package/src/integrations/sign-in-middleware.ts +42 -9
- package/src/server-fns/auth/passkey-admission.test.node.ts +173 -0
- package/src/server-fns/auth/passkey-admission.ts +125 -0
- package/src/server-fns/auth/passkeys.test.node.ts +247 -0
- package/src/server-fns/auth/passkeys.ts +252 -0
- package/src/server-fns/auth/reconcile-locale.ts +40 -0
- package/src/server-fns/auth/sign-in.ts +1 -32
- package/src/server-fns/collections/get.ts +3 -0
|
@@ -0,0 +1,223 @@
|
|
|
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 { ComponentProps, ReactNode } from 'react'
|
|
10
|
+
import { act } from 'react'
|
|
11
|
+
|
|
12
|
+
import { adminTranslations } from '@byline/i18n/admin'
|
|
13
|
+
import { I18nProvider } from '@byline/i18n/react'
|
|
14
|
+
import { createRoot } from 'react-dom/client'
|
|
15
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
16
|
+
|
|
17
|
+
const mocks = vi.hoisted(() => ({ toast: vi.fn(), writeText: vi.fn() }))
|
|
18
|
+
vi.mock('@byline/ui/react', () => ({
|
|
19
|
+
Container: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
20
|
+
Section: ({ children }: { children: ReactNode }) => <section>{children}</section>,
|
|
21
|
+
IconButton: ({
|
|
22
|
+
size: _size,
|
|
23
|
+
variant: _variant,
|
|
24
|
+
...props
|
|
25
|
+
}: ComponentProps<'button'> & {
|
|
26
|
+
size?: string
|
|
27
|
+
variant?: string
|
|
28
|
+
}) => <button {...props} />,
|
|
29
|
+
Tooltip: ({ children }: { children: ReactNode }) => children,
|
|
30
|
+
CopyIcon: () => null,
|
|
31
|
+
DownloadIcon: () => null,
|
|
32
|
+
useToastManager: () => ({ add: mocks.toast }),
|
|
33
|
+
}))
|
|
34
|
+
vi.mock('./view-menu.js', () => ({
|
|
35
|
+
ViewMenu: ({ dataActions }: { dataActions?: ReactNode }) => <div>{dataActions}</div>,
|
|
36
|
+
}))
|
|
37
|
+
|
|
38
|
+
import { ApiView } from './api.js'
|
|
39
|
+
|
|
40
|
+
const payload = {
|
|
41
|
+
id: 'document-1',
|
|
42
|
+
fields: {
|
|
43
|
+
title: 'Sample document',
|
|
44
|
+
content: [{ _type: 'text', children: [{ text: 'Hidden nested content' }] }],
|
|
45
|
+
related: { fields: { title: 'Populated relation' } },
|
|
46
|
+
},
|
|
47
|
+
} as ComponentProps<typeof ApiView>['initialData']
|
|
48
|
+
|
|
49
|
+
let container: HTMLDivElement
|
|
50
|
+
let root: ReturnType<typeof createRoot>
|
|
51
|
+
const originalURL = globalThis.URL
|
|
52
|
+
const createObjectURL = vi.fn((_blob: Blob) => 'blob:test-json')
|
|
53
|
+
const revokeObjectURL = vi.fn()
|
|
54
|
+
|
|
55
|
+
// `locale: null` renders as a route without a locale search param.
|
|
56
|
+
function render(data = payload, locale: string | null = 'en', depth = 0) {
|
|
57
|
+
act(() =>
|
|
58
|
+
root.render(
|
|
59
|
+
<I18nProvider
|
|
60
|
+
bundle={adminTranslations({ locales: ['en'] })}
|
|
61
|
+
activeLocale="en"
|
|
62
|
+
defaultLocale="en"
|
|
63
|
+
localeDefinitions={[{ code: 'en', nativeName: 'English' }]}
|
|
64
|
+
>
|
|
65
|
+
<ApiView
|
|
66
|
+
collectionDefinition={{
|
|
67
|
+
path: 'docs',
|
|
68
|
+
labels: { singular: 'Doc', plural: 'Docs' },
|
|
69
|
+
fields: [],
|
|
70
|
+
}}
|
|
71
|
+
initialData={data}
|
|
72
|
+
locale={locale ?? undefined}
|
|
73
|
+
depth={depth}
|
|
74
|
+
contentLocales={[
|
|
75
|
+
{ code: 'en', label: 'English' },
|
|
76
|
+
{ code: 'fr', label: 'French' },
|
|
77
|
+
]}
|
|
78
|
+
defaultContentLocale="fr"
|
|
79
|
+
/>
|
|
80
|
+
</I18nProvider>
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function click(label: string) {
|
|
86
|
+
const button = container.querySelector<HTMLButtonElement>(`button[aria-label="${label}"]`)
|
|
87
|
+
if (!button) throw new Error(`Button not found: ${label}`)
|
|
88
|
+
await act(async () => button.click())
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readBlob(blob: Blob): Promise<string> {
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const reader = new FileReader()
|
|
94
|
+
reader.onload = () => resolve(String(reader.result))
|
|
95
|
+
reader.onerror = () => reject(reader.error)
|
|
96
|
+
reader.readAsText(blob)
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
|
|
102
|
+
vi.stubGlobal(
|
|
103
|
+
'URL',
|
|
104
|
+
Object.assign(class extends originalURL {}, { createObjectURL, revokeObjectURL })
|
|
105
|
+
)
|
|
106
|
+
vi.stubGlobal('navigator', { clipboard: { writeText: mocks.writeText } })
|
|
107
|
+
mocks.writeText.mockResolvedValue(undefined)
|
|
108
|
+
container = document.createElement('div')
|
|
109
|
+
document.body.append(container)
|
|
110
|
+
root = createRoot(container)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
afterEach(() => {
|
|
114
|
+
act(() => root.unmount())
|
|
115
|
+
container.remove()
|
|
116
|
+
vi.useRealTimers()
|
|
117
|
+
vi.restoreAllMocks()
|
|
118
|
+
vi.unstubAllGlobals()
|
|
119
|
+
vi.clearAllMocks()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('API document exports', () => {
|
|
123
|
+
it('copies the complete payload without expanding or mounting nested content', async () => {
|
|
124
|
+
render()
|
|
125
|
+
expect(container.textContent).not.toContain('Hidden nested content')
|
|
126
|
+
await click('Copy JSON')
|
|
127
|
+
expect(JSON.parse(mocks.writeText.mock.calls[0]?.[0])).toEqual(payload)
|
|
128
|
+
expect(container.textContent).not.toContain('Hidden nested content')
|
|
129
|
+
expect(mocks.toast).toHaveBeenCalledWith(expect.objectContaining({ title: 'JSON copied' }))
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('copies new data after a locale and population-depth reload', async () => {
|
|
133
|
+
render()
|
|
134
|
+
const reloaded = {
|
|
135
|
+
...payload,
|
|
136
|
+
fields: { title: 'Français', related: { title: 'Relation française' } },
|
|
137
|
+
}
|
|
138
|
+
render(reloaded, 'fr', 2)
|
|
139
|
+
await click('Copy JSON')
|
|
140
|
+
expect(JSON.parse(mocks.writeText.mock.calls[0]?.[0])).toEqual(reloaded)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('reports clipboard rejection without claiming success', async () => {
|
|
144
|
+
mocks.writeText.mockRejectedValue(new Error('Permission denied'))
|
|
145
|
+
render()
|
|
146
|
+
await click('Copy JSON')
|
|
147
|
+
expect(mocks.toast).toHaveBeenCalledTimes(1)
|
|
148
|
+
expect(mocks.toast).toHaveBeenCalledWith(
|
|
149
|
+
expect.objectContaining({
|
|
150
|
+
title: 'Could not copy JSON. Try Download JSON.',
|
|
151
|
+
})
|
|
152
|
+
)
|
|
153
|
+
expect(container.querySelector('button')?.disabled).toBe(false)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('offers download when the clipboard API is unavailable', async () => {
|
|
157
|
+
vi.stubGlobal('navigator', {})
|
|
158
|
+
render()
|
|
159
|
+
await click('Copy JSON')
|
|
160
|
+
expect(mocks.toast).toHaveBeenCalledWith(
|
|
161
|
+
expect.objectContaining({
|
|
162
|
+
title: 'Could not copy JSON. Try Download JSON.',
|
|
163
|
+
})
|
|
164
|
+
)
|
|
165
|
+
expect(mocks.writeText).not.toHaveBeenCalled()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('names a download after the default locale when the route has none', async () => {
|
|
169
|
+
vi.useFakeTimers()
|
|
170
|
+
let filename = ''
|
|
171
|
+
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
|
172
|
+
this: HTMLAnchorElement
|
|
173
|
+
) {
|
|
174
|
+
filename = this.download
|
|
175
|
+
})
|
|
176
|
+
render(payload, null)
|
|
177
|
+
await click('Download JSON')
|
|
178
|
+
expect(filename).toBe('docs-document-1-fr.json')
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it('reports a download failure and releases the allocated URL', async () => {
|
|
182
|
+
vi.useFakeTimers()
|
|
183
|
+
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {
|
|
184
|
+
throw new Error('Download unavailable')
|
|
185
|
+
})
|
|
186
|
+
render()
|
|
187
|
+
await click('Download JSON')
|
|
188
|
+
expect(mocks.toast).toHaveBeenCalledWith(
|
|
189
|
+
expect.objectContaining({
|
|
190
|
+
title: 'Could not download JSON.',
|
|
191
|
+
})
|
|
192
|
+
)
|
|
193
|
+
expect(document.querySelector('a[download]')).toBeNull()
|
|
194
|
+
vi.advanceTimersByTime(1000)
|
|
195
|
+
expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-json')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('downloads complete current JSON and releases the temporary link and URL', async () => {
|
|
199
|
+
vi.useFakeTimers()
|
|
200
|
+
let filename = ''
|
|
201
|
+
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(function (
|
|
202
|
+
this: HTMLAnchorElement
|
|
203
|
+
) {
|
|
204
|
+
filename = this.download
|
|
205
|
+
expect(this.isConnected).toBe(true)
|
|
206
|
+
expect(this.href).toBe('blob:test-json')
|
|
207
|
+
})
|
|
208
|
+
render()
|
|
209
|
+
const reloaded = { ...payload, fields: { title: 'Français', content: payload.fields.content } }
|
|
210
|
+
render(reloaded, 'fr', 2)
|
|
211
|
+
await click('Download JSON')
|
|
212
|
+
const blob = createObjectURL.mock.calls[0]?.[0] as Blob
|
|
213
|
+
expect(blob.type).toBe('application/json;charset=utf-8')
|
|
214
|
+
expect(filename).toBe('docs-document-1-fr-depth2.json')
|
|
215
|
+
expect(document.querySelector('a[download]')).toBeNull()
|
|
216
|
+
expect(revokeObjectURL).not.toHaveBeenCalled()
|
|
217
|
+
vi.advanceTimersByTime(1000)
|
|
218
|
+
expect(revokeObjectURL).toHaveBeenCalledWith('blob:test-json')
|
|
219
|
+
vi.useRealTimers()
|
|
220
|
+
expect(JSON.parse(await readBlob(blob))).toEqual(reloaded)
|
|
221
|
+
expect(container.textContent).not.toContain('Hidden nested content')
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -6,10 +6,20 @@
|
|
|
6
6
|
* Copyright (c) Infonomic Company Limited
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { useState } from 'react'
|
|
10
|
+
|
|
9
11
|
import type { MultiCollectionDefinition } from '@byline/core'
|
|
10
12
|
import type { AnyCollectionSchemaTypes } from '@byline/core/zod-schemas'
|
|
11
13
|
import { useTranslation } from '@byline/i18n/react'
|
|
12
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
Container,
|
|
16
|
+
CopyIcon,
|
|
17
|
+
DownloadIcon,
|
|
18
|
+
IconButton,
|
|
19
|
+
Section,
|
|
20
|
+
Tooltip,
|
|
21
|
+
useToastManager,
|
|
22
|
+
} from '@byline/ui/react'
|
|
13
23
|
import cx from 'clsx'
|
|
14
24
|
import { darkStyles, JsonView } from 'react-json-view-lite'
|
|
15
25
|
|
|
@@ -36,8 +46,12 @@ const expandToFirstLevel = (level: number): boolean => level < 2
|
|
|
36
46
|
/**
|
|
37
47
|
* JSON inspector view for a collection document.
|
|
38
48
|
*
|
|
49
|
+
* Copy JSON and Download JSON render in the view menu's data group, beside the
|
|
50
|
+
* locale and depth controls whose selection they export.
|
|
51
|
+
*
|
|
39
52
|
* Stable override handles: `.byline-api-section`, `.byline-api-container`,
|
|
40
|
-
* `.byline-api-head`, `.byline-api-title`, `.byline-api-
|
|
53
|
+
* `.byline-api-head`, `.byline-api-title`, `.byline-api-action`,
|
|
54
|
+
* `.byline-api-viewer`.
|
|
41
55
|
*/
|
|
42
56
|
export const ApiView = ({
|
|
43
57
|
collectionDefinition,
|
|
@@ -49,7 +63,7 @@ export const ApiView = ({
|
|
|
49
63
|
}: {
|
|
50
64
|
collectionDefinition: MultiCollectionDefinition
|
|
51
65
|
initialData: AnyCollectionSchemaTypes['UpdateType']
|
|
52
|
-
/**
|
|
66
|
+
/** Locale from the route search param (undefined → the default content locale). */
|
|
53
67
|
locale?: string
|
|
54
68
|
/** Populate depth from the route search param (undefined → 0, no populate). */
|
|
55
69
|
depth?: number
|
|
@@ -58,6 +72,58 @@ export const ApiView = ({
|
|
|
58
72
|
}) => {
|
|
59
73
|
const { labels, path } = collectionDefinition
|
|
60
74
|
const { t } = useTranslation('byline-admin')
|
|
75
|
+
const toastManager = useToastManager()
|
|
76
|
+
const [copyPending, setCopyPending] = useState(false)
|
|
77
|
+
|
|
78
|
+
// Serialize only on demand, from the complete loaded payload rather than
|
|
79
|
+
// the mounted tree. Collapsed branches remain part of both exports.
|
|
80
|
+
const copyJson = async () => {
|
|
81
|
+
setCopyPending(true)
|
|
82
|
+
try {
|
|
83
|
+
await navigator.clipboard.writeText(JSON.stringify(initialData, null, 2))
|
|
84
|
+
toastManager.add({
|
|
85
|
+
title: t('collections.api.copied'),
|
|
86
|
+
data: { intent: 'success', iconType: 'success', icon: true, close: true },
|
|
87
|
+
})
|
|
88
|
+
} catch {
|
|
89
|
+
toastManager.add({
|
|
90
|
+
title: t('collections.api.copyFailed'),
|
|
91
|
+
data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
|
|
92
|
+
})
|
|
93
|
+
} finally {
|
|
94
|
+
setCopyPending(false)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const downloadJson = () => {
|
|
99
|
+
try {
|
|
100
|
+
const blob = new Blob([JSON.stringify(initialData, null, 2)], {
|
|
101
|
+
type: 'application/json;charset=utf-8',
|
|
102
|
+
})
|
|
103
|
+
const url = URL.createObjectURL(blob)
|
|
104
|
+
const link = document.createElement('a')
|
|
105
|
+
try {
|
|
106
|
+
link.href = url
|
|
107
|
+
// Name the selection, so exports of one document in different locales
|
|
108
|
+
// or population depths do not collide.
|
|
109
|
+
const selection = [path, String(initialData.id), locale ?? defaultContentLocale]
|
|
110
|
+
if (depth != null && depth > 0) selection.push(`depth${depth}`)
|
|
111
|
+
const filename = selection.join('-').replace(/[^a-zA-Z0-9_-]/g, '-')
|
|
112
|
+
link.download = `${filename}.json`
|
|
113
|
+
document.body.appendChild(link)
|
|
114
|
+
link.click()
|
|
115
|
+
} finally {
|
|
116
|
+
link.remove()
|
|
117
|
+
// Let the browser acquire the download before releasing its URL.
|
|
118
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000)
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
toastManager.add({
|
|
122
|
+
title: t('collections.api.downloadFailed'),
|
|
123
|
+
data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
61
127
|
|
|
62
128
|
return (
|
|
63
129
|
<Section className={cx('byline-api-section', styles.section)}>
|
|
@@ -74,6 +140,35 @@ export const ApiView = ({
|
|
|
74
140
|
depth={depth}
|
|
75
141
|
contentLocales={contentLocales}
|
|
76
142
|
defaultContentLocale={defaultContentLocale}
|
|
143
|
+
dataActions={
|
|
144
|
+
<>
|
|
145
|
+
<Tooltip text={t('collections.api.copyJson')} side="bottom" delay={0}>
|
|
146
|
+
<IconButton
|
|
147
|
+
type="button"
|
|
148
|
+
size="xs"
|
|
149
|
+
variant="text"
|
|
150
|
+
className="byline-view-menu-icon-button byline-api-action"
|
|
151
|
+
aria-label={t('collections.api.copyJson')}
|
|
152
|
+
disabled={copyPending}
|
|
153
|
+
onClick={copyJson}
|
|
154
|
+
>
|
|
155
|
+
<CopyIcon width="18px" height="18px" />
|
|
156
|
+
</IconButton>
|
|
157
|
+
</Tooltip>
|
|
158
|
+
<Tooltip text={t('collections.api.downloadJson')} side="bottom" delay={0}>
|
|
159
|
+
<IconButton
|
|
160
|
+
type="button"
|
|
161
|
+
size="xs"
|
|
162
|
+
variant="text"
|
|
163
|
+
className="byline-view-menu-icon-button byline-api-action"
|
|
164
|
+
aria-label={t('collections.api.downloadJson')}
|
|
165
|
+
onClick={downloadJson}
|
|
166
|
+
>
|
|
167
|
+
<DownloadIcon width="20px" height="20px" />
|
|
168
|
+
</IconButton>
|
|
169
|
+
</Tooltip>
|
|
170
|
+
</>
|
|
171
|
+
}
|
|
77
172
|
/>
|
|
78
173
|
</div>
|
|
79
174
|
<div className={cx('byline-api-viewer', styles.viewer)}>
|
|
@@ -9,7 +9,11 @@
|
|
|
9
9
|
import { useState } from 'react'
|
|
10
10
|
|
|
11
11
|
import { FormRenderer } from '@byline/admin/react'
|
|
12
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
CollectionAdminConfig,
|
|
14
|
+
MultiCollectionDefinition,
|
|
15
|
+
UnavailableBlock,
|
|
16
|
+
} from '@byline/core'
|
|
13
17
|
import {
|
|
14
18
|
getDefaultStatus,
|
|
15
19
|
getWorkflow,
|
|
@@ -321,6 +325,9 @@ export const EditView = ({
|
|
|
321
325
|
// orphan row was skipped because the collection schema has moved on
|
|
322
326
|
// since the document was written.
|
|
323
327
|
// biome-ignore lint/suspicious/noExplicitAny: storage shape
|
|
328
|
+
const unavailableBlocks = (initialData as any)?._unavailableBlocks as
|
|
329
|
+
| UnavailableBlock[]
|
|
330
|
+
| undefined
|
|
324
331
|
const restoreWarnings = (initialData as any)?._restoreWarnings as string[] | undefined
|
|
325
332
|
|
|
326
333
|
const handleUnpublish = async () => {
|
|
@@ -854,6 +861,7 @@ export const EditView = ({
|
|
|
854
861
|
contentLocales={contentLocales}
|
|
855
862
|
publishedVersion={publishedVersion}
|
|
856
863
|
restoreWarnings={restoreWarnings}
|
|
864
|
+
unavailableBlocks={unavailableBlocks}
|
|
857
865
|
nextStatus={nextStatus}
|
|
858
866
|
workflowStatuses={workflowStatuses}
|
|
859
867
|
onCancel={() => {
|
|
@@ -47,7 +47,7 @@ import { useState } from 'react'
|
|
|
47
47
|
|
|
48
48
|
import type { CollectionAdminConfig, PreviewDocument } from '@byline/core'
|
|
49
49
|
import { useTranslation } from '@byline/i18n/react'
|
|
50
|
-
import { ExternalLinkIcon, IconButton, useToastManager } from '@byline/ui/react'
|
|
50
|
+
import { ExternalLinkIcon, IconButton, Tooltip, useToastManager } from '@byline/ui/react'
|
|
51
51
|
import cx from 'clsx'
|
|
52
52
|
|
|
53
53
|
import { enablePreviewModeFn } from '../../server-fns/preview/index.js'
|
|
@@ -110,16 +110,17 @@ export const PreviewLink = ({
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
return (
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
113
|
+
<Tooltip text={t('collections.preview.title')} side="bottom" delay={0}>
|
|
114
|
+
<IconButton
|
|
115
|
+
className={cx('byline-preview-link', className)}
|
|
116
|
+
size="xs"
|
|
117
|
+
variant="text"
|
|
118
|
+
disabled={busy}
|
|
119
|
+
onClick={handleClick}
|
|
120
|
+
aria-label={t('collections.preview.openAriaLabel')}
|
|
121
|
+
>
|
|
122
|
+
<ExternalLinkIcon width="20px" height="20px" className="byline-preview-link-icon" />
|
|
123
|
+
</IconButton>
|
|
124
|
+
</Tooltip>
|
|
124
125
|
)
|
|
125
126
|
}
|
|
@@ -2,21 +2,47 @@
|
|
|
2
2
|
* ViewMenu — mini-nav bar for document-level views (Edit, History, API).
|
|
3
3
|
*
|
|
4
4
|
* Override handles:
|
|
5
|
-
* .byline-view-menu — outer
|
|
5
|
+
* .byline-view-menu — outer row; wraps between its two groups
|
|
6
|
+
* .byline-view-menu-data — locale/depth selects and the data actions that
|
|
7
|
+
* act on them (API view: Copy / Download JSON)
|
|
8
|
+
* .byline-view-menu-nav — preview link and view navigation
|
|
6
9
|
* .byline-view-menu-label — locale/depth label (lg-only)
|
|
7
10
|
* .byline-view-menu-button — primary view buttons (Edit/API)
|
|
8
|
-
* .byline-view-menu-icon-button — history icon
|
|
11
|
+
* .byline-view-menu-icon-button — preview, history, and data-action icon buttons
|
|
9
12
|
*/
|
|
10
13
|
|
|
14
|
+
/* Groups wrap as units, so a narrow viewport breaks the row between "what
|
|
15
|
+
* you are looking at" and "where you can go" rather than mid-group. The
|
|
16
|
+
* wider column gap marks the boundary when both groups share a line. */
|
|
11
17
|
.root,
|
|
12
18
|
:global(.byline-view-menu) {
|
|
13
19
|
display: flex;
|
|
20
|
+
flex-wrap: wrap;
|
|
14
21
|
align-items: center;
|
|
15
|
-
gap: 0.5rem;
|
|
22
|
+
gap: 0.5rem 1.25rem;
|
|
16
23
|
margin-top: 0.5rem;
|
|
17
24
|
margin-bottom: 0.5rem;
|
|
18
25
|
}
|
|
19
26
|
|
|
27
|
+
/* Beside the heading (every host switches to a row at 40rem), keep a wrapped
|
|
28
|
+
* second line flush with the right edge rather than stranded under the
|
|
29
|
+
* heading's side of the menu. */
|
|
30
|
+
@media (min-width: 40rem) {
|
|
31
|
+
.root,
|
|
32
|
+
:global(.byline-view-menu) {
|
|
33
|
+
justify-content: flex-end;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.group,
|
|
38
|
+
:global(.byline-view-menu-data),
|
|
39
|
+
:global(.byline-view-menu-nav) {
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-wrap: wrap;
|
|
42
|
+
align-items: center;
|
|
43
|
+
gap: 0.5rem;
|
|
44
|
+
}
|
|
45
|
+
|
|
20
46
|
.label,
|
|
21
47
|
:global(.byline-view-menu-label) {
|
|
22
48
|
display: none;
|