From ed5be8dd935cbf2e6234c5541b904dd1287ce801 Mon Sep 17 00:00:00 2001 From: Matthias G Date: Sat, 1 Aug 2026 18:16:05 +0200 Subject: [PATCH] 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. --- messages/de.json | 7 + messages/en.json | 7 + src/app/admin/entries/page.tsx | 84 ++--------- src/components/admin/EntryTable.tsx | 210 ++++++++++++++++++++++++++++ src/data/repositories/entries.ts | 10 ++ src/lib/entries.ts | 67 +++++++++ src/lib/entry-actions.ts | 22 +++ tests/lib/entries.test.ts | 38 +++++ 8 files changed, 372 insertions(+), 73 deletions(-) create mode 100644 src/components/admin/EntryTable.tsx diff --git a/messages/de.json b/messages/de.json index 4d23184..fa94eee 100644 --- a/messages/de.json +++ b/messages/de.json @@ -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": { diff --git a/messages/en.json b/messages/en.json index 7671a9a..c57d091 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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": { diff --git a/src/app/admin/entries/page.tsx b/src/app/admin/entries/page.tsx index 871f392..8af33db 100644 --- a/src/app/admin/entries/page.tsx +++ b/src/app/admin/entries/page.tsx @@ -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 + 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')} ) : ( - - - - - {(['number', 'title', 'project'] as SortKey[]).map(key => ( - - ))} - - - {(['status', 'date', 'author'] as SortKey[]).map(key => ( - - ))} - - - - {result.items.map(item => ( - - - - - - - - - - - ))} - -
- - {t(`columns.${key}`)} - {sort === key ? ( - descending - ? {t('columns.type')}{t('columns.audience')} - - {t(`columns.${key}`)} - {sort === key ? ( - descending - ?
- {entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number)} - - - {item.title} - - - - - {postTypeLabel(item.type, locale)}{t(`audience.${item.audience}`)} - - - - {item.authorName ?? t('noAuthor')}
-
+ )} {pages > 1 ? ( diff --git a/src/components/admin/EntryTable.tsx b/src/components/admin/EntryTable.tsx new file mode 100644 index 0000000..7c92491 --- /dev/null +++ b/src/components/admin/EntryTable.tsx @@ -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 + 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([]) + const [outcome, setOutcome] = useState(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 ( + + + {t(`columns.${key}`)} + {sort === key ? ( + descending + ?