Work on many entries at once

Rows carry a checkbox, the bar above publishes, withdraws, resets to draft,
sets the audience or deletes drafts for the whole selection and reports
what went through.
This commit is contained in:
Matthias G
2026-08-01 18:16:05 +02:00
parent cd3bf3f2b4
commit ed5be8dd93
8 changed files with 372 additions and 73 deletions
+7
View File
@@ -508,6 +508,13 @@
"scheduled": "Termin setzen",
"published": "Jetzt veröffentlichen",
"archived": "Zurückziehen"
},
"bulk": {
"selected": "{count, plural, =0 {nichts ausgewählt} one {# ausgewählt} other {# ausgewählt}}",
"selectAll": "Alle auf dieser Seite auswählen",
"audience": "Zielgruppe setzen",
"delete": "Entwürfe löschen",
"result": "{done} erledigt, {blocked} nicht freigegeben, {failed} fehlgeschlagen."
}
},
"brands": {
+7
View File
@@ -508,6 +508,13 @@
"scheduled": "Schedule",
"published": "Publish now",
"archived": "Withdraw"
},
"bulk": {
"selected": "{count, plural, =0 {nothing selected} one {# selected} other {# selected}}",
"selectAll": "Select everything on this page",
"audience": "Set audience",
"delete": "Delete drafts",
"result": "{done} done, {blocked} not releasable, {failed} failed."
}
},
"brands": {
+10 -72
View File
@@ -5,7 +5,7 @@ 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'
import { EntryStatusControl } from '~/components/admin/EntryStatusControl'
import { EntryTable } from '~/components/admin/EntryTable'
import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
import { EntryDate } from '~/components/ui/EntryDate'
import { entryMark, projectStyle, projectSurface } from '~/components/ui/Plate'
@@ -115,7 +115,7 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
query.set('dir', 'asc')
}
function sortPath(key: SortKey): Route {
function sortPath(key: SortKey): string {
const next = new URLSearchParams(query)
next.set('sort', key)
@@ -127,9 +127,11 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
next.delete('dir')
}
return `${adminEntriesPath}?${next.toString()}` as Route
return `${adminEntriesPath}?${next.toString()}`
}
const sortHrefs = Object.fromEntries(sortable.map(key => [key, sortPath(key)])) as Record<SortKey, string>
function pagePath(target: number): Route {
const next = new URLSearchParams(query)
@@ -196,76 +198,12 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
{filtered ? t('emptyFiltered') : t('empty')}
</AdminNotice>
) : (
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-4xl border-collapse text-left">
<thead>
<tr>
{(['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>
{(['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} whitespace-nowrap font-mono text-micro uppercase tracking-mark`}>
{entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number)}
</td>
<td className={cellClass}>
<Link
href={adminEntryPath(item.id)}
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
>
{item.title}
</Link>
</td>
<td className={`${cellClass} whitespace-nowrap`}>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
style={projectStyle(item.projectColor)}
className={`size-2.5 shrink-0 ${projectSurface}`}
<EntryTable
items={result.items}
sortHrefs={sortHrefs}
sort={sort ?? ''}
descending={descending}
/>
{item.projectName}
</span>
</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} whitespace-nowrap font-mono text-micro`}>
<EntryDate date={item.publishAt ?? item.updatedAt} />
</td>
<td className={`${cellClass} whitespace-nowrap`}>{item.authorName ?? t('noAuthor')}</td>
</tr>
))}
</tbody>
</table>
</Scroller>
)}
{pages > 1 ? (
+210
View File
@@ -0,0 +1,210 @@
'use client'
import { useState, useTransition } from 'react'
import Link from 'next/link'
import { useLocale, useTranslations } from 'next-intl'
import { TbArchive, TbArrowDown, TbArrowUp, TbRotate2, TbSend, TbTrash } from 'react-icons/tb'
import { EntryStatusControl } from './EntryStatusControl'
import { Select } from './Select'
import { cellClass, checkboxClass, ghostButtonClass, headCellClass } from './styles'
import { EntryDate } from '~/components/ui/EntryDate'
import { entryMark, projectStyle, projectSurface } from '~/components/ui/Plate'
import { Scroller } from '~/components/ui/Scroller'
import { postTypeLabel } from '~/domain/post-type'
import { adminEntryPath } from '~/lib/admin-routes'
import { bulkEntries } from '~/lib/entry-actions'
import type { Route } from 'next'
import type { EntryListItem } from '~/data/repositories/entries'
import type { Audience } from '~/domain/types'
import type { BulkAction, BulkOutcome } from '~/lib/entries'
import type { Locale } from '~/i18n/config'
export type EntrySortKey = 'number' | 'title' | 'project' | 'status' | 'date' | 'author'
export type EntryTableProps = {
items: EntryListItem[]
sortHrefs: Record<EntrySortKey, string>
sort: string
descending: boolean
}
const audiences: Audience[] = ['internal', 'customer', 'public']
const leading: EntrySortKey[] = ['number', 'title', 'project']
const trailing: EntrySortKey[] = ['status', 'date', 'author']
export function EntryTable({ items, sortHrefs, sort, descending }: EntryTableProps) {
const t = useTranslations('admin.entries')
const locale = useLocale() as Locale
const [chosen, setChosen] = useState<string[]>([])
const [outcome, setOutcome] = useState<BulkOutcome | null>(null)
const [pending, start] = useTransition()
const ids = items.map(item => item.id)
const selected = chosen.filter(id => ids.includes(id))
const all = selected.length === items.length && items.length > 0
function toggle(id: string) {
setOutcome(null)
setChosen(current => (current.includes(id) ? current.filter(entry => entry !== id) : [...current, id]))
}
function toggleAll() {
setOutcome(null)
setChosen(all ? [] : ids)
}
function apply(action: BulkAction, audience?: Audience) {
if (selected.length === 0) {
return
}
setOutcome(null)
start(async () => {
const result = await bulkEntries(selected, action, audience)
setOutcome(result)
setChosen([])
})
}
function head(key: EntrySortKey) {
return (
<th key={key} scope="col" className={headCellClass}>
<Link href={sortHrefs[key] as Route} 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>
)
}
return (
<div className="flex flex-col gap-4">
<div className="flex min-h-9 flex-wrap items-center gap-x-5 gap-y-3">
<span className="font-mono text-micro uppercase tracking-label text-ink-3">
{t('bulk.selected', { count: selected.length })}
</span>
{selected.length > 0 ? (
<>
<button type="button" disabled={pending} onClick={() => apply('publish')} className={ghostButtonClass}>
<TbSend aria-hidden="true" className="size-4 shrink-0" />
{t('actions.publish')}
</button>
<button type="button" disabled={pending} onClick={() => apply('archive')} className={ghostButtonClass}>
<TbArchive aria-hidden="true" className="size-4 shrink-0" />
{t('actions.archive')}
</button>
<button type="button" disabled={pending} onClick={() => apply('draft')} className={ghostButtonClass}>
<TbRotate2 aria-hidden="true" className="size-4 shrink-0" />
{t('actions.resume')}
</button>
<Select
value=""
disabled={pending}
aria-label={t('fields.audience')}
onChange={event => apply('audience', event.target.value as Audience)}
className="max-w-44"
>
<option value="">{t('bulk.audience')}</option>
{audiences.map(item => (
<option key={item} value={item}>
{t(`audience.${item}`)}
</option>
))}
</Select>
<button
type="button"
disabled={pending}
onClick={() => apply('delete')}
className={`${ghostButtonClass} hover:border-signal hover:text-signal`}
>
<TbTrash aria-hidden="true" className="size-4 shrink-0" />
{t('bulk.delete')}
</button>
</>
) : null}
{outcome ? (
<span role="status" className="font-mono text-micro text-ink-2">
{t('bulk.result', { done: outcome.done, blocked: outcome.blocked, failed: outcome.failed })}
</span>
) : null}
</div>
<Scroller options={{ overflow: { y: 'hidden' } }}>
<table className="w-full min-w-4xl border-collapse text-left">
<thead>
<tr>
<th scope="col" className={headCellClass}>
<input
type="checkbox"
checked={all}
aria-label={t('bulk.selectAll')}
onChange={toggleAll}
className={checkboxClass}
/>
</th>
{leading.map(head)}
<th scope="col" className={headCellClass}>{t('columns.type')}</th>
<th scope="col" className={headCellClass}>{t('columns.audience')}</th>
{trailing.map(head)}
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.id} className={selected.includes(item.id) ? 'bg-surface' : undefined}>
<td className={cellClass}>
<input
type="checkbox"
checked={selected.includes(item.id)}
aria-label={item.title}
onChange={() => toggle(item.id)}
className={checkboxClass}
/>
</td>
<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}>
<Link
href={adminEntryPath(item.id)}
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
>
{item.title}
</Link>
</td>
<td className={`${cellClass} whitespace-nowrap`}>
<span className="flex items-center gap-2">
<span
aria-hidden="true"
style={projectStyle(item.projectColor)}
className={`size-2.5 shrink-0 ${projectSurface}`}
/>
{item.projectName}
</span>
</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} whitespace-nowrap font-mono text-micro`}>
<EntryDate date={item.publishAt ?? item.updatedAt} />
</td>
<td className={`${cellClass} whitespace-nowrap`}>{item.authorName ?? t('noAuthor')}</td>
</tr>
))}
</tbody>
</table>
</Scroller>
</div>
)
}
+10
View File
@@ -278,6 +278,16 @@ export async function moveEntryToProject(id: string, projectId: string): Promise
return moved[0]
}
export async function setEntryAudience(id: string, audience: Audience): Promise<Post | undefined> {
const rows = await db
.update(posts)
.set({ audience, updatedAt: new Date() })
.where(eq(posts.id, id))
.returning()
return rows[0]
}
export async function updateEntry(id: string, values: EntryValues): Promise<Post | undefined> {
const rows = await db
.update(posts)
+67
View File
@@ -6,6 +6,7 @@ import {
findEntryBySlug,
listEntryBlocks,
replaceEntryBlocks,
setEntryAudience,
setEntryStatus,
moveEntryToProject,
updateEntry,
@@ -392,3 +393,69 @@ export async function performDeleteEntry(viewer: Viewer, id: string): Promise<En
return { ok: true }
}
export type BulkAction = 'publish' | 'archive' | 'draft' | 'delete' | 'audience'
export type BulkOutcome = {
done: number
failed: number
blocked: number
}
export async function performBulkEntries(
viewer: Viewer,
ids: string[],
action: BulkAction,
audience?: Audience,
): Promise<BulkOutcome> {
const outcome: BulkOutcome = { done: 0, failed: 0, blocked: 0 }
for (const id of ids) {
if (action === 'audience') {
const owned = await loadOwned(viewer, id)
if (!owned || !audience || !isAudience(audience)) {
outcome.failed += 1
continue
}
const saved = await setEntryAudience(id, audience)
if (saved) {
outcome.done += 1
} else {
outcome.failed += 1
}
continue
}
if (action === 'delete') {
const removed = await performDeleteEntry(viewer, id)
if (removed.ok) {
outcome.done += 1
} else {
outcome.failed += 1
}
continue
}
const result = action === 'publish'
? await performPublishEntry(viewer, { id })
: action === 'archive'
? await performArchiveEntry(viewer, { id })
: await performResumeEntry(viewer, { id })
if (result.ok) {
outcome.done += 1
} else if (result.error === 'blocked') {
outcome.blocked += 1
} else {
outcome.failed += 1
}
}
return outcome
}
+22
View File
@@ -9,15 +9,19 @@ import { readViewer } from './auth-guards'
import { adminEntriesPath, adminEntryPath, lastProjectCookie } from './admin-routes'
import {
performArchiveEntry,
performBulkEntries,
performDeleteEntry,
performPublishEntry,
performResumeEntry,
performSaveEntry,
performScheduleEntry,
performUnscheduleEntry,
type BulkAction,
type BulkOutcome,
} from './entries'
import { toEntryMediaList, toEntryMedia } from './entry-media'
import { readUploadBody, uploadMedia, type UploadFailure } from './media-upload'
import type { Audience } from '~/domain/types'
import type {
EntryDeleteResult,
EntryInput,
@@ -216,3 +220,21 @@ export async function removeEntry(id: string): Promise<EntryDeleteResult> {
return result
}
export async function bulkEntries(
ids: string[],
action: BulkAction,
audience?: Audience,
): Promise<BulkOutcome> {
const viewer = await readViewer()
if (!viewer) {
return { done: 0, failed: ids.length, blocked: 0 }
}
const outcome = await performBulkEntries(viewer, ids, action, audience)
revalidatePath(adminEntriesPath)
return outcome
}
+38
View File
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
import { findEntry, listEntryBlocks } from '~/data/repositories/entries'
import {
performArchiveEntry,
performBulkEntries,
performPublishEntry,
performResumeEntry,
performSaveEntry,
@@ -376,3 +377,40 @@ describe('performSaveEntry wechselt das Projekt', () => {
expect(result.ok).toBe(false)
})
})
describe('performBulkEntries', () => {
it('veröffentlicht mehrere Entwürfe auf einmal', async () => {
const first = await ready()
const second = await performSaveEntry(adminViewer(), entryInput(first.project.id, { title: 'Zweiter Beitrag' }))
if (!second.ok) {
throw new Error('Anlegen fehlgeschlagen')
}
const outcome = await performBulkEntries(adminViewer(), [first.entry.id, second.entry.id], 'publish')
expect(outcome).toMatchObject({ done: 2, failed: 0, blocked: 0 })
expect((await findEntry(first.entry.id))?.status).toBe('published')
expect((await findEntry(second.entry.id))?.status).toBe('published')
})
it('setzt die Zielgruppe für eine Auswahl', async () => {
const { entry } = await ready()
const outcome = await performBulkEntries(adminViewer(), [entry.id], 'audience', 'public')
expect(outcome).toMatchObject({ done: 1 })
expect((await findEntry(entry.id))?.audience).toBe('public')
})
it('löscht nur Entwürfe', async () => {
const { entry } = await ready()
await performPublishEntry(adminViewer(), { id: entry.id })
const outcome = await performBulkEntries(adminViewer(), [entry.id], 'delete')
expect(outcome).toMatchObject({ done: 0, failed: 1 })
expect(await findEntry(entry.id)).toBeDefined()
})
})