News-Liste: serverseitige Pagination (Kontrakt 0070-FE)
Die Uebersichtsliste (news_newsliststicky/news_pi1 ohne limit und ohne
Auswahl, d.h. genau /insights/news) liefert jetzt pro Request nur die
Karten der angefragten Seite: ?page=<n>&category=<slug>, Antwort additiv
mit pagination {page,pages,itemsPerPage,total}, categories[] mit
Gesamt-Zaehlern und activeCategory. page klemmt auf die letzte Seite,
unbekannte category faellt auf alle zurueck, Sortierung serverseitig
datetime DESC, itemsPerPage aus settings.list.paginate (Default 12).
Caching: page/category stehen in cacheHash.excludedParameters, der
TYPO3-Seitencache unterscheidet die Varianten also nicht - das Element
konvertiert sich deshalb im paginierten Fall per
convertToUserIntObject() zu USER_INT (EXT:headless wrappt den
INT-Marker selbst); ETag/304 je Query-Variante verifiziert, der
SSR-Cache haelt eine Variante pro Pfad+Query.
Karten im paginierten Modus in schlanker Form (Felderliste aus
0070-FE); media[] traegt jetzt ueberall alternative (Alt-Text-Fallback
B5). Teaser-Listen mit limit und Selected-/Search-Listen unveraendert;
ohne pagination-Feld bleibt das Frontend beim bisherigen Verhalten.
JSON der Liste: 700 kB -> ~100 kB pro Seite.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
be163a88c2
commit
c9b00e6f10
1 file changed
+217
@@ -155,7 +155,39 @@ final class NewsJsonRenderer
|
||||
return json_encode($response) ?: '';
|
||||
}
|
||||
|
||||
// Server-side pagination for the big overview list (contract
|
||||
// 0070-FE/0075-BE): only a plain/sticky list WITHOUT a limit and
|
||||
// WITHOUT a hand-picked selection paginates. That is exactly the
|
||||
// /insights/news element (#130); every teaser list carries a
|
||||
// limit (1-3) and keeps today's full behaviour, as do selected/
|
||||
// search lists - the frontend falls back to client-side paging
|
||||
// wherever the `pagination` key is absent.
|
||||
$paginated = in_array($type, ['news_pi1', 'news_newsliststicky'], true)
|
||||
&& (int)($settings['limit'] ?? 0) === 0
|
||||
&& trim((string)($settings['selectedList'] ?? '')) === '';
|
||||
|
||||
if ($paginated
|
||||
&& $this->cObj !== null
|
||||
&& $this->cObj->getUserObjectType() === ContentObjectRenderer::OBJECTTYPE_USER
|
||||
) {
|
||||
// `page`/`category` sit in cacheHash.excludedParameters, so the
|
||||
// TYPO3 page cache does NOT distinguish the query variants -
|
||||
// a cacheable render would bake page 1 in for every URL. As
|
||||
// USER_INT the element renders per request; EXT:headless wraps
|
||||
// the INT marker itself (JsonContentObject detects
|
||||
// '<!--INT_SCRIPT.'), and the SSR full-page cache keeps one
|
||||
// variant per path+query, so per-request cost stays off the
|
||||
// visitors' hot path.
|
||||
$this->cObj->convertToUserIntObject();
|
||||
return '';
|
||||
}
|
||||
|
||||
$newsRows = $this->fetchNewsList($contentElement, $settings);
|
||||
|
||||
if ($paginated) {
|
||||
return $this->renderPaginatedList($contentElement, $settings, $newsRows);
|
||||
}
|
||||
|
||||
$items = array_map(fn (array $newsRow): array => $this->serializeNews($newsRow, $settings, 'list'), $newsRows);
|
||||
|
||||
$response = [
|
||||
@@ -360,6 +392,188 @@ final class NewsJsonRenderer
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated list payload (contract 0070-FE/0075-BE): only the requested
|
||||
* page's cards plus pagination meta, filter chips with full-list counts
|
||||
* and the active category. Request: ?page=<n>&category=<slug> on the page
|
||||
* URL; page is clamped into [1..pages] (page > pages serves the last
|
||||
* page), an unknown category slug falls back to "all".
|
||||
*
|
||||
* @param array<string,mixed> $contentElement
|
||||
* @param array<string,mixed> $settings
|
||||
* @param array<int,array<string,mixed>> $rows full filtered list (no limit)
|
||||
*/
|
||||
private function renderPaginatedList(array $contentElement, array $settings, array $rows): string
|
||||
{
|
||||
$queryParams = $GLOBALS['TYPO3_REQUEST']?->getQueryParams() ?? [];
|
||||
$requestedPage = max(1, (int)($queryParams['page'] ?? 1));
|
||||
$requestedCategory = strtolower(trim((string)($queryParams['category'] ?? '')));
|
||||
|
||||
// All categories of all entries in ONE query - feeds both the filter
|
||||
// chips (counts over the FULL list, independent of the active filter)
|
||||
// and the per-card category labels.
|
||||
$categoriesByNews = $this->categoriesForNewsUids(
|
||||
array_map(static fn (array $row): int => (int)($row['uid'] ?? 0), $rows)
|
||||
);
|
||||
|
||||
$categoryIndex = [];
|
||||
foreach ($rows as $row) {
|
||||
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
|
||||
$slug = $this->slugify((string)$category['title']);
|
||||
if ($slug === '') {
|
||||
continue;
|
||||
}
|
||||
if (!isset($categoryIndex[$slug])) {
|
||||
$categoryIndex[$slug] = [
|
||||
'uid' => (int)$category['uid'],
|
||||
'title' => (string)$category['title'],
|
||||
'slug' => $slug,
|
||||
'count' => 0,
|
||||
];
|
||||
}
|
||||
$categoryIndex[$slug]['count']++;
|
||||
}
|
||||
}
|
||||
ksort($categoryIndex);
|
||||
|
||||
$activeCategory = isset($categoryIndex[$requestedCategory]) ? $requestedCategory : '';
|
||||
|
||||
if ($activeCategory !== '') {
|
||||
$rows = array_values(array_filter($rows, function (array $row) use ($categoriesByNews, $activeCategory): bool {
|
||||
foreach ($categoriesByNews[(int)($row['uid'] ?? 0)] ?? [] as $category) {
|
||||
if ($this->slugify((string)$category['title']) === $activeCategory) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
// Newest first, server-side (the flexform historically says
|
||||
// "tstamp asc"; the frontend re-sorted - now it no longer has to).
|
||||
usort($rows, static fn (array $a, array $b): int => ((int)($b['datetime'] ?? 0)) <=> ((int)($a['datetime'] ?? 0)));
|
||||
|
||||
$itemsPerPage = (int)($settings['list']['paginate']['itemsPerPage'] ?? 0);
|
||||
if ($itemsPerPage <= 0) {
|
||||
$itemsPerPage = 12;
|
||||
}
|
||||
|
||||
$total = count($rows);
|
||||
$pages = max(1, (int)ceil($total / $itemsPerPage));
|
||||
$page = min($requestedPage, $pages);
|
||||
|
||||
$pageRows = array_slice($rows, ($page - 1) * $itemsPerPage, $itemsPerPage);
|
||||
|
||||
$items = array_map(
|
||||
fn (array $row): array => $this->serializeListCard($row, $settings, $categoriesByNews[(int)($row['uid'] ?? 0)] ?? []),
|
||||
$pageRows
|
||||
);
|
||||
|
||||
$response = [
|
||||
'mode' => 'list',
|
||||
'items' => $items,
|
||||
'pagination' => [
|
||||
'page' => $page,
|
||||
'pages' => $pages,
|
||||
'itemsPerPage' => $itemsPerPage,
|
||||
'total' => $total,
|
||||
],
|
||||
'categories' => array_values($categoryIndex),
|
||||
'activeCategory' => $activeCategory,
|
||||
'settings' => $this->buildSettingsPayload($contentElement, $settings),
|
||||
];
|
||||
|
||||
return json_encode($response) ?: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Lean card shape for paginated pages - only what the cards render
|
||||
* (0070-FE field list); canonicalUrl, pathSegment, tags, related* are
|
||||
* deliberately absent here.
|
||||
*
|
||||
* @param array<string,mixed> $news
|
||||
* @param array<string,mixed> $settings
|
||||
* @param array<int,array<string,mixed>> $categories
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function serializeListCard(array $news, array $settings, array $categories): array
|
||||
{
|
||||
$uid = (int)($news['uid'] ?? 0);
|
||||
$urls = $this->buildNewsUrls($news, $settings);
|
||||
$teaser = trim((string)($news['teaser'] ?? ''));
|
||||
|
||||
return [
|
||||
'uid' => $uid,
|
||||
'title' => (string)($news['title'] ?? ''),
|
||||
'alternativeTitle' => (string)($news['alternative_title'] ?? ''),
|
||||
'type' => (string)($news['type'] ?? '0'),
|
||||
'detailUrl' => $urls['detailUrl'],
|
||||
'datetime' => (int)($news['datetime'] ?? 0),
|
||||
'istopnews' => (bool)($news['istopnews'] ?? false),
|
||||
'teaser' => $teaser,
|
||||
'bodytext' => $teaser !== '' ? '' : $this->listExcerpt((string)($news['bodytext'] ?? '')),
|
||||
'categories' => array_map(
|
||||
static fn (array $category): array => ['uid' => (int)$category['uid'], 'title' => (string)$category['title']],
|
||||
$categories
|
||||
),
|
||||
'media' => $this->resolveFiles('tx_news_domain_model_news', 'fal_media', $uid),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* uid => categories map for a set of news records, resolved in one query
|
||||
* (the per-record resolveCategories() would fire ~600 queries here).
|
||||
*
|
||||
* @param list<int> $newsUids
|
||||
* @return array<int,array<int,array{uid:int,title:string}>>
|
||||
*/
|
||||
private function categoriesForNewsUids(array $newsUids): array
|
||||
{
|
||||
$newsUids = array_values(array_filter($newsUids));
|
||||
if ($newsUids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
|
||||
->getQueryBuilderForTable('sys_category');
|
||||
|
||||
$rows = $queryBuilder
|
||||
->select('c.uid', 'c.title', 'mm.uid_foreign')
|
||||
->from('sys_category', 'c')
|
||||
->join('c', 'sys_category_record_mm', 'mm', 'mm.uid_local = c.uid')
|
||||
->where(
|
||||
$queryBuilder->expr()->eq('mm.tablenames', $queryBuilder->createNamedParameter('tx_news_domain_model_news', ParameterType::STRING)),
|
||||
$queryBuilder->expr()->eq('mm.fieldname', $queryBuilder->createNamedParameter('categories', ParameterType::STRING)),
|
||||
$queryBuilder->expr()->in('mm.uid_foreign', $queryBuilder->createNamedParameter($newsUids, ArrayParameterType::INTEGER)),
|
||||
$queryBuilder->expr()->eq('c.deleted', 0),
|
||||
$queryBuilder->expr()->eq('c.hidden', 0)
|
||||
)
|
||||
->orderBy('c.title', 'ASC')
|
||||
->executeQuery()
|
||||
->fetchAllAssociative();
|
||||
|
||||
$map = [];
|
||||
foreach ($rows as $row) {
|
||||
$map[(int)$row['uid_foreign']][] = [
|
||||
'uid' => (int)$row['uid'],
|
||||
'title' => (string)$row['title'],
|
||||
];
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Category title -> filter slug ("Press Release" -> "press-release"),
|
||||
* mirroring the slugs the page URLs already carry.
|
||||
*/
|
||||
private function slugify(string $value): string
|
||||
{
|
||||
$value = mb_strtolower(trim($value));
|
||||
$value = preg_replace('/[^a-z0-9]+/u', '-', $value) ?? $value;
|
||||
return trim($value, '-');
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain-text excerpt of an RTE body for list cards (teaser fallback).
|
||||
*/
|
||||
@@ -697,6 +911,9 @@ final class NewsJsonRenderer
|
||||
$files[] = [
|
||||
'uid' => (int)$row['uid'],
|
||||
'url' => $imageService->getImageUri($processed),
|
||||
// Editor-maintained alt text; the frontend falls back to
|
||||
// the article title when this is empty (0070-FE, B5).
|
||||
'alternative' => (string)($fileReference->getAlternative() ?? ''),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
continue;
|
||||
|
||||
Reference in new issue
Block a user