- new "Features" column: check with cards-and-icons count per product (e.g. "5·4i"), header carries the filled total like the other columns - detail pane lists every feature card with icon thumbnail (click to enlarge), title and text; cards without an icon show a red cross on the icon slot - the missing agency icon sets are visible at a glance Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
413 lines
18 KiB
PHP
413 lines
18 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* VITEC Product Content Check (2026-11-03) - live aufrufbare Audit-Seite,
|
|
* KEIN Plugin (nichts, was Arne in React nachbauen muesste):
|
|
*
|
|
* https://dev.vitec.com/product-check/
|
|
*
|
|
* Eine Tabellenzeile pro Produkt (alphabetisch), pro Spalte ein Feld mit
|
|
* Check/Kreuz nach Fuellstand; jede Zeile aufklappbar mit ALLEN Inhalten,
|
|
* Bilder als Thumbnail mit Klick-Vergroesserung (Lightbox). Der
|
|
* Refresh-Button laedt die Seite mit Cache-Buster neu - gelesen wird bei
|
|
* jedem Aufruf frisch per PDO aus der Datenbank (Pattern migrations/
|
|
* build_product_audit.php, das weiter die statische Badge-Ansicht baut).
|
|
*
|
|
* Interna-Tool: noindex, kein Cache, auf dem Live-Host gesperrt.
|
|
*/
|
|
|
|
error_reporting(E_ALL);
|
|
ini_set('display_errors', '1');
|
|
|
|
header('Content-Type: text/html; charset=utf-8');
|
|
header('Cache-Control: no-store');
|
|
header('X-Robots-Tag: noindex, nofollow');
|
|
|
|
$host = (string)($_SERVER['HTTP_HOST'] ?? '');
|
|
if ($host !== '' && preg_match('/^(www\.)?vitec\.com$/i', $host)) {
|
|
http_response_code(403);
|
|
exit('Not available on this host.');
|
|
}
|
|
|
|
$settings = require dirname(__DIR__, 2) . '/config/system/settings.php';
|
|
$db = $settings['DB']['Connections']['Default'];
|
|
$pdo = new PDO(
|
|
sprintf('mysql:host=%s;port=%s;dbname=%s;charset=utf8mb4', $db['host'] ?? 'localhost', $db['port'] ?? 3306, $db['dbname'] ?? ''),
|
|
$db['user'] ?? '',
|
|
$db['password'] ?? '',
|
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
|
|
);
|
|
|
|
// ---- data -------------------------------------------------------------------
|
|
|
|
$products = $pdo->query(
|
|
"SELECT uid, title, slug, teaser, subtitle, description, description2, highlights,
|
|
capabilities, applications, resources, resources_layout,
|
|
hidden, legacy, supportproduct, subproduct, hideonwebsite, hideonproducts, showdatapath
|
|
FROM tx_vitec_domain_model_product WHERE deleted = 0 ORDER BY LOWER(title), uid"
|
|
)->fetchAll();
|
|
|
|
// FAL references per product and fieldname, with file identifiers
|
|
$fileRefs = $pdo->query(
|
|
"SELECT r.uid_foreign, r.fieldname, f.identifier, f.name
|
|
FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
|
|
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_domain_model_product'
|
|
AND r.fieldname IN ('heroimage','productimage','ogimage','videofile')
|
|
ORDER BY r.uid_foreign, r.fieldname, r.sorting_foreign"
|
|
)->fetchAll();
|
|
$files = [];
|
|
foreach ($fileRefs as $r) {
|
|
$files[(int)$r['uid_foreign']][$r['fieldname']][] = ['url' => '/fileadmin' . $r['identifier'], 'name' => (string)$r['name']];
|
|
}
|
|
|
|
// inline resource cards
|
|
$resourceRows = $pdo->query(
|
|
'SELECT * FROM tx_vitec_product_resource WHERE deleted = 0 AND hidden = 0 ORDER BY product, sorting'
|
|
)->fetchAll();
|
|
$resources = [];
|
|
foreach ($resourceRows as $r) {
|
|
$resources[(int)$r['product']][] = $r;
|
|
}
|
|
|
|
// MM: datasheets (download records) and solutions, in MM order
|
|
$datasheets = [];
|
|
foreach ($pdo->query(
|
|
'SELECT mm.uid_local AS product, d.title FROM tx_vitec_product_datasheet_mm mm
|
|
JOIN tx_vitec_domain_model_download d ON d.uid = mm.uid_foreign AND d.deleted = 0
|
|
ORDER BY mm.uid_local, mm.sorting'
|
|
)->fetchAll() as $r) {
|
|
$datasheets[(int)$r['product']][] = (string)$r['title'];
|
|
}
|
|
$solutions = [];
|
|
foreach ($pdo->query(
|
|
'SELECT mm.uid_local AS product, s.title FROM tx_vitec_product_solution_mm mm
|
|
JOIN tx_vitec_domain_model_solution s ON s.uid = mm.uid_foreign AND s.deleted = 0
|
|
ORDER BY mm.uid_local, mm.sorting'
|
|
)->fetchAll() as $r) {
|
|
$solutions[(int)$r['product']][] = (string)$r['title'];
|
|
}
|
|
|
|
// inline feature cards (Why choose ...?) + their icon references
|
|
$features = [];
|
|
foreach ($pdo->query(
|
|
'SELECT uid, product, title, cardtext FROM tx_vitec_product_feature
|
|
WHERE deleted = 0 AND hidden = 0 ORDER BY product, sorting, uid'
|
|
)->fetchAll() as $r) {
|
|
$features[(int)$r['product']][] = $r;
|
|
}
|
|
$featureIcons = [];
|
|
foreach ($pdo->query(
|
|
"SELECT r.uid_foreign, f.identifier, f.name
|
|
FROM sys_file_reference r JOIN sys_file f ON f.uid = r.uid_local
|
|
WHERE r.deleted = 0 AND r.hidden = 0 AND r.tablenames = 'tx_vitec_product_feature'
|
|
AND r.fieldname = 'icon'
|
|
ORDER BY r.uid_foreign, r.sorting_foreign"
|
|
)->fetchAll() as $r) {
|
|
$featureIcons[(int)$r['uid_foreign']] ??= ['url' => '/fileadmin' . $r['identifier'], 'name' => (string)$r['name']];
|
|
}
|
|
|
|
// ---- helpers ------------------------------------------------------------------
|
|
|
|
function h(string $v): string
|
|
{
|
|
return htmlspecialchars($v, ENT_QUOTES | ENT_HTML5);
|
|
}
|
|
|
|
/** check/cross cell; a count > 1 is shown next to the check */
|
|
function cell(bool $ok, int $count = 0): string
|
|
{
|
|
if (!$ok) {
|
|
return '<td class="no">✗</td>';
|
|
}
|
|
return '<td class="ok">✓' . ($count > 1 ? '<small>' . $count . '</small>' : '') . '</td>';
|
|
}
|
|
|
|
function filled(?string $v): bool
|
|
{
|
|
return trim(strip_tags((string)$v)) !== '';
|
|
}
|
|
|
|
/** labeled block in the detail pane; HTML of RTE fields is rendered */
|
|
function textBlock(string $label, ?string $value): string
|
|
{
|
|
if (!filled($value)) {
|
|
return '';
|
|
}
|
|
return '<div class="fld"><div class="fldlabel">' . h($label) . '</div><div class="fldval">' . $value . '</div></div>';
|
|
}
|
|
|
|
/** @param array<int,array{url:string,name:string}> $images */
|
|
function imageBlock(string $label, array $images): string
|
|
{
|
|
if ($images === []) {
|
|
return '';
|
|
}
|
|
$out = '<div class="fld"><div class="fldlabel">' . h($label) . ' (' . count($images) . ')</div><div class="thumbs">';
|
|
foreach ($images as $img) {
|
|
$out .= '<a href="' . h($img['url']) . '" class="zoom" title="' . h($img['name']) . '">'
|
|
. '<img loading="lazy" src="' . h($img['url']) . '" alt="' . h($img['name']) . '"></a>';
|
|
}
|
|
return $out . '</div></div>';
|
|
}
|
|
|
|
/**
|
|
* Feature cards with their icon thumbnails (click to enlarge).
|
|
*
|
|
* @param array<int,array<string,mixed>> $items
|
|
* @param array<int,array{url:string,name:string}> $featureIcons
|
|
*/
|
|
function featuresBlock(array $items, array $featureIcons): string
|
|
{
|
|
if ($items === []) {
|
|
return '';
|
|
}
|
|
$withIcon = 0;
|
|
$cards = '';
|
|
foreach ($items as $it) {
|
|
$icon = $featureIcons[(int)$it['uid']] ?? null;
|
|
if ($icon !== null) {
|
|
$withIcon++;
|
|
}
|
|
$cards .= '<div class="feature">'
|
|
. ($icon !== null
|
|
? '<a href="' . h($icon['url']) . '" class="zoom ficon" title="' . h($icon['name']) . '">'
|
|
. '<img loading="lazy" src="' . h($icon['url']) . '" alt="' . h($icon['name']) . '"></a>'
|
|
: '<span class="ficon noicon" title="no icon">✗</span>')
|
|
. '<div><div class="ftitle">' . h((string)$it['title']) . '</div>'
|
|
. '<div class="ftext">' . h((string)$it['cardtext']) . '</div></div></div>';
|
|
}
|
|
return '<div class="fld"><div class="fldlabel">Features (' . count($items) . ' cards, ' . $withIcon . ' icons)</div>'
|
|
. '<div class="features">' . $cards . '</div></div>';
|
|
}
|
|
|
|
/** @param string[] $items */
|
|
function listBlock(string $label, array $items): string
|
|
{
|
|
if ($items === []) {
|
|
return '';
|
|
}
|
|
return '<div class="fld"><div class="fldlabel">' . h($label) . ' (' . count($items) . ')</div><ul class="fldlist"><li>'
|
|
. implode('</li><li>', array_map('h', $items)) . '</li></ul></div>';
|
|
}
|
|
|
|
// ---- columns ------------------------------------------------------------------
|
|
|
|
$columns = ['Hero', 'Photos', 'Video', 'Teaser', 'Subtitle', 'Description', 'Descr. 2', 'Highlights', 'Capabilities', 'Applications', 'Features', 'Resources', 'Datasheets', 'Solutions'];
|
|
|
|
$filledPerColumn = array_fill_keys($columns, 0);
|
|
$rowsHtml = '';
|
|
foreach ($products as $p) {
|
|
$uid = (int)$p['uid'];
|
|
$f = $files[$uid] ?? [];
|
|
$res = $resources[$uid] ?? [];
|
|
$ds = $datasheets[$uid] ?? [];
|
|
$sol = $solutions[$uid] ?? [];
|
|
$feat = $features[$uid] ?? [];
|
|
$featIconCount = count(array_filter($feat, static fn(array $it): bool => isset($featureIcons[(int)$it['uid']])));
|
|
|
|
$checks = [
|
|
'Hero' => [count($f['heroimage'] ?? []), true],
|
|
'Photos' => [count($f['productimage'] ?? []), true],
|
|
'Video' => [count($f['videofile'] ?? []), true],
|
|
'Teaser' => [filled($p['teaser']) ? 1 : 0, false],
|
|
'Subtitle' => [filled($p['subtitle']) ? 1 : 0, false],
|
|
'Description' => [filled($p['description']) ? 1 : 0, false],
|
|
'Descr. 2' => [filled($p['description2']) ? 1 : 0, false],
|
|
'Highlights' => [filled($p['highlights']) ? 1 : 0, false],
|
|
'Capabilities' => [filled($p['capabilities']) ? 1 : 0, false],
|
|
'Applications' => [filled($p['applications']) ? 1 : 0, false],
|
|
'Features' => [count($feat), true],
|
|
'Resources' => [count($res), true],
|
|
'Datasheets' => [count($ds), true],
|
|
'Solutions' => [count($sol), true],
|
|
];
|
|
|
|
$cells = '';
|
|
foreach ($columns as $column) {
|
|
[$count, $showCount] = $checks[$column];
|
|
$filledPerColumn[$column] += $count > 0 ? 1 : 0;
|
|
if ($column === 'Features' && $count > 0) {
|
|
// cards + how many of them carry an icon
|
|
$cells .= '<td class="ok">✓<small>' . $count . '·' . $featIconCount . 'i</small></td>';
|
|
continue;
|
|
}
|
|
$cells .= cell($count > 0, $showCount ? $count : 0);
|
|
}
|
|
|
|
$flags = [];
|
|
foreach (['hidden' => 'hidden', 'legacy' => 'legacy', 'supportproduct' => 'support', 'subproduct' => 'sub', 'hideonwebsite' => 'hideWeb', 'hideonproducts' => 'hideList', 'showdatapath' => 'datapath'] as $col => $label) {
|
|
if ((int)$p[$col] === 1) {
|
|
$flags[] = $label;
|
|
}
|
|
}
|
|
|
|
// detail pane: every content of the record
|
|
$resourceLines = [];
|
|
foreach ($res as $r) {
|
|
$parts = [];
|
|
foreach (['title', 'header', 'label', 'kind', 'link'] as $key) {
|
|
if (isset($r[$key]) && trim((string)$r[$key]) !== '') {
|
|
$parts[] = (string)$r[$key];
|
|
}
|
|
}
|
|
$resourceLines[] = $parts === [] ? ('#' . $r['uid']) : implode(' · ', $parts);
|
|
}
|
|
|
|
$detail = '<a class="openproduct" href="/product/' . h(trim((string)$p['slug'], '/')) . '" target="_blank" rel="noopener">Open Product ↗</a>'
|
|
. '<div class="meta">'
|
|
. '<a href="/product/' . h(trim((string)$p['slug'], '/')) . '" target="_blank" rel="noopener">/product/' . h(trim((string)$p['slug'], '/')) . '</a>'
|
|
. ' · uid ' . $uid
|
|
. ' · resources_layout: ' . h((string)$p['resources_layout'])
|
|
. ($flags !== [] ? ' · Flags: ' . h(implode(', ', $flags)) : '')
|
|
. '</div>'
|
|
. imageBlock('Hero Image', $f['heroimage'] ?? [])
|
|
. imageBlock('Product Images', $f['productimage'] ?? [])
|
|
. imageBlock('OG Image', $f['ogimage'] ?? [])
|
|
. listBlock('Video Files', array_map(static fn(array $v): string => $v['name'] . ' (' . $v['url'] . ')', $f['videofile'] ?? []))
|
|
. textBlock('Teaser', (string)$p['teaser'])
|
|
. textBlock('Subtitle', (string)$p['subtitle'])
|
|
. textBlock('Description', (string)$p['description'])
|
|
. textBlock('Description 2', (string)$p['description2'])
|
|
. textBlock('Highlights', (string)$p['highlights'])
|
|
. textBlock('Capabilities', (string)$p['capabilities'])
|
|
. textBlock('Applications', (string)$p['applications'])
|
|
. featuresBlock($feat, $featureIcons)
|
|
. listBlock('Resources', $resourceLines)
|
|
. listBlock('Datasheets', $ds)
|
|
. listBlock('Solutions', $sol);
|
|
|
|
$rowsHtml .= '<tr class="prod" data-uid="' . $uid . '" data-title="' . h(mb_strtolower((string)$p['title'])) . '">'
|
|
. '<td class="name"><span class="caret">▸</span>' . h((string)$p['title'])
|
|
. ($flags !== [] ? ' <small class="flags">' . h(implode(' ', $flags)) . '</small>' : '')
|
|
. '</td>' . $cells . '</tr>' . "\n"
|
|
. '<tr class="detail"><td colspan="' . (count($columns) + 1) . '"><div class="detailwrap">' . $detail . '</div></td></tr>' . "\n";
|
|
}
|
|
|
|
$headCells = '<th class="name" id="sortname" title="Click to sort by title">Product (' . count($products) . ') <span id="sortdir">▲</span></th>';
|
|
foreach ($columns as $column) {
|
|
$headCells .= '<th title="' . h($column) . ' filled: ' . $filledPerColumn[$column] . '/' . count($products) . '">'
|
|
. h($column) . '<small>' . $filledPerColumn[$column] . '</small></th>';
|
|
}
|
|
|
|
// ---- page ---------------------------------------------------------------------
|
|
?>
|
|
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="robots" content="noindex,nofollow">
|
|
<title>VITEC Product Content Check</title>
|
|
<style>
|
|
:root{--ok:#1a7f37;--miss:#c0392b;--line:#e3e5e8;color-scheme:light}
|
|
body{font:14px/1.45 system-ui,sans-serif;margin:0;background:#f6f7f9;color:#1c1e21}
|
|
header{position:sticky;top:0;background:#fff;border-bottom:1px solid var(--line);padding:10px 20px;z-index:3;display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}
|
|
h1{font-size:17px;margin:0}
|
|
.sub{color:#777;font-size:12.5px}
|
|
#refresh{margin-left:auto;border:1px solid #26358c;background:#26358c;color:#fff;border-radius:6px;padding:6px 16px;font-size:13px;cursor:pointer}
|
|
#refresh:hover{background:#1b2766}
|
|
main{margin:14px;overflow-x:auto}
|
|
table{border-collapse:collapse;background:#fff;border:1px solid var(--line);min-width:100%}
|
|
th,td{border-bottom:1px solid var(--line);padding:6px 8px;text-align:center;font-size:13px;white-space:nowrap}
|
|
thead th{position:sticky;top:0;background:#fff;z-index:2;border-bottom:2px solid #c6cad1;cursor:default}
|
|
th#sortname{cursor:pointer;user-select:none}
|
|
th#sortname:hover{color:#26358c}
|
|
#sortdir{color:#26358c;font-size:11px}
|
|
thead th small{display:block;font-weight:400;color:#999;font-size:10.5px}
|
|
td.name,th.name{text-align:left;font-weight:600;max-width:340px;white-space:normal}
|
|
tr.prod{cursor:pointer}
|
|
tr.prod:hover td{background:#f2f5ff}
|
|
td.ok{color:var(--ok);font-weight:700}
|
|
td.ok small{color:#7fae8c;font-weight:400;margin-left:2px}
|
|
td.no{color:var(--miss);background:#fdf0ee}
|
|
.caret{display:inline-block;margin-right:6px;color:#888;transition:transform .12s}
|
|
tr.prod.open .caret{transform:rotate(90deg)}
|
|
.flags{color:#8a6d00;background:#fff7e0;border:1px solid #eadfa8;border-radius:8px;padding:0 6px;font-weight:400}
|
|
tr.detail{display:none}
|
|
tr.detail.show{display:table-row}
|
|
tr.detail>td{background:#fafbfd;text-align:left;white-space:normal;padding:0}
|
|
.detailwrap{padding:14px 18px;max-width:1100px}
|
|
.openproduct{display:inline-block;background:#26358c;color:#fff;border-radius:6px;padding:6px 16px;font-size:13px;font-weight:600;text-decoration:none;margin-bottom:10px}
|
|
.openproduct:hover{background:#1b2766}
|
|
.meta{color:#666;font-size:12.5px;margin-bottom:10px}
|
|
.meta a{color:#26358c}
|
|
.fld{margin-bottom:12px}
|
|
.fldlabel{font-weight:600;font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#555;margin-bottom:3px}
|
|
.fldval{background:#fff;border:1px solid var(--line);border-radius:6px;padding:8px 12px;overflow-wrap:anywhere}
|
|
.fldval p:first-child{margin-top:0}.fldval p:last-child{margin-bottom:0}
|
|
.fldlist{margin:0;background:#fff;border:1px solid var(--line);border-radius:6px;padding:8px 12px 8px 28px}
|
|
.features{display:grid;gap:8px}
|
|
.feature{display:flex;gap:10px;align-items:flex-start;background:#fff;border:1px solid var(--line);border-radius:6px;padding:8px 12px}
|
|
.ficon{width:40px;height:40px;flex:0 0 40px;display:flex;align-items:center;justify-content:center;border:1px solid var(--line);border-radius:6px;background:#fff}
|
|
.ficon img{max-width:30px;max-height:30px}
|
|
.noicon{color:var(--miss);background:#fdf0ee;font-weight:700}
|
|
.ftitle{font-weight:600}
|
|
.ftext{color:#555;font-size:12.5px}
|
|
.thumbs{display:flex;flex-wrap:wrap;gap:8px}
|
|
.thumbs a{display:flex;width:140px;height:92px;align-items:center;justify-content:center;background:#fff;border:1px solid var(--line);border-radius:6px;overflow:hidden}
|
|
.thumbs img{max-width:100%;max-height:100%;object-fit:contain}
|
|
#lightbox{position:fixed;inset:0;background:rgba(10,12,20,.82);display:none;align-items:center;justify-content:center;z-index:9;cursor:zoom-out}
|
|
#lightbox.show{display:flex}
|
|
#lightbox img{max-width:92vw;max-height:92vh;background:#fff;border-radius:4px}
|
|
#lightbox .cap{position:fixed;bottom:14px;left:0;right:0;text-align:center;color:#eee;font-size:13px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>VITEC Product Content Check</h1>
|
|
<span class="sub"><?= count($products) ?> products · as of <?= h(date('Y-m-d H:i:s')) ?> · click a row for details</span>
|
|
<button id="refresh" type="button">↻ Refresh</button>
|
|
</header>
|
|
<main>
|
|
<table>
|
|
<thead><tr><?= $headCells ?></tr></thead>
|
|
<tbody>
|
|
<?= $rowsHtml ?>
|
|
</tbody>
|
|
</table>
|
|
</main>
|
|
<div id="lightbox"><img src="" alt=""><div class="cap"></div></div>
|
|
<script>
|
|
document.getElementById('refresh').addEventListener('click', function () {
|
|
location.href = location.pathname + '?r=' + Date.now();
|
|
});
|
|
document.querySelectorAll('tr.prod').forEach(function (row) {
|
|
row.addEventListener('click', function () {
|
|
row.classList.toggle('open');
|
|
row.nextElementSibling.classList.toggle('show');
|
|
});
|
|
});
|
|
var sortAsc = true;
|
|
document.getElementById('sortname').addEventListener('click', function () {
|
|
sortAsc = !sortAsc;
|
|
document.getElementById('sortdir').textContent = sortAsc ? '▲' : '▼';
|
|
var tbody = document.querySelector('tbody');
|
|
var pairs = Array.prototype.map.call(tbody.querySelectorAll('tr.prod'), function (row) {
|
|
return [row, row.nextElementSibling];
|
|
});
|
|
pairs.sort(function (a, b) {
|
|
var cmp = a[0].dataset.title.localeCompare(b[0].dataset.title);
|
|
return sortAsc ? cmp : -cmp;
|
|
});
|
|
pairs.forEach(function (pair) { tbody.appendChild(pair[0]); tbody.appendChild(pair[1]); });
|
|
});
|
|
var lb = document.getElementById('lightbox');
|
|
var lbImg = lb.querySelector('img');
|
|
var lbCap = lb.querySelector('.cap');
|
|
document.querySelectorAll('a.zoom').forEach(function (a) {
|
|
a.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
lbImg.src = a.getAttribute('href');
|
|
lbCap.textContent = a.getAttribute('title') || '';
|
|
lb.classList.add('show');
|
|
});
|
|
});
|
|
lb.addEventListener('click', function () { lb.classList.remove('show'); lbImg.src = ''; });
|
|
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { lb.classList.remove('show'); lbImg.src = ''; } });
|
|
</script>
|
|
</body>
|
|
</html>
|