Megamenu goes visual: spotlight layout, page images on pagetree columns

- customer wish (via Olli, 2026-10-27): a more visual main menu in the
  style of rossvideo.com - image cards instead of plain link lists; for
  Insights a left-rail submenu whose hover swaps a visual area showing
  the latest news and the next events
- new entry layout "spotlight" in evo_megamenu_json, built strictly on
  the catalog pattern so the extension stays project-neutral: `nav` is
  a flat rail from the linked page's live children (abstract as teaser,
  first page media as 640px-webp image from stage 2 - the hover swap
  material), `spotlight` is emitted verbatim from the new
  BuildSpotlightEvent (null without a listener); curated columns are
  ignored like on pagetree
- new vitec listener ProvideInsightsSpotlight supplies the cards: the
  three latest news (storage pid 11, type 0 - the Solr convention;
  link /news/article/<path_segment> per the NewsDetail route enhancer;
  fal_media as processed 640px webp) and the next two events
  (hideonwebsite = 0, upcoming by eventstart/eventend; link = eventurl
  or the events page)
- pagetree columns can now carry an image (stage 2+, first page media
  of the column's page): attach images in the page properties and the
  markets/solutions panels turn into image cards without further code
- TCA select and label extended; README brought up to date (the
  pagetree layout had never been documented) with the spotlight JSON
  contract, reference mirror synced; frontend briefing updated
- activation is editorial: switch the Insights menu entry to the
  spotlight layout and link it to the Insights page

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
orascheandClaude Fable 5 committed 2026-10-27 11:47:23 +01:00
1 parent 90c2f97c5b
commit 6423b7c7f8
6 files changed
+362 -14

No files matched your search

@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
namespace Evomedien\EvoMegamenuJson\Event;
/**
* Dispatched for every entry with the panel layout `spotlight`.
*
* Same contract idea as BuildCatalogEvent: the extension knows that a
* spotlight panel exists (left-rail nav from the linked page's children,
* visual card area on the right), not where the cards come from. A site
* package listens and supplies the card sections (latest news, upcoming
* events, ...) as a plain array; it is emitted verbatim as `spotlight` on
* the entry. Without a listener the entry carries `spotlight: null` and the
* front end falls back to the nav items' own images.
*/
final class BuildSpotlightEvent
{
/** @var array<string,mixed>|null */
private ?array $spotlight = null;
/**
* @param array<string,mixed> $entry the raw tx_evomegamenujson_entry row
*/
public function __construct(
private readonly array $entry,
private readonly int $stage,
) {}
/** @return array<string,mixed> */
public function getEntry(): array
{
return $this->entry;
}
/** Content stage of the menu (1 structure, 2 + images/teasers, 3 + featured). */
public function getStage(): int
{
return $this->stage;
}
/** @return array<string,mixed>|null */
public function getSpotlight(): ?array
{
return $this->spotlight;
}
/** @param array<string,mixed> $spotlight */
public function setSpotlight(array $spotlight): void
{
$this->spotlight = $spotlight;
}
}
@@ -6,6 +6,7 @@ namespace Evomedien\EvoMegamenuJson\Service;
use Doctrine\DBAL\ParameterType;
use Evomedien\EvoMegamenuJson\Event\BuildCatalogEvent;
use Evomedien\EvoMegamenuJson\Event\BuildSpotlightEvent;
use Psr\EventDispatcher\EventDispatcherInterface;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\QueryBuilder;
@@ -87,7 +88,7 @@ final class MenuBuilder
private function buildEntry(array $entry, int $stage, ContentObjectRenderer $cObj): array
{
$layout = (string)($entry['layout'] ?? 'columns');
if (!in_array($layout, ['columns', 'teasers', 'catalog', 'pagetree'], true)) {
if (!in_array($layout, ['columns', 'teasers', 'catalog', 'pagetree', 'spotlight'], true)) {
$layout = 'columns';
}
@@ -111,7 +112,18 @@ final class MenuBuilder
// trigger). Curated columns are ignored on this layout; teasers and
// the featured slot stay editorial.
if ($layout === 'pagetree') {
$columns = $this->treeColumns((string)$entry['link'], $cObj);
$columns = $this->treeColumns((string)$entry['link'], $stage, $cObj);
}
// Spotlight panels (2026-10-27, customer wish "visual menu like
// rossvideo.com"): a flat left-rail nav built live from the linked
// page's children (abstract + page media per item, so the front end
// can swap the visual area on hover) next to live content cards that
// a site package supplies via BuildSpotlightEvent - this extension
// does not know the data sources. Curated columns are ignored here
// like on pagetree; teasers and the featured slot stay editorial.
if ($layout === 'spotlight') {
$columns = [];
}
$built = [
@@ -129,6 +141,12 @@ final class MenuBuilder
$built['catalog'] = $event->getCatalog();
}
if ($layout === 'spotlight') {
$built['nav'] = $this->navItems((string)$entry['link'], $stage, $cObj);
$spotlightEvent = $this->eventDispatcher->dispatch(new BuildSpotlightEvent($entry, $stage));
$built['spotlight'] = $spotlightEvent->getSpotlight();
}
if ($layout === 'teasers' || $layout === 'catalog') {
$teasers = [];
foreach ($this->children(self::TABLE_TEASER, (int)$entry['uid']) as $teaser) {
@@ -195,14 +213,9 @@ final class MenuBuilder
*
* @return array<int,array<string,mixed>>
*/
private function treeColumns(string $entryLink, ContentObjectRenderer $cObj): array
private function treeColumns(string $entryLink, int $stage, ContentObjectRenderer $cObj): array
{
$rootUid = 0;
if (preg_match('/t3:\/\/page\?uid=(\d+)/', $entryLink, $matches)) {
$rootUid = (int)$matches[1];
} elseif (ctype_digit(trim($entryLink))) {
$rootUid = (int)trim($entryLink);
}
$rootUid = $this->linkedPageUid($entryLink);
if ($rootUid <= 0) {
return [];
}
@@ -215,15 +228,69 @@ final class MenuBuilder
'link' => $this->url('t3://page?uid=' . (int)$grandchild['uid'], $cObj),
];
}
$columns[] = [
$column = [
'title' => (string)(($child['nav_title'] ?? '') !== '' ? $child['nav_title'] : $child['title']),
'link' => $this->url('t3://page?uid=' . (int)$child['uid'], $cObj),
'items' => $items,
];
// visual menus (2026-10-27): a page with media gets its first
// image as the column visual - attach an image in the page
// properties and the panel turns into image cards by itself
if ($stage >= 2) {
$image = $this->image('pages', 'media', (int)$child['uid']);
if ($image !== null) {
$column['image'] = $image;
}
}
$columns[] = $column;
}
return $columns;
}
/**
* Flat left-rail nav for the spotlight layout: the linked page's children
* with abstract and page media, so the front end can render the rail and
* swap the visual area on hover. Same visibility rules as treeColumns
* (getMenu applies hidden, time, nav_hide and fe_group).
*
* @return array<int,array<string,mixed>>
*/
private function navItems(string $entryLink, int $stage, ContentObjectRenderer $cObj): array
{
$rootUid = $this->linkedPageUid($entryLink);
if ($rootUid <= 0) {
return [];
}
$items = [];
foreach ($this->pageRepository->getMenu($rootUid) as $child) {
$item = [
'title' => (string)(($child['nav_title'] ?? '') !== '' ? $child['nav_title'] : $child['title']),
'link' => $this->url('t3://page?uid=' . (int)$child['uid'], $cObj),
];
if ($stage >= 2) {
$teaser = trim((string)($child['abstract'] ?? ''));
if ($teaser !== '') {
$item['teaser'] = $teaser;
}
$item['image'] = $this->image('pages', 'media', (int)$child['uid']);
}
$items[] = $item;
}
return $items;
}
/** Page uid a menu entry links to; 0 when the link is no page link. */
private function linkedPageUid(string $entryLink): int
{
if (preg_match('/t3:\/\/page\?uid=(\d+)/', $entryLink, $matches)) {
return (int)$matches[1];
}
if (ctype_digit(trim($entryLink))) {
return (int)trim($entryLink);
}
return 0;
}
// ------------------------------------------------------------ internals
/** @return array<string,mixed>|null */
@@ -106,6 +106,7 @@ return [
['label' => $ll . 'layout.teasers', 'value' => 'teasers'],
['label' => $ll . 'layout.catalog', 'value' => 'catalog'],
['label' => $ll . 'layout.pagetree', 'value' => 'pagetree'],
['label' => $ll . 'layout.spotlight', 'value' => 'spotlight'],
],
],
],
+44 -4
View File
@@ -17,10 +17,12 @@ A plugin called **Megamenu**. Inside it:
| **Teaser card** | kicker, headline, text, link, image — for editorial panels |
| **Featured** | one highlighted block per entry, edited on the entry itself |
Three panel layouts per entry: `columns` for a structure menu (products,
solutions, markets), `teasers` for a small link column next to cards
(news, stories), and `catalog` for a data-driven panel whose content comes
from the site package (see below).
Five panel layouts per entry: `columns` for a curated structure menu,
`teasers` for a small link column next to cards (news, stories),
`pagetree` for a structure menu built live from the page tree,
`catalog` for a data-driven panel whose content comes from the site
package, and `spotlight` for a visual panel with a left-rail nav and live
content cards (see below).
## Installation
@@ -115,6 +117,44 @@ final class ProvideCatalog
`getEntry()` hands the listener the raw entry row (to tell several catalog
entries apart), `getStage()` the content stage.
### Layout `pagetree`
A structure panel that can never age against the site: the columns mirror
the live subtree of the page the entry links to - one column per child
page, its children as the items. Hidden or nav-hidden pages leave the menu
with the next cache flush. Curated columns are ignored; teasers and the
featured slot stay editorial. From stage 2 a child page with media gets its
first image as `image` on the column - attach images in the page
properties and the panel turns visual by itself.
### Layout `spotlight`
A visual panel ("rossvideo style"): `nav` is a flat left rail built live
from the linked page's children - each item with `title`, `link` and, from
stage 2, the page `abstract` as `teaser` plus the first page media as
`image`, so the front end can swap the visual area on hover. Next to it the
extension emits `spotlight`, filled by the site package: it dispatches
`Evomedien\EvoMegamenuJson\Event\BuildSpotlightEvent` per spotlight entry
and emits whatever a listener sets via `setSpotlight()` verbatim
(`null` without a listener). Curated columns are ignored.
The VITEC site package ships `ProvideInsightsSpotlight`, which supplies
```json
"spotlight": {
"news": [ {"title", "date", "link", "teaser?", "image?"} ],
"events": [ {"title", "start", "end", "venue", "booth", "city",
"country", "attendanceMode", "link", "teaser?", "image?"} ]
}
```
- `news`: the three latest news records (storage pid 11, type 0 - the Solr
convention), `link` = `/news/article/<path_segment>` per the NewsDetail
route enhancer, image from `fal_media` as processed 640px webp.
- `events`: the next two events (`hideonwebsite` = 0, upcoming by
eventstart/eventend), `link` = the record's `eventurl` or the events
page, image processed the same way.
## Notes
* Every link is a resolved URL — page, record, external or file, run through
@@ -26,6 +26,7 @@
<trans-unit id="tx_evomegamenujson_entry.layout.teasers"><source>Link column plus teaser cards</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.catalog"><source>Catalog (data-driven: categories and items from the site package, teaser cards as featured tiles)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.pagetree"><source>Page tree (data-driven: one column per child of the linked page, its children as items; curated columns are ignored)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.layout.spotlight"><source>Spotlight (data-driven: left-rail nav from the linked page's children plus live visual cards - latest news, next events - supplied by the site package; curated columns are ignored)</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.menu_columns"><source>Columns</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.menu_columns.description"><source>One column per group. In the teaser layout only the first column is rendered, next to the cards.</source></trans-unit>
<trans-unit id="tx_evomegamenujson_entry.menu_teasers"><source>Teaser cards</source></trans-unit>
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
namespace Evomedien\Vitec\EventListener;
use Doctrine\DBAL\ParameterType;
use Evomedien\EvoMegamenuJson\Event\BuildSpotlightEvent;
use TYPO3\CMS\Core\Attribute\AsEventListener;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Core\Database\Query\Restriction\FrontendRestrictionContainer;
use TYPO3\CMS\Core\Resource\FileRepository;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Extbase\Service\ImageService;
/**
* Supplies the card area for megamenu entries with the layout `spotlight`
* (2026-10-27, customer wish "visual menu like rossvideo.com"): the latest
* news and the next events as image cards, queried live - editors touch
* news and event records, never the menu.
*
* Conventions mirrored from the site set: news live on pid 11 (type 0 only,
* same filter as the Solr indexing TS), their details render on page 21
* through the NewsDetail route enhancer (/news/article/<path_segment>).
* Events come from the vitec event table; an event links to its own
* eventurl when set, otherwise to the events page.
*/
#[AsEventListener(identifier: 'vitec/megamenu-insights-spotlight')]
final class ProvideInsightsSpotlight
{
private const NEWS_TABLE = 'tx_news_domain_model_news';
private const NEWS_STORAGE_PID = 11;
private const NEWS_DETAIL_PATH = '/news/article/';
private const EVENT_TABLE = 'tx_vitec_domain_model_event';
private const EVENTS_PAGE = '/insights/events';
private const NEWS_COUNT = 3;
private const EVENT_COUNT = 2;
public function __construct(
private readonly ConnectionPool $connectionPool,
private readonly FileRepository $fileRepository,
) {}
public function __invoke(BuildSpotlightEvent $event): void
{
$stage = $event->getStage();
$event->setSpotlight([
'news' => $this->latestNews($stage),
'events' => $this->upcomingEvents($stage),
]);
}
/** @return array<int,array<string,mixed>> */
private function latestNews(int $stage): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::NEWS_TABLE);
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
try {
$rows = $queryBuilder->select('uid', 'title', 'teaser', 'datetime', 'path_segment')
->from(self::NEWS_TABLE)
->where(
$queryBuilder->expr()->eq('pid', $queryBuilder->createNamedParameter(self::NEWS_STORAGE_PID, ParameterType::INTEGER)),
$queryBuilder->expr()->eq('type', $queryBuilder->createNamedParameter('0'))
)
->orderBy('datetime', 'DESC')
->setMaxResults(self::NEWS_COUNT)
->executeQuery()->fetchAllAssociative();
} catch (\Throwable) {
return [];
}
$cards = [];
foreach ($rows as $row) {
$card = [
'title' => (string)$row['title'],
'date' => ((int)$row['datetime']) > 0 ? date('Y-m-d', (int)$row['datetime']) : '',
'link' => (string)$row['path_segment'] !== '' ? self::NEWS_DETAIL_PATH . $row['path_segment'] : '',
];
if ($stage >= 2) {
$teaser = trim((string)($row['teaser'] ?? ''));
if ($teaser !== '') {
$card['teaser'] = $teaser;
}
$card['image'] = $this->image(self::NEWS_TABLE, 'fal_media', (int)$row['uid']);
}
$cards[] = $card;
}
return $cards;
}
/** @return array<int,array<string,mixed>> */
private function upcomingEvents(int $stage): array
{
$now = time();
$queryBuilder = $this->connectionPool->getQueryBuilderForTable(self::EVENT_TABLE);
$queryBuilder->setRestrictions(GeneralUtility::makeInstance(FrontendRestrictionContainer::class));
try {
$rows = $queryBuilder->select('*')
->from(self::EVENT_TABLE)
->where(
$queryBuilder->expr()->eq('hideonwebsite', $queryBuilder->createNamedParameter(0, ParameterType::INTEGER)),
$queryBuilder->expr()->or(
$queryBuilder->expr()->gte('eventend', $queryBuilder->createNamedParameter($now, ParameterType::INTEGER)),
$queryBuilder->expr()->and(
$queryBuilder->expr()->eq('eventend', $queryBuilder->createNamedParameter(0, ParameterType::INTEGER)),
$queryBuilder->expr()->gte('eventstart', $queryBuilder->createNamedParameter($now - 86400, ParameterType::INTEGER))
)
)
)
->orderBy('eventstart', 'ASC')
->setMaxResults(self::EVENT_COUNT)
->executeQuery()->fetchAllAssociative();
} catch (\Throwable) {
return [];
}
$cards = [];
foreach ($rows as $row) {
$card = [
'title' => (string)$row['title'],
'start' => ((int)$row['eventstart']) > 0 ? date('Y-m-d', (int)$row['eventstart']) : '',
'end' => ((int)$row['eventend']) > 0 ? date('Y-m-d', (int)$row['eventend']) : '',
'venue' => (string)$row['venue'],
'booth' => (string)$row['booth'],
'city' => (string)$row['city'],
'country' => (string)$row['country'],
'attendanceMode' => (string)$row['attendancemode'],
'link' => (string)$row['eventurl'] !== '' ? (string)$row['eventurl'] : self::EVENTS_PAGE,
];
if ($stage >= 2) {
$teaser = trim((string)($row['teaser'] ?? ''));
if ($teaser !== '') {
$card['teaser'] = $teaser;
}
$card['image'] = $this->image(self::EVENT_TABLE, 'image', (int)$row['uid']);
}
$cards[] = $card;
}
return $cards;
}
/**
* First file reference as a processed 640px webp card image, original URL
* as the fallback - same contract as MenuBuilder::image() so every menu
* image behaves the same on the wire.
*
* @return array<string,mixed>|null
*/
private function image(string $table, string $field, int $uid): ?array
{
try {
$references = $this->fileRepository->findByRelation($table, $field, $uid);
} catch (\Throwable) {
return null;
}
$reference = $references[0] ?? null;
if ($reference === null) {
return null;
}
try {
try {
$imageService = GeneralUtility::makeInstance(ImageService::class);
$processed = $imageService->applyProcessingInstructions(
$reference,
['maxWidth' => 640, 'fileExtension' => 'webp']
);
return [
'url' => $imageService->getImageUri($processed),
'alternative' => (string)$reference->getProperty('alternative'),
'title' => (string)$reference->getProperty('title'),
'width' => (int)$processed->getProperty('width'),
'height' => (int)$processed->getProperty('height'),
];
} catch (\Throwable) {
return [
'url' => $reference->getPublicUrl(),
'alternative' => (string)$reference->getProperty('alternative'),
'title' => (string)$reference->getProperty('title'),
'width' => (int)$reference->getProperty('width'),
'height' => (int)$reference->getProperty('height'),
];
}
} catch (\Throwable) {
return null;
}
}
}