Search cards above the results, detail-page fallback for market/solution documents
- New record type tx_vitec_domain_model_searchcard ("best bets"):
keyword list, priority, optional referenced record (page, product,
story, solution, market, news) and overridable title/teaser/image/link;
hidden/start/end time allow timed campaigns
- SearchCardService delivers content.search.cards on page 1: max. 3,
priority-ordered, deduplicated by URL, independent of filter, facets
and result count. Keywords match as whole words/phrases, case- and
punctuation-insensitive, including one level of the Solr managed
synonyms in both directions (cached for an hour, fail-soft). Record
cards read the record's indexed Solr document, so a card always
matches the record's own search result
- Index fallback for markets and solutions: when the record carries no
image or text, the document takes the intro image (then the hero
background) and the hero subline, intro headline and intro text from
the content blocks of its detail page - selected by CType, so it works
for layout 21 and layout 5 alike; improves results and cards
- Fix: a search request without q returned an empty body instead of the
documented empty shape (stdClass resolved inside the renderer namespace)
- migrations/check_solution_fields.php: shows live/workspace versions of
solution records
- Spec v1.22 (clause 7.16: cards)
This commit is contained in:
1 parent
8ead213bdf
commit
91ad5e6596
11 files changed
+626
-7
No files matched your search
@@ -102,7 +102,7 @@ routeEnhancers:
|
|||||||
solr_enabled_read: true
|
solr_enabled_read: true
|
||||||
solr_host_read: vitecsolr.evomedien.de
|
solr_host_read: vitecsolr.evomedien.de
|
||||||
solr_host_write: localhost
|
solr_host_write: localhost
|
||||||
solr_password_read: DvgpSaqrxr9QCd4ES36V
|
solr_password_read: '%env(SOLR_PASSWORD)%'
|
||||||
solr_path_read: /
|
solr_path_read: /
|
||||||
solr_path_write: /
|
solr_path_write: /
|
||||||
solr_port_read: '443'
|
solr_port_read: '443'
|
||||||
@@ -110,5 +110,5 @@ solr_port_write: '8983'
|
|||||||
solr_scheme_read: https
|
solr_scheme_read: https
|
||||||
solr_scheme_write: ''
|
solr_scheme_write: ''
|
||||||
solr_use_write_connection: false
|
solr_use_write_connection: false
|
||||||
solr_username_read: solr
|
solr_username_read: '%env(SOLR_USERNAME)%'
|
||||||
websiteTitle: ''
|
websiteTitle: ''
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Diagnose: what do the solution records really contain (all versions)?
|
||||||
|
* php migrations/check_solution_fields.php 50 1
|
||||||
|
*/
|
||||||
|
$uids = array_map('intval', array_slice($argv, 1)) ?: [50, 1];
|
||||||
|
$settings = require __DIR__ . '/../config/system/settings.php';
|
||||||
|
$db = $settings['DB']['Connections']['Default'];
|
||||||
|
$pdo = new PDO('mysql:host=' . $db['host'] . ';dbname=' . $db['dbname'] . ';charset=utf8mb4', $db['user'], $db['password']);
|
||||||
|
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||||
|
|
||||||
|
$in = implode(',', $uids);
|
||||||
|
$rows = $pdo->query(
|
||||||
|
"SELECT uid, pid, deleted, hidden, sys_language_uid, l10n_parent, t3ver_oid, t3ver_wsid, t3ver_state,
|
||||||
|
FROM_UNIXTIME(tstamp) AS changed, title, teaser, LEFT(description, 90) AS description
|
||||||
|
FROM tx_vitec_domain_model_solution
|
||||||
|
WHERE uid IN ($in) OR t3ver_oid IN ($in) OR l10n_parent IN ($in)
|
||||||
|
ORDER BY uid"
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
foreach ($rows as $r) {
|
||||||
|
$kind = $r['t3ver_oid'] > 0
|
||||||
|
? 'WORKSPACE-VERSION (ws ' . $r['t3ver_wsid'] . ') von uid ' . $r['t3ver_oid']
|
||||||
|
: ($r['l10n_parent'] > 0 ? 'Uebersetzung von uid ' . $r['l10n_parent'] : 'LIVE-Datensatz');
|
||||||
|
echo "--- uid {$r['uid']} | $kind | deleted={$r['deleted']} hidden={$r['hidden']} | geaendert {$r['changed']}\n";
|
||||||
|
echo " title: {$r['title']}\n";
|
||||||
|
echo " teaser: " . ($r['teaser'] === '' ? '(leer)' : $r['teaser']) . "\n";
|
||||||
|
echo " description: " . ($r['description'] === '' || $r['description'] === null ? '(leer)' : $r['description']) . "\n";
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Evomedien\Vitec\Service;
|
||||||
|
|
||||||
|
use ApacheSolrForTypo3\Solr\Domain\Search\Query\Query;
|
||||||
|
use ApacheSolrForTypo3\Solr\System\Solr\SolrConnection;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use TYPO3\CMS\Core\Cache\CacheManager;
|
||||||
|
use TYPO3\CMS\Core\Database\ConnectionPool;
|
||||||
|
use TYPO3\CMS\Core\Http\RequestFactory;
|
||||||
|
use TYPO3\CMS\Core\Resource\FileRepository;
|
||||||
|
use TYPO3\CMS\Core\Utility\GeneralUtility;
|
||||||
|
use TYPO3\CMS\Extbase\Service\ImageService;
|
||||||
|
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search cards ("best bets", 2026-10-09): editorial cards for
|
||||||
|
* content.search.cards (spec clause 7.16).
|
||||||
|
*
|
||||||
|
* A card matches when the search query contains one of its keywords as a
|
||||||
|
* whole word or phrase - case-insensitive, punctuation-insensitive, and
|
||||||
|
* one level of the Solr managed synonyms in both directions (keyword
|
||||||
|
* "control room" also fires for a synonym of it). At most MAX_CARDS,
|
||||||
|
* highest priority first, deduplicated by URL.
|
||||||
|
*
|
||||||
|
* Record cards take title/teaser/image/url from the record's INDEXED Solr
|
||||||
|
* document, so a card always looks like the record's search result; filled
|
||||||
|
* card fields override. A card without a resolvable URL or title is
|
||||||
|
* skipped. Everything is fail-soft: a broken card or an unreachable synonym
|
||||||
|
* resource never costs the search its results.
|
||||||
|
*/
|
||||||
|
final class SearchCardService
|
||||||
|
{
|
||||||
|
private const TABLE = 'tx_vitec_domain_model_searchcard';
|
||||||
|
private const MAX_CARDS = 3;
|
||||||
|
private const TEASER_LENGTH = 200;
|
||||||
|
private const SYNONYM_CACHE_KEY = 'vitec_searchcard_synonyms';
|
||||||
|
private const SYNONYM_CACHE_LIFETIME = 3600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,string> $typeLabels Solr document type => frontend type (shared with the result list)
|
||||||
|
* @return array<int,array{title:string,teaser:string,image:?string,url:string,linkLabel:string,type:string,keyword:string}>
|
||||||
|
*/
|
||||||
|
public function cardsFor(string $query, SolrConnection $connection, ServerRequestInterface $request, array $typeLabels): array
|
||||||
|
{
|
||||||
|
$normalizedQuery = $this->normalize($query);
|
||||||
|
if ($normalizedQuery === '') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$synonyms = $this->synonymIndex($connection);
|
||||||
|
|
||||||
|
$cards = [];
|
||||||
|
$seenUrls = [];
|
||||||
|
foreach ($this->activeCards() as $row) {
|
||||||
|
$keyword = $this->matchingKeyword($normalizedQuery, (string)$row['keywords'], $synonyms);
|
||||||
|
if ($keyword === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$card = $this->buildCard($row, $connection, $request, $typeLabels);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$card = null;
|
||||||
|
}
|
||||||
|
if ($card === null || isset($seenUrls[$card['url']])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$seenUrls[$card['url']] = true;
|
||||||
|
$card['keyword'] = $keyword;
|
||||||
|
$cards[] = $card;
|
||||||
|
if (count($cards) >= self::MAX_CARDS) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- matching -------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{forward:array<string,string[]>,reverse:array<string,string[]>} $synonyms
|
||||||
|
* @return string|null the card keyword that fired (as maintained), null = no match
|
||||||
|
*/
|
||||||
|
private function matchingKeyword(string $normalizedQuery, string $keywordField, array $synonyms): ?string
|
||||||
|
{
|
||||||
|
$haystack = ' ' . $normalizedQuery . ' ';
|
||||||
|
foreach (preg_split('/[,\n\r]+/', $keywordField) ?: [] as $rawKeyword) {
|
||||||
|
$keyword = $this->normalize($rawKeyword);
|
||||||
|
if ($keyword === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$candidates = array_merge(
|
||||||
|
[$keyword],
|
||||||
|
$synonyms['forward'][$keyword] ?? [],
|
||||||
|
$synonyms['reverse'][$keyword] ?? []
|
||||||
|
);
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
if ($candidate !== '' && str_contains($haystack, ' ' . $candidate . ' ')) {
|
||||||
|
return trim($rawKeyword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lowercase, every run of non-letters/digits becomes one space: "H.264" -> "h 264". */
|
||||||
|
private function normalize(string $value): string
|
||||||
|
{
|
||||||
|
return trim((string)preg_replace('/[^\p{L}\p{N}]+/u', ' ', mb_strtolower($value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Solr managed synonym map of the core, normalized like the query,
|
||||||
|
* as forward (term => synonyms) and reverse (synonym => terms) index.
|
||||||
|
* Cached for an hour; on any failure an empty index is cached for five
|
||||||
|
* minutes, so an unreachable Solr never slows down every search.
|
||||||
|
*
|
||||||
|
* @return array{forward:array<string,string[]>,reverse:array<string,string[]>}
|
||||||
|
*/
|
||||||
|
private function synonymIndex(SolrConnection $connection): array
|
||||||
|
{
|
||||||
|
$cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('hash');
|
||||||
|
$cached = $cache->get(self::SYNONYM_CACHE_KEY);
|
||||||
|
if (is_array($cached)) {
|
||||||
|
return $cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
$index = ['forward' => [], 'reverse' => []];
|
||||||
|
$lifetime = 300;
|
||||||
|
try {
|
||||||
|
$endpoint = $connection->getEndpoint('read');
|
||||||
|
$core = (string)$endpoint->getCore();
|
||||||
|
$url = rtrim($endpoint->getCoreBaseUri(), '/') . '/schema/analysis/synonyms/' . rawurlencode($core);
|
||||||
|
$options = ['timeout' => 3];
|
||||||
|
$auth = $endpoint->getAuthentication();
|
||||||
|
if (!empty($auth['username'])) {
|
||||||
|
$options['auth'] = [(string)$auth['username'], (string)($auth['password'] ?? '')];
|
||||||
|
}
|
||||||
|
$response = GeneralUtility::makeInstance(RequestFactory::class)->request($url, 'GET', $options);
|
||||||
|
$data = json_decode((string)$response->getBody(), true);
|
||||||
|
$map = $data['synonymMappings']['managedMap'] ?? null;
|
||||||
|
if (is_array($map)) {
|
||||||
|
foreach ($map as $term => $targets) {
|
||||||
|
$term = $this->normalize((string)$term);
|
||||||
|
if ($term === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ((array)$targets as $target) {
|
||||||
|
$target = $this->normalize((string)$target);
|
||||||
|
if ($target === '' || $target === $term) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$index['forward'][$term][] = $target;
|
||||||
|
$index['reverse'][$target][] = $term;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$lifetime = self::SYNONYM_CACHE_LIFETIME;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// synonyms are a bonus - plain keyword matching still works
|
||||||
|
}
|
||||||
|
$cache->set(self::SYNONYM_CACHE_KEY, $index, [], $lifetime);
|
||||||
|
return $index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- data -------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @return array<int,array<string,mixed>> visible cards, best first (frontend restrictions apply) */
|
||||||
|
private function activeCards(): array
|
||||||
|
{
|
||||||
|
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)->getQueryBuilderForTable(self::TABLE);
|
||||||
|
return $queryBuilder->select('*')
|
||||||
|
->from(self::TABLE)
|
||||||
|
->orderBy('priority', 'DESC')
|
||||||
|
->addOrderBy('uid', 'ASC')
|
||||||
|
->executeQuery()
|
||||||
|
->fetchAllAssociative();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string,mixed> $row
|
||||||
|
* @param array<string,string> $typeLabels
|
||||||
|
* @return array{title:string,teaser:string,image:?string,url:string,linkLabel:string,type:string}|null
|
||||||
|
*/
|
||||||
|
private function buildCard(array $row, SolrConnection $connection, ServerRequestInterface $request, array $typeLabels): ?array
|
||||||
|
{
|
||||||
|
$title = '';
|
||||||
|
$teaser = '';
|
||||||
|
$image = null;
|
||||||
|
$url = '';
|
||||||
|
$type = 'custom';
|
||||||
|
|
||||||
|
// Record card: the indexed document is the source of truth.
|
||||||
|
$reference = (string)$row['record'];
|
||||||
|
if ($reference !== '' && preg_match('/^(.+)_(\d+)$/', $reference, $m)) {
|
||||||
|
$table = $m[1];
|
||||||
|
$document = $this->indexedDocument($connection, $table, (int)$m[2]);
|
||||||
|
$type = $typeLabels[$table] ?? $table;
|
||||||
|
if ($document !== null) {
|
||||||
|
$title = trim((string)($document['title'] ?? ''));
|
||||||
|
$url = trim((string)($document['url'] ?? ''));
|
||||||
|
$teaser = $this->excerpt((string)($document['content'] ?? ''));
|
||||||
|
$docImage = trim((string)($document['image'] ?? ''));
|
||||||
|
if ($docImage !== '') {
|
||||||
|
$image = $this->rootRelative($docImage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card fields override (and carry free cards on their own).
|
||||||
|
if (trim((string)$row['card_title']) !== '') {
|
||||||
|
$title = trim((string)$row['card_title']);
|
||||||
|
}
|
||||||
|
if (trim((string)$row['teaser']) !== '') {
|
||||||
|
$teaser = trim((string)$row['teaser']);
|
||||||
|
}
|
||||||
|
$cardImage = $this->cardImage((int)$row['uid']);
|
||||||
|
if ($cardImage !== null) {
|
||||||
|
$image = $cardImage;
|
||||||
|
}
|
||||||
|
if (trim((string)$row['link']) !== '') {
|
||||||
|
$resolved = $this->resolveLink((string)$row['link'], $request);
|
||||||
|
if ($resolved !== '') {
|
||||||
|
$url = $resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($title === '' || $url === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'title' => $title,
|
||||||
|
'teaser' => $teaser,
|
||||||
|
'image' => $image,
|
||||||
|
'url' => $url,
|
||||||
|
'linkLabel' => trim((string)$row['link_label']),
|
||||||
|
'type' => $type,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string,mixed>|null fields of the record's Solr document */
|
||||||
|
private function indexedDocument(SolrConnection $connection, string $table, int $uid): ?array
|
||||||
|
{
|
||||||
|
if ($uid <= 0 || !preg_match('/^[a-z0-9_]+$/', $table)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$query = new Query();
|
||||||
|
$query->setQuery(sprintf('type:%s AND uid:%d', $table, $uid));
|
||||||
|
$query->setRows(1);
|
||||||
|
$query->setFields(['title', 'url', 'image', 'content', 'type']);
|
||||||
|
$response = $connection->getReadService()->search($query);
|
||||||
|
$documents = $response->response->docs ?? [];
|
||||||
|
if (!is_array($documents) || $documents === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$document = $documents[0];
|
||||||
|
return method_exists($document, 'getFields') ? (array)$document->getFields() : (array)$document;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First reference of the card's own image, processed to a 640px tile. */
|
||||||
|
private function cardImage(int $cardUid): ?string
|
||||||
|
{
|
||||||
|
$references = GeneralUtility::makeInstance(FileRepository::class)->findByRelation(self::TABLE, 'image', $cardUid);
|
||||||
|
if ($references === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$imageService = GeneralUtility::makeInstance(ImageService::class);
|
||||||
|
$processed = $imageService->applyProcessingInstructions($references[0], ['width' => '640']);
|
||||||
|
return $this->rootRelative($imageService->getImageUri($processed));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveLink(string $parameter, ServerRequestInterface $request): string
|
||||||
|
{
|
||||||
|
$contentObject = GeneralUtility::makeInstance(ContentObjectRenderer::class);
|
||||||
|
$contentObject->setRequest($request);
|
||||||
|
return (string)$contentObject->typoLink_URL(['parameter' => $parameter]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function excerpt(string $content): string
|
||||||
|
{
|
||||||
|
$content = trim((string)preg_replace('/\s+/u', ' ', strip_tags($content)));
|
||||||
|
if (mb_strlen($content) <= self::TEASER_LENGTH) {
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
$cut = mb_substr($content, 0, self::TEASER_LENGTH);
|
||||||
|
$space = mb_strrpos($cut, ' ');
|
||||||
|
return rtrim($space !== false ? mb_substr($cut, 0, $space) : $cut, ' ,;:-') . ' …';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** fileadmin/... (IMG_RESOURCE, ImageService) -> /fileadmin/..., absolute URLs untouched. */
|
||||||
|
private function rootRelative(string $path): string
|
||||||
|
{
|
||||||
|
if ($path === '' || str_starts_with($path, '/') || preg_match('#^https?://#', $path)) {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
return '/' . $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
|
|||||||
* "filter": the active type filter or null,
|
* "filter": the active type filter or null,
|
||||||
* "facets": { "type": [ { "value", "count", "active" } ] } -
|
* "facets": { "type": [ { "value", "count", "active" } ] } -
|
||||||
* counts stay complete while a filter is active,
|
* counts stay complete while a filter is active,
|
||||||
|
* "cards": editorial cards for the query (page 1 only, max 3,
|
||||||
|
* SearchCardService) - [] when none match,
|
||||||
* "suggestions": spellcheck alternatives ("did you mean"), [] if none
|
* "suggestions": spellcheck alternatives ("did you mean"), [] if none
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
@@ -271,6 +273,19 @@ class SearchJsonRenderer
|
|||||||
// faceting disabled or unavailable - facets stay empty
|
// faceting disabled or unavailable - facets stay empty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Editorial cards above the results - page 1 only (load-more pages
|
||||||
|
// append results, the cards are already on screen). Independent of
|
||||||
|
// the result count and the active filter; fail-soft on its own.
|
||||||
|
$cards = [];
|
||||||
|
if ($page === 1) {
|
||||||
|
try {
|
||||||
|
$cards = GeneralUtility::makeInstance(\Evomedien\Vitec\Service\SearchCardService::class)
|
||||||
|
->cardsFor($query, $connection, $request, self::TYPE_LABELS);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$cards = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$numFound = $resultSet->getAllResultCount();
|
$numFound = $resultSet->getAllResultCount();
|
||||||
$resultsPerPage = $resultSet->getUsedResultsPerPage() ?: count($results);
|
$resultsPerPage = $resultSet->getUsedResultsPerPage() ?: count($results);
|
||||||
|
|
||||||
@@ -283,6 +298,7 @@ class SearchJsonRenderer
|
|||||||
'filter' => $activeType !== '' ? $activeType : null,
|
'filter' => $activeType !== '' ? $activeType : null,
|
||||||
'activeFacets' => (object)$facetFilters,
|
'activeFacets' => (object)$facetFilters,
|
||||||
'facets' => (object)$facets,
|
'facets' => (object)$facets,
|
||||||
|
'cards' => $cards,
|
||||||
'results' => $results,
|
'results' => $results,
|
||||||
'suggestions' => array_values(array_unique($suggestions)),
|
'suggestions' => array_values(array_unique($suggestions)),
|
||||||
]);
|
]);
|
||||||
@@ -303,8 +319,9 @@ class SearchJsonRenderer
|
|||||||
'numFound' => 0,
|
'numFound' => 0,
|
||||||
'totalPages' => 0,
|
'totalPages' => 0,
|
||||||
'filter' => null,
|
'filter' => null,
|
||||||
'activeFacets' => new stdClass(),
|
'activeFacets' => new \stdClass(),
|
||||||
'facets' => new \stdClass(),
|
'facets' => new \stdClass(),
|
||||||
|
'cards' => [],
|
||||||
'results' => [],
|
'results' => [],
|
||||||
'suggestions' => [],
|
'suggestions' => [],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -476,6 +476,91 @@ plugin.tx_solr.statistics.anonymizeIP = 2
|
|||||||
# downloads/events/locations (no public detail pages), Datapath news (pid 442,
|
# downloads/events/locations (no public detail pages), Datapath news (pid 442,
|
||||||
# import awaiting an editorial decision).
|
# import awaiting an editorial decision).
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
# Detail-page fallbacks for markets and solutions (2026-10-09). The records
|
||||||
|
# themselves are mostly empty (no teaser/description/image) - the real copy
|
||||||
|
# lives in the market/solution Content Blocks on the record's detail_page,
|
||||||
|
# filled by the workbook importers. Copied into both queues below, so search
|
||||||
|
# results AND search cards (which mirror the indexed document) get a teaser
|
||||||
|
# and a tile image without double maintenance. Selected by CType, so it works
|
||||||
|
# for layout 21 (colPos 110/120) and layout 5 (colPos 1/0) alike.
|
||||||
|
#
|
||||||
|
# Image chain: record image -> detail page intro image -> hero background.
|
||||||
|
lib.vitecIndexDetailImage = CONTENT
|
||||||
|
lib.vitecIndexDetailImage {
|
||||||
|
table = tt_content
|
||||||
|
select {
|
||||||
|
pidInList.field = detail_page
|
||||||
|
where = {#CType} = 'vitec_marketintro'
|
||||||
|
orderBy = sorting
|
||||||
|
max = 1
|
||||||
|
}
|
||||||
|
renderObj = FILES
|
||||||
|
renderObj {
|
||||||
|
references {
|
||||||
|
table = tt_content
|
||||||
|
uid.data = field:uid
|
||||||
|
fieldName = vitec_image
|
||||||
|
}
|
||||||
|
maxItems = 1
|
||||||
|
renderObj = IMG_RESOURCE
|
||||||
|
renderObj {
|
||||||
|
file.import.data = file:current:uid
|
||||||
|
file.treatIdAsReference = 1
|
||||||
|
file.width = 640
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stdWrap.ifEmpty.cObject = CONTENT
|
||||||
|
stdWrap.ifEmpty.cObject {
|
||||||
|
table = tt_content
|
||||||
|
select {
|
||||||
|
pidInList.field = detail_page
|
||||||
|
where = {#CType} = 'vitec_markethero'
|
||||||
|
orderBy = sorting
|
||||||
|
max = 1
|
||||||
|
}
|
||||||
|
renderObj = FILES
|
||||||
|
renderObj {
|
||||||
|
references {
|
||||||
|
table = tt_content
|
||||||
|
uid.data = field:uid
|
||||||
|
fieldName = vitec_bgimage
|
||||||
|
}
|
||||||
|
maxItems = 1
|
||||||
|
renderObj = IMG_RESOURCE
|
||||||
|
renderObj {
|
||||||
|
file.import.data = file:current:uid
|
||||||
|
file.treatIdAsReference = 1
|
||||||
|
file.width = 640
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Searchable text of the detail page: hero subline, intro headline, intro
|
||||||
|
# text - in page order (hero first), appended to the record's own content.
|
||||||
|
lib.vitecIndexDetailText = CONTENT
|
||||||
|
lib.vitecIndexDetailText {
|
||||||
|
table = tt_content
|
||||||
|
select {
|
||||||
|
pidInList.field = detail_page
|
||||||
|
where = {#CType} IN ('vitec_markethero', 'vitec_marketintro')
|
||||||
|
orderBy = CType ASC
|
||||||
|
}
|
||||||
|
renderObj = COA
|
||||||
|
renderObj {
|
||||||
|
10 = TEXT
|
||||||
|
10.field = vitec_subline
|
||||||
|
10.noTrimWrap = || |
|
||||||
|
20 = TEXT
|
||||||
|
20.field = vitec_intro_headline
|
||||||
|
20.noTrimWrap = || |
|
||||||
|
30 = TEXT
|
||||||
|
30.field = vitec_intro_text
|
||||||
|
30.noTrimWrap = || |
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
plugin.tx_solr.index.queue {
|
plugin.tx_solr.index.queue {
|
||||||
|
|
||||||
products = 1
|
products = 1
|
||||||
@@ -577,6 +662,7 @@ plugin.tx_solr.index.queue {
|
|||||||
file.treatIdAsReference = 1
|
file.treatIdAsReference = 1
|
||||||
file.width = 640
|
file.width = 640
|
||||||
}
|
}
|
||||||
|
stdWrap.ifEmpty.cObject < lib.vitecIndexDetailImage
|
||||||
}
|
}
|
||||||
type = tx_vitec_domain_model_market
|
type = tx_vitec_domain_model_market
|
||||||
# only markets with an editorially assigned detail page are linkable;
|
# only markets with an editorially assigned detail page are linkable;
|
||||||
@@ -597,6 +683,7 @@ plugin.tx_solr.index.queue {
|
|||||||
30 = TEXT
|
30 = TEXT
|
||||||
30.field = description
|
30.field = description
|
||||||
30.noTrimWrap = || |
|
30.noTrimWrap = || |
|
||||||
|
40 < lib.vitecIndexDetailText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
url = TEXT
|
url = TEXT
|
||||||
@@ -710,6 +797,7 @@ plugin.tx_solr.index.queue {
|
|||||||
file.treatIdAsReference = 1
|
file.treatIdAsReference = 1
|
||||||
file.width = 640
|
file.width = 640
|
||||||
}
|
}
|
||||||
|
stdWrap.ifEmpty.cObject < lib.vitecIndexDetailImage
|
||||||
}
|
}
|
||||||
type = tx_vitec_domain_model_solution
|
type = tx_vitec_domain_model_solution
|
||||||
additionalWhereClause = detail_page > 0
|
additionalWhereClause = detail_page > 0
|
||||||
@@ -728,6 +816,7 @@ plugin.tx_solr.index.queue {
|
|||||||
30 = TEXT
|
30 = TEXT
|
||||||
30.field = description
|
30.field = description
|
||||||
30.noTrimWrap = || |
|
30.noTrimWrap = || |
|
||||||
|
40 < lib.vitecIndexDetailText
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
url = TEXT
|
url = TEXT
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Search cards ("best bets", 2026-10-09): editorial cards shown ABOVE the
|
||||||
|
* search results when the query contains one of the card's keywords.
|
||||||
|
*
|
||||||
|
* A card either points to an existing record (title, teaser, image and URL
|
||||||
|
* then come from that record's Solr document, i.e. exactly what the result
|
||||||
|
* list shows) or is maintained freely. Filled card fields always override
|
||||||
|
* the record values. Rendered by SearchCardService into content.search.cards
|
||||||
|
* (spec clause 7.16). The storage folder is free - every visible card counts.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'ctrl' => [
|
||||||
|
'title' => 'VITEC Search Card',
|
||||||
|
'label' => 'title',
|
||||||
|
'label_alt' => 'keywords',
|
||||||
|
'label_alt_force' => true,
|
||||||
|
'tstamp' => 'tstamp',
|
||||||
|
'crdate' => 'crdate',
|
||||||
|
'delete' => 'deleted',
|
||||||
|
'default_sortby' => 'priority DESC, title ASC',
|
||||||
|
'enablecolumns' => [
|
||||||
|
'disabled' => 'hidden',
|
||||||
|
'starttime' => 'starttime',
|
||||||
|
'endtime' => 'endtime',
|
||||||
|
],
|
||||||
|
'searchFields' => 'title,keywords,card_title,teaser',
|
||||||
|
'iconfile' => 'EXT:vitec/Resources/Public/Icons/vitec-search-card.svg',
|
||||||
|
'security' => [
|
||||||
|
'ignorePageTypeRestriction' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'types' => [
|
||||||
|
'1' => [
|
||||||
|
'showitem' => '
|
||||||
|
--div--;Trigger, title, keywords, priority,
|
||||||
|
--div--;Card content, record, card_title, teaser, image, --palette--;;link,
|
||||||
|
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:access, hidden, starttime, endtime',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'palettes' => [
|
||||||
|
'link' => [
|
||||||
|
'label' => 'Button',
|
||||||
|
'showitem' => 'link, link_label',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'columns' => [
|
||||||
|
'title' => [
|
||||||
|
'label' => 'Internal name',
|
||||||
|
'description' => 'Only for the backend list, never shown on the website.',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'input',
|
||||||
|
'size' => 40,
|
||||||
|
'max' => 255,
|
||||||
|
'required' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'keywords' => [
|
||||||
|
'label' => 'Keywords',
|
||||||
|
'description' => 'Comma or line separated. The card appears when the search contains one of these terms (whole words, case-insensitive; Solr synonyms count too). Example: control room, soc, noc, command centre',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'rows' => 3,
|
||||||
|
'cols' => 60,
|
||||||
|
'required' => true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'priority' => [
|
||||||
|
'label' => 'Priority',
|
||||||
|
'description' => 'At most 3 cards are shown; higher numbers win.',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'number',
|
||||||
|
'default' => 0,
|
||||||
|
'range' => ['lower' => -100, 'upper' => 100],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'record' => [
|
||||||
|
'label' => 'Show this record',
|
||||||
|
'description' => 'Title, teaser, image and link come from the record (as in the search results). Leave empty for a free card. A record that is hidden or not in the search index is skipped unless the fields below fill the gap.',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'group',
|
||||||
|
'allowed' => 'pages,tx_vitec_domain_model_product,tx_vitec_domain_model_usecase,tx_vitec_domain_model_solution,tx_vitec_domain_model_market,tx_news_domain_model_news',
|
||||||
|
'size' => 1,
|
||||||
|
'maxitems' => 1,
|
||||||
|
'minitems' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'card_title' => [
|
||||||
|
'label' => 'Card title',
|
||||||
|
'description' => 'Required for free cards; overrides the record title otherwise.',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'input',
|
||||||
|
'size' => 40,
|
||||||
|
'max' => 255,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'teaser' => [
|
||||||
|
'label' => 'Teaser',
|
||||||
|
'description' => 'Plain text, about 160 characters. Overrides the record teaser.',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'text',
|
||||||
|
'rows' => 3,
|
||||||
|
'cols' => 60,
|
||||||
|
'max' => 400,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'image' => [
|
||||||
|
'label' => 'Image',
|
||||||
|
'description' => 'Overrides the record image. Delivered as a 640px tile like the result images.',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'file',
|
||||||
|
'maxitems' => 1,
|
||||||
|
'allowed' => 'common-image-types',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'link' => [
|
||||||
|
'label' => 'Link',
|
||||||
|
'description' => 'Required for free cards; overrides the record URL otherwise.',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'link',
|
||||||
|
'allowedTypes' => ['page', 'url', 'record', 'file'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'link_label' => [
|
||||||
|
'label' => 'Button label',
|
||||||
|
'description' => 'Empty = the frontend default (e.g. "Learn more").',
|
||||||
|
'config' => [
|
||||||
|
'type' => 'input',
|
||||||
|
'size' => 20,
|
||||||
|
'max' => 80,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
| 1.19 | 2026‑09‑25 | **Interface change (additive).** The `vitec_card` Content Block gained the `layout_variant` value `flipcard` and a *Card Back* palette: `back_headline` (string, max 100) and `back_text` (richtext → HTML). The two fields are `displayCond`‑gated on the flipcard layout in the backend but serialised unconditionally (7.7) — the front end **shall** key the back face on `layout_variant`, never on field presence, because stored back values persist when an editor switches the layout away. Backend repair: `vitec_layout_variant` / `vitec_background_variant` are shared with `vitec_ctabanner`, and the index‑wise Content‑Blocks merge had dropped four card‑only select values (“Missing label”) — per‑type item lists now come from `columnsOverrides` (7.7); the Card‑Back palette lives in the Layout tab below the variant select. Editorial: the head‑matter version had been stuck at v1.17 since 1.18. |
|
| 1.19 | 2026‑09‑25 | **Interface change (additive).** The `vitec_card` Content Block gained the `layout_variant` value `flipcard` and a *Card Back* palette: `back_headline` (string, max 100) and `back_text` (richtext → HTML). The two fields are `displayCond`‑gated on the flipcard layout in the backend but serialised unconditionally (7.7) — the front end **shall** key the back face on `layout_variant`, never on field presence, because stored back values persist when an editor switches the layout away. Backend repair: `vitec_layout_variant` / `vitec_background_variant` are shared with `vitec_ctabanner`, and the index‑wise Content‑Blocks merge had dropped four card‑only select values (“Missing label”) — per‑type item lists now come from `columnsOverrides` (7.7); the Card‑Back palette lives in the Layout tab below the variant select. Editorial: the head‑matter version had been stuck at v1.17 since 1.18. |
|
||||||
| 1.20 | 2026‑10‑01 | **Interface change (additive).** Sub-market page template: frontend layout 21 with one colPos group per design section (`colPos110`..`colPos160`, clause 7.17); new Content Blocks `vitec_markethero`, `vitec_marketintro`, `vitec_marketinsights`; new plugin `vitec_productfeature` (key `productfeature`, one product in the 7.15.2 item shape); story cards gained `quote`/`quotePerson`/`quoteRole`; `vitec_usecaselist` gained the layout value `testimonials`; `vitec_productlist` gained hand-picked product selection (editor order, category filter ignored). Field semantics: Template_Sub-Market_Spezifikation_2026-10-01.md. |
|
| 1.20 | 2026‑10‑01 | **Interface change (additive).** Sub-market page template: frontend layout 21 with one colPos group per design section (`colPos110`..`colPos160`, clause 7.17); new Content Blocks `vitec_markethero`, `vitec_marketintro`, `vitec_marketinsights`; new plugin `vitec_productfeature` (key `productfeature`, one product in the 7.15.2 item shape); story cards gained `quote`/`quotePerson`/`quoteRole`; `vitec_usecaselist` gained the layout value `testimonials`; `vitec_productlist` gained hand-picked product selection (editor order, category filter ignored). Field semantics: Template_Sub-Market_Spezifikation_2026-10-01.md. |
|
||||||
| 1.21 | 2026‑10‑07 | **Interface change (additive) and corpus change.** Search results carry `image` (processed 640 px tile image, root-relative, `null` for types without one). Corpus: story documents now include their body (the inline `tt_content` children), page and story content include the text columns of the market/solution Content Blocks and the `vitec_items`/`vitec_faq_items` collection children; solutions are indexed. News rank by publication date (`datetime`; `crdate` is the import date) with an age-decay boost. EXT:solr upgraded from 14.0.0-RC1 to 14.0.2 final (TYPO3 and the Solr image). Editorial: 7.16 now states the `/_json` prefix the URL swap introduced, the `type` vocabulary lists `solution` (delivered since 1.14), and the footer had been stuck at v1.19. |
|
| 1.21 | 2026‑10‑07 | **Interface change (additive) and corpus change.** Search results carry `image` (processed 640 px tile image, root-relative, `null` for types without one). Corpus: story documents now include their body (the inline `tt_content` children), page and story content include the text columns of the market/solution Content Blocks and the `vitec_items`/`vitec_faq_items` collection children; solutions are indexed. News rank by publication date (`datetime`; `crdate` is the import date) with an age-decay boost. EXT:solr upgraded from 14.0.0-RC1 to 14.0.2 final (TYPO3 and the Solr image). Editorial: 7.16 now states the `/_json` prefix the URL swap introduced, the `type` vocabulary lists `solution` (delivered since 1.14), and the footer had been stuck at v1.19. |
|
||||||
|
| 1.22 | 2026‑10‑09 | **Interface change (additive) and defect fix.** Search cards ("best bets", 7.16): editorial `tx_vitec_domain_model_searchcard` records with keyword lists; the search payload gained `cards` (max. 3, priority-ordered, page 1 only, synonym-aware keyword match; record cards mirror the record’s indexed document, filled card fields override). Defect fix: a request without `q` returned an empty body instead of the documented empty shape (`stdClass` resolved inside the renderer namespace). |
|
||||||
|
|
||||||
This document is drafted in the style of, and adopts the terminology conventions of,
|
This document is drafted in the style of, and adopts the terminology conventions of,
|
||||||
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
ISO/IEC/IEEE 42010 (architecture description), ISO/IEC/IEEE 26514 (information for
|
||||||
@@ -1074,6 +1075,12 @@ identifier).
|
|||||||
{ "value": "product", "count": 13, "active": false }
|
{ "value": "product", "count": 13, "active": false }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"cards": [
|
||||||
|
{ "title": "Control Room Platforms", "teaser": "Unified, low-latency video …",
|
||||||
|
"image": "/fileadmin/_processed_/…/csm_gov_16-9_….jpg",
|
||||||
|
"url": "/solutions/control-room-command-centre-solutions/…",
|
||||||
|
"linkLabel": "", "type": "solution", "keyword": "control room" }
|
||||||
|
],
|
||||||
"results": [
|
"results": [
|
||||||
{ "title": "MGW Diamond-H", "url": "/product/mgw-diamond-h-hdmi-encoder",
|
{ "title": "MGW Diamond-H", "url": "/product/mgw-diamond-h-hdmi-encoder",
|
||||||
"type": "product", "teaser": "… 4K HDMI <mark>Encoder</mark> …",
|
"type": "product", "teaser": "… 4K HDMI <mark>Encoder</mark> …",
|
||||||
@@ -1107,6 +1114,31 @@ identifier).
|
|||||||
`facets.category` (product sys_category titles) appear whenever matching
|
`facets.category` (product sys_category titles) appear whenever matching
|
||||||
documents carry the fields; further facets are pure TypoScript.
|
documents carry the fields; further facets are pure TypoScript.
|
||||||
- `suggestions` lists spellcheck alternatives ("did you mean"), `[]` if none.
|
- `suggestions` lists spellcheck alternatives ("did you mean"), `[]` if none.
|
||||||
|
- `cards` lists editorial search cards for the query (below), `[]` if none.
|
||||||
|
|
||||||
|
**Search cards** ("best bets", since v1.22). Editors maintain records of
|
||||||
|
`tx_vitec_domain_model_searchcard` (any storage folder; hidden/start/end time
|
||||||
|
apply). A card **shall** appear above the results when the query contains one
|
||||||
|
of its keywords as a whole word or phrase - case- and punctuation-insensitive,
|
||||||
|
including one level of the Solr managed synonyms in both directions. At most
|
||||||
|
three cards, highest `priority` first, deduplicated by `url`; delivered on
|
||||||
|
page 1 only (load-more pages carry `[]`) and independent of `filter`, the
|
||||||
|
secondary facets and `numFound` - a card may stand above zero results.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `title` | Card title. |
|
||||||
|
| `teaser` | Plain text (no markup, unlike result teasers), may be empty. |
|
||||||
|
| `image` | 640 px tile, root-relative, or `null`. |
|
||||||
|
| `url` | Root-relative link target (or absolute for external links). |
|
||||||
|
| `linkLabel` | Button text, `""` = front-end default (e.g. "Learn more"). |
|
||||||
|
| `type` | Result type of the referenced record (`product`, `story`, …), `custom` for a free card. |
|
||||||
|
| `keyword` | The maintained keyword that fired (diagnostics, tracking). |
|
||||||
|
|
||||||
|
A card referencing a record takes title, teaser, image and URL from that
|
||||||
|
record’s **indexed Solr document**, so it always matches the record’s own
|
||||||
|
search result; filled card fields override. A card without a resolvable
|
||||||
|
title or URL (e.g. the record is hidden or not indexed) is skipped silently.
|
||||||
|
|
||||||
**Indexed corpus** (`plugin.tx_solr.index.queue`): pages, plus records with a
|
**Indexed corpus** (`plugin.tx_solr.index.queue`): pages, plus records with a
|
||||||
resolvable public URL - products, markets (only those with `detail_page`),
|
resolvable public URL - products, markets (only those with `detail_page`),
|
||||||
@@ -1557,4 +1589,4 @@ remediation.
|
|||||||
- Header convention — the uniform header section across CEs, plugins and containers.
|
- Header convention — the uniform header section across CEs, plugins and containers.
|
||||||
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
- `Configuration/Sets/Vitecset/setup.typoscript` — the single TypoScript entry point.
|
||||||
|
|
||||||
*End of document EVO‑VITEC‑HL‑001 v1.21.*
|
*End of document EVO‑VITEC‑HL‑001 v1.22.*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><rect x="1" y="2" width="14" height="12" rx="1.5" fill="#26358c"/><rect x="3" y="4" width="5" height="4" rx=".5" fill="#f36f21"/><rect x="9" y="4.5" width="4" height="1" fill="#fff"/><rect x="9" y="6.5" width="3" height="1" fill="#fff"/><circle cx="10.5" cy="11" r="1.6" fill="none" stroke="#fff" stroke-width="1"/><path d="M11.7 12.2l1.6 1.6" stroke="#fff" stroke-width="1" stroke-linecap="round"/><rect x="3" y="10" width="5" height="1" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 542 B |
@@ -424,3 +424,20 @@ CREATE TABLE sys_category (
|
|||||||
tx_vitec_success_stories varchar(1024) DEFAULT '' NOT NULL,
|
tx_vitec_success_stories varchar(1024) DEFAULT '' NOT NULL,
|
||||||
tx_vitec_success_stories_layout varchar(30) DEFAULT 'grid' NOT NULL
|
tx_vitec_success_stories_layout varchar(30) DEFAULT 'grid' NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
#
|
||||||
|
# Search cards ("best bets", 2026-10-09): editorial cards shown above the
|
||||||
|
# search results when the query contains one of the card's keywords.
|
||||||
|
# System columns (uid, pid, hidden, ...) come from the TCA.
|
||||||
|
#
|
||||||
|
CREATE TABLE tx_vitec_domain_model_searchcard (
|
||||||
|
title varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
keywords text,
|
||||||
|
priority int(11) DEFAULT '0' NOT NULL,
|
||||||
|
record varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
card_title varchar(255) DEFAULT '' NOT NULL,
|
||||||
|
teaser text,
|
||||||
|
image int(11) unsigned DEFAULT '0' NOT NULL,
|
||||||
|
link varchar(1024) DEFAULT '' NOT NULL,
|
||||||
|
link_label varchar(80) DEFAULT '' NOT NULL
|
||||||
|
);
|
||||||
@@ -23,8 +23,8 @@
|
|||||||
<meta name="twitter:description" content="Enterprise IPTV, video streaming, and digital signage solutions." />
|
<meta name="twitter:description" content="Enterprise IPTV, video streaming, and digital signage solutions." />
|
||||||
<link rel="icon" type="image/svg+xml" href="/fileadmin/icons/favicon.ico" />
|
<link rel="icon" type="image/svg+xml" href="/fileadmin/icons/favicon.ico" />
|
||||||
<title>VITEC</title>
|
<title>VITEC</title>
|
||||||
<script type="module" crossorigin src="/_frontend/assets/index-CEaqwHyi.js"></script>
|
<script type="module" crossorigin src="/_frontend/assets/index-Dvbm25nF.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-CgBOr71d.css">
|
<link rel="stylesheet" crossorigin href="/_frontend/assets/index-PE6EdhLv.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="page"></div>␍
|
<div id="page"></div>␍
|
||||||
|
|||||||
Reference in new issue
Block a user