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:
+11
@@ -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
@@ -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
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
`)
|
`)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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' })
|
||||||
|
|||||||
@@ -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 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user