[web] add inline editor unpublish action #171

Merged
eleith merged 1 commits from feat/unpublish-editor into main 2026-10-30 12:45:45 +00:00
8 changed files with 441 additions and 7 deletions

No files matched your search

+7
View File
@@ -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()
})
+125
View File
@@ -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%);