From b6294699f20db03f85709c8ef29836e2ae04cafe Mon Sep 17 00:00:00 2001 From: eleith Date: Fri, 30 Oct 2026 05:37:17 -0700 Subject: [PATCH] [web] add inline editor unpublish action --- .changeset/editor-unpublish-action.md | 7 + .../lib/components/editor/PieceActions.svelte | 22 ++- .../components/editor/PieceActions.test.ts | 3 + .../lib/components/editor/PieceToolbar.svelte | 17 +- .../components/editor/PieceToolbar.test.ts | 13 +- .../src/lib/pieces/unpublish-client.test.ts | 178 ++++++++++++++++++ apps/web/src/lib/pieces/unpublish-client.ts | 125 ++++++++++++ .../admin/piece/[...path]/source/+page.svelte | 83 +++++++- 8 files changed, 441 insertions(+), 7 deletions(-) create mode 100644 .changeset/editor-unpublish-action.md create mode 100644 apps/web/src/lib/pieces/unpublish-client.test.ts create mode 100644 apps/web/src/lib/pieces/unpublish-client.ts diff --git a/.changeset/editor-unpublish-action.md b/.changeset/editor-unpublish-action.md new file mode 100644 index 00000000..87a9ce36 --- /dev/null +++ b/.changeset/editor-unpublish-action.md @@ -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. diff --git a/apps/web/src/lib/components/editor/PieceActions.svelte b/apps/web/src/lib/components/editor/PieceActions.svelte index b72c539c..1803b1b4 100644 --- a/apps/web/src/lib/components/editor/PieceActions.svelte +++ b/apps/web/src/lib/components/editor/PieceActions.svelte @@ -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() @@ -39,6 +51,14 @@ {/if} + {#if hasPublishedVersion && onUnpublish} + + {#snippet child({ props })} + + {/snippet} + + {/if} + {#snippet child({ props })} diff --git a/apps/web/src/lib/components/editor/PieceActions.test.ts b/apps/web/src/lib/components/editor/PieceActions.test.ts index 56482293..634db877 100644 --- a/apps/web/src/lib/components/editor/PieceActions.test.ts +++ b/apps/web/src/lib/components/editor/PieceActions.test.ts @@ -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() diff --git a/apps/web/src/lib/components/editor/PieceToolbar.svelte b/apps/web/src/lib/components/editor/PieceToolbar.svelte index 7f011c30..3b6e7525 100644 --- a/apps/web/src/lib/components/editor/PieceToolbar.svelte +++ b/apps/web/src/lib/components/editor/PieceToolbar.svelte @@ -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) @@ -40,10 +49,12 @@ Saved {/if} - {#if hasPublishedVersion} + {#if isUnpublishing} + Unpublishing… + {:else if hasPublishedVersion} Published {:else} - Not published + Unpublished {/if} diff --git a/apps/web/src/lib/components/editor/PieceToolbar.test.ts b/apps/web/src/lib/components/editor/PieceToolbar.test.ts index ea915133..b6e5a684 100644 --- a/apps/web/src/lib/components/editor/PieceToolbar.test.ts +++ b/apps/web/src/lib/components/editor/PieceToolbar.test.ts @@ -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') + expect(output).not.toContain('>Unpublished') + 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"') diff --git a/apps/web/src/lib/pieces/unpublish-client.test.ts b/apps/web/src/lib/pieces/unpublish-client.test.ts new file mode 100644 index 00000000..bb6e61a6 --- /dev/null +++ b/apps/web/src/lib/pieces/unpublish-client.test.ts @@ -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() +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 + const cancel = vi.fn() + const body = new ReadableStream({ + 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() +}) diff --git a/apps/web/src/lib/pieces/unpublish-client.ts b/apps/web/src/lib/pieces/unpublish-client.ts new file mode 100644 index 00000000..fadcd029 --- /dev/null +++ b/apps/web/src/lib/pieces/unpublish-client.ts @@ -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 { + 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 { + 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 { + 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)) + } +} diff --git a/apps/web/src/routes/(auth)/admin/piece/[...path]/source/+page.svelte b/apps/web/src/routes/(auth)/admin/piece/[...path]/source/+page.svelte index 253efc4f..97a3b41d 100644 --- a/apps/web/src/routes/(auth)/admin/piece/[...path]/source/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/piece/[...path]/source/+page.svelte @@ -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() + let isUnpublishing = $state(false) + let unpublishOutcome = $state(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 @@ {/if} + {#if unpublishOutcome?.state === 'failed' || unpublishRefreshFailed} + + {/if} +
{#key data.file} 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%); -- 2.54.0