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:
+4
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user