403Webshell
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 :
current_dir [ Writeable ] document_root [ Writeable ]

 

Command :


[ Back ]     

Current File : /var/www/arni-solutions.de/web/terminal/terminal.php
<?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;
}

Youez - 2016 - github.com/yon3zu
LinuXploit