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:
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user