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,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;
}
}
}