Manage entries at scale

- the admin uses the full window width, table cells stop wrapping
- columns sort by number, title, project, status, date and author
- filters sit in one row with search first and a page size next to them
- entries carry a real date: publish_at on create and change, publishing
  keeps that date, a date in the past publishes backdated
This commit is contained in:
Matthias G
2026-08-01 15:43:05 +02:00
parent f8bc2d6108
commit cd3bf3f2b4
14 changed files with 221 additions and 44 deletions
+11
View File
@@ -162,6 +162,17 @@ Wer über die API schreibt, schreibt für Menschen. Diese Regeln gelten für jed
**Wer veröffentlicht.** Nicht der Zugang. Ein Mensch schaut drauf und gibt frei. Ein über die API verfasster Beitrag ist immer ein Vorschlag. **Wer veröffentlicht.** Nicht der Zugang. Ein Mensch schaut drauf und gibt frei. Ein über die API verfasster Beitrag ist immer ein Vorschlag.
## Datum
Beim Anlegen und beim Ändern nimmt die API `publish_at` als ISO-Zeitangabe entgegen. Der Zugang veröffentlicht damit nicht, er hinterlegt nur das Datum. Gibt ein Mensch den Beitrag frei, übernimmt Logbuch genau dieses Datum statt der aktuellen Uhrzeit. Damit lassen sich ältere Meldungen richtig einsortieren.
```bash
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "content-type: application/json" \
-d '{"project":"trakk","title":"...","type":"fix","publish_at":"2026-06-04T09:00:00Z"}' \
http://localhost:4700/api/v1/posts
```
## Nummern ## Nummern
Die Nummer eines Eintrags wird beim Veröffentlichen vergeben, nicht beim Anlegen. Ein Entwurf hat `number: null`. Damit bleiben die Nummern im Archiv lückenlos, auch wenn Entwürfe wieder verworfen werden. Die Nummer eines Eintrags wird beim Veröffentlichen vergeben, nicht beim Anlegen. Ein Entwurf hat `number: null`. Damit bleiben die Nummern im Archiv lückenlos, auch wenn Entwürfe wieder verworfen werden.
+3 -2
View File
@@ -343,7 +343,8 @@
"search": "Suche", "search": "Suche",
"searchPlaceholder": "Titel und Anreißer", "searchPlaceholder": "Titel und Anreißer",
"submit": "Anwenden", "submit": "Anwenden",
"reset": "Eingrenzung zurücksetzen" "reset": "Eingrenzung zurücksetzen",
"perPage": "Pro Seite"
}, },
"status": { "status": {
"draft": "Entwurf", "draft": "Entwurf",
@@ -374,7 +375,7 @@
"slug": "Teil der Adresse im Archiv. Leer lassen, dann wird er aus dem Titel gebildet.", "slug": "Teil der Adresse im Archiv. Leer lassen, dann wird er aus dem Titel gebildet.",
"cover": "Steht groß über dem Beitrag. Ohne Bild trägt die Kennplatte den Platz.", "cover": "Steht groß über dem Beitrag. Ohne Bild trägt die Kennplatte den Platz.",
"drop": "Bilder hierher ziehen oder mit Strg+V einfügen, auch mehrere auf einmal.", "drop": "Bilder hierher ziehen oder mit Strg+V einfügen, auch mehrere auf einmal.",
"schedule": "Ein Termin in der Zukunft. Der Beitrag erscheint automatisch, sobald er erreicht ist.", "schedule": "Liegt der Termin in der Zukunft, erscheint der Beitrag automatisch. Liegt er in der Vergangenheit, wird der Beitrag sofort mit diesem Datum veröffentlicht.",
"projectMove": "Beim Wechsel bekommt der Beitrag die nächste freie Nummer im Zielprojekt." "projectMove": "Beim Wechsel bekommt der Beitrag die nächste freie Nummer im Zielprojekt."
}, },
"save": { "save": {
+3 -2
View File
@@ -343,7 +343,8 @@
"search": "Search", "search": "Search",
"searchPlaceholder": "Title and teaser", "searchPlaceholder": "Title and teaser",
"submit": "Apply", "submit": "Apply",
"reset": "Reset filter" "reset": "Reset filter",
"perPage": "Per page"
}, },
"status": { "status": {
"draft": "Draft", "draft": "Draft",
@@ -374,7 +375,7 @@
"slug": "Part of the address in the archive. Leave it empty to derive it from the title.", "slug": "Part of the address in the archive. Leave it empty to derive it from the title.",
"cover": "Sits large above the entry. Without an image the plate takes that space.", "cover": "Sits large above the entry. Without an image the plate takes that space.",
"drop": "Drag images here or paste them with Ctrl+V, several at once works.", "drop": "Drag images here or paste them with Ctrl+V, several at once works.",
"schedule": "A moment in the future. The entry appears automatically once it is reached.", "schedule": "A date in the future publishes the entry automatically. A date in the past publishes it right away, carrying that date.",
"projectMove": "On a move the entry receives the next free number in the target project." "projectMove": "On a move the entry receives the next free number in the target project."
}, },
"save": { "save": {
+80 -15
View File
@@ -1,7 +1,7 @@
import type { Metadata, Route } from 'next' import type { Metadata, Route } from 'next'
import Link from 'next/link' import Link from 'next/link'
import { getLocale, getTranslations } from 'next-intl/server' import { getLocale, getTranslations } from 'next-intl/server'
import { TbPlus } from 'react-icons/tb' import { TbArrowDown, TbArrowUp, TbPlus } from 'react-icons/tb'
import { AdminHeading } from '~/components/admin/AdminHeading' import { AdminHeading } from '~/components/admin/AdminHeading'
import { AdminNotice } from '~/components/admin/AdminNotice' import { AdminNotice } from '~/components/admin/AdminNotice'
import { EntryFilters } from '~/components/admin/EntryFilters' import { EntryFilters } from '~/components/admin/EntryFilters'
@@ -23,7 +23,17 @@ import { requireAdminArea } from '~/lib/auth-guards'
import { toTypeOptions } from '~/lib/post-types' import { toTypeOptions } from '~/lib/post-types'
import type { Locale } from '~/i18n/config' import type { Locale } from '~/i18n/config'
const perPage = 25 const defaultPerPage = 25
const pageSizes = [25, 50, 100, 200]
const sortable = ['number', 'title', 'project', 'status', 'date', 'author'] as const
type SortKey = typeof sortable[number]
function isSort(value: string): value is SortKey {
return (sortable as readonly string[]).includes(value)
}
type SearchParams = Promise<Record<string, string | string[] | undefined>> type SearchParams = Promise<Record<string, string | string[] | undefined>>
@@ -52,6 +62,11 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
const typeValue = single(params.type) const typeValue = single(params.type)
const search = single(params.q) const search = single(params.q)
const page = Math.max(1, Number(single(params.page)) || 1) const page = Math.max(1, Number(single(params.page)) || 1)
const wantedSize = Number(single(params.per_page))
const perPage = pageSizes.includes(wantedSize) ? wantedSize : defaultPerPage
const sortValue = single(params.sort)
const sort = isSort(sortValue) ? sortValue : undefined
const descending = single(params.dir) !== 'asc'
const selected = projects.find(project => project.slug === projectSlug) const selected = projects.find(project => project.slug === projectSlug)
const filtered = projectSlug !== '' || statusValue !== '' || typeValue !== '' || search !== '' const filtered = projectSlug !== '' || statusValue !== '' || typeValue !== '' || search !== ''
@@ -63,6 +78,8 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
status: isPostStatus(statusValue) ? statusValue : undefined, status: isPostStatus(statusValue) ? statusValue : undefined,
type: isPostTypeKey(typeValue) ? typeValue : undefined, type: isPostTypeKey(typeValue) ? typeValue : undefined,
search, search,
sort,
descending,
page, page,
perPage, perPage,
}) })
@@ -86,6 +103,33 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
query.set('q', search) query.set('q', search)
} }
if (perPage !== defaultPerPage) {
query.set('per_page', String(perPage))
}
if (sort) {
query.set('sort', sort)
}
if (!descending) {
query.set('dir', 'asc')
}
function sortPath(key: SortKey): Route {
const next = new URLSearchParams(query)
next.set('sort', key)
next.delete('page')
if (sort === key && descending) {
next.set('dir', 'asc')
} else {
next.delete('dir')
}
return `${adminEntriesPath}?${next.toString()}` as Route
}
function pagePath(target: number): Route { function pagePath(target: number): Route {
const next = new URLSearchParams(query) const next = new URLSearchParams(query)
@@ -125,6 +169,9 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
status={statusValue} status={statusValue}
type={typeValue} type={typeValue}
search={search} search={search}
perPage={perPage}
sort={sort ?? ''}
descending={descending}
filtered={filtered} filtered={filtered}
/> />
@@ -150,23 +197,41 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
</AdminNotice> </AdminNotice>
) : ( ) : (
<Scroller options={{ overflow: { y: 'hidden' } }}> <Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-3xl border-collapse text-left"> <table className="w-full min-w-4xl border-collapse text-left">
<thead> <thead>
<tr> <tr>
<th scope="col" className={headCellClass}>{t('columns.number')}</th> {(['number', 'title', 'project'] as SortKey[]).map(key => (
<th scope="col" className={headCellClass}>{t('columns.title')}</th> <th key={key} scope="col" className={headCellClass}>
<th scope="col" className={headCellClass}>{t('columns.project')}</th> <Link href={sortPath(key)} className="inline-flex items-center gap-1 text-inherit no-underline hover:text-ink">
{t(`columns.${key}`)}
{sort === key ? (
descending
? <TbArrowDown aria-hidden="true" className="size-3.5" />
: <TbArrowUp aria-hidden="true" className="size-3.5" />
) : null}
</Link>
</th>
))}
<th scope="col" className={headCellClass}>{t('columns.type')}</th> <th scope="col" className={headCellClass}>{t('columns.type')}</th>
<th scope="col" className={headCellClass}>{t('columns.audience')}</th> <th scope="col" className={headCellClass}>{t('columns.audience')}</th>
<th scope="col" className={headCellClass}>{t('columns.status')}</th> {(['status', 'date', 'author'] as SortKey[]).map(key => (
<th scope="col" className={headCellClass}>{t('columns.date')}</th> <th key={key} scope="col" className={headCellClass}>
<th scope="col" className={headCellClass}>{t('columns.author')}</th> <Link href={sortPath(key)} className="inline-flex items-center gap-1 text-inherit no-underline hover:text-ink">
{t(`columns.${key}`)}
{sort === key ? (
descending
? <TbArrowDown aria-hidden="true" className="size-3.5" />
: <TbArrowUp aria-hidden="true" className="size-3.5" />
) : null}
</Link>
</th>
))}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{result.items.map(item => ( {result.items.map(item => (
<tr key={item.id}> <tr key={item.id}>
<td className={`${cellClass} font-mono text-micro uppercase tracking-mark`}> <td className={`${cellClass} whitespace-nowrap font-mono text-micro uppercase tracking-mark`}>
{entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number)} {entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number)}
</td> </td>
<td className={cellClass}> <td className={cellClass}>
@@ -177,7 +242,7 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
{item.title} {item.title}
</Link> </Link>
</td> </td>
<td className={cellClass}> <td className={`${cellClass} whitespace-nowrap`}>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span <span
aria-hidden="true" aria-hidden="true"
@@ -187,15 +252,15 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
{item.projectName} {item.projectName}
</span> </span>
</td> </td>
<td className={cellClass}>{postTypeLabel(item.type, locale)}</td> <td className={`${cellClass} whitespace-nowrap`}>{postTypeLabel(item.type, locale)}</td>
<td className={cellClass}>{t(`audience.${item.audience}`)}</td> <td className={`${cellClass} whitespace-nowrap`}>{t(`audience.${item.audience}`)}</td>
<td className={cellClass}> <td className={cellClass}>
<EntryStatusControl id={item.id} status={item.status} /> <EntryStatusControl id={item.id} status={item.status} />
</td> </td>
<td className={`${cellClass} font-mono text-micro`}> <td className={`${cellClass} whitespace-nowrap font-mono text-micro`}>
<EntryDate date={item.publishAt ?? item.updatedAt} /> <EntryDate date={item.publishAt ?? item.updatedAt} />
</td> </td>
<td className={cellClass}>{item.authorName ?? t('noAuthor')}</td> <td className={`${cellClass} whitespace-nowrap`}>{item.authorName ?? t('noAuthor')}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
+1 -2
View File
@@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
import { getTranslations } from 'next-intl/server' import { getTranslations } from 'next-intl/server'
import { AppShell } from '~/components/layout/AppShell' import { AppShell } from '~/components/layout/AppShell'
import { AdminNav } from '~/components/layout/AdminNav' import { AdminNav } from '~/components/layout/AdminNav'
import { Wrap } from '~/components/layout/Wrap'
import { Wordmark } from '~/components/layout/Wordmark' import { Wordmark } from '~/components/layout/Wordmark'
import { loadHighestNumber } from '~/data/repositories/archive' import { loadHighestNumber } from '~/data/repositories/archive'
import { requireSession } from '~/lib/auth-guards' import { requireSession } from '~/lib/auth-guards'
@@ -29,7 +28,7 @@ export default async function AdminLayout({ children }: { children: ReactNode })
mark={<Wordmark number={number} />} mark={<Wordmark number={number} />}
> >
<main id="content"> <main id="content">
<Wrap wide className="pb-16 pt-8">{children}</Wrap> <div className="w-full px-6 pb-16 pt-8">{children}</div>
</main> </main>
</AppShell> </AppShell>
) )
+2
View File
@@ -15,6 +15,7 @@ const updateSchema = z.object({
audience: z.enum(['internal', 'customer', 'public']).optional(), audience: z.enum(['internal', 'customer', 'public']).optional(),
slug: z.string().nullish(), slug: z.string().nullish(),
cover_media_id: z.string().nullish(), cover_media_id: z.string().nullish(),
publish_at: z.string().nullish(),
}) })
export async function GET(request: Request, context: { params: Promise<{ slug: string }> }) { export async function GET(request: Request, context: { params: Promise<{ slug: string }> }) {
@@ -76,6 +77,7 @@ export async function PATCH(request: Request, context: { params: Promise<{ slug:
audience: body.audience, audience: body.audience,
slug: body.slug === undefined ? undefined : body.slug, slug: body.slug === undefined ? undefined : body.slug,
coverMediaId: body.cover_media_id === undefined ? undefined : body.cover_media_id, coverMediaId: body.cover_media_id === undefined ? undefined : body.cover_media_id,
publishAt: body.publish_at === undefined ? undefined : body.publish_at,
}) })
return result.ok ? Response.json(result.value) : entryProblem(result.error) return result.ok ? Response.json(result.value) : entryProblem(result.error)
+2
View File
@@ -57,6 +57,7 @@ const createSchema = z.object({
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(), cover_media_id: z.string().uuid().nullish(),
publish_at: z.string().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(),
}) })
@@ -84,6 +85,7 @@ export async function POST(request: Request) {
authorName: body.author_name ?? null, authorName: body.author_name ?? null,
slug: body.slug ?? null, slug: body.slug ?? null,
coverMediaId: body.cover_media_id ?? null, coverMediaId: body.cover_media_id ?? null,
publishAt: body.publish_at ?? null,
blocks: body.blocks, blocks: body.blocks,
idempotencyKey: body.idempotency_key ?? null, idempotencyKey: body.idempotency_key ?? null,
}) })
+40 -11
View File
@@ -18,10 +18,26 @@ export type EntryFiltersProps = {
status: string status: string
type: string type: string
search: string search: string
perPage: number
sort: string
descending: boolean
filtered: boolean filtered: boolean
} }
export function EntryFilters({ projects, types, project, status, type, search, filtered }: EntryFiltersProps) { const sizes = [25, 50, 100, 200]
export function EntryFilters({
projects,
types,
project,
status,
type,
search,
perPage,
sort,
descending,
filtered,
}: EntryFiltersProps) {
const t = useTranslations('admin.entries.filters') const t = useTranslations('admin.entries.filters')
const states = useTranslations('admin.entries.status') const states = useTranslations('admin.entries.status')
const form = useRef<HTMLFormElement>(null) const form = useRef<HTMLFormElement>(null)
@@ -36,7 +52,21 @@ export function EntryFilters({ projects, types, project, status, type, search, f
action={adminEntriesPath} action={adminEntriesPath}
className="flex flex-col gap-5 border border-rule bg-surface px-5 py-4" className="flex flex-col gap-5 border border-rule bg-surface px-5 py-4"
> >
<div className="grid gap-5 md:grid-cols-4"> <input type="hidden" name="sort" value={sort} />
<input type="hidden" name="dir" value={descending ? 'desc' : 'asc'} />
<div className="grid gap-5 md:grid-cols-2 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)_minmax(0,7rem)]">
<AdminField id="filter-search" label={t('search')}>
<input
id="filter-search"
name="q"
type="search"
defaultValue={search}
placeholder={t('searchPlaceholder')}
className={inputClass}
/>
</AdminField>
<AdminField id="filter-project" label={t('project')}> <AdminField id="filter-project" label={t('project')}>
<Select <Select
id="filter-project" id="filter-project"
@@ -75,15 +105,14 @@ export function EntryFilters({ projects, types, project, status, type, search, f
</Select> </Select>
</AdminField> </AdminField>
<AdminField id="filter-search" label={t('search')}> <AdminField id="filter-per-page" label={t('perPage')}>
<input <Select id="filter-per-page" name="per_page" defaultValue={String(perPage)} onChange={submit}>
id="filter-search" {sizes.map(size => (
name="q" <option key={size} value={size}>
type="search" {size}
defaultValue={search} </option>
placeholder={t('searchPlaceholder')} ))}
className={inputClass} </Select>
/>
</AdminField> </AdminField>
</div> </div>
+21 -3
View File
@@ -11,6 +11,7 @@ export type EntryValues = {
typeId: string typeId: string
audience: Audience audience: Audience
coverMediaId: string | null coverMediaId: string | null
publishAt?: Date | null
} }
export type EntryCreateValues = EntryValues & { export type EntryCreateValues = EntryValues & {
@@ -18,12 +19,16 @@ export type EntryCreateValues = EntryValues & {
authorName: string | null authorName: string | null
} }
export type EntrySort = 'number' | 'title' | 'project' | 'status' | 'date' | 'author' | 'updated'
export type EntryFilter = { export type EntryFilter = {
projectIds?: string[] projectIds?: string[]
projectId?: string projectId?: string
status?: PostStatus status?: PostStatus
type?: string type?: string
search?: string search?: string
sort?: EntrySort
descending?: boolean
page: number page: number
perPage: number perPage: number
} }
@@ -127,8 +132,20 @@ function filters(filter: EntryFilter): SQL[] {
return parts return parts
} }
const sortColumns = {
number: posts.number,
title: posts.title,
project: projects.name,
status: posts.status,
date: posts.publishAt,
author: posts.authorName,
updated: posts.updatedAt,
}
export async function listEntries(filter: EntryFilter): Promise<EntryListResult> { export async function listEntries(filter: EntryFilter): Promise<EntryListResult> {
const where = and(...filters(filter)) const where = and(...filters(filter))
const column = sortColumns[filter.sort ?? 'updated']
const direction = filter.descending === false ? asc : desc
const items = await db const items = await db
.select(listSelection) .select(listSelection)
@@ -136,7 +153,7 @@ export async function listEntries(filter: EntryFilter): Promise<EntryListResult>
.innerJoin(projects, eq(projects.id, posts.projectId)) .innerJoin(projects, eq(projects.id, posts.projectId))
.innerJoin(postTypes, typeJoin) .innerJoin(postTypes, typeJoin)
.where(where) .where(where)
.orderBy(desc(posts.updatedAt), desc(posts.id)) .orderBy(sql`${column} ${filter.descending === false ? sql.raw('asc') : sql.raw('desc')} nulls last`, direction(posts.id))
.limit(filter.perPage) .limit(filter.perPage)
.offset((filter.page - 1) * filter.perPage) .offset((filter.page - 1) * filter.perPage)
@@ -186,7 +203,7 @@ export async function createEntry(values: EntryCreateValues): Promise<Post> {
where ${posts.projectId} = ${values.projectId}::uuid where ${posts.projectId} = ${values.projectId}::uuid
and (${posts.slug} = ${values.slug} or ${posts.slug} like ${values.slug} || '-%') and (${posts.slug} = ${values.slug} or ${posts.slug} like ${values.slug} || '-%')
) )
insert into post (project_id, 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, publish_at)
select select
${values.projectId}::uuid, ${values.projectId}::uuid,
case case
@@ -202,7 +219,8 @@ export async function createEntry(values: EntryCreateValues): Promise<Post> {
${values.typeId}::uuid, ${values.typeId}::uuid,
${values.audience}::post_audience, ${values.audience}::post_audience,
${values.authorName}, ${values.authorName},
${values.coverMediaId}::uuid ${values.coverMediaId}::uuid,
${values.publishAt ?? null}::timestamptz
from used from used
returning id returning id
`) `)
+19
View File
@@ -32,6 +32,7 @@ export type ApiEntryError =
| 'slug_invalid' | 'slug_invalid'
| 'blocks_invalid' | 'blocks_invalid'
| 'too_many_blocks' | 'too_many_blocks'
| 'invalid_date'
| 'not_found' | 'not_found'
| 'already_published' | 'already_published'
@@ -66,6 +67,7 @@ export type ApiCreateInput = {
authorName?: string | null authorName?: string | null
slug?: string | null slug?: string | null
coverMediaId?: string | null coverMediaId?: string | null
publishAt?: string | null
blocks?: unknown blocks?: unknown
idempotencyKey?: string | null idempotencyKey?: string | null
} }
@@ -77,6 +79,7 @@ export type ApiUpdateInput = {
audience?: Audience audience?: Audience
slug?: string | null slug?: string | null
coverMediaId?: string | null coverMediaId?: string | null
publishAt?: string | null
} }
export function canWrite(client: ApiClient): boolean { export function canWrite(client: ApiClient): boolean {
@@ -212,6 +215,13 @@ export async function apiCreateEntry(client: ApiClient, input: ApiCreateInput):
return blocks return blocks
} }
const wantedDate = (input.publishAt ?? '').trim()
const when = wantedDate === '' ? null : new Date(wantedDate)
if (when && Number.isNaN(when.getTime())) {
return { ok: false, error: 'invalid_date' }
}
const derived = slugify(title) const derived = slugify(title)
const created: Post = await createEntry({ const created: Post = await createEntry({
@@ -221,6 +231,7 @@ export async function apiCreateEntry(client: ApiClient, input: ApiCreateInput):
typeId: type.id, typeId: type.id,
audience, audience,
coverMediaId: input.coverMediaId ?? null, coverMediaId: input.coverMediaId ?? null,
publishAt: when,
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,
}) })
@@ -300,6 +311,13 @@ export async function apiUpdateEntry(client: ApiClient, id: string, input: ApiUp
return { ok: false, error: 'slug_invalid' } return { ok: false, error: 'slug_invalid' }
} }
const wantedDate = input.publishAt === undefined ? undefined : (input.publishAt ?? '').trim()
const when = wantedDate === undefined ? undefined : wantedDate === '' ? null : new Date(wantedDate)
if (when && Number.isNaN(when.getTime())) {
return { ok: false, error: 'invalid_date' }
}
const saved = await updateEntry(entry.id, { const saved = await updateEntry(entry.id, {
title, title,
teaser, teaser,
@@ -307,6 +325,7 @@ export async function apiUpdateEntry(client: ApiClient, id: string, input: ApiUp
audience, audience,
slug: wanted === '' ? entry.slug : wanted, slug: wanted === '' ? entry.slug : wanted,
coverMediaId: input.coverMediaId === undefined ? entry.coverMediaId : input.coverMediaId, coverMediaId: input.coverMediaId === undefined ? entry.coverMediaId : input.coverMediaId,
publishAt: when,
}) })
if (!saved) { if (!saved) {
+5 -3
View File
@@ -13,6 +13,7 @@ const statuses: Record<ApiEntryError, number> = {
slug_invalid: 400, slug_invalid: 400,
blocks_invalid: 400, blocks_invalid: 400,
too_many_blocks: 400, too_many_blocks: 400,
invalid_date: 400,
not_found: 404, not_found: 404,
already_published: 409, already_published: 409,
} }
@@ -25,10 +26,11 @@ const details: Record<ApiEntryError, string> = {
audience_not_allowed: 'Dieser Zugang darf diese Zielgruppe nicht setzen.', audience_not_allowed: 'Dieser Zugang darf diese Zielgruppe nicht setzen.',
title_missing: 'Der Titel fehlt.', title_missing: 'Der Titel fehlt.',
title_too_long: 'Der Titel ist zu lang.', title_too_long: 'Der Titel ist zu lang.',
teaser_too_long: 'Der Anreisser ist zu lang.', teaser_too_long: 'Der Anreißer ist zu lang.',
slug_invalid: 'Der Slug enthaelt unerlaubte Zeichen.', slug_invalid: 'Der Slug enthält unerlaubte Zeichen.',
blocks_invalid: 'Mindestens ein Block hat einen unbekannten Typ oder fehlende Felder.', blocks_invalid: 'Mindestens ein Block hat einen unbekannten Typ oder fehlende Felder.',
too_many_blocks: 'Zu viele Bloecke.', too_many_blocks: 'Zu viele Blöcke.',
invalid_date: 'Das Datum ist keine gültige Zeitangabe.',
not_found: 'Nicht gefunden.', not_found: 'Nicht gefunden.',
already_published: 'Der Beitrag ist bereits veröffentlicht und lässt sich so nicht mehr ändern.', already_published: 'Der Beitrag ist bereits veröffentlicht und lässt sich so nicht mehr ändern.',
} }
+12 -2
View File
@@ -309,7 +309,17 @@ async function move(
} }
export async function performPublishEntry(viewer: Viewer, input: EntryStatusInput): Promise<EntryResult> { export async function performPublishEntry(viewer: Viewer, input: EntryStatusInput): Promise<EntryResult> {
return move(viewer, input, 'published', { checked: true, publishAt: new Date(), action: 'post.publish' }) const wanted = (input.publishAt ?? '').trim()
const chosen = wanted === '' ? undefined : new Date(wanted)
if (chosen && Number.isNaN(chosen.getTime())) {
return { ok: false, error: 'scheduleMissing' }
}
const current = chosen ? undefined : await loadOwned(viewer, input.id)
const publishAt = chosen ?? current?.publishAt ?? new Date()
return move(viewer, input, 'published', { checked: true, publishAt, action: 'post.publish' })
} }
export async function performScheduleEntry( export async function performScheduleEntry(
@@ -330,7 +340,7 @@ export async function performScheduleEntry(
} }
if (publishAt.getTime() <= now.getTime()) { if (publishAt.getTime() <= now.getTime()) {
return { ok: false, error: 'schedulePast' } return move(viewer, input, 'published', { checked: true, publishAt, action: 'post.publish' })
} }
return move(viewer, input, 'scheduled', { checked: true, publishAt, action: 'post.schedule' }) return move(viewer, input, 'scheduled', { checked: true, publishAt, action: 'post.schedule' })
+7
View File
@@ -186,6 +186,12 @@ export function openApiDocument(baseUrl: string) {
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 }, cover_media_id: { type: 'string', format: 'uuid', nullable: true },
publish_at: {
type: 'string',
format: 'date-time',
nullable: true,
description: 'Gewünschtes Datum. Beim Veröffentlichen übernimmt ein Mensch genau dieses Datum.',
},
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 },
}, },
@@ -223,6 +229,7 @@ export function openApiDocument(baseUrl: string) {
audience: { type: 'string', enum: audiences }, audience: { type: 'string', enum: audiences },
slug: { type: 'string', nullable: true }, slug: { type: 'string', nullable: true },
cover_media_id: { type: 'string', format: 'uuid', nullable: true }, cover_media_id: { type: 'string', format: 'uuid', nullable: true },
publish_at: { type: 'string', format: 'date-time', nullable: true },
}, },
}, },
}, },
+15 -4
View File
@@ -180,13 +180,24 @@ describe('Statusübergänge', () => {
expect(dropped.ok && dropped.entry.publishAt).toBeNull() expect(dropped.ok && dropped.entry.publishAt).toBeNull()
}) })
it('lehnt einen Termin in der Vergangenheit ab', async () => { it('veröffentlicht rückwirkend, wenn der Termin in der Vergangenheit liegt', async () => {
const { entry } = await ready() const { entry } = await ready()
const earlier = new Date(Date.now() - 60 * 1000).toISOString() const earlier = new Date(Date.now() - 60 * 60 * 1000)
const state = await performScheduleEntry(adminViewer(), { id: entry.id, publishAt: earlier }) const state = await performScheduleEntry(adminViewer(), { id: entry.id, publishAt: earlier.toISOString() })
expect(state).toMatchObject({ ok: false, error: 'schedulePast' }) expect(state).toMatchObject({ ok: true })
expect(state.ok && state.entry.status).toBe('published')
expect(state.ok && state.entry.publishAt).toBe(earlier.toISOString())
})
it('übernimmt beim Veröffentlichen ein mitgegebenes Datum', async () => {
const { entry } = await ready()
const backdated = new Date('2026-01-15T09:00:00.000Z')
const state = await performPublishEntry(adminViewer(), { id: entry.id, publishAt: backdated.toISOString() })
expect(state.ok && state.entry.publishAt).toBe(backdated.toISOString())
}) })
it('lehnt einen Übergang ab, den der Status nicht hergibt', async () => { it('lehnt einen Übergang ab, den der Status nicht hergibt', async () => {