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",
"clear": "Bild entfernen",
"upload": "Hochladen",
"close": "Schließen"
"close": "Schließen",
"delete": "Entwurf löschen",
"deleteConfirm": "Wirklich löschen?",
"deleting": "Wird gelöscht"
},
"blocks": {
"text": "Text",
+4 -1
View File
@@ -402,7 +402,10 @@
"change": "Other image",
"clear": "Remove image",
"upload": "Upload",
"close": "Close"
"close": "Close",
"delete": "Delete draft",
"deleteConfirm": "Really delete?",
"deleting": "Deleting"
},
"blocks": {
"text": "Text",
+41
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import {
TbArchive,
@@ -14,6 +15,7 @@ import {
TbPlus,
TbRotate2,
TbSend,
TbTrash,
TbX,
} from 'react-icons/tb'
import { AdminField } from './AdminField'
@@ -43,6 +45,7 @@ import {
archiveEntry,
loadEntryMedia,
publishEntry,
removeEntry,
resumeEntry,
saveEntry,
scheduleEntry,
@@ -163,6 +166,8 @@ function localTime(date: Date): string {
export function EntryEditor({ projects, types, media: initialMedia, accept, defaultProjectId, entry }: EntryEditorProps) {
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 [media, setMedia] = useState<EntryMedia[]>(initialMedia)
const [status, setStatus] = useState<EntryState | null>(
@@ -401,6 +406,29 @@ export function EntryEditor({ projects, types, media: initialMedia, accept, defa
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>) {
setForm(current => ({ ...current, ...patch }))
}
@@ -497,6 +525,19 @@ export function EntryEditor({ projects, types, media: initialMedia, accept, defa
{t('actions.save')}
</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' ? (
<button type="button" disabled={busy} onClick={() => void run(id => archiveEntry({ id }))} className={ghostButtonClass}>
<TbArchive aria-hidden="true" className="size-4" />
+27 -1
View File
@@ -1,6 +1,7 @@
import { recordAudit } from '~/data/repositories/audit'
import {
createEntry,
deleteEntry,
findEntry,
findEntryBySlug,
listEntryBlocks,
@@ -19,7 +20,7 @@ import { isSlug, slugify } from '~/domain/slug'
import type { Audience, PostStatus } from '~/domain/types'
import { canAccessProject, type Viewer } from './auth-access'
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'
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> {
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 {
performArchiveEntry,
performDeleteEntry,
performPublishEntry,
performResumeEntry,
performSaveEntry,
@@ -18,6 +19,7 @@ import {
import { toEntryMediaList, toEntryMedia } from './entry-media'
import { readUploadBody, uploadMedia, type UploadFailure } from './media-upload'
import type {
EntryDeleteResult,
EntryInput,
EntryMediaResult,
EntryResult,
@@ -198,3 +200,19 @@ export async function uploadEntryImage(formData: FormData): Promise<EntryUploadR
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: false, error: EntryErrorKey, blockers?: string[], hints?: string[] }
export type EntryDeleteResult =
| { ok: true }
| { ok: false, error: EntryErrorKey }
export type EntryUploadError =
| EntryErrorKey
| 'fileMissing'