Choose any allowed status in the table, set the cover on create
- the status cell offers every transition the entry allows, scheduling asks for the date right there - POST /api/v1/posts takes cover_media_id, no second call needed - the row grid follows its content so long type names stop overlapping
This commit is contained in:
+8
-1
@@ -409,7 +409,8 @@
|
|||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
"delete": "Entwurf löschen",
|
"delete": "Entwurf löschen",
|
||||||
"deleteConfirm": "Wirklich löschen?",
|
"deleteConfirm": "Wirklich löschen?",
|
||||||
"deleting": "Wird gelöscht"
|
"deleting": "Wird gelöscht",
|
||||||
|
"setStatus": "Status ändern"
|
||||||
},
|
},
|
||||||
"blocks": {
|
"blocks": {
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
@@ -500,6 +501,12 @@
|
|||||||
"unlink": "Verweis entfernen",
|
"unlink": "Verweis entfernen",
|
||||||
"linkTarget": "Adresse des Verweises",
|
"linkTarget": "Adresse des Verweises",
|
||||||
"linkApply": "Übernehmen"
|
"linkApply": "Übernehmen"
|
||||||
|
},
|
||||||
|
"move": {
|
||||||
|
"draft": "Zu Entwurf machen",
|
||||||
|
"scheduled": "Termin setzen",
|
||||||
|
"published": "Jetzt veröffentlichen",
|
||||||
|
"archived": "Zurückziehen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"brands": {
|
"brands": {
|
||||||
|
|||||||
+8
-1
@@ -409,7 +409,8 @@
|
|||||||
"close": "Close",
|
"close": "Close",
|
||||||
"delete": "Delete draft",
|
"delete": "Delete draft",
|
||||||
"deleteConfirm": "Really delete?",
|
"deleteConfirm": "Really delete?",
|
||||||
"deleting": "Deleting"
|
"deleting": "Deleting",
|
||||||
|
"setStatus": "Change status"
|
||||||
},
|
},
|
||||||
"blocks": {
|
"blocks": {
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
@@ -500,6 +501,12 @@
|
|||||||
"unlink": "Remove link",
|
"unlink": "Remove link",
|
||||||
"linkTarget": "Link address",
|
"linkTarget": "Link address",
|
||||||
"linkApply": "Apply"
|
"linkApply": "Apply"
|
||||||
|
},
|
||||||
|
"move": {
|
||||||
|
"draft": "Move to draft",
|
||||||
|
"scheduled": "Schedule",
|
||||||
|
"published": "Publish now",
|
||||||
|
"archived": "Withdraw"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"brands": {
|
"brands": {
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ const createSchema = z.object({
|
|||||||
audience: z.enum(['internal', 'customer', 'public']).optional(),
|
audience: z.enum(['internal', 'customer', 'public']).optional(),
|
||||||
author_name: z.string().nullish(),
|
author_name: z.string().nullish(),
|
||||||
slug: z.string().nullish(),
|
slug: z.string().nullish(),
|
||||||
|
cover_media_id: z.string().uuid().nullish(),
|
||||||
blocks: z.unknown().optional(),
|
blocks: z.unknown().optional(),
|
||||||
idempotency_key: z.string().min(1).max(200).nullish(),
|
idempotency_key: z.string().min(1).max(200).nullish(),
|
||||||
})
|
})
|
||||||
@@ -82,6 +83,7 @@ export async function POST(request: Request) {
|
|||||||
audience: body.audience,
|
audience: body.audience,
|
||||||
authorName: body.author_name ?? null,
|
authorName: body.author_name ?? null,
|
||||||
slug: body.slug ?? null,
|
slug: body.slug ?? null,
|
||||||
|
coverMediaId: body.cover_media_id ?? null,
|
||||||
blocks: body.blocks,
|
blocks: body.blocks,
|
||||||
idempotencyKey: body.idempotency_key ?? null,
|
idempotencyKey: body.idempotency_key ?? null,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,74 +2,124 @@
|
|||||||
|
|
||||||
import { useState, useTransition } from 'react'
|
import { useState, useTransition } from 'react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { TbArchive, TbRotate2, TbSend } from 'react-icons/tb'
|
import { TbCalendarClock } from 'react-icons/tb'
|
||||||
import { EntryStatusChip } from './EntryStatusChip'
|
import { Select } from './Select'
|
||||||
import { archiveEntry, publishEntry, resumeEntry, unscheduleEntry } from '~/lib/entry-actions'
|
import { inputClass } from './styles'
|
||||||
|
import { canTransition } from '~/domain/post-status'
|
||||||
|
import { archiveEntry, publishEntry, resumeEntry, scheduleEntry } from '~/lib/entry-actions'
|
||||||
import type { EntryErrorKey, EntryResult } from '~/lib/entry-types'
|
import type { EntryErrorKey, EntryResult } from '~/lib/entry-types'
|
||||||
import type { PostStatus } from '~/domain/types'
|
import type { PostStatus } from '~/domain/types'
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
export type EntryStatusControlProps = {
|
export type EntryStatusControlProps = {
|
||||||
id: string
|
id: string
|
||||||
status: PostStatus
|
status: PostStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
type Move = {
|
const targets: PostStatus[] = ['draft', 'scheduled', 'published', 'archived']
|
||||||
key: string
|
|
||||||
icon: ReactNode
|
const tones: Record<PostStatus, string> = {
|
||||||
run: (id: string) => Promise<EntryResult>
|
draft: 'text-ink-3',
|
||||||
|
review: 'text-caution',
|
||||||
|
scheduled: 'text-link',
|
||||||
|
published: 'text-positive',
|
||||||
|
archived: 'text-ink-3',
|
||||||
}
|
}
|
||||||
|
|
||||||
const moves: Partial<Record<PostStatus, Move>> = {
|
function run(id: string, target: PostStatus, when: string): Promise<EntryResult> {
|
||||||
draft: { key: 'publish', icon: <TbSend className="size-4 shrink-0" />, run: id => publishEntry({ id }) },
|
if (target === 'published') {
|
||||||
review: { key: 'publish', icon: <TbSend className="size-4 shrink-0" />, run: id => publishEntry({ id }) },
|
return publishEntry({ id })
|
||||||
scheduled: { key: 'unschedule', icon: <TbRotate2 className="size-4 shrink-0" />, run: id => unscheduleEntry({ id }) },
|
}
|
||||||
published: { key: 'archive', icon: <TbArchive className="size-4 shrink-0" />, run: id => archiveEntry({ id }) },
|
|
||||||
archived: { key: 'resume', icon: <TbRotate2 className="size-4 shrink-0" />, run: id => resumeEntry({ id }) },
|
if (target === 'archived') {
|
||||||
|
return archiveEntry({ id })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target === 'scheduled') {
|
||||||
|
return scheduleEntry({ id, publishAt: new Date(when).toISOString() })
|
||||||
|
}
|
||||||
|
|
||||||
|
return resumeEntry({ id })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EntryStatusControl({ id, status }: EntryStatusControlProps) {
|
export function EntryStatusControl({ id, status }: EntryStatusControlProps) {
|
||||||
const t = useTranslations('admin.entries')
|
const t = useTranslations('admin.entries')
|
||||||
const [pending, start] = useTransition()
|
const [pending, start] = useTransition()
|
||||||
|
const [when, setWhen] = useState('')
|
||||||
|
const [asking, setAsking] = useState(false)
|
||||||
const [error, setError] = useState<EntryErrorKey | null>(null)
|
const [error, setError] = useState<EntryErrorKey | null>(null)
|
||||||
const [blockers, setBlockers] = useState<string[]>([])
|
const [blockers, setBlockers] = useState<string[]>([])
|
||||||
const move = moves[status]
|
const options = targets.filter(target => canTransition(status, target))
|
||||||
|
|
||||||
function apply() {
|
|
||||||
if (!move) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function apply(target: PostStatus, at: string) {
|
||||||
setError(null)
|
setError(null)
|
||||||
setBlockers([])
|
setBlockers([])
|
||||||
|
|
||||||
start(async () => {
|
start(async () => {
|
||||||
const result = await move.run(id)
|
const result = await run(id, target, at)
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
setAsking(false)
|
||||||
|
setWhen('')
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
setError(result.error)
|
setError(result.error)
|
||||||
setBlockers(result.blockers ?? [])
|
setBlockers(result.blockers ?? [])
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function choose(value: string) {
|
||||||
|
if (value === '' || value === status) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'scheduled') {
|
||||||
|
setAsking(true)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(value as PostStatus, '')
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span className="flex flex-col items-start gap-1">
|
<span className="flex flex-col items-start gap-1">
|
||||||
<span className="flex items-center gap-2">
|
<Select
|
||||||
<EntryStatusChip status={status} label={t(`status.${status}`)} />
|
value={status}
|
||||||
{move ? (
|
disabled={pending || options.length === 0}
|
||||||
|
aria-label={t('actions.setStatus')}
|
||||||
|
onChange={event => choose(event.target.value)}
|
||||||
|
className={`min-w-32 font-mono text-micro font-semibold uppercase tracking-chip ${tones[status]}`}
|
||||||
|
>
|
||||||
|
<option value={status}>{t(`status.${status}`)}</option>
|
||||||
|
{options.map(target => (
|
||||||
|
<option key={target} value={target}>
|
||||||
|
{t(`move.${target}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{asking ? (
|
||||||
|
<span className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={when}
|
||||||
|
aria-label={t('fields.publishAt')}
|
||||||
|
onChange={event => setWhen(event.target.value)}
|
||||||
|
className={`${inputClass} max-w-56 font-mono text-micro`}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={pending}
|
disabled={pending || when === ''}
|
||||||
onClick={apply}
|
onClick={() => apply('scheduled', when)}
|
||||||
title={t(`actions.${move.key}`)}
|
className="inline-flex cursor-pointer items-center gap-1.5 bg-transparent p-1 font-mono text-micro font-semibold uppercase tracking-label text-ink-2 hover:text-ink disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
aria-label={t(`actions.${move.key}`)}
|
|
||||||
className="inline-flex cursor-pointer items-center border-0 bg-transparent p-1 text-ink-3 hover:text-ink disabled:cursor-progress disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{move.icon}
|
<TbCalendarClock aria-hidden="true" className="size-4 shrink-0" />
|
||||||
|
{t('actions.schedule')}
|
||||||
</button>
|
</button>
|
||||||
) : null}
|
|
||||||
</span>
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<span role="alert" className="font-mono text-micro text-signal">
|
<span role="alert" className="font-mono text-micro text-signal">
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export function EntryRow({ item, showCode = true, highlight, className }: EntryR
|
|||||||
href={postPath(item.projectSlug, item.slug)}
|
href={postPath(item.projectSlug, item.slug)}
|
||||||
className={`group flex items-stretch gap-4 border-b border-rule py-3 no-underline last:border-b-0 ${className ?? ''}`}
|
className={`group flex items-stretch gap-4 border-b border-rule py-3 no-underline last:border-b-0 ${className ?? ''}`}
|
||||||
>
|
>
|
||||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-4 gap-y-1 transition-transform duration-120 group-hover:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0 lg:grid lg:grid-cols-12 lg:gap-x-5">
|
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-4 gap-y-1 transition-transform duration-120 group-hover:translate-x-0.5 motion-reduce:transition-none motion-reduce:group-hover:translate-x-0 lg:grid lg:grid-cols-[max-content_max-content_minmax(0,1fr)_minmax(0,16rem)_max-content] lg:items-center lg:gap-x-5">
|
||||||
<span className="shrink-0 self-center lg:col-span-2">
|
<span className="shrink-0">
|
||||||
<Plate
|
<Plate
|
||||||
project={source}
|
project={source}
|
||||||
color={item.projectColor}
|
color={item.projectColor}
|
||||||
@@ -37,21 +37,21 @@ export function EntryRow({ item, showCode = true, highlight, className }: EntryR
|
|||||||
<span className="sr-only">{t('number', { number: item.number })}</span>
|
<span className="sr-only">{t('number', { number: item.number })}</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<Badge type={item.type} className="shrink-0 lg:col-span-1" />
|
<Badge type={item.type} className="shrink-0" />
|
||||||
|
|
||||||
<span className="min-w-0 flex-1 basis-full font-display text-lead font-semibold leading-snug text-balance text-ink lg:col-span-4 lg:basis-auto">
|
<span className="min-w-0 flex-1 basis-full font-display text-lead font-semibold leading-snug text-balance text-ink lg:basis-auto">
|
||||||
<Highlight text={item.title} query={highlight} />
|
<Highlight text={item.title} query={highlight} />
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{item.teaser ? (
|
{item.teaser ? (
|
||||||
<span className="hidden min-w-0 text-small leading-snug text-ink-2 lg:col-span-3 lg:line-clamp-2">
|
<span className="hidden min-w-0 text-small leading-snug text-ink-2 lg:line-clamp-2">
|
||||||
{item.teaser}
|
{item.teaser}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span aria-hidden="true" className="hidden lg:col-span-3 lg:block" />
|
<span aria-hidden="true" className="hidden lg:block" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<span className="ml-auto flex shrink-0 items-center gap-2 self-center font-mono text-small text-ink-3 lg:col-span-2 lg:ml-0 lg:justify-end">
|
<span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-small text-ink-3 lg:ml-0 lg:justify-end">
|
||||||
{item.publishAt ? <EntryDate date={item.publishAt} /> : null}
|
{item.publishAt ? <EntryDate date={item.publishAt} /> : null}
|
||||||
{item.authorName ? <AuthorMark name={item.authorName} /> : null}
|
{item.authorName ? <AuthorMark name={item.authorName} /> : null}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ export type ApiCreateInput = {
|
|||||||
audience?: Audience
|
audience?: Audience
|
||||||
authorName?: string | null
|
authorName?: string | null
|
||||||
slug?: string | null
|
slug?: string | null
|
||||||
|
coverMediaId?: string | null
|
||||||
blocks?: unknown
|
blocks?: unknown
|
||||||
idempotencyKey?: string | null
|
idempotencyKey?: string | null
|
||||||
}
|
}
|
||||||
@@ -219,7 +220,7 @@ export async function apiCreateEntry(client: ApiClient, input: ApiCreateInput):
|
|||||||
teaser: teaser === '' ? null : teaser,
|
teaser: teaser === '' ? null : teaser,
|
||||||
typeId: type.id,
|
typeId: type.id,
|
||||||
audience,
|
audience,
|
||||||
coverMediaId: null,
|
coverMediaId: input.coverMediaId ?? null,
|
||||||
slug: wanted === '' ? (isSlug(derived) ? derived : slugFallback) : wanted,
|
slug: wanted === '' ? (isSlug(derived) ? derived : slugFallback) : wanted,
|
||||||
authorName: input.authorName?.trim() || client.name,
|
authorName: input.authorName?.trim() || client.name,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ export function openApiDocument(baseUrl: string) {
|
|||||||
audience: { type: 'string', enum: audiences, default: 'internal' },
|
audience: { type: 'string', enum: audiences, default: 'internal' },
|
||||||
author_name: { type: 'string', nullable: true },
|
author_name: { type: 'string', nullable: true },
|
||||||
slug: { type: 'string', nullable: true },
|
slug: { type: 'string', nullable: true },
|
||||||
|
cover_media_id: { type: 'string', format: 'uuid', nullable: true },
|
||||||
blocks: { type: 'array', items: { type: 'object' }, maxItems: 100 },
|
blocks: { type: 'array', items: { type: 'object' }, maxItems: 100 },
|
||||||
idempotency_key: { type: 'string', maxLength: 200, nullable: true },
|
idempotency_key: { type: 'string', maxLength: 200, nullable: true },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ vi.mock('~/lib/entry-actions', () => ({
|
|||||||
archiveEntry: vi.fn(),
|
archiveEntry: vi.fn(),
|
||||||
publishEntry: vi.fn(),
|
publishEntry: vi.fn(),
|
||||||
resumeEntry: vi.fn(),
|
resumeEntry: vi.fn(),
|
||||||
unscheduleEntry: vi.fn(),
|
scheduleEntry: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const { EntryStatusControl } = await import('~/components/admin/EntryStatusControl')
|
const { EntryStatusControl } = await import('~/components/admin/EntryStatusControl')
|
||||||
@@ -20,14 +20,31 @@ function render(status: PostStatus): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('EntryStatusControl', () => {
|
describe('EntryStatusControl', () => {
|
||||||
it('bietet zu jedem Status den passenden Schritt an', () => {
|
it('stellt jedem Status seine erlaubten Ziele zur Wahl', () => {
|
||||||
expect(render('draft')).toContain('actions.publish')
|
const draft = render('draft')
|
||||||
expect(render('scheduled')).toContain('actions.unschedule')
|
|
||||||
expect(render('published')).toContain('actions.archive')
|
expect(draft).toContain('move.scheduled')
|
||||||
expect(render('archived')).toContain('actions.resume')
|
expect(draft).toContain('move.published')
|
||||||
|
expect(draft).toContain('move.archived')
|
||||||
|
expect(draft).not.toContain('move.draft')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('zeigt den Status immer als Marke', () => {
|
it('lässt Veröffentlichtes nur zurückziehen', () => {
|
||||||
|
const published = render('published')
|
||||||
|
|
||||||
|
expect(published).toContain('move.archived')
|
||||||
|
expect(published).not.toContain('move.published')
|
||||||
|
expect(published).not.toContain('move.scheduled')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('führt Zurückgezogenes zurück in den Entwurf', () => {
|
||||||
|
const archived = render('archived')
|
||||||
|
|
||||||
|
expect(archived).toContain('move.draft')
|
||||||
|
expect(archived).not.toContain('move.archived')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('zeigt den Status immer an', () => {
|
||||||
expect(render('draft')).toContain('status.draft')
|
expect(render('draft')).toContain('status.draft')
|
||||||
expect(render('published')).toContain('status.published')
|
expect(render('published')).toContain('status.published')
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user