| Server IP : 202.61.199.114 / Your IP : 216.73.217.139 Web Server : nginx/1.22.1 System : Linux de.arni-solutions.de 6.1.0-49-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.174-1 (2026-05-26) x86_64 User : web20 ( 1018) PHP Version : 8.4.23 Disable Function : NONE MySQL : OFF | cURL : ON | WGET : ON | Perl : ON | Python : OFF | Sudo : ON | Pkexec : ON Directory : /var/www/arni-solutions.de/web/terminal/ |
Upload File : |
<?php
/**
* Öffentliches Terminal-Portal: terminal.php?h=<hash>
* Der Hash ist das Geheimnis (fester Link). PIN-Gate → Terminal-Startseite.
* Grundstruktur: Startseite ist (noch) leer – Ansichten werden später ausgestaltet.
*/
require_once __DIR__ . '/config.inc.php';
$db = term_db();
$hash = (string)($_GET['h'] ?? '');
$self = 'terminal.php?h=' . urlencode($hash);
$terminal = $hash !== '' ? term_terminal_load($db, $hash) : null;
// Unbekannt oder deaktiviert → neutrale Fehlermeldung (kein Hinweis auf Existenz).
if (!$terminal || (int)$terminal['terminal_active'] !== 1) {
http_response_code(404);
t_html('Nicht verfügbar',
'<div class="t-gate"><div class="t-card t-gate-card">'
. '<div class="t-empty"><div class="ico">🔒</div>Dieses Terminal ist nicht verfügbar.</div>'
. '</div></div>');
}
$label = (string)$terminal['terminal_label'];
$error = '';
// Slots-Ansicht dieses Terminals (für die Einstellungen: Guthaben & Spielauswahl)
$slotsView = null;
foreach (term_terminal_accesses($db, (int)$terminal['id']) as $vv) {
if (($vv['module'] ?? '') === 'slots' && (int)$vv['view_active'] === 1) { $slotsView = $vv; break; }
}
// Logout: Freischalt-Cookie entfernen → zurück zum PIN-Gate
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['logout'])) {
term_terminal_clear_unlock($hash);
term_redirect($self);
}
// Einstellungen: PIN ändern (AJAX, nur wenn entsperrt + CSRF)
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['settings_action']) && term_terminal_cookie_ok($hash)) {
if (!term_csrf_ok($_POST['_csrf'] ?? null)) term_json(['ok' => false, 'error' => 'csrf'], 403);
$act = (string)$_POST['settings_action'];
if ($act === 'verify') {
$pin = (string)($_POST['pin'] ?? '');
term_json(['ok' => term_hash_equals((string)$terminal['terminal_pin'], term_terminal_pin_hash($hash, $pin))]);
}
if ($act === 'change') {
$old = (string)($_POST['old'] ?? '');
$new = (string)($_POST['new'] ?? '');
if (!term_hash_equals((string)$terminal['terminal_pin'], term_terminal_pin_hash($hash, $old))) {
term_json(['ok' => false, 'error' => 'old']);
}
if (!preg_match('/^\d{4,12}$/', $new)) term_json(['ok' => false, 'error' => 'len']);
$st = $db->prepare('UPDATE ' . term_tbl('terminals') . ' SET terminal_pin = ?, updated_at = NOW() WHERE id = ?');
$st->execute([term_terminal_pin_hash($hash, $new), (int)$terminal['id']]);
term_json(['ok' => true]);
}
if ($act === 'slots_state' || $act === 'slots_save') {
if (!$slotsView) term_json(['ok' => false, 'error' => 'no_slots']);
$vtok = (string)$slotsView['view_token'];
if ($act === 'slots_state') {
$res = term_slots_request($hash, $vtok, 'state', [], 'GET');
} else {
$p = [];
if (isset($_POST['balance'])) $p['balance'] = (string)$_POST['balance'];
if (isset($_POST['games']) && is_array($_POST['games'])) $p['games'] = $_POST['games'];
$res = term_slots_request($hash, $vtok, 'set', $p, 'POST');
}
if ($res === null) term_json(['ok' => false, 'error' => 'slots_unreachable'], 502);
header('Content-Type: application/json; charset=utf-8');
echo $res; exit;
}
// Kachel-Reihenfolge + ausgeblendete Widgets der Startseite speichern (Tab „Startseite").
// Ausblenden ist eine reine Nutzer-Präferenz: die Zuordnung (Berechtigung) bleibt in der
// CRM bestehen, daher kann jedes ausgeblendete Widget hier jederzeit wieder aktiviert werden.
if ($act === 'tiles_save') {
$valid = [];
foreach (term_terminal_accesses($db, (int)$terminal['id']) as $vv) $valid[(int)$vv['id']] = true;
$clean = function ($key) use ($valid) {
$in = (isset($_POST[$key]) && is_array($_POST[$key])) ? $_POST[$key] : [];
$out = [];
foreach ($in as $oid) {
$oid = (int)$oid;
if (isset($valid[$oid]) && !in_array($oid, $out, true)) $out[] = $oid;
}
return $out;
};
term_terminal_meta_set_json($db, (int)$terminal['id'], 'tile_order', $clean('order'));
term_terminal_meta_set_json($db, (int)$terminal['id'], 'tile_hidden', $clean('hidden'));
term_json(['ok' => true]);
}
term_json(['ok' => false, 'error' => 'unknown'], 400);
}
// PIN-Prüfung
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['pin'])) {
$pin = trim((string)$_POST['pin']);
if ($pin === '') {
$error = 'Bitte PIN eingeben.';
} elseif (term_hash_equals((string)$terminal['terminal_pin'], term_terminal_pin_hash($hash, $pin))) {
term_terminal_set_unlock($hash);
term_redirect($self); // Post/Redirect/Get
} else {
$error = 'Falsche PIN.';
}
}
// ----- PIN-Gate -----
if (!term_terminal_cookie_ok($hash)) {
$errHtml = $error !== '' ? '<div class="t-error">' . e($error) . '</div>' : '';
$body = '<div class="t-gate"><div class="t-card t-gate-card">'
. '<div class="t-empty" style="padding:8px 0 0"><div class="ico">🔐</div></div>'
. '<h2>' . e($label) . '</h2>'
. '<p class="t-muted">PIN eingeben – Ziffer tippen oder Muster ziehen.</p>'
. $errHtml
. '<form method="post" action="' . e($self) . '" id="pinForm" autocomplete="off">'
. '<input type="hidden" name="pin" id="pinValue">'
. '<div id="pinPad"></div>'
. '<button class="t-btn t-btn-block" type="submit" id="pinGo" disabled style="margin-top:14px">Entsperren</button>'
. '</form></div></div>'
. '<link rel="stylesheet" href="assets/patternpad.css">'
. '<script src="assets/patternpad.js"></script>'
. '<script>(function(){'
. 'var go=document.getElementById("pinGo");'
. 'PatternPad.create(document.getElementById("pinPad"),{min:4,max:12,onChange:function(v,len){'
. 'document.getElementById("pinValue").value=v; go.disabled=len<4;}});'
. '})();</script>';
t_html($label . ' · PIN', $body, $hash);
}
// ----- Einstellungen (Tabs: PIN ändern · Startseite sortieren · Slots) -----
if (isset($_GET['settings'])) {
$csrf = term_csrf();
// Aktive Ansichten in gespeicherter Kachel-Reihenfolge (Tab „Startseite")
$svs = array_values(array_filter(term_terminal_accesses($db, (int)$terminal['id']),
function ($v) { return (int)$v['view_active'] === 1; }));
$tileOrder = term_terminal_meta_get_json($db, (int)$terminal['id'], 'tile_order', []);
$svs = term_tiles_apply_order($svs, is_array($tileOrder) ? $tileOrder : []);
$tileHidden = term_terminal_meta_get_json($db, (int)$terminal['id'], 'tile_hidden', []);
$tileHidden = is_array($tileHidden) ? array_map('intval', $tileHidden) : [];
$sbody = '<header class="t-topbar">'
. '<a class="t-btn t-btn-ghost ml-icon" href="' . e($self) . '" title="Zurück" style="padding:8px 12px;font-size:1.3rem;line-height:1">‹</a>'
. '<div><h1>Einstellungen</h1><div class="t-sub">' . e($label) . '</div></div>'
. '</header>'
. '<div class="t-wrap" style="max-width:452px">'
. '<div class="s-tabs" role="tablist">'
. '<button type="button" class="s-tab is-on" data-tab="pin" role="tab" aria-selected="true">🔒 PIN</button>'
. '<button type="button" class="s-tab" data-tab="tiles" role="tab" aria-selected="false">🧩 Startseite</button>'
. ($slotsView ? '<button type="button" class="s-tab" data-tab="slots" role="tab" aria-selected="false">🎰 Slots</button>' : '')
. '</div>';
// --- Tab: PIN ändern ---
$sbody .= '<div class="s-panel" id="sp-pin" role="tabpanel">'
. '<div class="t-card" style="text-align:center">'
. '<h2 style="margin:.2rem 0 .4rem" id="sTitle">PIN ändern</h2>'
. '<p class="t-muted" id="sHint">Zur Bestätigung bitte die aktuelle PIN eingeben.</p>'
. '<div class="t-error d-none" id="sErr"></div>'
. '<div class="t-ok-msg d-none" id="sOk"><div class="ico" style="font-size:42px">✅</div><p>PIN erfolgreich geändert.</p>'
. '<a class="t-btn t-btn-block" href="' . e($self) . '">Fertig</a></div>'
. '<div id="sWizard"><div id="sPad"></div>'
. '<button class="t-btn t-btn-block" id="sGo" disabled style="margin-top:14px">Weiter</button></div>'
. '</div></div>';
// --- Tab: Startseite (Widgets sortieren) ---
$stRows = '';
foreach ($svs as $sv) {
$mdef = term_module_def((string)($sv['module'] ?? ''));
$sic = ($mdef && !empty($mdef['icon'])) ? $mdef['icon'] : '🗂️';
$off = in_array((int)$sv['id'], $tileHidden, true);
$stRows .= '<div class="st-row' . ($off ? ' is-off' : '') . '" data-id="' . (int)$sv['id'] . '">'
. '<span class="st-grip" aria-hidden="true">⠿</span>'
. '<span class="st-ic" aria-hidden="true">' . $sic . '</span>'
. '<span class="st-t">' . e($sv['view_label']) . '</span>'
. '<button type="button" class="st-tg" role="switch" aria-checked="' . ($off ? 'false' : 'true')
. '" aria-label="Widget auf der Startseite anzeigen"><span class="st-knob"></span></button>'
. '<span class="st-btns">'
. '<button type="button" class="st-mv" data-dir="up" aria-label="Nach oben">▲</button>'
. '<button type="button" class="st-mv" data-dir="down" aria-label="Nach unten">▼</button>'
. '</span></div>';
}
if ($stRows === '') $stRows = '<div class="t-muted" style="padding:8px 2px">Keine Widgets zugeordnet.</div>';
$sbody .= '<div class="s-panel d-none" id="sp-tiles" role="tabpanel">'
. '<div class="t-card">'
. '<h2 style="margin:.2rem 0 .4rem;text-align:center">Widgets anpassen</h2>'
. '<p class="t-muted" style="text-align:center">Reihenfolge per ▲▼, Schalter blendet Kacheln aus – '
. 'ausgeblendete Widgets lassen sich hier jederzeit wieder einschalten. Änderungen werden sofort gespeichert.</p>'
. '<div id="stList">' . $stRows . '</div>'
. '<div class="t-ok-msg d-none" id="stMsg" role="status" style="margin-top:10px;text-align:center;color:#bbf7d0"></div>'
. '</div></div>';
// --- Tab: Slots-Konfiguration (nur wenn eine Slots-Ansicht zugeordnet ist) ---
if ($slotsView) {
$sbody .= '<div class="s-panel d-none" id="sp-slots" role="tabpanel">'
. '<div class="t-card sl-card" style="text-align:left">'
. '<h2 style="margin:.2rem 0 .6rem;text-align:center">🎰 Slots</h2>'
. '<div class="t-error d-none" id="slErr"></div>'
. '<label class="sl-l">Guthaben <span class="t-muted" id="slCur"></span></label>'
. '<input type="text" inputmode="decimal" id="slBal" class="sl-in" placeholder="0">'
. '<div class="sl-l" style="margin-top:14px">Verfügbare Spiele <span class="t-muted">(keins gewählt = alle)</span></div>'
. '<div id="slGames" class="sl-games"><div class="t-muted">Lädt…</div></div>'
. '<button class="t-btn t-btn-block" id="slSave" style="margin-top:14px">Speichern</button>'
. '<div class="t-ok-msg d-none" id="slOk" style="margin-top:10px;text-align:center;color:#bbf7d0">Gespeichert.</div>'
. '</div></div>';
}
$sbody .= '</div>'
. '<style>.sl-l{display:block;font-size:.78rem;color:var(--t-soft);font-weight:600;margin:0 0 6px}'
. '.sl-in{width:100%;box-sizing:border-box;background:#0c1118;border:1px solid var(--t-line);border-radius:11px;color:var(--t-text);padding:13px 12px;font-size:1.05rem;font-family:inherit}'
. '.sl-in:focus{outline:none;border-color:var(--t-accent)}'
. '.sl-games{display:grid;gap:8px}'
. '.sl-games label{display:flex;align-items:center;gap:10px;background:#0c1118;border:1px solid var(--t-line);border-radius:10px;padding:11px 12px;font-size:.95rem}'
. '.sl-games input{width:20px;height:20px}'
. '.s-tabs{display:flex;gap:8px;margin:0 0 14px;overflow-x:auto;-webkit-overflow-scrolling:touch}'
. '.s-tab{flex:1;white-space:nowrap;border:1px solid var(--t-line);background:var(--t-card);color:var(--t-soft);'
. 'border-radius:999px;padding:10px 14px;font-size:.92rem;font-weight:700;font-family:inherit;cursor:pointer}'
. '.s-tab.is-on{color:#fff;border-color:var(--t-accent);background:linear-gradient(135deg,var(--t-accent),var(--t-accent-2))}'
. '.s-tab:focus-visible{outline:2px solid var(--t-accent);outline-offset:2px}'
. '#stList{display:grid;gap:8px;margin-top:10px}'
. '.st-row{display:flex;align-items:center;gap:10px;background:#0c1118;border:1px solid var(--t-line);'
. 'border-radius:12px;padding:9px 10px;transition:border-color .25s ease,background .25s ease}'
. '.st-row.st-flash{border-color:var(--t-accent);background:#101b2c}'
. '.st-grip{color:var(--t-soft);font-size:1rem;flex:none;opacity:.7}'
. '.st-ic{font-size:1.25rem;flex:none}'
. '.st-t{flex:1;min-width:0;font-weight:600;font-size:.95rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
. '.st-btns{display:flex;gap:6px;flex:none}'
. '.st-mv{width:38px;height:38px;border-radius:10px;border:1px solid var(--t-line);background:var(--t-card);'
. 'color:var(--t-text);font-size:.85rem;cursor:pointer;display:flex;align-items:center;justify-content:center}'
. '.st-mv:hover,.st-mv:focus-visible{border-color:var(--t-accent);outline:none}'
. '.st-mv:disabled{opacity:.35;cursor:default;border-color:var(--t-line)}'
. '.st-row.is-off .st-ic,.st-row.is-off .st-t{opacity:.45}'
. '.st-tg{flex:none;width:44px;height:26px;border-radius:999px;border:1px solid var(--t-line);'
. 'background:#0c1118;cursor:pointer;padding:2px;display:flex;align-items:center;transition:background .18s ease,border-color .18s ease}'
. '.st-tg[aria-checked="true"]{background:linear-gradient(135deg,var(--t-accent),var(--t-accent-2));border-color:var(--t-accent)}'
. '.st-knob{width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.45);'
. 'transform:translateX(0);transition:transform .18s ease}'
. '.st-tg[aria-checked="true"] .st-knob{transform:translateX(18px)}'
. '.st-tg:focus-visible{outline:2px solid var(--t-accent);outline-offset:2px}</style>'
. '<link rel="stylesheet" href="assets/patternpad.css">'
. '<script src="assets/patternpad.js"></script>'
. '<script>' . term_settings_js($self, $csrf) . '</script>'
. '<script>' . term_tabs_settings_js() . '</script>'
. ($slotsView ? '<script>' . term_slots_settings_js($self, $csrf) . '</script>' : '');
t_html('Einstellungen · ' . $label, $sbody, $hash);
}
// ----- Entsperrt: Terminal-Startseite (Smart-TV-Oberfläche) -----
$views = term_terminal_accesses($db, (int)$terminal['id']);
$active = array_values(array_filter($views, function ($v) { return (int)$v['view_active'] === 1; }));
// Vom Nutzer gespeicherte Kachel-Reihenfolge + ausgeblendete Widgets anwenden
// (Einstellungen → Startseite; Ausblenden ist nur Präferenz, keine Rechteänderung)
$tileOrder = term_terminal_meta_get_json($db, (int)$terminal['id'], 'tile_order', []);
if (is_array($tileOrder) && $tileOrder) $active = term_tiles_apply_order($active, $tileOrder);
$allHidden = false;
$tileHidden = term_terminal_meta_get_json($db, (int)$terminal['id'], 'tile_hidden', []);
if (is_array($tileHidden) && $tileHidden) {
$hmap = array_flip(array_map('intval', $tileHidden));
$shown = array_values(array_filter($active, function ($v) use ($hmap) { return !isset($hmap[(int)$v['id']]); }));
$allHidden = !$shown && $active;
$active = $shown;
}
$csrf = term_csrf();
// Begrüßung + Datum (deutsch)
$hour = (int)date('G');
$greet = $hour < 5 ? 'Gute Nacht' : ($hour < 11 ? 'Guten Morgen' : ($hour < 18 ? 'Guten Tag' : 'Guten Abend'));
$wdays = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag'];
$wdaysAb = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
$months = [1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
$dateLine = $wdays[(int)date('w')] . ', ' . (int)date('j') . '. ' . $months[(int)date('n')] . ' ' . date('Y');
// Smart-TV-Kacheln: Farbakzent + Kurzbeschreibung je Widget-Typ
$tvAccent = [
'pinnwand' => ['#3b82f6', '#8b5cf6'],
'worklog' => ['#f59e0b', '#ef4444'],
'monthly_list' => ['#14b8a6', '#0ea5e9'],
'time_tracking' => ['#06b6d4', '#3b82f6'],
'invoices' => ['#22c55e', '#10b981'],
'media' => ['#ef4444', '#ec4899'],
'slots' => ['#eab308', '#f97316'],
'websites' => ['#0ea5e9', '#6366f1'],
'slf' => ['#6366f1', '#a855f7'],
'vpn' => ['#10b981', '#14b8a6'],
];
$tvTag = [
'pinnwand' => 'Aktuelles aus dem Netzwerk',
'worklog' => 'Zeiterfassung & PDF',
'monthly_list' => 'Monatsliste & PDF-Export',
'time_tracking' => 'Arbeitszeiten erfassen',
'invoices' => 'Rechnungen erstellen',
'media' => 'Filme, Serien & Musik',
'slots' => 'Casino – jetzt spielen',
'websites' => 'Webseiten-Statistiken',
'slf' => 'Stadt Land Fluss spielen',
'vpn' => 'VPN-Status & Exit-IP',
];
$content = '';
if (!$active) {
$content = $allHidden
? '<div class="t-empty"><div class="ico">🙈</div>Alle Widgets sind ausgeblendet.<br>'
. '<a href="' . e($self . '&settings=1') . '" style="color:var(--t-accent)">In den Einstellungen wieder aktivieren</a></div>'
: '<div class="t-empty"><div class="ico">🗂️</div>'
. 'Diesem Terminal sind noch keine Ansichten zugeordnet.</div>';
} else {
$tiles = '';
foreach ($active as $v) {
$mod = (string)($v['module'] ?? '');
$url = 'view.php?t=' . urlencode((string)$v['view_token']) . '&term=' . urlencode($hash);
$mdef = term_module_def($mod);
$icon = ($mdef && !empty($mdef['icon'])) ? $mdef['icon'] : '🗂️';
$ac = $tvAccent[$mod] ?? ['#64748b', '#94a3b8'];
$style = ' style="--tv-a:' . $ac[0] . ';--tv-b:' . $ac[1] . '"';
$status = $tvTag[$mod] ?? 'Ansicht öffnen';
// --- Pinnwand: Hero-Kachel mit Feed-Vorschau + Schnell-Beitrag ---
if ($mod === 'pinnwand' && term_module_load('pinnwand')) {
$tile = module_pinnwand_tile($db, $v, $terminal);
$badgeHtml = $tile['badge'] > 0
? ' <span class="tv-badge">' . ($tile['badge'] > 99 ? '99+' : (int)$tile['badge']) . '</span>' : '';
$posts = '';
foreach ($tile['posts'] as $p) {
$av = ($p['avatar'] !== null && $p['avatar'] !== '')
? '<img src="' . e($p['avatar']) . '" alt="" loading="lazy">'
: e($p['initials']);
// Echtes Vorschaubild rechts (Bild-Thumb, Video-Poster oder Link-Vorschau)
$th = '';
if (!empty($p['thumb'])) {
$th = '<span class="tv-post-th' . ($p['tkind'] === 'video' ? ' is-vid' : '') . '" aria-hidden="true">'
. '<img src="' . e($p['thumb']) . '" alt="" loading="lazy">'
. ($p['tkind'] === 'video' ? '<span class="tv-post-play">▶</span>' : '')
. (!empty($p['tmore']) ? '<span class="tv-post-more">+' . (int)$p['tmore'] . '</span>' : '')
. '</span>';
}
// Jeder Beitrag öffnet direkt seinen Direktlink (?post=<id>) im Feed.
$posts .= '<a class="tv-post" data-tv href="' . e($url . '&post=' . (int)$p['id']) . '">'
. '<span class="tv-post-av" style="background:' . e($p['color']) . '">' . $av . '</span>'
. '<span class="tv-post-txt">'
. '<span class="tv-post-top"><span class="tv-post-name">' . e($p['name']) . '</span>'
. '<span class="tv-post-meta">' . e($p['time']) . '</span></span>'
. '<span class="tv-post-snip">' . e($p['snippet']) . '</span>'
. '</span>' . $th . '<span class="tv-item-go" aria-hidden="true">›</span></a>';
}
if ($posts === '') $posts = '<span class="tv-post-empty">Noch keine Beiträge – schreib den ersten! ✍️</span>';
$tiles .= '<div class="tv-tile tv-hero"' . $style . '>'
. '<a class="tv-top" data-tv href="' . e($url) . '">'
. '<span class="tv-ic" aria-hidden="true">' . $icon . '</span>'
. '<span class="tv-tt"><span class="tv-name">' . e($v['view_label']) . $badgeHtml . '</span>'
. '<span class="tv-tag">' . e($status) . '</span></span>'
. '<span class="tv-arrow" aria-hidden="true">›</span>'
. '</a>'
. '<div class="tv-hero-posts" id="tvHeroPosts">' . $posts . '</div>';
if (!empty($tile['can_post'])) {
$tiles .= '<form class="tv-qp" id="tvQp" data-url="' . e($url . '&api=post_create') . '" autocomplete="off">'
. '<input type="text" id="tvQpIn" maxlength="' . (int)$tile['text_max'] . '"'
. ' placeholder="Was gibt’s Neues? Direkt posten …" aria-label="Neuen Pinnwand-Beitrag schreiben">'
. '<button type="submit" class="tv-qp-btn" id="tvQpGo" aria-label="Beitrag senden">➤</button>'
. '</form>'
. '<div class="tv-qp-msg d-none" id="tvQpMsg" role="status"></div>';
}
$tiles .= '</div>';
continue;
}
// --- Vorschau-Einträge mit echten Daten (nur billige, lokale Quellen) ---
// Jeder Eintrag ist als Karte-Zeile direkt anklickbar (Deep-Link in die Ansicht).
$items = [];
if ($mod === 'worklog' && term_module_load('worklog')) {
$wldata = widget_meta_get_json($db, (int)$v['id'], module_worklog_key_data((int)$terminal['id'], date('Y-m')), []);
$min = 0; $ndays = 0; $lastIso = ''; $lastMin = 0;
if (is_array($wldata)) foreach ($wldata as $iso => $day) {
$dm = module_worklog_day_min(is_array($day) ? $day : []);
$min += $dm;
if ($dm > 0) {
$ndays++;
if ((string)$iso > $lastIso) { $lastIso = (string)$iso; $lastMin = $dm; }
}
}
$murl = $url . '&m=' . date('Y-m');
$items[] = ['ic' => '🕒',
't' => $min > 0 ? module_worklog_hm($min) . ' Std diesen Monat' : 'Noch keine Zeiten',
's' => $ndays > 0 ? $ndays . ' ' . ($ndays === 1 ? 'Tag' : 'Tage') . ' erfasst'
: $months[(int)date('n')] . ' ' . date('Y') . ' – jetzt eintragen',
'url' => $murl];
if ($lastIso !== '' && ($ts = strtotime($lastIso))) {
$items[] = ['ic' => '📅', 't' => 'Zuletzt: ' . $wdaysAb[(int)date('w', $ts)] . ', ' . date('d.m.', $ts),
's' => module_worklog_hm($lastMin) . ' Std', 'url' => $murl];
}
} elseif ($mod === 'monthly_list' && term_module_load('monthly_list')) {
$mldata = widget_meta_get_json($db, (int)$v['id'], module_monthly_list_key_data((int)$terminal['id'], date('Y-m')), []);
$nd = 0; $lastIso = '';
if (is_array($mldata)) foreach ($mldata as $iso => $day) {
if (!is_array($day)) continue;
foreach ($day as $val) {
if (is_array($val)) $val = implode('', array_map('strval', $val));
if (trim((string)$val) !== '') {
$nd++;
if ((string)$iso > $lastIso) $lastIso = (string)$iso;
break;
}
}
}
$murl = $url . '&m=' . date('Y-m');
$items[] = ['ic' => '📋',
't' => $nd > 0 ? $nd . ' ' . ($nd === 1 ? 'Tag' : 'Tage') . ' ausgefüllt' : 'Noch keine Einträge',
's' => $months[(int)date('n')] . ' ' . date('Y'), 'url' => $murl];
if ($lastIso !== '' && ($ts = strtotime($lastIso))) {
$items[] = ['ic' => '📅', 't' => 'Zuletzt: ' . $wdaysAb[(int)date('w', $ts)] . ', ' . date('d.m.', $ts),
'url' => $murl];
}
} elseif ($mod === 'slf') {
$items[] = ['ic' => '🎲', 't' => 'Neue Runde starten', 's' => 'Stadt, Land, Fluss …', 'url' => $url];
}
// CRM-/VPN-/Slots-Kacheln: Vorschau asynchron nachladen (api=tile), damit
// die Startseite sofort steht und kein externer Aufruf das Rendern blockiert.
// Bis die Daten da sind, zeigen Skeleton-Zeilen den Ladezustand.
$async = in_array($mod, ['vpn', 'media', 'time_tracking', 'invoices', 'websites', 'slots'], true);
if ($async) {
$itemsHtml = str_repeat('<span class="tv-item is-skel" aria-hidden="true">'
. '<span class="tv-item-ic"></span>'
. '<span class="tv-item-tx"><span class="tv-item-t"></span><span class="tv-item-s"></span></span>'
. '</span>', 2);
} else {
$itemsHtml = '';
foreach ($items as $it) $itemsHtml .= term_tv_item($it);
}
$tiles .= '<div class="tv-tile"' . $style
. ($async ? ' data-tile-url="' . e($url . '&api=tile') . '"' : '') . '>'
. '<a class="tv-top" data-tv href="' . e($url) . '">'
. '<span class="tv-ic" aria-hidden="true">' . $icon . '</span>'
. '<span class="tv-tt"><span class="tv-name">' . e($v['view_label']) . '</span>'
. '<span class="tv-tag">' . e($status) . '</span></span>'
. '<span class="tv-arrow" aria-hidden="true">›</span></a>'
. ($itemsHtml !== '' ? '<div class="tv-items">' . $itemsHtml . '</div>' : '')
. '</div>';
}
$content = '<div class="tv-grid" id="tvGrid">' . $tiles . '</div>';
}
$cssV = (int)@filemtime(__DIR__ . '/assets/home.css') ?: 1;
$jsV = (int)@filemtime(__DIR__ . '/assets/home.js') ?: 1;
// Initialen des Terminals für den Konto-Button (max. 2 Zeichen)
$ini = '';
foreach (preg_split('/\s+/', trim($label)) as $w) {
if ($w === '') continue;
$ini .= mb_strtoupper(mb_substr($w, 0, 1));
if (mb_strlen($ini) >= 2) break;
}
if ($ini === '') $ini = 'T';
$body = '<header class="t-topbar tv-bar">'
. '<div class="tv-bar-t"><h1>' . e($greet . ', ' . $label) . '</h1><div class="t-sub">' . e($dateLine) . '</div></div>'
. '<div class="t-spacer"></div>'
. '<div class="tv-clock" id="tvClock" aria-hidden="true"></div>'
. '<div class="t-usermenu" id="tUserMenu">'
. '<button class="tv-ub" id="tUserBtn" aria-haspopup="true" aria-expanded="false" title="Konto" aria-label="Konto">' . e($ini) . '</button>'
. '<div class="t-menu d-none" id="tMenu" role="menu">'
. '<a class="t-menu-item" role="menuitem" href="' . e($self . '&settings=1') . '"><span class="t-menu-ic" aria-hidden="true">⚙</span> Einstellungen</a>'
. '<form method="post" action="' . e($self) . '"><input type="hidden" name="logout" value="1">'
. '<button type="submit" class="t-menu-item t-menu-danger" role="menuitem"><span class="t-menu-ic" aria-hidden="true">⎋</span> Abmelden</button>'
. '</form>'
. '</div>'
. '</div>'
. '</header>'
. '<div class="tv-wrap">'
. $content
. '</div>'
. '<link rel="stylesheet" href="assets/home.css?v=' . $cssV . '">'
. '<script>var TVCFG={csrf:' . json_encode($csrf) . '};</script>'
. '<script src="assets/home.js?v=' . $jsV . '" defer></script>'
. '<script>(function(){var b=document.getElementById("tUserBtn"),m=document.getElementById("tMenu"),w=document.getElementById("tUserMenu");'
. 'function close(){m.classList.add("d-none");b.setAttribute("aria-expanded","false");}'
. 'b.addEventListener("click",function(e){e.stopPropagation();var hidden=m.classList.toggle("d-none");b.setAttribute("aria-expanded",hidden?"false":"true");});'
. 'document.addEventListener("click",function(e){if(!w.contains(e.target))close();});'
. 'document.addEventListener("keydown",function(e){if(e.key==="Escape")close();});'
. '})();</script>';
t_html($label, $body, $hash);
/**
* Ansichten in die vom Nutzer gespeicherte Kachel-Reihenfolge bringen.
* Unbekannte (neu zugeordnete) Ansichten hängen sich hinten an.
*/
function term_tiles_apply_order(array $views, array $order): array
{
if (!$order) return $views;
$pos = array_flip(array_map('intval', $order));
$n = count($pos);
$i = 0;
foreach ($views as $k => $v) {
$views[$k]['_ord'] = $pos[(int)$v['id']] ?? ($n + $i);
$i++;
}
usort($views, function ($a, $b) { return $a['_ord'] <=> $b['_ord']; });
return $views;
}
/** Inline-JS für die Einstellungen: Tab-Umschaltung + Widgets sortieren/ausblenden. */
function term_tabs_settings_js(): string
{
return <<<'JS'
(function(){
var tabs=[].slice.call(document.querySelectorAll('.s-tab'));
tabs.forEach(function(b){b.addEventListener('click',function(){
tabs.forEach(function(x){x.classList.remove('is-on');x.setAttribute('aria-selected','false');});
b.classList.add('is-on');b.setAttribute('aria-selected','true');
[].slice.call(document.querySelectorAll('.s-panel')).forEach(function(p){p.classList.add('d-none');});
var p=document.getElementById('sp-'+b.getAttribute('data-tab'));if(p)p.classList.remove('d-none');
});});
// Widgets sortieren & ein-/ausblenden – jede Änderung speichert sofort
var list=document.getElementById('stList');if(!list)return;
var msg=document.getElementById('stMsg'),tmr=null;
function note(t){if(!msg)return;msg.textContent=t;msg.classList.remove('d-none');
clearTimeout(tmr);tmr=setTimeout(function(){msg.classList.add('d-none');},2500);}
function save(){
var fd=new FormData();fd.append('_csrf',CSRF);fd.append('settings_action','tiles_save');
[].slice.call(list.querySelectorAll('.st-row')).forEach(function(r){
fd.append('order[]',r.getAttribute('data-id'));
if(r.classList.contains('is-off'))fd.append('hidden[]',r.getAttribute('data-id'));
});
fetch(SELF,{method:'POST',body:fd,credentials:'same-origin'})
.then(function(r){return r.json();})
.then(function(r){note(r&&r.ok?'Gespeichert ✓':'Speichern fehlgeschlagen');})
.catch(function(){note('Keine Verbindung – bitte erneut versuchen.');});
}
list.addEventListener('click',function(e){
var tg=e.target.closest('.st-tg');
if(tg){
var row=tg.closest('.st-row'),off=row.classList.toggle('is-off');
tg.setAttribute('aria-checked',off?'false':'true');
save();return;
}
var b=e.target.closest('.st-mv');if(!b)return;
var r2=b.closest('.st-row');
if(b.getAttribute('data-dir')==='up'&&r2.previousElementSibling&&r2.previousElementSibling.classList.contains('st-row'))
list.insertBefore(r2,r2.previousElementSibling);
else if(b.getAttribute('data-dir')==='down'&&r2.nextElementSibling)
list.insertBefore(r2.nextElementSibling,r2);
else return;
r2.classList.add('st-flash');setTimeout(function(){r2.classList.remove('st-flash');},400);
save();
});
})();
JS;
}
/**
* Eine Vorschau-Zeile einer Smart-TV-Kachel:
* ['ic'=>Emoji,'t'=>Text,'s'=>Subtext,'url'=>Ziel,'img'=>Bild-URL,'ok'=>bool,'warn'=>bool].
* Mit 'url' wird die Zeile ein eigener Link (Datensatz direkt aus der Karte öffnen),
* ohne 'url' eine rein informative Zeile; 'img' ersetzt das Emoji durch ein echtes
* Vorschaubild, 'ok' färbt den Subtext grün (nur VPN), 'warn' den Text rot (nur
* Warnungen). Muss zum JS-Renderer itemNode() in assets/home.js passen.
*/
function term_tv_item(array $it): string
{
$hasUrl = !empty($it['url']);
$tag = $hasUrl ? 'a' : 'span';
$cls = 'tv-item' . (!empty($it['ok']) ? ' is-ok' : '') . (!empty($it['warn']) ? ' is-warn' : '');
$sub = (isset($it['s']) && $it['s'] !== '')
? '<span class="tv-item-s">' . e($it['s']) . '</span>' : '';
$icon = !empty($it['img'])
? '<span class="tv-item-ic has-img" aria-hidden="true"><img src="' . e($it['img']) . '" alt="" loading="lazy"></span>'
: '<span class="tv-item-ic" aria-hidden="true">' . e($it['ic'] ?? '•') . '</span>';
return '<' . $tag . ' class="' . $cls . '"'
. ($hasUrl ? ' data-tv href="' . e($it['url']) . '"' : '') . '>'
. $icon
. '<span class="tv-item-tx"><span class="tv-item-t">' . e($it['t'] ?? '') . '</span>' . $sub . '</span>'
. ($hasUrl ? '<span class="tv-item-go" aria-hidden="true">›</span>' : '')
. '</' . $tag . '>';
}
/** Inline-JS für den PIN-Ändern-Assistenten (Einstellungen). */
function term_settings_js($self, $csrf)
{
$head = 'var SELF=' . json_encode($self) . ',CSRF=' . json_encode($csrf) . ';';
$js = <<<'JS'
(function(){
var pad, buf='', state='old', oldPin='', newPin='';
var title=document.getElementById('sTitle'), hint=document.getElementById('sHint');
var err=document.getElementById('sErr'), go=document.getElementById('sGo');
function showErr(m){err.textContent=m;err.classList.remove('d-none');}
function reset(){pad.clear();buf='';go.disabled=true;}
function setStep(s){
state=s;err.classList.add('d-none');reset();
if(s==='old'){title.textContent='PIN ändern';hint.textContent='Zur Bestätigung bitte die aktuelle PIN eingeben.';go.textContent='Weiter';}
else if(s==='new'){title.textContent='Neue PIN';hint.textContent='Neue PIN festlegen (4–12 Ziffern).';go.textContent='Weiter';}
else if(s==='confirm'){title.textContent='Neue PIN bestätigen';hint.textContent='Neue PIN zur Bestätigung erneut eingeben.';go.textContent='Speichern';}
}
function post(data){var fd=new FormData();fd.append('_csrf',CSRF);Object.keys(data).forEach(function(k){fd.append(k,data[k]);});
return fetch(SELF,{method:'POST',headers:{'X-CSRF-Token':CSRF},body:fd}).then(function(r){return r.json().catch(function(){return{ok:false};});});}
pad=PatternPad.create(document.getElementById('sPad'),{min:4,max:12,onChange:function(v,len){buf=v;go.disabled=len<4;}});
setStep('old');
go.addEventListener('click',function(){
if(buf.length<4)return;
if(state==='old'){
post({settings_action:'verify',pin:buf}).then(function(r){ if(r.ok){oldPin=buf;setStep('new');} else {showErr('Falsche PIN.');reset();} });
} else if(state==='new'){
newPin=buf;setStep('confirm');
} else if(state==='confirm'){
if(buf!==newPin){showErr('Die PINs stimmen nicht überein.');setStep('new');return;}
post({settings_action:'change',old:oldPin,new:newPin}).then(function(r){
if(r.ok){document.getElementById('sWizard').classList.add('d-none');hint.classList.add('d-none');document.getElementById('sOk').classList.remove('d-none');}
else if(r.error==='old'){showErr('Aktuelle PIN ist nicht mehr korrekt.');setStep('old');}
else if(r.error==='len'){showErr('Neue PIN muss 4–12 Ziffern haben.');setStep('new');}
else {showErr('Fehler beim Speichern.');}
});
}
});
})();
JS;
return $head . $js;
}
/** Inline-JS für die Slots-Konfiguration in den Einstellungen. */
function term_slots_settings_js($self, $csrf)
{
$head = 'var SELF=' . json_encode($self) . ',CSRF=' . json_encode($csrf) . ';';
$js = <<<'JS'
(function(){
var errEl=document.getElementById('slErr'),okEl=document.getElementById('slOk'),
balEl=document.getElementById('slBal'),curEl=document.getElementById('slCur'),gamesEl=document.getElementById('slGames');
if(!balEl)return;
function esc(s){var d=document.createElement('div');d.textContent=s==null?'':String(s);return d.innerHTML;}
function post(data){var fd=new FormData();fd.append('_csrf',CSRF);
Object.keys(data).forEach(function(k){var v=data[k];if(Array.isArray(v))v.forEach(function(x){fd.append(k+'[]',x);});else fd.append(k,v);});
return fetch(SELF,{method:'POST',headers:{'X-CSRF-Token':CSRF},body:fd}).then(function(r){return r.json().catch(function(){return{ok:false};});});}
post({settings_action:'slots_state'}).then(function(r){
if(!r||!r.ok){gamesEl.innerHTML='<div class="t-muted">Slots nicht erreichbar.</div>';return;}
balEl.value=(r.balance!=null?String(r.balance).replace('.',','):'');
if(curEl)curEl.textContent='('+(r.currency||'Coins')+')';
var sel=r.selected||[];
gamesEl.innerHTML=(r.games||[]).map(function(g){var ck=sel.indexOf(g.id)>-1?' checked':'';
return '<label><input type="checkbox" class="slg" value="'+esc(g.id)+'"'+ck+'> '+esc(g.title)+'</label>';}).join('')
||'<div class="t-muted">Keine Spiele.</div>';
});
document.getElementById('slSave').addEventListener('click',function(){
errEl.classList.add('d-none');okEl.classList.add('d-none');
var games=Array.prototype.map.call(document.querySelectorAll('.slg:checked'),function(c){return c.value;});
var btn=this;btn.disabled=true;
post({settings_action:'slots_save',balance:balEl.value.trim(),games:games}).then(function(r){
btn.disabled=false;
if(!r||!r.ok){errEl.textContent='Speichern fehlgeschlagen.';errEl.classList.remove('d-none');return;}
okEl.classList.remove('d-none');setTimeout(function(){okEl.classList.add('d-none');},2000);
});
});
})();
JS;
return $head . $js;
}