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.
## 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
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",
"searchPlaceholder": "Titel und Anreißer",
"submit": "Anwenden",
"reset": "Eingrenzung zurücksetzen"
"reset": "Eingrenzung zurücksetzen",
"perPage": "Pro Seite"
},
"status": {
"draft": "Entwurf",
@@ -374,7 +375,7 @@
"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.",
"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."
},
"save": {
+3 -2
View File
@@ -343,7 +343,8 @@
"search": "Search",
"searchPlaceholder": "Title and teaser",
"submit": "Apply",
"reset": "Reset filter"
"reset": "Reset filter",
"perPage": "Per page"
},
"status": {
"draft": "Draft",
@@ -374,7 +375,7 @@
"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.",
"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."
},
"save": {
+80 -15
View File
@@ -1,7 +1,7 @@
import type { Metadata, Route } from 'next'
import Link from 'next/link'
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 { AdminNotice } from '~/components/admin/AdminNotice'
import { EntryFilters } from '~/components/admin/EntryFilters'
@@ -23,7 +23,17 @@ import { requireAdminArea } from '~/lib/auth-guards'
import { toTypeOptions } from '~/lib/post-types'
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>>
@@ -52,6 +62,11 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
const typeValue = single(params.type)
const search = single(params.q)
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 filtered = projectSlug !== '' || statusValue !== '' || typeValue !== '' || search !== ''
@@ -63,6 +78,8 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
status: isPostStatus(statusValue) ? statusValue : undefined,
type: isPostTypeKey(typeValue) ? typeValue : undefined,
search,
sort,
descending,
page,
perPage,
})
@@ -86,6 +103,33 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
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 {
const next = new URLSearchParams(query)
@@ -125,6 +169,9 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
status={statusValue}
type={typeValue}
search={search}
perPage={perPage}
sort={sort ?? ''}
descending={descending}
filtered={filtered}
/>
@@ -150,23 +197,41 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
</AdminNotice>
) : (
<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>
<tr>
<th scope="col" className={headCellClass}>{t('columns.number')}</th>
<th scope="col" className={headCellClass}>{t('columns.title')}</th>
<th scope="col" className={headCellClass}>{t('columns.project')}</th>
{(['number', 'title', 'project'] as SortKey[]).map(key => (
<th key={key} scope="col" className={headCellClass}>
<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.audience')}</th>
<th scope="col" className={headCellClass}>{t('columns.status')}</th>
<th scope="col" className={headCellClass}>{t('columns.date')}</th>
<th scope="col" className={headCellClass}>{t('columns.author')}</th>
{(['status', 'date', 'author'] as SortKey[]).map(key => (
<th key={key} scope="col" className={headCellClass}>
<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>
</thead>
<tbody>
{result.items.map(item => (
<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)}
</td>
<td className={cellClass}>
@@ -177,7 +242,7 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
{item.title}
</Link>
</td>
<td className={cellClass}>
<td className={`${cellClass} whitespace-nowrap`}>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
@@ -187,15 +252,15 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
{item.projectName}
</span>
</td>
<td className={cellClass}>{postTypeLabel(item.type, locale)}</td>
<td className={cellClass}>{t(`audience.${item.audience}`)}</td>
<td className={`${cellClass} whitespace-nowrap`}>{postTypeLabel(item.type, locale)}</td>
<td className={`${cellClass} whitespace-nowrap`}>{t(`audience.${item.audience}`)}</td>
<td className={cellClass}>
<EntryStatusControl id={item.id} status={item.status} />
</td>
<td className={`${cellClass} font-mono text-micro`}>
<td className={`${cellClass} whitespace-nowrap font-mono text-micro`}>
<EntryDate date={item.publishAt ?? item.updatedAt} />
</td>
<td className={cellClass}>{item.authorName ?? t('noAuthor')}</td>
<td className={`${cellClass} whitespace-nowrap`}>{item.authorName ?? t('noAuthor')}</td>
</tr>
))}
</tbody>
+1 -2
View File
@@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
import { getTranslations } from 'next-intl/server'
import { AppShell } from '~/components/layout/AppShell'
import { AdminNav } from '~/components/layout/AdminNav'
import { Wrap } from '~/components/layout/Wrap'
import { Wordmark } from '~/components/layout/Wordmark'
import { loadHighestNumber } from '~/data/repositories/archive'
import { requireSession } from '~/lib/auth-guards'
@@ -29,7 +28,7 @@ export default async function AdminLayout({ children }: { children: ReactNode })
mark={<Wordmark number={number} />}
>
<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>
</AppShell>
)
+2
View File
@@ -15,6 +15,7 @@ const updateSchema = z.object({
audience: z.enum(['internal', 'customer', 'public']).optional(),
slug: 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 }> }) {
@@ -76,6 +77,7 @@ export async function PATCH(request: Request, context: { params: Promise<{ slug:
audience: body.audience,
slug: body.slug === undefined ? undefined : body.slug,
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)
+2
View File
@@ -57,6 +57,7 @@ const createSchema = z.object({
author_name: z.string().nullish(),
slug: z.string().nullish(),
cover_media_id: z.string().uuid().nullish(),
publish_at: z.string().nullish(),
blocks: z.unknown().optional(),
idempotency_key: z.string().min(1).max(200).nullish(),
})
@@ -84,6 +85,7 @@ export async function POST(request: Request) {
authorName: body.author_name ?? null,
slug: body.slug ?? null,
coverMediaId: body.cover_media_id ?? null,
publishAt: body.publish_at ?? null,
blocks: body.blocks,
idempotencyKey: body.idempotency_key ?? null,
})
+40 -11
View File
@@ -18,10 +18,26 @@ export type EntryFiltersProps = {
status: string
type: string
search: string
perPage: number
sort: string
descending: 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 states = useTranslations('admin.entries.status')
const form = useRef<HTMLFormElement>(null)
@@ -36,7 +52,21 @@ export function EntryFilters({ projects, types, project, status, type, search, f
action={adminEntriesPath}
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')}>
<Select
id="filter-project"
@@ -75,15 +105,14 @@ export function EntryFilters({ projects, types, project, status, type, search, f
</Select>
</AdminField>
<AdminField id="filter-search" label={t('search')}>
<input
id="filter-search"
name="q"
type="search"
defaultValue={search}
placeholder={t('searchPlaceholder')}
className={inputClass}
/>
<AdminField id="filter-per-page" label={t('perPage')}>
<Select id="filter-per-page" name="per_page" defaultValue={String(perPage)} onChange={submit}>
{sizes.map(size => (
<option key={size} value={size}>
{size}
</option>
))}
</Select>
</AdminField>
</div>
+21 -3
View File
@@ -11,6 +11,7 @@ export type EntryValues = {
typeId: string
audience: Audience
coverMediaId: string | null
publishAt?: Date | null
}
export type EntryCreateValues = EntryValues & {
@@ -18,12 +19,16 @@ export type EntryCreateValues = EntryValues & {
authorName: string | null
}
export type EntrySort = 'number' | 'title' | 'project' | 'status' | 'date' | 'author' | 'updated'
export type EntryFilter = {
projectIds?: string[]
projectId?: string
status?: PostStatus
type?: string
search?: string
sort?: EntrySort
descending?: boolean
page: number
perPage: number
}
@@ -127,8 +132,20 @@ function filters(filter: EntryFilter): SQL[] {
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> {
const where = and(...filters(filter))
const column = sortColumns[filter.sort ?? 'updated']
const direction = filter.descending === false ? asc : desc
const items = await db
.select(listSelection)
@@ -136,7 +153,7 @@ export async function listEntries(filter: EntryFilter): Promise<EntryListResult>
.innerJoin(projects, eq(projects.id, posts.projectId))
.innerJoin(postTypes, typeJoin)
.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)
.offset((filter.page - 1) * filter.perPage)
@@ -186,7 +203,7 @@ export async function createEntry(values: EntryCreateValues): Promise<Post> {
where ${posts.projectId} = ${values.projectId}::uuid
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
${values.projectId}::uuid,
case
@@ -202,7 +219,8 @@ export async function createEntry(values: EntryCreateValues): Promise<Post> {
${values.typeId}::uuid,
${values.audience}::post_audience,
${values.authorName},
${values.coverMediaId}::uuid
${values.coverMediaId}::uuid,
${values.publishAt ?? null}::timestamptz
from used
returning id
`)
+19
View File
@@ -32,6 +32,7 @@ export type ApiEntryError =
| 'slug_invalid'
| 'blocks_invalid'
| 'too_many_blocks'
| 'invalid_date'
| 'not_found'
| 'already_published'
@@ -66,6 +67,7 @@ export type ApiCreateInput = {
authorName?: string | null
slug?: string | null
coverMediaId?: string | null
publishAt?: string | null
blocks?: unknown
idempotencyKey?: string | null
}
@@ -77,6 +79,7 @@ export type ApiUpdateInput = {
audience?: Audience
slug?: string | null
coverMediaId?: string | null
publishAt?: string | null
}
export function canWrite(client: ApiClient): boolean {
@@ -212,6 +215,13 @@ export async function apiCreateEntry(client: ApiClient, input: ApiCreateInput):
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 created: Post = await createEntry({
@@ -221,6 +231,7 @@ export async function apiCreateEntry(client: ApiClient, input: ApiCreateInput):
typeId: type.id,
audience,
coverMediaId: input.coverMediaId ?? null,
publishAt: when,
slug: wanted === '' ? (isSlug(derived) ? derived : slugFallback) : wanted,
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' }
}
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, {
title,
teaser,
@@ -307,6 +325,7 @@ export async function apiUpdateEntry(client: ApiClient, id: string, input: ApiUp
audience,
slug: wanted === '' ? entry.slug : wanted,
coverMediaId: input.coverMediaId === undefined ? entry.coverMediaId : input.coverMediaId,
publishAt: when,
})
if (!saved) {
+5 -3
View File
@@ -13,6 +13,7 @@ const statuses: Record<ApiEntryError, number> = {
slug_invalid: 400,
blocks_invalid: 400,
too_many_blocks: 400,
invalid_date: 400,
not_found: 404,
already_published: 409,
}
@@ -25,10 +26,11 @@ const details: Record<ApiEntryError, string> = {
audience_not_allowed: 'Dieser Zugang darf diese Zielgruppe nicht setzen.',
title_missing: 'Der Titel fehlt.',
title_too_long: 'Der Titel ist zu lang.',
teaser_too_long: 'Der Anreisser ist zu lang.',
slug_invalid: 'Der Slug enthaelt unerlaubte Zeichen.',
teaser_too_long: 'Der Anreißer ist zu lang.',
slug_invalid: 'Der Slug enthält unerlaubte Zeichen.',
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.',
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> {
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(
@@ -330,7 +340,7 @@ export async function performScheduleEntry(
}
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' })
+7
View File
@@ -186,6 +186,12 @@ export function openApiDocument(baseUrl: string) {
author_name: { type: 'string', nullable: true },
slug: { type: 'string', 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 },
idempotency_key: { type: 'string', maxLength: 200, nullable: true },
},
@@ -223,6 +229,7 @@ export function openApiDocument(baseUrl: string) {
audience: { type: 'string', enum: audiences },
slug: { type: 'string', 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()
})
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 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 () => {