[web] add inline editor unpublish action #171
No files matched your search
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@luzzle/web": patch
|
||||
---
|
||||
|
||||
Add inline Unpublish to the editor's More menu. Show progress in publication
|
||||
status and failures in the existing banner, without a modal. Preserve editor
|
||||
drafts when refreshing publication status.
|
||||
@@ -5,12 +5,24 @@
|
||||
type Props = {
|
||||
canGenerate: boolean
|
||||
disabled?: boolean
|
||||
hasPublishedVersion?: boolean
|
||||
unpublishDisabled?: boolean
|
||||
onUnpublish?: () => void
|
||||
onDelete: () => void
|
||||
onAttach?: () => void
|
||||
onGenerate?: () => void
|
||||
}
|
||||
|
||||
let { canGenerate, disabled = false, onDelete, onAttach, onGenerate }: Props = $props()
|
||||
let {
|
||||
canGenerate,
|
||||
disabled = false,
|
||||
hasPublishedVersion = false,
|
||||
unpublishDisabled = false,
|
||||
onUnpublish,
|
||||
onDelete,
|
||||
onAttach,
|
||||
onGenerate
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
@@ -39,6 +51,14 @@
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if hasPublishedVersion && onUnpublish}
|
||||
<DropdownMenu.Item onSelect={onUnpublish} disabled={unpublishDisabled}>
|
||||
{#snippet child({ props })}
|
||||
<div class="dropdown-item destructive" {...props}>Unpublish</div>
|
||||
{/snippet}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
<DropdownMenu.Item onSelect={onDelete}>
|
||||
{#snippet child({ props })}
|
||||
<div class="dropdown-item destructive" {...props}>Delete</div>
|
||||
|
||||
@@ -8,6 +8,9 @@ test.each([false, true])('More is labeled and disabled=%s while saving', (disabl
|
||||
props: {
|
||||
canGenerate: true,
|
||||
disabled,
|
||||
hasPublishedVersion: true,
|
||||
unpublishDisabled: disabled,
|
||||
onUnpublish: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onAttach: vi.fn(),
|
||||
onGenerate: vi.fn()
|
||||
|
||||
@@ -9,11 +9,20 @@
|
||||
saveFormId: string
|
||||
isDirty: boolean
|
||||
isSaving: boolean
|
||||
isUnpublishing?: boolean
|
||||
hasPublishedVersion: boolean
|
||||
children: Snippet
|
||||
}
|
||||
|
||||
let { file, saveFormId, isDirty, isSaving, hasPublishedVersion, children }: Props = $props()
|
||||
let {
|
||||
file,
|
||||
saveFormId,
|
||||
isDirty,
|
||||
isSaving,
|
||||
isUnpublishing = false,
|
||||
hasPublishedVersion,
|
||||
children
|
||||
}: Props = $props()
|
||||
const needsSave = $derived(isDirty || isSaving)
|
||||
</script>
|
||||
|
||||
@@ -40,10 +49,12 @@
|
||||
Saved
|
||||
{/if}
|
||||
</span>
|
||||
{#if hasPublishedVersion}
|
||||
{#if isUnpublishing}
|
||||
<span>Unpublishing…</span>
|
||||
{:else if hasPublishedVersion}
|
||||
<a href={publishedHref(file)}>Published</a>
|
||||
{:else}
|
||||
<span>Not published</span>
|
||||
<span>Unpublished</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,10 +84,21 @@ test('publication status is an encoded link only when a public version exists',
|
||||
const href = '/admin/piece/books/%E6%BA%90%20%23%3F%25.books.md/published'
|
||||
expect(html()).toContain(`href="${href}"`)
|
||||
const unpublished = html({ hasPublishedVersion: false })
|
||||
expect(unpublished).toContain('Not published')
|
||||
expect(unpublished).toContain('Unpublished')
|
||||
expect(unpublished).not.toContain(href)
|
||||
})
|
||||
|
||||
test.each([false, true])(
|
||||
'pending Unpublish replaces publication status and its link when published=%s',
|
||||
(hasPublishedVersion) => {
|
||||
const output = html({ hasPublishedVersion, isUnpublishing: true })
|
||||
expect(output).toContain('Unpublishing…')
|
||||
expect(output).not.toContain('>Published</a>')
|
||||
expect(output).not.toContain('>Unpublished</span>')
|
||||
expect(output).not.toContain('/published')
|
||||
}
|
||||
)
|
||||
|
||||
test('editor actions do not duplicate the surrounding archive navigation', () => {
|
||||
const output = html()
|
||||
expect(output).toContain('aria-label="Editor actions"')
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { afterEach, beforeEach, expect, test, vi } from 'vitest'
|
||||
import { encodeEvent, SSE_HEADERS } from '$lib/server/sse'
|
||||
import { submitUnpublish } from './unpublish-client'
|
||||
|
||||
const fetchMock = vi.fn<typeof fetch>()
|
||||
const encoder = new TextEncoder()
|
||||
const filePath = 'books/源 #?%.books.md'
|
||||
const completed = () =>
|
||||
encodeEvent('done', {
|
||||
workflowName: 'Unpublish',
|
||||
state: 'completed',
|
||||
result: 'ok',
|
||||
errors: null
|
||||
})
|
||||
|
||||
function accepted() {
|
||||
return new Response(JSON.stringify({ jobId: 'job-1' }), {
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
function stream(text = '') {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array>
|
||||
const cancel = vi.fn()
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(value) {
|
||||
controller = value
|
||||
},
|
||||
cancel
|
||||
})
|
||||
const send = (value: string) => controller.enqueue(encoder.encode(value))
|
||||
if (text) send(text)
|
||||
return {
|
||||
response: new Response(body, { headers: SSE_HEADERS }),
|
||||
send,
|
||||
cancel,
|
||||
close: () => controller.close()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => vi.stubGlobal('fetch', fetchMock))
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
test('posts the exact file and waits for its own completion, not just admission', async () => {
|
||||
const source = stream(encodeEvent('state', { workflowName: 'Unpublish', state: 'waiting' }))
|
||||
fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response)
|
||||
const observer = new AbortController()
|
||||
let settled = false
|
||||
const pending = submitUnpublish(filePath, observer.signal).then((result) => {
|
||||
settled = true
|
||||
return result
|
||||
})
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
|
||||
expect(settled).toBe(false)
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/admin/unpublish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filePath }),
|
||||
signal: observer.signal
|
||||
})
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/admin/publish/job-1/stream', {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
signal: observer.signal
|
||||
})
|
||||
source.send(completed())
|
||||
await expect(pending).resolves.toEqual({ state: 'completed', jobId: 'job-1' })
|
||||
})
|
||||
|
||||
test('does not adopt a conflicting job or submit again', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(JSON.stringify({ jobId: 'foreign-job' }), { status: 409 })
|
||||
)
|
||||
const result = await submitUnpublish(filePath, new AbortController().signal)
|
||||
expect(result).toMatchObject({ state: 'failed', reason: 'busy' })
|
||||
expect(result?.jobId).toBeUndefined()
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('preserves serialized cleanup diagnostics without retrying the job', async () => {
|
||||
const detail = 'Unpublish cleanup incomplete:\nremote cover.png: deletion refused'
|
||||
const source = stream(
|
||||
encodeEvent('done', {
|
||||
workflowName: 'Unpublish',
|
||||
state: 'failed',
|
||||
result: null,
|
||||
errors: [JSON.stringify({ message: detail })]
|
||||
})
|
||||
)
|
||||
fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response)
|
||||
await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({
|
||||
state: 'failed',
|
||||
reason: 'job',
|
||||
jobId: 'job-1',
|
||||
detail
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('rejects a different workflow rather than claiming Unpublish succeeded', async () => {
|
||||
const source = stream(
|
||||
encodeEvent('done', {
|
||||
workflowName: 'Publish',
|
||||
state: 'completed',
|
||||
result: 'ok'
|
||||
})
|
||||
)
|
||||
fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response)
|
||||
await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({
|
||||
state: 'failed',
|
||||
reason: 'response',
|
||||
jobId: 'job-1'
|
||||
})
|
||||
})
|
||||
|
||||
test('reports an interrupted stream as unknown completion and does not reconnect', async () => {
|
||||
const source = stream(encodeEvent('state', { workflowName: 'Unpublish', state: 'running' }))
|
||||
source.close()
|
||||
fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response)
|
||||
await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({
|
||||
state: 'failed',
|
||||
reason: 'connection',
|
||||
jobId: 'job-1'
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('reports authentication failure without observing a job', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('Unauthorized', { status: 401 }))
|
||||
const result = await submitUnpublish(filePath, new AbortController().signal)
|
||||
expect(result).toMatchObject({ state: 'failed', reason: 'sign-in' })
|
||||
expect(result?.jobId).toBeUndefined()
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('distinguishes authentication loss after admission from a worker failure', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(accepted())
|
||||
.mockResolvedValueOnce(new Response('Unauthorized', { status: 401 }))
|
||||
await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({
|
||||
state: 'failed',
|
||||
reason: 'sign-in',
|
||||
jobId: 'job-1'
|
||||
})
|
||||
})
|
||||
|
||||
test('reports request diagnostics without treating them as success', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('queue unavailable', { status: 500 }))
|
||||
await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({
|
||||
state: 'failed',
|
||||
reason: 'request',
|
||||
detail: 'queue unavailable'
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('requires a job ID before opening a status stream', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({ result: 'ok' })))
|
||||
await expect(submitUnpublish(filePath, new AbortController().signal)).resolves.toMatchObject({
|
||||
state: 'failed',
|
||||
reason: 'response'
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
test('detaching stops observation, not the admitted server job', async () => {
|
||||
const source = stream()
|
||||
fetchMock.mockResolvedValueOnce(accepted()).mockResolvedValueOnce(source.response)
|
||||
const observer = new AbortController()
|
||||
const pending = submitUnpublish(filePath, observer.signal)
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2))
|
||||
observer.abort()
|
||||
await expect(pending).resolves.toBeUndefined()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(source.cancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { readJsonEvents } from '$lib/sse'
|
||||
|
||||
export type UnpublishFailureReason =
|
||||
| 'busy'
|
||||
| 'sign-in'
|
||||
| 'request'
|
||||
| 'connection'
|
||||
| 'response'
|
||||
| 'job'
|
||||
export type UnpublishOutcome =
|
||||
| { state: 'completed'; jobId: string }
|
||||
| { state: 'failed'; reason: UnpublishFailureReason; detail: string; jobId?: string }
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function messageDetail(value: unknown): string {
|
||||
if (value instanceof Error) return value.message
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value)
|
||||
if (isObject(parsed) && typeof parsed.message === 'string') return parsed.message
|
||||
} catch {
|
||||
// Server diagnostics can be plain text or a serialized workflow error.
|
||||
}
|
||||
return value
|
||||
}
|
||||
if (isObject(value) && typeof value.message === 'string') return value.message
|
||||
return ''
|
||||
}
|
||||
|
||||
function failure(reason: UnpublishFailureReason, detail = '', jobId?: string): UnpublishOutcome {
|
||||
return { state: 'failed', reason, detail, jobId }
|
||||
}
|
||||
|
||||
function requiresSignIn(response: Response): boolean {
|
||||
return response.redirected || response.status === 401 || response.status === 403
|
||||
}
|
||||
|
||||
async function observeUnpublishJob(
|
||||
jobId: string,
|
||||
signal: AbortSignal
|
||||
): Promise<UnpublishOutcome | undefined> {
|
||||
try {
|
||||
const stream = await fetch(`/api/admin/publish/${encodeURIComponent(jobId)}/stream`, {
|
||||
headers: { Accept: 'text/event-stream' },
|
||||
signal
|
||||
})
|
||||
if (signal.aborted) {
|
||||
await stream.body?.cancel()
|
||||
return
|
||||
}
|
||||
if (requiresSignIn(stream)) {
|
||||
await stream.body?.cancel()
|
||||
return failure('sign-in', '', jobId)
|
||||
}
|
||||
if (!stream.ok) return failure('connection', messageDetail(await stream.text()), jobId)
|
||||
if (!stream.headers.get('content-type')?.startsWith('text/event-stream')) {
|
||||
await stream.body?.cancel()
|
||||
return failure('response', '', jobId)
|
||||
}
|
||||
for await (const event of readJsonEvents(stream, signal)) {
|
||||
if (signal.aborted) return
|
||||
if (event.type === 'error') return failure('connection', messageDetail(event.data), jobId)
|
||||
if (event.type !== 'state' && event.type !== 'done') continue
|
||||
if (!isObject(event.data) || event.data.workflowName !== 'Unpublish') {
|
||||
return failure('response', '', jobId)
|
||||
}
|
||||
if (event.type !== 'done') continue
|
||||
if (event.data.state === 'completed' && event.data.result === 'ok') {
|
||||
return { state: 'completed', jobId }
|
||||
}
|
||||
const details: string[] = []
|
||||
if (Array.isArray(event.data.errors)) {
|
||||
for (const error of event.data.errors) {
|
||||
const detail = messageDetail(error)
|
||||
if (detail) details.push(detail)
|
||||
}
|
||||
}
|
||||
return failure('job', details.join('\n'), jobId)
|
||||
}
|
||||
if (signal.aborted) return
|
||||
return failure('connection', '', jobId)
|
||||
} catch (error) {
|
||||
if (!signal.aborted) return failure('connection', messageDetail(error), jobId)
|
||||
}
|
||||
}
|
||||
|
||||
export async function submitUnpublish(
|
||||
filePath: string,
|
||||
signal: AbortSignal
|
||||
): Promise<UnpublishOutcome | undefined> {
|
||||
if (signal.aborted) return
|
||||
try {
|
||||
const response = await fetch('/api/admin/unpublish', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filePath }),
|
||||
signal
|
||||
})
|
||||
if (signal.aborted) {
|
||||
await response.body?.cancel()
|
||||
return
|
||||
}
|
||||
if (requiresSignIn(response)) {
|
||||
await response.body?.cancel()
|
||||
return failure('sign-in')
|
||||
}
|
||||
if (response.status === 409) {
|
||||
await response.body?.cancel()
|
||||
return failure('busy')
|
||||
}
|
||||
if (!response.ok) return failure('request', messageDetail(await response.text()))
|
||||
const accepted = await response.json()
|
||||
if (!isObject(accepted)) return failure('response')
|
||||
if (typeof accepted.jobId !== 'string' || accepted.jobId.length === 0) {
|
||||
return failure('response')
|
||||
}
|
||||
if (signal.aborted) return
|
||||
return observeUnpublishJob(accepted.jobId, signal)
|
||||
} catch (error) {
|
||||
if (!signal.aborted) return failure('request', messageDetail(error))
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,15 @@
|
||||
import Button from '$lib/components/ui/Button.svelte'
|
||||
import PieceActions from '$lib/components/editor/PieceActions.svelte'
|
||||
import PieceToolbar from '$lib/components/editor/PieceToolbar.svelte'
|
||||
import { submitUnpublish, type UnpublishOutcome } from '$lib/pieces/unpublish-client'
|
||||
import { editorHref } from '$lib/pieces/navigation'
|
||||
import GenerationDialog from '$lib/components/editor/GenerationDialog.svelte'
|
||||
import type { PageProps } from './$types'
|
||||
import { Dialog } from 'bits-ui'
|
||||
import { fade, fly } from 'svelte/transition'
|
||||
|
||||
import { page } from '$app/state'
|
||||
import { beforeNavigate, goto, replaceState } from '$app/navigation'
|
||||
import { navigating, page } from '$app/state'
|
||||
import { beforeNavigate, goto, invalidateAll, replaceState } from '$app/navigation'
|
||||
import { onMount, onDestroy, tick, untrack } from 'svelte'
|
||||
|
||||
let { data, form }: PageProps = $props()
|
||||
@@ -30,8 +31,10 @@
|
||||
let editorErrorReason = $state<'sign-in' | 'save' | 'refresh' | null>(null)
|
||||
let editorErrorDetail = $state('')
|
||||
let disposed = false
|
||||
let activeUnpublish: AbortController | undefined
|
||||
onDestroy(() => {
|
||||
disposed = true
|
||||
activeUnpublish?.abort()
|
||||
})
|
||||
|
||||
beforeNavigate((navigation) => {
|
||||
@@ -62,6 +65,9 @@
|
||||
|
||||
let dialog: HTMLDialogElement
|
||||
let editorRef = $state<MarkdownEditor>()
|
||||
let isUnpublishing = $state(false)
|
||||
let unpublishOutcome = $state<UnpublishOutcome | null>(null)
|
||||
let unpublishRefreshFailed = $state(false)
|
||||
|
||||
function initialRawContent(): string {
|
||||
if (form && 'rawContent' in form) {
|
||||
@@ -122,9 +128,41 @@
|
||||
|
||||
const hasChanges = $derived(rawContent !== (data.rawContent ?? ''))
|
||||
const isDirty = $derived(!!form?.error || hasChanges)
|
||||
const unpublishDisabled = $derived(
|
||||
isDirty || isSaving || isUnpublishing || !data.hasPublishedVersion
|
||||
)
|
||||
|
||||
const sourceUrl = $derived(editorHref(data.file))
|
||||
|
||||
async function handleUnpublish() {
|
||||
if (unpublishDisabled) return
|
||||
const file = data.file
|
||||
const observer = new AbortController()
|
||||
activeUnpublish = observer
|
||||
isUnpublishing = true
|
||||
unpublishOutcome = null
|
||||
unpublishRefreshFailed = false
|
||||
const isCurrent = () => !disposed && activeUnpublish === observer && data.file === file
|
||||
try {
|
||||
const result = await submitUnpublish(file, observer.signal)
|
||||
if (!isCurrent() || !result) return
|
||||
unpublishOutcome = result
|
||||
if (!result.jobId) return
|
||||
if (result.state === 'failed' && result.reason === 'sign-in') return
|
||||
if (navigating.to) return
|
||||
try {
|
||||
await invalidateAll()
|
||||
} catch {
|
||||
if (isCurrent()) unpublishRefreshFailed = true
|
||||
}
|
||||
} finally {
|
||||
if (isCurrent()) {
|
||||
activeUnpublish = undefined
|
||||
isUnpublishing = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const refreshed = { file: data.file, content: data.rawContent ?? '' }
|
||||
untrack(() => {
|
||||
@@ -134,6 +172,11 @@
|
||||
if (!changedFile) return
|
||||
activeSave = undefined
|
||||
isSaving = false
|
||||
activeUnpublish?.abort()
|
||||
activeUnpublish = undefined
|
||||
isUnpublishing = false
|
||||
unpublishOutcome = null
|
||||
unpublishRefreshFailed = false
|
||||
bypassWarning = false
|
||||
})
|
||||
})
|
||||
@@ -536,6 +579,31 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if unpublishOutcome?.state === 'failed' || unpublishRefreshFailed}
|
||||
<div class="banner error-banner" role="alert">
|
||||
{#if unpublishOutcome?.state === 'failed'}
|
||||
{#if unpublishOutcome.reason === 'busy'}
|
||||
Another publication operation is running.
|
||||
{:else if unpublishOutcome.reason === 'sign-in'}
|
||||
Please sign in again in another tab. Your edits are still here.
|
||||
{:else if unpublishOutcome.reason === 'job'}
|
||||
{#if data.hasPublishedVersion}Unpublish did not complete.
|
||||
{:else}Unpublished, but cleanup did not complete.{/if}
|
||||
{:else if unpublishOutcome.reason === 'connection'}
|
||||
Unable to confirm completion. The job may still be running.
|
||||
{:else if unpublishOutcome.reason === 'response'}
|
||||
Could not read the Unpublish response. The job may have been accepted.
|
||||
{:else}
|
||||
Unable to confirm the Unpublish request.
|
||||
{/if}
|
||||
{#if unpublishOutcome.detail}<pre class="error-details">{unpublishOutcome.detail}</pre>{/if}
|
||||
{/if}
|
||||
{#if unpublishRefreshFailed}
|
||||
<p>Unable to refresh publication status. Your edits are still here.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="editor-container">
|
||||
{#key data.file}
|
||||
<MarkdownEditor
|
||||
@@ -551,11 +619,15 @@
|
||||
{saveFormId}
|
||||
{isDirty}
|
||||
{isSaving}
|
||||
{isUnpublishing}
|
||||
hasPublishedVersion={data.hasPublishedVersion}
|
||||
>
|
||||
<PieceActions
|
||||
canGenerate={data.canGenerate}
|
||||
disabled={isSaving}
|
||||
hasPublishedVersion={data.hasPublishedVersion}
|
||||
{unpublishDisabled}
|
||||
onUnpublish={handleUnpublish}
|
||||
onDelete={() => dialog.showModal()}
|
||||
onGenerate={() => {
|
||||
editorErrorReason = null
|
||||
@@ -596,6 +668,13 @@
|
||||
border: 1px solid var(--color-error);
|
||||
}
|
||||
|
||||
.error-details {
|
||||
margin: var(--space-2) 0 0;
|
||||
font: inherit;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
dialog {
|
||||
position: fixed;
|
||||
transform: translate(-50%, -50%);
|
||||
|
||||
Reference in new issue
Block a user