diff --git a/docs/api.md b/docs/api.md index 9a91e3d..5345dcc 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. diff --git a/messages/de.json b/messages/de.json index 0ec4b9b..4d23184 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 8b9e854..7671a9a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/src/app/admin/entries/page.tsx b/src/app/admin/entries/page.tsx index 6091070..871f392 100644 --- a/src/app/admin/entries/page.tsx +++ b/src/app/admin/entries/page.tsx @@ -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> @@ -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: ) : ( - +
- - - + {(['number', 'title', 'project'] as SortKey[]).map(key => ( + + ))} - - - + {(['status', 'date', 'author'] as SortKey[]).map(key => ( + + ))} {result.items.map(item => ( - - - - + + - - + ))} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index e916f88..c2286f1 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -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={} >
- {children} +
{children}
) diff --git a/src/app/api/v1/posts/[slug]/route.ts b/src/app/api/v1/posts/[slug]/route.ts index 66b728a..98ad0b7 100644 --- a/src/app/api/v1/posts/[slug]/route.ts +++ b/src/app/api/v1/posts/[slug]/route.ts @@ -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) diff --git a/src/app/api/v1/posts/route.ts b/src/app/api/v1/posts/route.ts index fed3812..5b0abed 100644 --- a/src/app/api/v1/posts/route.ts +++ b/src/app/api/v1/posts/route.ts @@ -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, }) diff --git a/src/components/admin/EntryFilters.tsx b/src/components/admin/EntryFilters.tsx index cbdb6c9..7a4bc9c 100644 --- a/src/components/admin/EntryFilters.tsx +++ b/src/components/admin/EntryFilters.tsx @@ -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(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" > -
+ + + +
+ + + + + +
diff --git a/src/data/repositories/entries.ts b/src/data/repositories/entries.ts index 9edd3e2..cfffd66 100644 --- a/src/data/repositories/entries.ts +++ b/src/data/repositories/entries.ts @@ -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 { 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 .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 { 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 { ${values.typeId}::uuid, ${values.audience}::post_audience, ${values.authorName}, - ${values.coverMediaId}::uuid + ${values.coverMediaId}::uuid, + ${values.publishAt ?? null}::timestamptz from used returning id `) diff --git a/src/lib/api-entries.ts b/src/lib/api-entries.ts index f822b22..f3a8448 100644 --- a/src/lib/api-entries.ts +++ b/src/lib/api-entries.ts @@ -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) { diff --git a/src/lib/api-problem.ts b/src/lib/api-problem.ts index 983cc50..fb75390 100644 --- a/src/lib/api-problem.ts +++ b/src/lib/api-problem.ts @@ -13,6 +13,7 @@ const statuses: Record = { 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 = { 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.', } diff --git a/src/lib/entries.ts b/src/lib/entries.ts index a1d27e0..6d1d274 100644 --- a/src/lib/entries.ts +++ b/src/lib/entries.ts @@ -309,7 +309,17 @@ async function move( } export async function performPublishEntry(viewer: Viewer, input: EntryStatusInput): Promise { - 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' }) diff --git a/src/lib/openapi.ts b/src/lib/openapi.ts index 268cc62..df35f82 100644 --- a/src/lib/openapi.ts +++ b/src/lib/openapi.ts @@ -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 }, }, }, }, diff --git a/tests/lib/entries.test.ts b/tests/lib/entries.test.ts index b221a31..45971a7 100644 --- a/tests/lib/entries.test.ts +++ b/tests/lib/entries.test.ts @@ -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 () => {
{t('columns.number')}{t('columns.title')}{t('columns.project')} + + {t(`columns.${key}`)} + {sort === key ? ( + descending + ? {t('columns.type')} {t('columns.audience')}{t('columns.status')}{t('columns.date')}{t('columns.author')} + + {t(`columns.${key}`)} + {sort === key ? ( + descending + ?
+ {entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number)} @@ -177,7 +242,7 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams: {item.title} + {postTypeLabel(item.type, locale)}{t(`audience.${item.audience}`)}{postTypeLabel(item.type, locale)}{t(`audience.${item.audience}`)} + {item.authorName ?? t('noAuthor')}{item.authorName ?? t('noAuthor')}