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