Delete drafts from the admin editor

The editor could publish, archive and resume, but never remove. A draft
whose id was lost was unreachable from the interface.
This commit is contained in:
Matthias G
2026-08-01 12:03:53 +02:00
parent 7a594eeacf
commit afaa323848
6 changed files with 98 additions and 3 deletions
+4 -1
View File
@@ -402,7 +402,10 @@
"change": "Anderes Bild", "change": "Anderes Bild",
"clear": "Bild entfernen", "clear": "Bild entfernen",
"upload": "Hochladen", "upload": "Hochladen",
"close": "Schließen" "close": "Schließen",
"delete": "Entwurf löschen",
"deleteConfirm": "Wirklich löschen?",
"deleting": "Wird gelöscht"
}, },
"blocks": { "blocks": {
"text": "Text", "text": "Text",
+4 -1
View File
@@ -402,7 +402,10 @@
"change": "Other image", "change": "Other image",
"clear": "Remove image", "clear": "Remove image",
"upload": "Upload", "upload": "Upload",
"close": "Close" "close": "Close",
"delete": "Delete draft",
"deleteConfirm": "Really delete?",
"deleting": "Deleting"
}, },
"blocks": { "blocks": {
"text": "Text", "text": "Text",
+41
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { import {
TbArchive, TbArchive,
@@ -14,6 +15,7 @@ import {
TbPlus, TbPlus,
TbRotate2, TbRotate2,
TbSend, TbSend,
TbTrash,
TbX, TbX,
} from 'react-icons/tb' } from 'react-icons/tb'
import { AdminField } from './AdminField' import { AdminField } from './AdminField'
@@ -43,6 +45,7 @@ import {
archiveEntry, archiveEntry,
loadEntryMedia, loadEntryMedia,
publishEntry, publishEntry,
removeEntry,
resumeEntry, resumeEntry,
saveEntry, saveEntry,
scheduleEntry, scheduleEntry,
@@ -163,6 +166,8 @@ function localTime(date: Date): string {
export function EntryEditor({ projects, types, media: initialMedia, accept, defaultProjectId, entry }: EntryEditorProps) { export function EntryEditor({ projects, types, media: initialMedia, accept, defaultProjectId, entry }: EntryEditorProps) {
const t = useTranslations('admin.entries') const t = useTranslations('admin.entries')
const router = useRouter()
const [asking, setAsking] = useState(false)
const [form, setForm] = useState<Form>(() => initialForm(entry, defaultProjectId, types[0]?.id ?? '')) const [form, setForm] = useState<Form>(() => initialForm(entry, defaultProjectId, types[0]?.id ?? ''))
const [media, setMedia] = useState<EntryMedia[]>(initialMedia) const [media, setMedia] = useState<EntryMedia[]>(initialMedia)
const [status, setStatus] = useState<EntryState | null>( const [status, setStatus] = useState<EntryState | null>(
@@ -401,6 +406,29 @@ export function EntryEditor({ projects, types, media: initialMedia, accept, defa
setSavedAt(new Date()) setSavedAt(new Date())
} }
async function remove() {
const id = idRef.current
if (id === '') {
return
}
setBusy(true)
setError(null)
const result = await removeEntry(id)
if (!result.ok) {
setBusy(false)
setAsking(false)
setError(result.error)
return
}
router.push(adminEntriesPath)
}
function update(patch: Partial<Form>) { function update(patch: Partial<Form>) {
setForm(current => ({ ...current, ...patch })) setForm(current => ({ ...current, ...patch }))
} }
@@ -497,6 +525,19 @@ export function EntryEditor({ projects, types, media: initialMedia, accept, defa
{t('actions.save')} {t('actions.save')}
</button> </button>
{status && current === 'draft' ? (
<button
type="button"
disabled={busy}
onClick={() => (asking ? void remove() : setAsking(true))}
onBlur={() => setAsking(false)}
className={`${ghostButtonClass} ${asking ? 'border-signal text-signal' : ''}`}
>
<TbTrash aria-hidden="true" className="size-4" />
{busy ? t('actions.deleting') : asking ? t('actions.deleteConfirm') : t('actions.delete')}
</button>
) : null}
{current === 'published' ? ( {current === 'published' ? (
<button type="button" disabled={busy} onClick={() => void run(id => archiveEntry({ id }))} className={ghostButtonClass}> <button type="button" disabled={busy} onClick={() => void run(id => archiveEntry({ id }))} className={ghostButtonClass}>
<TbArchive aria-hidden="true" className="size-4" /> <TbArchive aria-hidden="true" className="size-4" />
+27 -1
View File
@@ -1,6 +1,7 @@
import { recordAudit } from '~/data/repositories/audit' import { recordAudit } from '~/data/repositories/audit'
import { import {
createEntry, createEntry,
deleteEntry,
findEntry, findEntry,
findEntryBySlug, findEntryBySlug,
listEntryBlocks, listEntryBlocks,
@@ -19,7 +20,7 @@ import { isSlug, slugify } from '~/domain/slug'
import type { Audience, PostStatus } from '~/domain/types' import type { Audience, PostStatus } from '~/domain/types'
import { canAccessProject, type Viewer } from './auth-access' import { canAccessProject, type Viewer } from './auth-access'
import { hasAlt } from './media-url' import { hasAlt } from './media-url'
import type { EntryInput, EntryResult, EntryState, EntryStatusInput } from './entry-types' import type { EntryDeleteResult, EntryInput, EntryResult, EntryState, EntryStatusInput } from './entry-types'
import type { Block, Post } from '~/data/schema' import type { Block, Post } from '~/data/schema'
export const maxTitleLength = 200 export const maxTitleLength = 200
@@ -356,3 +357,28 @@ export async function performArchiveEntry(viewer: Viewer, input: EntryStatusInpu
export async function performResumeEntry(viewer: Viewer, input: EntryStatusInput): Promise<EntryResult> { export async function performResumeEntry(viewer: Viewer, input: EntryStatusInput): Promise<EntryResult> {
return move(viewer, input, 'draft', { checked: false, publishAt: null, action: 'post.resume' }) return move(viewer, input, 'draft', { checked: false, publishAt: null, action: 'post.resume' })
} }
export async function performDeleteEntry(viewer: Viewer, id: string): Promise<EntryDeleteResult> {
const current = await loadOwned(viewer, id)
if (!current) {
return { ok: false, error: 'notFound' }
}
if (current.status !== 'draft') {
return { ok: false, error: 'statusInvalid' }
}
await deleteEntry(current.id)
await recordAudit({
actorId: viewer.id,
actorLabel: viewer.email,
action: 'post.delete',
entity: 'post',
entityId: current.id,
data: { projectId: current.projectId, number: current.number, slug: current.slug, title: current.title },
})
return { ok: true }
}
+18
View File
@@ -9,6 +9,7 @@ import { readViewer } from './auth-guards'
import { adminEntriesPath, adminEntryPath, lastProjectCookie } from './admin-routes' import { adminEntriesPath, adminEntryPath, lastProjectCookie } from './admin-routes'
import { import {
performArchiveEntry, performArchiveEntry,
performDeleteEntry,
performPublishEntry, performPublishEntry,
performResumeEntry, performResumeEntry,
performSaveEntry, performSaveEntry,
@@ -18,6 +19,7 @@ import {
import { toEntryMediaList, toEntryMedia } from './entry-media' import { toEntryMediaList, toEntryMedia } from './entry-media'
import { readUploadBody, uploadMedia, type UploadFailure } from './media-upload' import { readUploadBody, uploadMedia, type UploadFailure } from './media-upload'
import type { import type {
EntryDeleteResult,
EntryInput, EntryInput,
EntryMediaResult, EntryMediaResult,
EntryResult, EntryResult,
@@ -198,3 +200,19 @@ export async function uploadEntryImage(formData: FormData): Promise<EntryUploadR
return { ok: true, media: toEntryMedia(result.media) } return { ok: true, media: toEntryMedia(result.media) }
} }
export async function removeEntry(id: string): Promise<EntryDeleteResult> {
const viewer = await readViewer()
if (!viewer) {
return { ok: false, error: 'forbidden' }
}
const result = await performDeleteEntry(viewer, id)
if (result.ok) {
revalidatePath(adminEntriesPath)
}
return result
}
+4
View File
@@ -75,6 +75,10 @@ export type EntryResult =
| { ok: true, entry: EntryState } | { ok: true, entry: EntryState }
| { ok: false, error: EntryErrorKey, blockers?: string[], hints?: string[] } | { ok: false, error: EntryErrorKey, blockers?: string[], hints?: string[] }
export type EntryDeleteResult =
| { ok: true }
| { ok: false, error: EntryErrorKey }
export type EntryUploadError = export type EntryUploadError =
| EntryErrorKey | EntryErrorKey
| 'fileMissing' | 'fileMissing'