Number entries on publish, delete media, read the cover
- drafts stay unnumbered, the number is handed out when an entry goes live - DELETE /api/v1/media/:id and a delete control in the media library - cover_media_id in the draft read, coverMediaId and coverUrl in the archive read
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import { findMedia } from '~/data/repositories/media'
|
||||
import { resolveClient } from '~/lib/api-auth'
|
||||
import { removeMedia } from '~/lib/media-delete'
|
||||
import { problem } from '~/lib/problem'
|
||||
|
||||
export async function DELETE(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||
const client = await resolveClient(request)
|
||||
|
||||
if (!client) {
|
||||
return problem(401, 'unauthorized')
|
||||
}
|
||||
|
||||
if (client.mode !== 'write') {
|
||||
return problem(403, 'forbidden', 'Dieses Token darf nur lesen.')
|
||||
}
|
||||
|
||||
const { id } = await context.params
|
||||
const item = await findMedia(id)
|
||||
|
||||
if (!item || (client.projectId !== null && item.projectId !== client.projectId)) {
|
||||
return problem(404, 'media_not_found')
|
||||
}
|
||||
|
||||
const result = await removeMedia(id, item.projectId)
|
||||
|
||||
if (!result.ok) {
|
||||
return result.reason === 'in_use'
|
||||
? problem(409, 'media_in_use', 'Das Bild hängt noch an einem Beitrag.')
|
||||
: problem(404, 'media_not_found')
|
||||
}
|
||||
|
||||
return Response.json({ deleted: id })
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
import { findPost, findPostIdBySlug } from '~/data/repositories/posts'
|
||||
import { resolveClient } from '~/lib/api-auth'
|
||||
import { withCover } from '~/lib/api-covers'
|
||||
import { apiDeleteEntry, apiReadEntry, apiUpdateEntry } from '~/lib/api-entries'
|
||||
import { entryProblem } from '~/lib/api-problem'
|
||||
import { problem } from '~/lib/problem'
|
||||
@@ -34,7 +35,7 @@ export async function GET(request: Request, context: { params: Promise<{ slug: s
|
||||
const post = await findPost(slug, client.scope, client.projectId ?? undefined)
|
||||
|
||||
if (post) {
|
||||
return Response.json(post)
|
||||
return Response.json(await withCover(post))
|
||||
}
|
||||
|
||||
const id = await findPostIdBySlug(slug)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { isPostTypeKey } from '~/domain/post-type'
|
||||
import { apiCreateEntry } from '~/lib/api-entries'
|
||||
import { entryProblem } from '~/lib/api-problem'
|
||||
import { resolveClient } from '~/lib/api-auth'
|
||||
import { withCovers } from '~/lib/api-covers'
|
||||
import { problem } from '~/lib/problem'
|
||||
|
||||
const querySchema = z.object({
|
||||
@@ -42,7 +43,7 @@ export async function GET(request: Request) {
|
||||
})
|
||||
|
||||
return Response.json({
|
||||
items: result.items,
|
||||
items: await withCovers(result.items),
|
||||
meta: { total: result.total, page: query.page, per_page: query.per_page },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
textareaClass,
|
||||
} from './styles'
|
||||
import { Select } from './Select'
|
||||
import { entryMark } from '~/components/ui/Plate'
|
||||
import { blockTypes, emptyBlockData, isBlockEmpty, type BlockType, type BlockValues } from '~/domain/blocks'
|
||||
import { checkPublish } from '~/domain/publish-checks'
|
||||
import { defaultPostTypeColor } from '~/domain/post-type'
|
||||
@@ -64,7 +65,7 @@ export type EntryEditorProject = {
|
||||
|
||||
export type EntryEditorEntry = {
|
||||
id: string
|
||||
number: number
|
||||
number: number | null
|
||||
slug: string
|
||||
status: PostStatus
|
||||
publishAt: string | null
|
||||
@@ -498,7 +499,7 @@ export function EntryEditor({ projects, types, media: initialMedia, accept, defa
|
||||
|
||||
{status ? (
|
||||
<span className={metaClass}>
|
||||
{project ? `${project.code}-${String(status.number).padStart(4, '0')}` : status.number}
|
||||
{entryMark({ code: project?.code ?? null, slug: project?.slug ?? '' }, status.number)}
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Image from 'next/image'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { TbAlertTriangle } from 'react-icons/tb'
|
||||
import { MediaDeleteButton } from './MediaDeleteButton'
|
||||
import { MediaMetaForm } from './MediaMetaForm'
|
||||
import { metaClass } from './styles'
|
||||
import { hasAlt, mediaAlt, mediaThumbnail } from '~/lib/media-url'
|
||||
@@ -58,6 +59,8 @@ export async function MediaCard({ item, project }: MediaCardProps) {
|
||||
)}
|
||||
|
||||
<MediaMetaForm mediaId={item.id} project={project} alt={item.alt ?? ''} caption={item.caption ?? ''} />
|
||||
|
||||
<MediaDeleteButton mediaId={item.id} project={project} />
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client'
|
||||
|
||||
import { useActionState, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbTrash } from 'react-icons/tb'
|
||||
import { AdminSubmit } from './AdminSubmit'
|
||||
import { MediaMessage } from './MediaMessage'
|
||||
import { deleteMediaAction } from '~/lib/media-actions'
|
||||
import { emptyAdminFormState, type AdminFormState } from '~/lib/admin-forms'
|
||||
|
||||
export type MediaDeleteButtonProps = {
|
||||
mediaId: string
|
||||
project: string
|
||||
}
|
||||
|
||||
export function MediaDeleteButton({ mediaId, project }: MediaDeleteButtonProps) {
|
||||
const t = useTranslations('media')
|
||||
const [asking, setAsking] = useState(false)
|
||||
const [state, formAction] = useActionState<AdminFormState, FormData>(deleteMediaAction, emptyAdminFormState)
|
||||
|
||||
return (
|
||||
<form action={formAction} className="flex flex-col gap-2">
|
||||
<input type="hidden" name="mediaId" value={mediaId} />
|
||||
<input type="hidden" name="project" value={project} />
|
||||
|
||||
<MediaMessage state={state} />
|
||||
|
||||
{asking ? (
|
||||
<span className="flex flex-wrap items-center gap-3">
|
||||
<AdminSubmit quiet pendingLabel={t('card.delete')} icon={<TbTrash className="size-4" />}>
|
||||
{t('card.deleteConfirm')}
|
||||
</AdminSubmit>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAsking(false)}
|
||||
className="cursor-pointer bg-transparent p-1 font-mono text-micro font-semibold uppercase tracking-label text-ink-3 hover:text-ink"
|
||||
>
|
||||
{t('card.keep')}
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAsking(true)}
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 self-start bg-transparent p-1 font-mono text-micro font-semibold uppercase tracking-label text-ink-3 hover:text-signal"
|
||||
>
|
||||
<TbTrash aria-hidden="true" className="size-4 shrink-0" />
|
||||
{t('card.delete')}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -132,7 +132,9 @@ export function PostDetail({ detail }: PostDetailProps) {
|
||||
{media.length > 0 ? (
|
||||
<FieldRow label={t('fields.images')}>{entry('images', { count: media.length })}</FieldRow>
|
||||
) : null}
|
||||
<FieldRow label={t('fields.number')}>{entryNumber(post.number)}</FieldRow>
|
||||
{post.number === null ? null : (
|
||||
<FieldRow label={t('fields.number')}>{entryNumber(post.number)}</FieldRow>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Link
|
||||
|
||||
@@ -6,7 +6,7 @@ export type PlateSize = 'lead' | 'card' | 'row'
|
||||
export type PlateProps = {
|
||||
project: ProjectCodeSource
|
||||
color: string
|
||||
number: number
|
||||
number: number | null
|
||||
size?: PlateSize
|
||||
showNumber?: boolean
|
||||
className?: string
|
||||
@@ -22,8 +22,8 @@ export function entryNumber(number: number): string {
|
||||
return String(number).padStart(4, '0')
|
||||
}
|
||||
|
||||
export function entryMark(project: ProjectCodeSource, number: number): string {
|
||||
return `${projectCode(project)}-${entryNumber(number)}`
|
||||
export function entryMark(project: ProjectCodeSource, number: number | null): string {
|
||||
return number === null ? projectCode(project) : `${projectCode(project)}-${entryNumber(number)}`
|
||||
}
|
||||
|
||||
const shells: Record<PlateSize, string> = {
|
||||
@@ -51,7 +51,7 @@ export function Plate({ project, color, number, size = 'row', showNumber = true,
|
||||
className={`${projectSurface} ${shells[size]} font-mono font-semibold text-paper ${className ?? ''}`}
|
||||
>
|
||||
<span className={`block uppercase ${codeSizes[size]}`}>{projectCode(project)}</span>
|
||||
{showNumber ? (
|
||||
{showNumber && number !== null ? (
|
||||
<span className={`block tabular-nums ${numberSizes[size]}`}>{entryNumber(number)}</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { entryNumber } from './Plate'
|
||||
|
||||
export type WatermarkProps = {
|
||||
number: number
|
||||
number: number | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Watermark({ number, className }: WatermarkProps) {
|
||||
if (number === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -106,7 +106,7 @@ const selection = {
|
||||
audience: posts.audience,
|
||||
publishAt: posts.publishAt,
|
||||
authorName: posts.authorName,
|
||||
number: posts.number,
|
||||
number: sql<number>`coalesce(${posts.number}, 0)`.as('number'),
|
||||
projectSlug: projects.slug,
|
||||
projectName: projects.name,
|
||||
projectCode: projects.code,
|
||||
|
||||
@@ -30,7 +30,7 @@ export type EntryFilter = {
|
||||
|
||||
export type EntryListItem = {
|
||||
id: string
|
||||
number: number
|
||||
number: number | null
|
||||
slug: string
|
||||
title: string
|
||||
teaser: string | null
|
||||
@@ -180,26 +180,21 @@ export async function listEntryBlocks(postId: string): Promise<Block[]> {
|
||||
|
||||
export async function createEntry(values: EntryCreateValues): Promise<Post> {
|
||||
const rows = await db.execute<{ id: string }>(sql`
|
||||
with taken as (
|
||||
select coalesce(max(${posts.number}), 0) + 1 as value
|
||||
with used as (
|
||||
select count(*) + 1 as value
|
||||
from ${posts}
|
||||
where ${posts.projectId} = ${values.projectId}::uuid
|
||||
), counter as (
|
||||
insert into post_counter (project_id, value)
|
||||
select ${values.projectId}::uuid, taken.value from taken
|
||||
on conflict (project_id) do update set value = greatest(post_counter.value + 1, excluded.value)
|
||||
returning value
|
||||
and (${posts.slug} = ${values.slug} or ${posts.slug} like ${values.slug} || '-%')
|
||||
)
|
||||
insert into post (project_id, number, slug, title, teaser, type_id, audience, author_name, cover_media_id)
|
||||
insert into post (project_id, slug, title, teaser, type_id, audience, author_name, cover_media_id)
|
||||
select
|
||||
${values.projectId}::uuid,
|
||||
counter.value,
|
||||
case
|
||||
when exists (
|
||||
select 1 from post taken_slug
|
||||
where taken_slug.project_id = ${values.projectId}::uuid and taken_slug.slug = ${values.slug}
|
||||
)
|
||||
then ${values.slug} || '-' || counter.value
|
||||
then ${values.slug} || '-' || used.value
|
||||
else ${values.slug}
|
||||
end,
|
||||
${values.title},
|
||||
@@ -208,7 +203,7 @@ export async function createEntry(values: EntryCreateValues): Promise<Post> {
|
||||
${values.audience}::post_audience,
|
||||
${values.authorName},
|
||||
${values.coverMediaId}::uuid
|
||||
from counter
|
||||
from used
|
||||
returning id
|
||||
`)
|
||||
|
||||
@@ -244,7 +239,7 @@ export async function moveEntryToProject(id: string, projectId: string): Promise
|
||||
)
|
||||
update post set
|
||||
project_id = ${projectId}::uuid,
|
||||
number = counter.value,
|
||||
number = case when post.number is null then null else counter.value end,
|
||||
slug = case
|
||||
when exists (
|
||||
select 1 from post taken_slug
|
||||
@@ -286,6 +281,36 @@ export async function setEntryStatus(
|
||||
.where(eq(posts.id, id))
|
||||
.returning()
|
||||
|
||||
const post = rows[0]
|
||||
|
||||
if (!post || status !== 'published' || post.number !== null) {
|
||||
return post
|
||||
}
|
||||
|
||||
return assignEntryNumber(id)
|
||||
}
|
||||
|
||||
export async function assignEntryNumber(id: string): Promise<Post | undefined> {
|
||||
await db.execute(sql`
|
||||
with target as (
|
||||
select project_id from post where id = ${id}::uuid and number is null
|
||||
), taken as (
|
||||
select coalesce(max(${posts.number}), 0) + 1 as value
|
||||
from ${posts}, target
|
||||
where ${posts.projectId} = target.project_id
|
||||
), counter as (
|
||||
insert into post_counter (project_id, value)
|
||||
select target.project_id, taken.value from target, taken
|
||||
on conflict (project_id) do update set value = greatest(post_counter.value + 1, excluded.value)
|
||||
returning value
|
||||
)
|
||||
update post set number = counter.value
|
||||
from counter
|
||||
where post.id = ${id}::uuid and post.number is null
|
||||
`)
|
||||
|
||||
const rows = await db.select().from(posts).where(eq(posts.id, id)).limit(1)
|
||||
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
@@ -325,5 +350,13 @@ export async function publishDue(now: Date): Promise<PublishedEntry[]> {
|
||||
title: posts.title,
|
||||
})
|
||||
|
||||
return rows
|
||||
const done: PublishedEntry[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
const numbered = row.number === null ? await assignEntryNumber(row.id) : undefined
|
||||
|
||||
done.push({ ...row, number: numbered?.number ?? row.number ?? 0 })
|
||||
}
|
||||
|
||||
return done
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, inArray } from 'drizzle-orm'
|
||||
import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm'
|
||||
import { db } from '../db'
|
||||
import { media, type Media, type NewMedia } from '../schema'
|
||||
|
||||
@@ -51,3 +51,19 @@ export async function deleteMedia(id: string, projectId: string): Promise<Media
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
export async function countMediaReferences(id: string): Promise<number> {
|
||||
const rows = await db.execute<{ value: number }>(sql`
|
||||
select
|
||||
(select count(*) from post where cover_media_id = ${id}::uuid)
|
||||
+ (
|
||||
select count(*) from block
|
||||
where data->>'mediaId' = ${id}
|
||||
or data->>'beforeMediaId' = ${id}
|
||||
or data->>'afterMediaId' = ${id}
|
||||
or (jsonb_typeof(data->'mediaIds') = 'array' and data->'mediaIds' @> to_jsonb(${id}::text))
|
||||
) as value
|
||||
`)
|
||||
|
||||
return Number(rows.rows[0]?.value ?? 0)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export type PostListItem = {
|
||||
audience: string
|
||||
publishAt: Date | null
|
||||
authorName: string | null
|
||||
coverMediaId: string | null
|
||||
number: number
|
||||
projectSlug: string
|
||||
projectName: string
|
||||
@@ -55,7 +56,8 @@ const selection = {
|
||||
audience: posts.audience,
|
||||
publishAt: posts.publishAt,
|
||||
authorName: posts.authorName,
|
||||
number: posts.number,
|
||||
coverMediaId: posts.coverMediaId,
|
||||
number: sql<number>`coalesce(${posts.number}, 0)`.as('number'),
|
||||
projectSlug: projects.slug,
|
||||
projectName: projects.name,
|
||||
projectCode: projects.code,
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function listUnread(args: UnreadArgs): Promise<PostListItem[]> {
|
||||
audience: posts.audience,
|
||||
publishAt: posts.publishAt,
|
||||
authorName: posts.authorName,
|
||||
number: posts.number,
|
||||
number: sql<number>`coalesce(${posts.number}, 0)`.as('number'),
|
||||
projectSlug: projects.slug,
|
||||
projectName: projects.name,
|
||||
projectCode: projects.code,
|
||||
|
||||
@@ -24,7 +24,7 @@ export const posts = pgTable('post', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
projectId: uuid('project_id').notNull().references(() => projects.id, { onDelete: 'cascade' }),
|
||||
issueId: uuid('issue_id').references(() => issues.id, { onDelete: 'set null' }),
|
||||
number: integer('number').notNull(),
|
||||
number: integer('number'),
|
||||
slug: text('slug').notNull(),
|
||||
title: text('title').notNull(),
|
||||
teaser: text('teaser'),
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { listMediaByIds } from '~/data/repositories/media'
|
||||
import type { PostListItem } from '~/data/repositories/posts'
|
||||
import { mediaSrc } from './media-url'
|
||||
|
||||
export type PostListItemWithCover = PostListItem & { coverUrl: string | null }
|
||||
|
||||
export async function withCovers(items: PostListItem[]): Promise<PostListItemWithCover[]> {
|
||||
const ids = [...new Set(items.map(item => item.coverMediaId).filter((id): id is string => id !== null))]
|
||||
const found = ids.length === 0 ? [] : await listMediaByIds(ids)
|
||||
const urls = new Map(found.map(item => [item.id, mediaSrc(item)]))
|
||||
|
||||
return items.map(item => ({
|
||||
...item,
|
||||
coverUrl: item.coverMediaId === null ? null : urls.get(item.coverMediaId) ?? null,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function withCover(item: PostListItem): Promise<PostListItemWithCover> {
|
||||
const [done] = await withCovers([item])
|
||||
|
||||
return done!
|
||||
}
|
||||
@@ -41,7 +41,8 @@ export type ApiEntryResult<T> =
|
||||
|
||||
export type ApiEntryView = {
|
||||
id: string
|
||||
number: number
|
||||
number: number | null
|
||||
cover_media_id: string | null
|
||||
slug: string
|
||||
title: string
|
||||
teaser: string | null
|
||||
@@ -89,6 +90,7 @@ function view(entry: EntryRecord, blocks: Block[]): ApiEntryView {
|
||||
return {
|
||||
id: entry.id,
|
||||
number: entry.number,
|
||||
cover_media_id: entry.coverMediaId,
|
||||
slug: entry.slug,
|
||||
title: entry.title,
|
||||
teaser: entry.teaser,
|
||||
|
||||
@@ -64,7 +64,7 @@ export type EntryErrorKey =
|
||||
|
||||
export type EntryState = {
|
||||
id: string
|
||||
number: number
|
||||
number: number | null
|
||||
slug: string
|
||||
status: PostStatus
|
||||
publishAt: string | null
|
||||
|
||||
@@ -6,6 +6,7 @@ import { findProjectBySlug } from '~/data/repositories/projects'
|
||||
import { invalid, readField, type AdminFormState } from './admin-forms'
|
||||
import { adminMediaProjectPath } from './auth-routes'
|
||||
import { requireProjectAccess } from './auth-guards'
|
||||
import { removeMedia } from './media-delete'
|
||||
import { readUploadBody, uploadMedia, type UploadFailure } from './media-upload'
|
||||
|
||||
const failureKeys: Record<UploadFailure, string> = {
|
||||
@@ -80,3 +81,27 @@ export async function saveMediaMetaAction(_state: AdminFormState, formData: Form
|
||||
|
||||
return { status: 'ok', message: 'saved', id: saved.id }
|
||||
}
|
||||
|
||||
export async function deleteMediaAction(_state: AdminFormState, formData: FormData): Promise<AdminFormState> {
|
||||
const item = await findMedia(readField(formData, 'mediaId'))
|
||||
|
||||
if (!item) {
|
||||
return invalid('mediaUnknown')
|
||||
}
|
||||
|
||||
await requireProjectAccess(item.projectId)
|
||||
|
||||
const result = await removeMedia(item.id, item.projectId)
|
||||
|
||||
if (!result.ok) {
|
||||
return invalid(result.reason === 'in_use' ? 'mediaInUse' : 'mediaUnknown')
|
||||
}
|
||||
|
||||
const slug = readField(formData, 'project')
|
||||
|
||||
if (slug !== '') {
|
||||
revalidatePath(adminMediaProjectPath(slug))
|
||||
}
|
||||
|
||||
return { status: 'ok', message: 'deleted', id: item.id }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { countMediaReferences, deleteMedia, findMedia } from '~/data/repositories/media'
|
||||
import { getStorage, type Storage } from './storage'
|
||||
|
||||
export type MediaDeleteFailure = 'not_found' | 'in_use'
|
||||
|
||||
export type MediaDeleteResult =
|
||||
| { ok: true }
|
||||
| { ok: false, reason: MediaDeleteFailure }
|
||||
|
||||
export async function removeMedia(id: string, projectId: string, storage?: Storage): Promise<MediaDeleteResult> {
|
||||
const item = await findMedia(id)
|
||||
|
||||
if (!item || item.projectId !== projectId) {
|
||||
return { ok: false, reason: 'not_found' }
|
||||
}
|
||||
|
||||
if (await countMediaReferences(id) > 0) {
|
||||
return { ok: false, reason: 'in_use' }
|
||||
}
|
||||
|
||||
const removed = await deleteMedia(id, projectId)
|
||||
|
||||
if (!removed) {
|
||||
return { ok: false, reason: 'not_found' }
|
||||
}
|
||||
|
||||
const files = storage ?? getStorage()
|
||||
|
||||
await files.remove(removed.path)
|
||||
|
||||
for (const variant of removed.variants) {
|
||||
await files.remove(variant.path)
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
+15
-1
@@ -33,6 +33,8 @@ const postListItem = {
|
||||
audience: { type: 'string', enum: audiences },
|
||||
publishAt: { type: 'string', format: 'date-time', nullable: true },
|
||||
number: { type: 'integer' },
|
||||
coverMediaId: { type: 'string', format: 'uuid', nullable: true },
|
||||
coverUrl: { type: 'string', nullable: true },
|
||||
projectSlug: { type: 'string' },
|
||||
projectName: { type: 'string' },
|
||||
projectCode: { type: 'string', nullable: true },
|
||||
@@ -44,7 +46,8 @@ const entryView = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
number: { type: 'integer' },
|
||||
number: { type: 'integer', nullable: true, description: 'Wird beim Veröffentlichen vergeben, Entwürfe haben keine.' },
|
||||
cover_media_id: { type: 'string', format: 'uuid', nullable: true },
|
||||
slug: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
teaser: { type: 'string', nullable: true },
|
||||
@@ -335,6 +338,17 @@ export function openApiDocument(baseUrl: string) {
|
||||
},
|
||||
},
|
||||
},
|
||||
'/api/v1/media/{id}': {
|
||||
delete: {
|
||||
summary: 'Bild löschen',
|
||||
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }],
|
||||
responses: {
|
||||
'200': { description: 'Gelöscht' },
|
||||
'409': { description: 'Das Bild hängt noch an einem Beitrag' },
|
||||
...errors([401, 403, 404]),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user