Ctrl K
ComunidadGuías…

index.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?php
/**
 * Paragrafy - Public Router, Document Viewer, Embed Drawer, JSON API & Cron Audit
 */
declare(strict_types=1);
ini_set('display_errors', '0');
ini_set('log_errors', '1');
error_reporting(E_ALL);

require_once __DIR__ . '/db.php';
require_once __DIR__ . '/cache.php';
if (session_status() === PHP_SESSION_NONE) {
    $isHttps = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
    session_set_cookie_params(['lifetime' => 0, 'path' => '/', 'secure' => $isHttps, 'httponly' => true, 'samesite' => 'Lax']);
    session_start();
}

if (!is_installed()) {
    require_once __DIR__ . '/install.php';
    exit;
}

$uri = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$uri = rtrim($uri, '/');
if ($uri === '') $uri = '/';

if ($uri === '/paragrafy.svg') {
    header('Content-Type: image/svg+xml');
    if (file_exists(__DIR__ . '/paragrafy.svg')) {
        readfile(__DIR__ . '/paragrafy.svg');
    }
    exit;
}

// Rollierendes Backup (7 Tage) -- per externem Cron täglich aufzurufen, z. B. via crontab oder Uptime-Kuma-Healthcheck
if ($uri === '/api/cron/backup') {
    require_cron_secret();
    header('Content-Type: application/json');
    echo json_encode(run_scheduled_backup());
    exit;
}

// Webhook-Warteschlange abarbeiten -- per externem Cron alle paar Minuten aufzurufen
if ($uri === '/api/cron/webhooks') {
    require_cron_secret();
    header('Content-Type: application/json');
    echo json_encode(process_webhook_queue());
    exit;
}

// Geplante Veröffentlichungen live schalten -- projektübergreifend, für explizite Cron-Aufrufe
if ($uri === '/api/cron/publish') {
    require_cron_secret();
    header('Content-Type: application/json');
    echo json_encode(check_and_publish_scheduled(get_db()));
    exit;
}

// Alte Public-Cache-Dateien aufräumen -- optional, per externem Cron; der File-Cache
// rotiert auch ohne dies opportunistisch bei Schreibzugriffen (siehe cache.php).
if ($uri === '/api/cron/cache-cleanup') {
    require_cron_secret();
    header('Content-Type: application/json');
    echo json_encode(['deleted' => public_cache_cleanup_all()]);
    exit;
}

if (str_starts_with($uri, '/admin')) {
    require_once __DIR__ . '/admin.php';
    exit;
}

$host = get_current_host();
$db = get_db();

$stmt = $db->prepare("SELECT * FROM projects WHERE domain = ? OR domain = 'localhost' ORDER BY (domain = ?) DESC LIMIT 1");
$stmt->execute([$host, $host]);
$project = $stmt->fetch();

if ($uri === '/embed.js') {
    header('Content-Type: application/javascript; charset=utf-8');
    render_embed_js();
    exit;
}

if ($uri === '/consent.js') {
    header('Content-Type: application/javascript; charset=utf-8');
    render_consent_js($project ?: null);
    exit;
}

if ($uri === '/api/consent-log') {
    handle_consent_log($project ?: null, $db);
    exit;
}

if (!$project) {
    http_response_code(404);
    echo "<h1>" . htmlspecialchars(t('public.error.no_project_title')) . "</h1><p>" . t('public.error.no_project_desc', ['domain' => htmlspecialchars($host)]) . "</p>";
    exit;
}

// Zero-Config-Fallback: bei jedem Seitenaufruf prüfen, ob für dieses Projekt etwas fällig ist.
// Ersetzt keinen Cron (der greift auch ohne Traffic), fängt aber Instanzen ohne eingerichteten Cron auf.
check_and_publish_scheduled($db, $project);

$parts = array_values(array_filter(explode('/', $uri)));
$primaryLang = $project['primary_lang'] ?: 'de';
$activeLangs = array_filter(array_map('trim', explode(',', $project['active_languages'] ?? 'de,en')));

// Cron Audit Endpoint (/api/cron/audit)
if (!empty($parts) && $parts[0] === 'api' && ($parts[1] ?? '') === 'cron' && ($parts[2] ?? '') === 'audit') {
    require_cron_secret();
    handle_cron_audit($project, $db);
    exit;
}

// Vorschau-Kennzeichnung (.../preview) abtrennen, gilt für Seiten- und API-Routen gleichermaßen
$isPreview = false;
if (!empty($parts) && end($parts) === 'preview') {
    array_pop($parts);
    $isPreview = true;
}

// JSON API Endpoint (/api/de/impressum oder /api/impressum)
if (!empty($parts) && $parts[0] === 'api') {
    if (rate_limit_wait($db, 'api:' . get_client_ip(), 120, 5) > 0) {
        http_response_code(429);
        header('Content-Type: application/json');
        echo json_encode(['error' => 'rate_limited']);
        exit;
    }
    record_login_failure($db, 'api:' . get_client_ip());
    handle_json_api($parts, $project, $db, $primaryLang, $isPreview);
    exit;
}

if (empty($parts)) {
    $negotiated = negotiate_document_lang($activeLangs, $primaryLang);
    if ($negotiated) {
        header('Location: /' . $negotiated . '/', true, 302);
        exit;
    }
    render_public_overview($project, $db, $primaryLang, $primaryLang);
    exit;
}

if (count($parts) === 1) {
    $first = strtolower($parts[0]);
    if (in_array($first, $activeLangs) || (strlen($first) === 2 && !is_numeric($first))) {
        render_public_overview($project, $db, $first, $primaryLang);
        exit;
    }
    $lang = $primaryLang;
    $slug = $first;

    $negotiated = negotiate_document_lang($activeLangs, $primaryLang);
    if ($negotiated && find_public_translation($db, (int)$project['id'], $negotiated, $slug)) {
        $suffix = $isPreview ? '/preview' : '';
        $qs = $_SERVER['QUERY_STRING'] ?? '';
        header('Location: /' . $negotiated . '/' . $slug . $suffix . ($qs !== '' ? '?' . $qs : ''), true, 302);
        exit;
    }
} else {
    $lang = strtolower($parts[0]);
    $slug = strtolower($parts[1]);
}

$trans = find_public_translation($db, (int)$project['id'], $lang, $slug);

$fallbackFromLang = null;
if (!$trans && !$isPreview) {
    $trans = find_public_translation_with_fallback($db, (int)$project['id'], $lang, $slug, $primaryLang);
    if ($trans) {
        $fallbackFromLang = $lang;
    }
}

if (!$trans) {
    http_response_code(404);
    render_public_404($project, $lang);
    exit;
}

$liveSlug = $trans['slug'];
$etagHash = null;

if ($isPreview) {
    if (empty($trans['scheduled_at'])) {
        http_response_code(404);
        render_public_404($project, $lang);
        exit;
    }
    header('Cache-Control: private, no-store');
    $trans['title'] = $trans['scheduled_title'] !== '' ? $trans['scheduled_title'] : $trans['title'];
    $trans['slug'] = $trans['scheduled_slug'] !== '' ? $trans['scheduled_slug'] : $trans['slug'];
    $previewContent = $trans['scheduled_content'] !== '' ? $trans['scheduled_content'] : $trans['content'];
} else {
    $previewContent = $trans['content'];

    $etag = build_public_cache_etag((int)$project['id'], $lang, $liveSlug, $trans['updated_at'], (int)($project['settings_version'] ?? 1), $trans['lang'], (int)$trans['id']);
    $etagHash = trim($etag, '"');
    $lastMod = build_public_last_modified($trans['updated_at'], (string)($project['settings_updated_at'] ?? ''));

    if (check_conditional_request($etag, $lastMod)) {
        exit;
    }

    $cachedHtml = public_cache_get((int)$project['id'], 'html', $lang, $liveSlug, $etagHash);
    if ($cachedHtml !== null) {
        echo public_cache_fill_dynamic($cachedHtml);
        exit;
    }
}

$stmt = $db->prepare("
    SELECT t.lang, t.slug, dt.slug as default_slug
    FROM translations t
    JOIN documents d ON t.document_id = d.id
    JOIN doc_types dt ON d.doc_type_id = dt.id
    WHERE t.document_id = ? AND t.status = 'published'
");
$stmt->execute([$trans['document_id']]);
$languages = $stmt->fetchAll();

$content = replace_placeholders(sanitize_legal_html($previewContent), $project);

ob_start();
render_public_document($project, $trans, $content, $languages, $trans['lang'], $isPreview, $liveSlug, $fallbackFromLang);
$html = ob_get_clean();

if (!$isPreview) {
    public_cache_put((int)$project['id'], 'html', $lang, $liveSlug, $etagHash, $html);
}
echo public_cache_fill_dynamic($html);

function get_i18n_strings(string $lang): array {
    $dict = [
        'de' => [
            'toc' => 'Inhaltsverzeichnis',
            'search_ph' => 'Dokument durchsuchen (z. B. Cookies, Haftung, Widerruf)...',
            'read_time' => '~%d Min. Lesezeit',
            'last_updated' => 'Stand: %s',
            'print' => 'Drucken',
            'overview_title' => 'Rechtliche Dokumente',
            'overview_sub' => 'Rechtliche Hinweise und Bestimmungen.',
            'not_found_title' => 'Dokument nicht gefunden',
            'not_found_desc' => 'Der angeforderte Rechtstext existiert nicht oder wurde noch nicht freigegeben.',
            'back_to_overview' => 'Zurück zur Übersicht',
            'copy_anchor' => 'Direktlink kopieren',
            'preview_suffix' => ' (Vorschau)',
            'print_title' => 'Drucken oder als PDF speichern',
            'preview_banner_text' => 'Vorschau: Dies ist die geplante Neufassung. Sie geht am %s Uhr live. Bis dahin gilt weiterhin die aktuelle Fassung.',
            'preview_banner_link' => 'Aktuelle Fassung ansehen &rarr;',
            'upcoming_banner_text' => 'Geplante Neufassung verfügbar: Diese Bestimmungen werden zum %s aktualisiert.',
            'upcoming_banner_link' => 'Vorab ansehen &rarr;',
            'powered_by_toc' => 'Bereitgestellt mit Paragrafy',
            'powered_by_footer' => 'Bereitgestellt über Paragrafy',
            'lang_fallback_notice' => 'Dieser Text liegt aktuell nicht auf %s vor — hier die verfügbare Version.'
        ],
        'en' => [
            'toc' => 'Table of Contents',
            'search_ph' => 'Search document (e.g. cookies, liability, revocation)...',
            'read_time' => '~%d min read',
            'last_updated' => 'Last updated: %s',
            'print' => 'Print',
            'overview_title' => 'Legal Documents',
            'overview_sub' => 'Legal notices, policies and terms.',
            'not_found_title' => 'Document not found',
            'not_found_desc' => 'The requested legal document does not exist or has not been published yet.',
            'back_to_overview' => 'Back to Overview',
            'copy_anchor' => 'Copy section link',
            'preview_suffix' => ' (Preview)',
            'print_title' => 'Print or save as PDF',
            'preview_banner_text' => 'Preview: this is the scheduled new version. It goes live at %s. Until then, the current version remains in effect.',
            'preview_banner_link' => 'View current version &rarr;',
            'upcoming_banner_text' => 'Upcoming revision available: these terms will be updated on %s.',
            'upcoming_banner_link' => 'Preview now &rarr;',
            'powered_by_toc' => 'Powered by Paragrafy',
            'powered_by_footer' => 'Powered by Paragrafy',
            'lang_fallback_notice' => 'This document isn\'t available in %s yet — showing the available version instead.'
        ],
        'es' => [
            'toc' => 'Índice de contenidos',
            'search_ph' => 'Buscar en el documento (ej. cookies, responsabilidad)...',
            'read_time' => '~%d min de lectura',
            'last_updated' => 'Última actualización: %s',
            'print' => 'Imprimir',
            'overview_title' => 'Documentos legales',
            'overview_sub' => 'Avisos legales, términos y políticas.',
            'not_found_title' => 'Documento no encontrado',
            'not_found_desc' => 'El documento legal solicitado no existe o aún no ha sido publicado.',
            'back_to_overview' => 'Volver a la vista general',
            'copy_anchor' => 'Copiar enlace',
            'preview_suffix' => ' (Vista previa)',
            'print_title' => 'Imprimir o guardar como PDF',
            'preview_banner_text' => 'Vista previa: esta es la nueva versión programada. Estará activa a partir del %s. Hasta entonces, sigue vigente la versión actual.',
            'preview_banner_link' => 'Ver versión actual &rarr;',
            'upcoming_banner_text' => 'Nueva versión programada disponible: estas condiciones se actualizarán el %s.',
            'upcoming_banner_link' => 'Ver vista previa &rarr;',
            'powered_by_toc' => 'Desarrollado con Paragrafy',
            'powered_by_footer' => 'Desarrollado con Paragrafy',
            'lang_fallback_notice' => 'Este documento todavía no está disponible en %s — se muestra la versión disponible.'
        ],
        'fr' => [
            'toc' => 'Table des matières',
            'search_ph' => 'Rechercher dans le document (ex. cookies, responsabilité)...',
            'read_time' => '~%d min de lecture',
            'last_updated' => 'Dernière mise à jour: %s',
            'print' => 'Imprimer',
            'overview_title' => 'Documents juridiques',
            'overview_sub' => 'Mentions légales, conditions et politiques.',
            'not_found_title' => 'Document non trouvé',
            'not_found_desc' => 'Le document juridique demandé n\'existe pas ou n\'a pas encore été publié.',
            'back_to_overview' => 'Retour à la vue d\'ensemble',
            'copy_anchor' => 'Copier le lien',
            'preview_suffix' => ' (Aperçu)',
            'print_title' => 'Imprimer ou enregistrer en PDF',
            'preview_banner_text' => 'Aperçu : voici la nouvelle version prévue. Elle entrera en vigueur le %s. D\'ici là, la version actuelle reste applicable.',
            'preview_banner_link' => 'Voir la version actuelle &rarr;',
            'upcoming_banner_text' => 'Nouvelle version prévue : ces dispositions seront mises à jour le %s.',
            'upcoming_banner_link' => 'Aperçu anticipé &rarr;',
            'powered_by_toc' => 'Propulsé par Paragrafy',
            'powered_by_footer' => 'Propulsé par Paragrafy',
            'lang_fallback_notice' => 'Ce document n\'est pas encore disponible en %s — voici la version disponible.'
        ],
        'it' => [
            'toc' => 'Indice dei contenuti',
            'search_ph' => 'Cerca nel documento (es. cookie, responsabilità)...',
            'read_time' => '~%d min di lettura',
            'last_updated' => 'Ultimo aggiornamento: %s',
            'print' => 'Stampa',
            'overview_title' => 'Documenti legali',
            'overview_sub' => 'Note legali, termini e informative.',
            'not_found_title' => 'Documento non trovato',
            'not_found_desc' => 'Il documento legale richiesto non esiste o non è ancora stato pubblicato.',
            'back_to_overview' => 'Torna alla panoramica',
            'copy_anchor' => 'Copia link',
            'preview_suffix' => ' (Anteprima)',
            'print_title' => 'Stampa o salva come PDF',
            'preview_banner_text' => 'Anteprima: questa è la nuova versione pianificata. Sarà attiva dal %s. Fino ad allora resta valida la versione attuale.',
            'preview_banner_link' => 'Vedi versione attuale &rarr;',
            'upcoming_banner_text' => 'Nuova versione pianificata disponibile: queste disposizioni saranno aggiornate il %s.',
            'upcoming_banner_link' => 'Anteprima anticipata &rarr;',
            'powered_by_toc' => 'Offerto da Paragrafy',
            'powered_by_footer' => 'Offerto da Paragrafy',
            'lang_fallback_notice' => 'Questo documento non è ancora disponibile in %s — ecco la versione disponibile.'
        ],
        'nl' => [
            'toc' => 'Inhoudsopgave',
            'search_ph' => 'Document doorzoeken (bijv. cookies, aansprakelijkheid)...',
            'read_time' => '~%d min leestijd',
            'last_updated' => 'Laatst bijgewerkt: %s',
            'print' => 'Afdrukken',
            'overview_title' => 'Juridische documenten',
            'overview_sub' => 'Juridische kennisgevingen en voorwaarden.',
            'not_found_title' => 'Document niet gevonden',
            'not_found_desc' => 'Het opgevraagde juridische document bestaat niet of is nog niet gepubliceerd.',
            'back_to_overview' => 'Terug naar het overzicht',
            'copy_anchor' => 'Kopieer link',
            'preview_suffix' => ' (Voorbeeld)',
            'print_title' => 'Afdrukken of opslaan als PDF',
            'preview_banner_text' => 'Voorbeeld: dit is de geplande nieuwe versie. Deze gaat live op %s. Tot die tijd blijft de huidige versie geldig.',
            'preview_banner_link' => 'Huidige versie bekijken &rarr;',
            'upcoming_banner_text' => 'Geplande wijziging beschikbaar: deze bepalingen worden bijgewerkt op %s.',
            'upcoming_banner_link' => 'Vooraf bekijken &rarr;',
            'powered_by_toc' => 'Mogelijk gemaakt door Paragrafy',
            'powered_by_footer' => 'Mogelijk gemaakt door Paragrafy',
            'lang_fallback_notice' => 'Dit document is nog niet beschikbaar in %s — hier de beschikbare versie.'
        ]
    ];
    return $dict[$lang] ?? $dict['en'];
}

/**
 * DSGVO-Nachweispflicht: receives the fire-and-forget POST that /consent.js
 * sends whenever a visitor accepts/declines the cookie banner, and stores a
 * privacy-preserving proof record via log_consent(). Called from an
 * arbitrary third-party website, so it needs its own CORS handling (the
 * JSON Content-Type makes this a non-simple request, requiring an OPTIONS
 * preflight response) independent of the read-only JSON API above.
 */
function handle_consent_log(?array $project, PDO $db): void {
    header('Access-Control-Allow-Origin: *');
    header('Access-Control-Allow-Methods: POST, OPTIONS');
    header('Access-Control-Allow-Headers: Content-Type');

    if (($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
        http_response_code(204);
        return;
    }

    header('Content-Type: application/json; charset=utf-8');

    if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'POST' || !$project || empty($project['consent_logging_enabled'])) {
        echo json_encode(['success' => false]);
        return;
    }

    $body = json_decode((string)file_get_contents('php://input'), true);
    if (!is_array($body)) {
        $body = [];
    }

    $action = (string)($body['action'] ?? '');
    if ($action !== 'accepted' && $action !== 'declined') {
        http_response_code(400);
        echo json_encode(['success' => false]);
        return;
    }

    $consentId = substr(trim((string)($body['consentId'] ?? '')), 0, 100);
    $lang = substr(trim((string)($body['lang'] ?? '')), 0, 20);
    $textHash = substr(trim((string)($body['textHash'] ?? '')), 0, 64);

    if (!preg_match('/^[A-Za-z0-9\-]{1,100}$/', $consentId) || !preg_match('/^[a-f0-9]{64}$/', $textHash)) {
        http_response_code(400);
        echo json_encode(['success' => false]);
        return;
    }

    $rateIdentifier = 'consent:' . $project['id'] . ':' . get_client_ip();
    if (rate_limit_wait($db, $rateIdentifier, 30, 15) > 0) {
        http_response_code(429);
        echo json_encode(['success' => false]);
        return;
    }
    record_login_failure($db, $rateIdentifier);

    log_consent(
        $db,
        (int)$project['id'],
        $consentId,
        $action,
        $lang,
        $textHash,
        get_client_ip(),
        (string)($_SERVER['HTTP_USER_AGENT'] ?? '')
    );

    echo json_encode(['success' => true]);
}

function handle_cron_audit(array $project, PDO $db): void {
    header('Content-Type: application/json');
    echo json_encode(run_audit_check($project, $db));
}

function handle_json_api(array $parts, array $project, PDO $db, string $primaryLang, bool $isPreview = false): void {
    header('Content-Type: application/json; charset=utf-8');
    header('Access-Control-Allow-Origin: *');

    $lang = $primaryLang;
    $slug = '';

    if (count($parts) === 2) {
        $slug = strtolower($parts[1]);
    } elseif (count($parts) >= 3) {
        $lang = strtolower($parts[1]);
        $slug = strtolower($parts[2]);
    }

    if (empty($slug)) {
        http_response_code(400);
        echo json_encode(['error' => 'Missing document slug']);
        return;
    }

    $doc = find_public_translation($db, (int)$project['id'], $lang, $slug);

    $fallbackFromLang = null;
    if (!$doc && !$isPreview) {
        $doc = find_public_translation_with_fallback($db, (int)$project['id'], $lang, $slug, $primaryLang);
        if ($doc) {
            $fallbackFromLang = $lang;
        }
    }

    if (!$doc) {
        http_response_code(404);
        echo json_encode(['error' => 'Document not found or not published']);
        return;
    }

    $etagHash = null;

    if ($isPreview) {
        if (empty($doc['scheduled_at'])) {
            http_response_code(404);
            echo json_encode(['error' => 'No scheduled preview available for this document']);
            return;
        }
        header('Cache-Control: private, no-store');
        $doc['title'] = $doc['scheduled_title'] !== '' ? $doc['scheduled_title'] : $doc['title'];
        $doc['slug'] = $doc['scheduled_slug'] !== '' ? $doc['scheduled_slug'] : $doc['slug'];
        $doc['content'] = $doc['scheduled_content'] !== '' ? $doc['scheduled_content'] : $doc['content'];
    } else {
        $etag = build_public_cache_etag((int)$project['id'], $lang, $doc['slug'], $doc['updated_at'], (int)($project['settings_version'] ?? 1), $doc['lang'], (int)$doc['id']);
        $etagHash = trim($etag, '"');
        $lastMod = build_public_last_modified($doc['updated_at'], (string)($project['settings_updated_at'] ?? ''));

        if (check_conditional_request($etag, $lastMod)) {
            return;
        }

        $cachedJson = public_cache_get((int)$project['id'], 'json', $lang, $doc['slug'], $etagHash);
        if ($cachedJson !== null) {
            echo $cachedJson;
            return;
        }
    }

    $doc['rendered_html'] = replace_placeholders(sanitize_legal_html($doc['content']), $project);
    $response = [
        'project' => $project['name'],
        'title' => $doc['title'],
        'slug' => $doc['slug'],
        'lang' => $doc['lang'],
        'updated_at' => $doc['updated_at'],
        'change_note' => $doc['change_note'],
        'html' => $doc['rendered_html'],
        'preview' => $isPreview
    ];
    if ($isPreview) {
        $response['effective_date'] = date('c', strtotime($doc['scheduled_at']));
    }
    if ($fallbackFromLang !== null) {
        $response['fallback'] = true;
        $response['requested_lang'] = $fallbackFromLang;
    }
    $json = json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);

    if (!$isPreview && $json !== false) {
        public_cache_put((int)$project['id'], 'json', $lang, $doc['slug'], $etagHash, $json);
    }

    echo $json;
}

/**
 * render_public_document() enthaelt zwei Stellen, die trotz gleichem $trans['updated_at']
 * pro Request unterschiedlich sein koennen (aktuelles Jahr im Footer, UI-Locale-Switcher
 * inkl. $_GET-Query). Damit der File-Cache trotzdem die vollstaendige Seite als String
 * speichern kann, rendert render_public_document() an diesen Stellen Sentinel-Tokens statt
 * der Live-Werte; diese Funktion ersetzt sie unmittelbar vor der Auslieferung (egal ob der
 * HTML-String frisch gerendert oder aus dem Datei-Cache gelesen wurde).
 */
function public_cache_fill_dynamic(string $html): string {
    return str_replace(
        ['__CACHE_YEAR__', '__CACHE_LOCALE_SWITCHER__'],
        [date('Y'), render_locale_switcher()],
        $html
    );
}

function render_public_document(array $project, array $trans, string $content, array $languages, string $currentLang, bool $isPreview = false, ?string $liveSlug = null, ?string $fallbackFromLang = null): void {
    $liveSlug = $liveSlug ?? $trans['slug'];
    $brand = htmlspecialchars($project['brand_color'] ?: '#F0A63C');
    $logoUrl = $project['logo_url'] ?: '/paragrafy.svg';
    $i18n = get_i18n_strings($currentLang);

    $wordCount = str_word_count(strip_tags($content));
    $readMinutes = max(1, (int)ceil($wordCount / 200));
    $readTimeStr = sprintf($i18n['read_time'], $readMinutes);
    $lastUpdatedStr = sprintf($i18n['last_updated'], date('d.m.Y', strtotime($trans['updated_at'])));
    $hasUpcoming = !$isPreview && !empty($trans['scheduled_at']);
    ?>
    <!DOCTYPE html>
    <html lang="<?= htmlspecialchars($currentLang) ?>">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <?php if ($isPreview): ?><meta name="robots" content="noindex, nofollow"><?php endif; ?>
        <title><?= htmlspecialchars($trans['title']) ?><?= $isPreview ? htmlspecialchars($i18n['preview_suffix']) : '' ?> - <?= htmlspecialchars($project['name']) ?></title>
        <link rel="icon" type="image/svg+xml" href="<?= htmlspecialchars($logoUrl) ?>">
        <?= theme_head_tags() ?>
        <?= theme_base_css($project['brand_color'] ?: '#F0A63C', false) ?>
        <style>
            body { line-height: 1.7; }
            .preview-banner { background: oklch(24% 0.05 250); border-radius: 10px; padding: 14px 18px; margin-bottom: 22px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
            .preview-banner span { font-size: 13px; color: oklch(85% 0.04 250); }
            .preview-banner a.btn-preview { border: none; background: oklch(58% 0.16 250); color: #fff; border-radius: 7px; padding: 7px 14px; font-size: 12px; font-weight: 700; text-decoration: none; margin-left: auto; }
            .hero { background: #17141b; padding: 24px 40px 40px; }
            .hero-inner { max-width: 1160px; margin: 0 auto; }
            .hero-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 1rem; }
            .brand-wrap { display: flex; align-items: center; gap: 10px; text-decoration: none; }
            .brand-wrap img { width: 30px; height: 30px; border-radius: 7px; }
            .brand-name { font-family: 'Plus Jakarta Sans', sans-serif; font-weight: 800; font-size: 17px; color: #fff; }

            .header-actions { display: flex; gap: 8px; flex-wrap: wrap; }
            .lang-switch a { text-decoration: none; padding: 6px 14px; border-radius: 7px; font-size: 12px; border: 1px solid rgba(255,255,255,0.16); color: rgba(255,255,255,0.6); font-weight: 700; }
            .lang-switch a.active { background: var(--accent); color: #fff; border-color: var(--accent); }
            .btn-action { background: transparent; border: 1px solid rgba(255,255,255,0.16); color: rgba(255,255,255,0.75); padding: 6px 14px; border-radius: 7px; font-size: 12px; cursor: pointer; font-weight: 600; display: inline-flex; align-items: center; gap: 6px; font-family: 'IBM Plex Sans', sans-serif; }
            .btn-action:hover { border-color: rgba(255,255,255,0.4); color: #fff; }

            h1 { font-size: 32px; margin: 0 0 14px; font-weight: 800; letter-spacing: -0.02em; line-height: 1.2; color: #fff; }
            .meta-bar { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 20px; font-size: 12px; }
            .meta-pill { background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.75); padding: 6px 12px; border-radius: 20px; font-weight: 500; display: inline-flex; align-items: center; gap: 6px; }

            .search-box { position: relative; }
            .search-box input { width: 100%; box-sizing: border-box; padding: 12px 16px 12px 40px; border: 1px solid rgba(255,255,255,0.14); border-radius: 8px; background: rgba(255,255,255,0.06); color: #fff; font-size: 13px; outline: none; font-family: 'IBM Plex Sans', sans-serif; }
            .search-box input::placeholder { color: rgba(255,255,255,0.4); }
            .search-box input:focus { border-color: var(--accent); }
            .search-box svg { position: absolute; left: 13px; top: 50%; transform: translateY(-50%); color: rgba(255,255,255,0.4); pointer-events: none; }

            .wrapper { max-width: 1160px; margin: 0 auto; padding: 36px 40px 90px; display: grid; grid-template-columns: 1fr 280px; gap: 48px; align-items: start; }
            @media (max-width: 980px) { .wrapper { grid-template-columns: 1fr; } .toc-sidebar { display: none; } .hero { padding: 20px 20px 28px; } .wrapper { padding: 28px 20px 60px; } }

            .content { font-size: 14.5px; color: var(--text); }
            .content h1, .content h2, .content h3 { scroll-margin-top: 2rem; position: relative; }
            .content h1 { font-size: 26px; font-weight: 800; margin-top: 2.25rem; margin-bottom: 0.75rem; color: var(--text); }
            .content h2 { font-size: 22px; font-weight: 700; margin-top: 2.25rem; margin-bottom: 0.75rem; }
            .content h3 { font-size: 16px; font-weight: 700; margin-top: 1.75rem; margin-bottom: 0.5rem; }
            .content a { text-decoration: underline; }
            .content p { margin: 1rem 0; }

            .anchor-link { opacity: 0; margin-left: 0.5rem; text-decoration: none !important; color: var(--text-faint) !important; font-weight: normal; transition: opacity 0.2s; font-size: 0.9em; }
            .content h1:hover .anchor-link, .content h2:hover .anchor-link, .content h3:hover .anchor-link { opacity: 1; }

            .toc-sidebar { position: sticky; top: 24px; border-left: 1px solid var(--border); padding-left: 24px; }
            .toc-title { font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-faint); letter-spacing: 0.06em; margin-bottom: 14px; }
            .toc-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 11px; max-height: calc(100vh - 12rem); overflow-y: auto; }
            .toc-link { display: block; font-size: 13px; color: var(--text-muted); text-decoration: none; border-left: 2px solid transparent; padding-left: 10px; margin-left: -11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
            .toc-link:hover { color: var(--text); }
            .toc-link.active { color: var(--accent); font-weight: 600; border-left-color: var(--accent); }
            .toc-brand { display: flex; align-items: center; gap: 6px; margin-top: 28px; font-size: 12px; color: var(--text-faint); font-weight: 500; }
            .toc-brand img { width: 15px; height: 15px; border-radius: 4px; display: block; }

            footer { text-align: center; padding-bottom: 2rem; font-size: 12px; color: var(--text-faint); }
            @media print { .header-actions, .toc-sidebar, .search-box, .anchor-link, .hero a, .preview-banner, footer { display: none; } .hero { background: #fff; padding: 0; } h1, .brand-name { color: #000; } .wrapper { display: block; padding: 0; } body { background: #fff; color: #000; } }
        </style>
    </head>
    <body>
        <div class="hero">
            <div class="hero-inner">
                <div class="hero-top">
                    <a href="/<?= htmlspecialchars($currentLang) ?>" class="brand-wrap">
                        <img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo">
                        <span class="brand-name"><?= htmlspecialchars($project['name']) ?></span>
                    </a>
                    <div class="header-actions">
                        <div class="lang-switch">
                            <?php foreach ($languages as $l): ?>
                                <a href="/<?= htmlspecialchars($l['lang']) ?>/<?= htmlspecialchars($l['slug'] ?: $l['default_slug']) ?><?= $isPreview ? '/preview' : '' ?>" class="<?= $l['lang'] === $currentLang ? 'active' : '' ?>">
                                    <?= strtoupper(htmlspecialchars($l['lang'])) ?>
                                </a>
                            <?php endforeach; ?>
                        </div>
                        <button onclick="window.print()" class="btn-action" title="<?= htmlspecialchars($i18n['print_title']) ?>">
                            <?= svg_icon('print', '', 14) ?> <?= htmlspecialchars($i18n['print']) ?>
                        </button>
                    </div>
                </div>

                <?php if ($isPreview): ?>
                    <div class="preview-banner">
                        <span><?= sprintf($i18n['preview_banner_text'], '<strong style="color:#fff">' . date('d.m.Y \u\m H:i', strtotime($trans['scheduled_at'])) . '</strong>') ?></span>
                        <a href="/<?= htmlspecialchars($currentLang) ?>/<?= htmlspecialchars($liveSlug) ?>" class="btn-preview"><?= $i18n['preview_banner_link'] ?></a>
                    </div>
                <?php elseif ($hasUpcoming): ?>
                    <div class="preview-banner">
                        <span><?= sprintf($i18n['upcoming_banner_text'], '<strong style="color:#fff">' . date('d.m.Y', strtotime($trans['scheduled_at'])) . '</strong>') ?></span>
                        <a href="/<?= htmlspecialchars($currentLang) ?>/<?= htmlspecialchars($trans['slug']) ?>/preview" class="btn-preview"><?= $i18n['upcoming_banner_link'] ?></a>
                    </div>
                <?php elseif ($fallbackFromLang !== null): ?>
                    <div class="preview-banner">
                        <span><?= sprintf($i18n['lang_fallback_notice'], strtoupper(htmlspecialchars($fallbackFromLang))) ?></span>
                    </div>
                <?php endif; ?>

                <h1><?= htmlspecialchars($trans['title']) ?></h1>
                <div class="meta-bar">
                    <span class="meta-pill"><?= svg_icon('clock', '', 13) ?> <?= htmlspecialchars($lastUpdatedStr) ?></span>
                    <span class="meta-pill"><?= svg_icon('eye', '', 13) ?> <?= htmlspecialchars($readTimeStr) ?></span>
                    <?php if (!empty($trans['change_note'])): ?>
                        <span class="meta-pill"><?= svg_icon('edit', '', 13) ?> <?= htmlspecialchars($trans['change_note']) ?></span>
                    <?php endif; ?>
                </div>

                <div class="search-box">
                    <?= svg_icon('search', '', 16) ?>
                    <input type="text" id="docSearchInput" placeholder="<?= htmlspecialchars($i18n['search_ph']) ?>" oninput="filterDocumentText(this.value)">
                </div>
            </div>
        </div>

        <div class="wrapper">
            <main>
                <div class="content" id="documentContent">
                    <?= $content ?>
                </div>
            </main>

            <aside class="toc-sidebar">
                <div class="toc-title"><?= htmlspecialchars($i18n['toc']) ?></div>
                <ul class="toc-list" id="tocList" style="list-style:none;padding:0;margin:0"></ul>
                <div class="toc-brand"><img src="<?= htmlspecialchars($logoUrl) ?>" alt=""> <?= htmlspecialchars($i18n['powered_by_toc']) ?></div>
            </aside>
        </div>

        <footer>
            &copy; __CACHE_YEAR__ <?= htmlspecialchars($project['company_name'] ?: $project['name']) ?> &bull; <?= htmlspecialchars($i18n['powered_by_footer']) ?>
            __CACHE_LOCALE_SWITCHER__
        </footer>

        <script>
            document.addEventListener('DOMContentLoaded', function() {
                const content = document.getElementById('documentContent');
                const headings = content.querySelectorAll('h1, h2, h3');
                const tocList = document.getElementById('tocList');

                if (headings.length === 0) {
                    document.querySelector('.toc-sidebar').style.display = 'none';
                    document.querySelector('.wrapper').style.gridTemplateColumns = '1fr';
                    return;
                }

                headings.forEach((h, idx) => {
                    const id = 'section-' + idx;
                    h.id = id;

                    const anchor = document.createElement('a');
                    anchor.className = 'anchor-link';
                    anchor.href = '#' + id;
                    anchor.innerHTML = '#';
                    anchor.title = '<?= htmlspecialchars($i18n['copy_anchor']) ?>';
                    anchor.onclick = (e) => {
                        e.preventDefault();
                        navigator.clipboard.writeText(window.location.origin + window.location.pathname + '#' + id);
                        window.location.hash = id;
                    };
                    h.appendChild(anchor);

                    const li = document.createElement('li');
                    li.className = 'toc-item';
                    const link = document.createElement('a');
                    link.className = 'toc-link' + (h.tagName === 'H3' ? ' toc-sub' : '');
                    link.href = '#' + id;
                    link.innerText = h.childNodes[0].nodeValue.trim();
                    link.dataset.target = id;
                    li.appendChild(link);
                    tocList.appendChild(li);
                });

                const observer = new IntersectionObserver((entries) => {
                    entries.forEach(entry => {
                        if (entry.isIntersecting) {
                            document.querySelectorAll('.toc-link').forEach(l => l.classList.remove('active'));
                            const activeLink = document.querySelector(`.toc-link[data-target="${entry.target.id}"]`);
                            if (activeLink) activeLink.classList.add('active');
                        }
                    });
                }, { rootMargin: '0px 0px -70% 0px' });

                headings.forEach(h => observer.observe(h));
            });

            function filterDocumentText(term) {
                const paragraphs = document.querySelectorAll('#documentContent p, #documentContent li, #documentContent h1, #documentContent h2, #documentContent h3');
                const query = term.toLowerCase().trim();
                paragraphs.forEach(p => {
                    if (query === '' || p.innerText.toLowerCase().includes(query)) {
                        p.style.opacity = '1';
                        p.style.display = '';
                    } else {
                        p.style.opacity = '0.15';
                    }
                });
            }
        </script>
    </body>
    </html>
    <?php
}

function render_public_overview(array $project, PDO $db, string $lang, string $primaryLang): void {
    $brand = htmlspecialchars($project['brand_color'] ?: '#F0A63C');
    $logoUrl = $project['logo_url'] ?: '/paragrafy.svg';
    $i18n = get_i18n_strings($lang);

    // Dokumente + alle veroeffentlichten Uebersetzungen getrennt laden (statt eines
    // LEFT JOINs nur fuer $lang), damit pro Dokument per pick_fallback_translation() auf
    // eine andere Sprache ausgewichen werden kann, statt es bei fehlender $lang-Version
    // stillschweigend aus der Uebersicht zu verstecken.
    $stmt = $db->prepare("SELECT d.id, dt.slug as default_slug, dt.title as fallback_title FROM documents d JOIN doc_types dt ON d.doc_type_id = dt.id WHERE d.project_id = ?");
    $stmt->execute([$project['id']]);
    $documents = $stmt->fetchAll();

    $stmt = $db->prepare("
        SELECT t.document_id, t.lang, t.title
        FROM translations t
        JOIN documents d ON t.document_id = d.id
        WHERE d.project_id = ? AND t.status = 'published'
    ");
    $stmt->execute([$project['id']]);
    $translationsByDoc = [];
    foreach ($stmt->fetchAll() as $t) {
        $translationsByDoc[$t['document_id']][] = $t;
    }

    $docs = [];
    foreach ($documents as $document) {
        $candidates = $translationsByDoc[$document['id']] ?? [];
        $best = pick_fallback_translation($candidates, $lang, $primaryLang);
        $docs[] = [
            'title' => $best['title'] ?? null,
            'default_slug' => $document['default_slug'],
            'fallback_title' => $document['fallback_title'],
            'is_fallback' => $best !== null && $best['lang'] !== $lang,
            'fallback_lang' => $best['lang'] ?? null,
        ];
    }
    ?>
    <!DOCTYPE html>
    <html lang="<?= htmlspecialchars($lang) ?>">
    <head>
        <meta charset="utf-8">
        <title><?= htmlspecialchars($i18n['overview_title']) ?> - <?= htmlspecialchars($project['name']) ?></title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <link rel="icon" type="image/svg+xml" href="<?= htmlspecialchars($logoUrl) ?>">
        <?= theme_head_tags() ?>
        <?= theme_base_css($project['brand_color'] ?: '#F0A63C', false) ?>
        <style>
            body { display: flex; align-items: center; justify-content: center; padding: 70px 20px; }
            .box { background: #fff; border: 1px solid var(--border); border-radius: 16px; padding: 36px; max-width: 460px; width: 100%; box-shadow: 0 2px 12px rgba(0,0,0,.04); }
            .head-logo { display:flex; align-items:center; gap:10px; margin-bottom:6px; }
            .head-logo img { width: 32px; height: 32px; border-radius: 8px; }
            .head-logo span { font-family: 'Plus Jakarta Sans', sans-serif; font-weight: 800; font-size: 19px; letter-spacing: -0.01em; }
            ul { list-style: none; padding: 0; margin: 20px 0 0; display: flex; flex-direction: column; gap: 10px; }
            li a { display: flex; justify-content: space-between; align-items: center; padding: 14px 16px; border: 1px solid var(--border); border-radius: 10px; color: var(--text); text-decoration: none; font-weight: 600; font-size: 14px; }
            li a:hover { border-color: var(--accent); }
            li a span:last-child { color: var(--text-faint); }
            .toc-brand { display: flex; justify-content: center; align-items: center; gap: 6px; margin-top: 14px; font-size: 12px; color: var(--text-faint); font-weight: 500; }
            .toc-brand img { width: 15px; height: 15px; border-radius: 4px; display: block; }
        </style>
    </head>
    <body>
        <div class="box">
            <div class="head-logo">
                <img src="<?= htmlspecialchars($logoUrl) ?>" alt="Logo">
                <span><?= htmlspecialchars($project['name']) ?></span>
            </div>
            <p style="font-size:14px;color:var(--text-muted);margin:0 0 20px;"><?= htmlspecialchars($i18n['overview_sub']) ?></p>
            <ul>
                <?php foreach ($docs as $doc): ?>
                    <?php if ($doc['title']): ?>
                        <li>
                            <!-- Immer default_slug (dt.slug, projektweit eindeutig per UNIQUE-Constraint)
                                 statt der aufgeloesten Uebersetzung eigenem Slug: der eigene Slug ist NICHT
                                 global eindeutig und koennte beim Aufruf ueber find_public_translation()/
                                 find_public_translation_with_fallback() als mehrdeutig verworfen werden,
                                 obwohl die Uebersicht das Dokument hier eindeutig kannte. -->
                            <a href="/<?= htmlspecialchars($lang) ?>/<?= htmlspecialchars($doc['default_slug']) ?>">
                                <span><?= htmlspecialchars($doc['title']) ?><?php if ($doc['is_fallback']): ?> <span style="font-size:10px;font-weight:700;color:var(--text-faint);border:1px solid var(--border);border-radius:4px;padding:1px 5px;vertical-align:middle;"><?= htmlspecialchars(strtoupper($doc['fallback_lang'])) ?></span><?php endif; ?></span>
                                <span>&rarr;</span>
                            </a>
                        </li>
                    <?php endif; ?>
                <?php endforeach; ?>
            </ul>
            <p style="font-size:11px;color:var(--text-faintest);line-height:1.5;text-align:center;margin:22px 0 0"><?= htmlspecialchars($i18n['overview_title']) ?></p>
            <div class="toc-brand"><img src="<?= htmlspecialchars($logoUrl) ?>" alt=""> <?= htmlspecialchars($i18n['powered_by_toc']) ?></div>
            <?= render_locale_switcher() ?>
        </div>
    </body>
    </html>
    <?php
}

/**
 * Small language switcher for admin-facing UI chrome on public pages
 * (distinct from the document content-language switcher above, which is
 * driven by lang_meta()/active_languages). Links to the current URL with
 * ?locale=xx appended, using the labels/flags from ui_locales().
 */
function render_locale_switcher(): string {
    $locales = ui_locales();
    $current = current_locale();
    $qs = $_GET ?? [];
    $links = [];
    foreach ($locales as $code => $meta) {
        $qs['locale'] = $code;
        $href = htmlspecialchars('?' . http_build_query($qs));
        $label = htmlspecialchars(($meta['flag'] ?? '') . ' ' . ($meta['label'] ?? strtoupper($code)));
        $active = $code === $current ? ' style="font-weight:700;text-decoration:underline"' : '';
        $links[] = '<a href="' . $href . '"' . $active . '>' . $label . '</a>';
    }
    if (empty($links)) return '';
    return '<div style="margin-top:8px;font-size:11px;display:flex;gap:8px;justify-content:center;opacity:0.8"><span>' . htmlspecialchars(t('public.locale_switch_label')) . ':</span> ' . implode(' ', $links) . '</div>';
}

function render_public_404(array $project, string $lang): void {
    $i18n = get_i18n_strings($lang);
    ?>
    <!DOCTYPE html>
    <html lang="<?= htmlspecialchars($lang) ?>">
    <head>
        <meta charset="utf-8"><title>404 - <?= htmlspecialchars($i18n['not_found_title']) ?></title>
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <?= theme_head_tags() ?>
        <?= theme_base_css($project['brand_color'] ?: '#F0A63C', false) ?>
    </head>
    <body style="text-align:center; padding: 5rem 1rem;">
        <h2 style="font-size:20px;font-weight:800;"><?= htmlspecialchars($i18n['not_found_title']) ?></h2>
        <p style="color:var(--text-muted)"><?= htmlspecialchars($i18n['not_found_desc']) ?></p>
        <p><a href="/<?= htmlspecialchars($lang) ?>" style="font-weight:700;">&larr; <?= htmlspecialchars($i18n['back_to_overview']) ?></a></p>
        <?= render_locale_switcher() ?>
    </body></html>
    <?php
}

/**
 * In-App Embed-Drawer: bind buttons with data-paragrafy-slug / data-paragrafy-lang
 * and open a modal sheet loading the document via the public JSON API.
 */
function render_embed_js(): void {
    $i18nJs = json_encode([
        'loading' => t('public.embed.loading_title'),
        'close' => t('public.embed.close_label'),
        'loadingBody' => t('public.embed.loading_body'),
        'error' => t('public.embed.error_title'),
        'errorBody' => t('public.embed.error_body'),
    ], JSON_UNESCAPED_UNICODE);
    ?>
(function () {
    var i18n = <?= $i18nJs ?>;
    var scriptEl = document.currentScript;
    if (!scriptEl) {
        var scripts = document.getElementsByTagName('script');
        scriptEl = scripts[scripts.length - 1];
    }
    var origin;
    try { origin = new URL(scriptEl.src, window.location.href).origin; } catch (e) { origin = window.location.origin; }

    function injectStyles() {
        if (document.getElementById('paragrafy-embed-style')) return;
        var style = document.createElement('style');
        style.id = 'paragrafy-embed-style';
        style.textContent =
            '.paragrafy-embed-backdrop{position:fixed;inset:0;background:rgba(20,16,22,.5);z-index:999999;display:flex;align-items:flex-end;justify-content:center;padding:0}' +
            '@media (min-width:720px){.paragrafy-embed-backdrop{align-items:center;padding:20px}}' +
            '.paragrafy-embed-sheet{background:#fff;width:100%;max-width:640px;max-height:85vh;border-radius:16px 16px 0 0;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 -10px 40px rgba(0,0,0,.2);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}' +
            '@media (min-width:720px){.paragrafy-embed-sheet{border-radius:16px}}' +
            '.paragrafy-embed-header{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid #E8E4DC;flex-shrink:0}' +
            '.paragrafy-embed-header h2{margin:0;font-size:16px;font-weight:700;color:#201C24}' +
            '.paragrafy-embed-close{border:none;background:transparent;font-size:22px;line-height:1;cursor:pointer;color:#746E78;padding:4px;flex-shrink:0}' +
            '.paragrafy-embed-body{padding:20px;overflow-y:auto;font-size:14px;line-height:1.7;color:#201C24}' +
            '.paragrafy-embed-body h2{font-size:18px;margin-top:20px}' +
            '.paragrafy-embed-body h3{font-size:15px;margin-top:16px}' +
            '.paragrafy-embed-body p{margin:0.75em 0}' +
            '.paragrafy-embed-loading,.paragrafy-embed-error{padding:40px 20px;text-align:center;color:#746E78;font-size:14px}';
        document.head.appendChild(style);
    }

    function openDrawer(lang, slug) {
        injectStyles();
        var backdrop = document.createElement('div');
        backdrop.className = 'paragrafy-embed-backdrop';
        backdrop.innerHTML =
            '<div class="paragrafy-embed-sheet" role="dialog" aria-modal="true">' +
                '<div class="paragrafy-embed-header"><h2>' + i18n.loading + '</h2>' +
                '<button type="button" class="paragrafy-embed-close" aria-label="' + i18n.close + '">&times;</button></div>' +
                '<div class="paragrafy-embed-body"><div class="paragrafy-embed-loading">' + i18n.loadingBody + '</div></div>' +
            '</div>';
        document.body.appendChild(backdrop);

        function close() {
            backdrop.remove();
            document.removeEventListener('keydown', onKey);
        }
        function onKey(e) { if (e.key === 'Escape') close(); }
        document.addEventListener('keydown', onKey);
        backdrop.addEventListener('click', function (e) { if (e.target === backdrop) close(); });
        backdrop.querySelector('.paragrafy-embed-close').addEventListener('click', close);

        fetch(origin + '/api/' + encodeURIComponent(lang) + '/' + encodeURIComponent(slug))
            .then(function (res) {
                if (!res.ok) throw new Error('not_found');
                return res.json();
            })
            .then(function (data) {
                backdrop.querySelector('.paragrafy-embed-header h2').textContent = data.title || '';
                backdrop.querySelector('.paragrafy-embed-body').innerHTML = data.html || '';
            })
            .catch(function () {
                backdrop.querySelector('.paragrafy-embed-header h2').textContent = i18n.error;
                backdrop.querySelector('.paragrafy-embed-body').innerHTML =
                    '<div class="paragrafy-embed-error">' + i18n.errorBody + '</div>';
            });
    }

    function bind(el) {
        if (el.__paragrafyBound) return;
        el.__paragrafyBound = true;
        el.addEventListener('click', function (e) {
            e.preventDefault();
            var slug = el.getAttribute('data-paragrafy-slug');
            var lang = el.getAttribute('data-paragrafy-lang') || 'de';
            if (slug) openDrawer(lang, slug);
        });
    }

    function scan() {
        var els = document.querySelectorAll('[data-paragrafy-slug]');
        for (var i = 0; i < els.length; i++) bind(els[i]);
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', scan);
    } else {
        scan();
    }

    if (window.MutationObserver) {
        new MutationObserver(scan).observe(document.documentElement, { childList: true, subtree: true });
    }
})();
    <?php
}

/**
 * DSGVO Cookie-Consent-Banner. $project may be null (unknown domain) — falls
 * back to generic text/colors in that case.
 */
function render_consent_js(?array $project): void {
    $brand = $project['brand_color'] ?? '#F0A63C';
    $primaryLang = $project['primary_lang'] ?? 'de';
    $locale = current_locale();
    $bannerMap = cookie_banner_text_map($project ?? []);
    $bannerText = trim($bannerMap[$locale] ?? '') ?: t('public.consent.default_text');

    // consent.js runs embedded on the CLIENT's website, so the privacy link
    // must be an absolute URL back to this Paragrafy instance -- a relative
    // path like "/de/datenschutz" would resolve against the embedding site
    // instead and 404 (or hit an unrelated page) there.
    $scheme = ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https')) ? 'https' : 'http';
    $selfHost = get_current_host();
    $privacySlug = 'datenschutz';
    if ($project) {
        $db = get_db();
        $stmt = $db->prepare("
            SELECT t.slug, dt.slug AS default_slug
            FROM translations t
            JOIN documents d ON t.document_id = d.id
            JOIN doc_types dt ON d.doc_type_id = dt.id
            WHERE d.project_id = ? AND t.lang = ? AND dt.slug = 'datenschutz' AND t.status = 'published'
            LIMIT 1
        ");
        $stmt->execute([$project['id'], $primaryLang]);
        $privacyDoc = $stmt->fetch();
        if ($privacyDoc) {
            $privacySlug = $privacyDoc['slug'] ?: $privacyDoc['default_slug'];
        }
    }
    $privacyUrl = $scheme . '://' . $selfHost . '/' . $primaryLang . '/' . $privacySlug;

    $brandJs = json_encode($brand, JSON_UNESCAPED_UNICODE);
    $textJs = json_encode($bannerText, JSON_UNESCAPED_UNICODE);
    $privacyUrlJs = json_encode($privacyUrl, JSON_UNESCAPED_UNICODE);
    $loggingEnabledJs = json_encode(!empty($project['consent_logging_enabled']));
    $bannerTextHashJs = json_encode(hash('sha256', $bannerText));
    $i18nJs = json_encode([
        'learnMore' => t('public.consent.learn_more'),
        'decline' => t('public.consent.decline'),
        'accept' => t('public.consent.accept'),
        'ariaLabel' => t('public.consent.aria_label'),
    ], JSON_UNESCAPED_UNICODE);
    ?>
(function () {
    var CONSENT_KEY = 'paragrafy_consent';
    var CONSENT_ID_KEY = 'paragrafy_consent_id';
    try { if (localStorage.getItem(CONSENT_KEY)) return; } catch (e) {}

    var brand = <?= $brandJs ?>;
    var text = <?= $textJs ?>;
    var privacyUrl = <?= $privacyUrlJs ?>;
    var i18n = <?= $i18nJs ?>;
    var loggingEnabled = <?= $loggingEnabledJs ?>;
    var bannerTextHash = <?= $bannerTextHashJs ?>;
    var origin;
    try { origin = new URL((document.currentScript || {}).src || '', window.location.href).origin; } catch (e) { origin = window.location.origin; }

    var style = document.createElement('style');
    style.textContent =
        '.paragrafy-consent-bar{position:fixed;left:16px;right:16px;bottom:16px;max-width:560px;margin:0 auto;background:#201C24;color:#fff;border-radius:12px;padding:16px 18px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:13px;line-height:1.5;z-index:999998;box-shadow:0 10px 40px rgba(0,0,0,.25);display:flex;flex-direction:column;gap:12px}' +
        '.paragrafy-consent-bar a{color:#fff;text-decoration:underline}' +
        '.paragrafy-consent-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}' +
        '.paragrafy-consent-actions button{border:none;border-radius:8px;padding:8px 14px;font-size:12.5px;font-weight:700;cursor:pointer;font-family:inherit}' +
        '.paragrafy-consent-decline{background:transparent !important;color:#fff;border:1px solid rgba(255,255,255,.3) !important}' +
        '.paragrafy-consent-accept{background:' + brand + ';color:#fff}';
    document.head.appendChild(style);

    var bar = document.createElement('div');
    bar.className = 'paragrafy-consent-bar';
    bar.setAttribute('role', 'dialog');
    bar.setAttribute('aria-label', i18n.ariaLabel);

    var textEl = document.createElement('div');
    textEl.textContent = text + ' ';
    var link = document.createElement('a');
    link.href = privacyUrl;
    link.target = '_blank';
    link.rel = 'noopener';
    link.textContent = i18n.learnMore;
    textEl.appendChild(link);

    var actions = document.createElement('div');
    actions.className = 'paragrafy-consent-actions';
    var declineBtn = document.createElement('button');
    declineBtn.type = 'button';
    declineBtn.className = 'paragrafy-consent-decline';
    declineBtn.textContent = i18n.decline;
    var acceptBtn = document.createElement('button');
    acceptBtn.type = 'button';
    acceptBtn.className = 'paragrafy-consent-accept';
    acceptBtn.textContent = i18n.accept;

    actions.appendChild(declineBtn);
    actions.appendChild(acceptBtn);
    bar.appendChild(textEl);
    bar.appendChild(actions);
    document.body.appendChild(bar);

    function setConsent(value) {
        try { localStorage.setItem(CONSENT_KEY, value); } catch (e) {}
        bar.remove();
        document.dispatchEvent(new CustomEvent('paragrafy:consent', { detail: { consent: value } }));

        if (loggingEnabled) {
            var consentId = '';
            try { consentId = localStorage.getItem(CONSENT_ID_KEY) || ''; } catch (e) {}
            if (!consentId) {
                consentId = (window.crypto && crypto.randomUUID) ? crypto.randomUUID() :
                    ('cid-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2));
                try { localStorage.setItem(CONSENT_ID_KEY, consentId); } catch (e) {}
            }
            try {
                fetch(origin + '/api/consent-log', {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        action: value,
                        lang: navigator.language || '',
                        textHash: bannerTextHash,
                        consentId: consentId
                    }),
                    keepalive: true
                }).catch(function () {});
            } catch (e) {}
        }
    }

    acceptBtn.addEventListener('click', function () { setConsent('accepted'); });
    declineBtn.addEventListener('click', function () { setConsent('declined'); });
})();
    <?php
}