record, null = removed. */ function raijuNewsOverlay(): array { return is_array($GLOBALS['raijuNewsOverlay'] ?? null) ? $GLOBALS['raijuNewsOverlay'] : []; } function raijuEventsOverlay(): array { return is_array($GLOBALS['raijuEventsOverlay'] ?? null) ? $GLOBALS['raijuEventsOverlay'] : []; } /** * In-memory content that replaces the stored JSON of a page while rendering * (preview, render check). Use these helpers rather than writing the global * directly, so cached copies are invalidated. Pass null to remove one page. */ function raijuContentOverlaySet($page, $data) { if (is_array($data)) { $GLOBALS['raijuContentOverlay'][$page] = $data; } else { unset($GLOBALS['raijuContentOverlay'][$page]); } $GLOBALS['raijuContentOverlayVersion'] = (int)($GLOBALS['raijuContentOverlayVersion'] ?? 0) + 1; } function raijuContentOverlayClear() { $GLOBALS['raijuContentOverlay'] = []; $GLOBALS['raijuContentOverlayVersion'] = (int)($GLOBALS['raijuContentOverlayVersion'] ?? 0) + 1; } function raijuContentOverlayFor($page) { $overlay = $GLOBALS['raijuContentOverlay'][$page] ?? null; return is_array($overlay) ? $overlay : null; } /** * Check if admin is logged in */ function isAdminLoggedIn() { if (raijuContentScanActive()) { return true; } return raijuAccessIsLoggedIn(); } /** * Load content from JSON file * @param string $page Page name (e.g. 'de_beispiel') * @return array Content array or empty array if not found */ function loadContent($page) { $overlay = raijuContentOverlayFor($page); if ($overlay !== null) { return $overlay; } $filepath = CONTENT_BASE_PATH . $page . '.json'; if (!file_exists($filepath)) { return ['sections' => []]; } $content = json_decode(file_get_contents($filepath), true); return $content ?: ['sections' => []]; } /** * Returns the section type display name */ function getBlockTypes() { static $blockTypes = null; if ($blockTypes === null) { $blockTypes = require __DIR__ . '/block-types.php'; } return $blockTypes; } function getSectionTypeName($type) { // Legacy alias if ($type === 'project') return t('block.card'); $blockTypes = getBlockTypes(); return $blockTypes[$type]['label'] ?? $type; } /** * Renders a section based on its type * @param array $section Section data * @param int $index Section index * @param bool $editable Make editable * @return string HTML output */ function renderSection($section, $index = 0, $editable = false, $page = '') { $html = ''; $innerHtml = ''; // Resolve legacy type aliases $type = $section['type']; if ($type === 'project') $type = 'card'; // Look up renderer file from registry $blockTypes = getBlockTypes(); $rendererFile = __DIR__ . '/block-renderers/' . $type . '.php'; if (isset($blockTypes[$type]) && file_exists($rendererFile)) { $innerHtml = (function() use ($section, $editable, $rendererFile, $page, $index) { return require $rendererFile; })(); } $isHiddenSection = !empty($section['hidden']); // If editable, wrap in container if ($editable && !empty($innerHtml)) { $sectionId = htmlspecialchars($section['id'] ?? 'section_' . $index); $sectionType = htmlspecialchars($section['type']); $typeName = getSectionTypeName($section['type']); $hiddenAttr = $isHiddenSection ? ' data-hidden="true"' : ''; $html .= '
' . "\n"; $html .= '
✎ ' . $typeName . '
' . "\n"; $html .= $innerHtml; $html .= '
' . "\n"; } elseif ($isHiddenSection) { // Hidden sections produce no output for visitors $html = ''; } else { $html = $innerHtml; } return $html; } /** * Renders all sections of a page * @param string $page Page name * @param bool $staggerCards Enable stagger animation on card grids * @return string HTML output of all sections */ function renderAllSections($page, $staggerCards = false) { $content = loadContent($page); $html = ''; $isEditable = isAdminLoggedIn(); if ($isEditable) { $html .= '
' . "\n"; } if (!empty($content['sections'])) { $inCardGrid = false; $sectionCount = count($content['sections']); foreach ($content['sections'] as $index => $section) { $isCard = in_array($section['type'] ?? '', ['card', 'project']); $nextIsCard = isset($content['sections'][$index + 1]) && in_array($content['sections'][$index + 1]['type'] ?? '', ['card', 'project']); if ($isCard && !$inCardGrid) { $gridClass = 'cards-grid' . ($staggerCards ? ' stagger-reveal' : ''); $html .= '
' . "\n"; $inCardGrid = true; } $html .= renderSection($section, $index, $isEditable, $page); if ($inCardGrid && (!$nextIsCard || $index === $sectionCount - 1)) { $html .= '
' . "\n"; $inCardGrid = false; } } } if ($isEditable) { $html .= '
' . "\n"; } return $html; } /** * Gets a specific value from content * @param string $page Page name * @param string $key Key (e.g. 'title') * @return mixed Value or null */ function getContentValue($page, $key) { $content = loadContent($page); return $content[$key] ?? null; } // ============================================================ // EDITABLE FIELDS FOR CUSTOM LAYOUTS // ============================================================ /** * Navigate a nested array using dot notation. * Example: getNestedValue($data, 'hero.title') returns $data['hero']['title'] */ function getNestedValue($data, $dotKey) { $keys = explode('.', $dotKey); $current = $data; foreach ($keys as $key) { if (!is_array($current) || !array_key_exists($key, $current)) { return null; } $current = $current[$key]; } return $current; } /** * Load content with per-request caching. * Prevents re-reading JSON on every editableText() call. */ function &loadContentCached($page) { static $cache = []; // An overlay gets its own cache slot, so setting or changing it never serves stale data. $key = raijuContentOverlayFor($page) !== null ? $page . '#overlay' . (int)($GLOBALS['raijuContentOverlayVersion'] ?? 0) : $page; if (!isset($cache[$key])) { $cache[$key] = loadContent($page); } return $cache[$key]; } /** * Set a nested value using dot notation for auto-generation. */ function setNestedValue(&$data, $dotKey, $value) { if (!is_array($data)) $data = []; $keys = explode('.', $dotKey); $current = &$data; foreach ($keys as $i => $key) { if ($i === count($keys) - 1) { $current[$key] = $value; } else { if (!isset($current[$key]) || !is_array($current[$key])) { $current[$key] = []; } $current = &$current[$key]; } } } /** * Track fields that were auto-generated during this request. * Returns the list when called without arguments. */ function autoGeneratedFields($page = null, $fieldKey = null) { static $fields = []; if ($page === null) return $fields; $fields[] = ['page' => $page, 'field' => $fieldKey]; } /** * Auto-generate a missing field in the JSON file during admin browsing. * When an editable function encounters a key that doesn't exist in the JSON, * this writes the fallback value to disk so the Content Editor can see it. * Tracked via autoGeneratedFields() and surfaced as a toast notification. */ function autoGenerateContentField($page, $fieldKey, $value) { if (empty($page) || empty($fieldKey)) return; // Scan and preview renders must never write, and an overlay is not what is on disk. if (raijuContentReadonly() || raijuContentOverlayFor($page) !== null) return; require_once __DIR__ . '/json-store.php'; $filepath = CONTENT_BASE_PATH . $page . '.json'; $latest = null; $created = false; $ok = raijuJsonUpdate($filepath, static function (&$data) use ($page, $fieldKey, $value, &$latest, &$created) { $cursor = $data; $exists = true; foreach (explode('.', $fieldKey) as $key) { if (!is_array($cursor) || !array_key_exists($key, $cursor)) { $exists = false; break; } $cursor = $cursor[$key]; } if (!$exists) { $data['page'] = $data['page'] ?? $page; $data['lang'] = $data['lang'] ?? substr($page, 0, 2); setNestedValue($data, $fieldKey, $value); $data['lastModified'] = date('c'); $created = true; } $latest = $data; return $created; }); if ($latest !== null) { $cache = &loadContentCached($page); $cache = $latest; } if ($ok && $created) autoGeneratedFields($page, $fieldKey); } /** * Check if a field is marked as hidden via a sibling "__hidden" key. * E.g. for fieldKey "header.title", checks "header.title__hidden". */ function isFieldHidden($data, $fieldKey) { return getNestedValue($data, $fieldKey . '__hidden') === true; } /** * Render an editable plain-text field for custom layouts. * When admin is logged in: wraps text in a with data attributes. * For visitors: outputs plain escaped text (or empty string if hidden). * If the key is missing from JSON and an admin is browsing, the $default * value is auto-written to the JSON file (see autoGenerateContentField). * * @param string $page JSON page name (e.g. 'en_home') * @param string $fieldKey Dot-notation key (e.g. 'hero.title') * @param string $default Fallback text — becomes initial JSON value on auto-write * @return string HTML output */ function editableText($page, $fieldKey, $default = '') { $data = loadContentCached($page); $value = getNestedValue($data, $fieldKey); if ($value === null) { $value = $default; if (isAdminLoggedIn()) { autoGenerateContentField($page, $fieldKey, $value); } } $hidden = isFieldHidden($data, $fieldKey); // Decode HTML entities first (e.g. → → →), then escape for safe output. // This prevents double-encoding when content contains named entities. $safeValue = htmlspecialchars(html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8')); if (isAdminLoggedIn()) { $hiddenAttr = $hidden ? ' data-hidden="true"' : ''; return '' . $safeValue . ''; } // Hidden fields are not rendered for visitors if ($hidden) return ''; return $safeValue; } /** * Render an editable rich-text (HTML) field for custom layouts. * When admin is logged in: wraps content in a
with data attributes. * For visitors: outputs sanitized HTML. * If the key is missing from JSON and an admin is browsing, the $default * value is auto-written to the JSON file (see autoGenerateContentField). * * @param string $page JSON page name (e.g. 'en_home') * @param string $fieldKey Dot-notation key (e.g. 'hero.subtitle') * @param string $default Fallback HTML — becomes initial JSON value on auto-write * @return string HTML output */ function editableHtml($page, $fieldKey, $default = '') { $data = loadContentCached($page); $value = getNestedValue($data, $fieldKey); if ($value === null) { $value = $default; if (isAdminLoggedIn()) { autoGenerateContentField($page, $fieldKey, $value); } } $hidden = isFieldHidden($data, $fieldKey); if (isAdminLoggedIn()) { $hiddenAttr = $hidden ? ' data-hidden="true"' : ''; return '
' . sanitizeHtml($value) . '
'; } if ($hidden) return ''; return sanitizeHtml($value); } /** * Render an editable link/button for custom layouts. * Stores both text and href in JSON (fieldKey.text and fieldKey.href). * Admin: adds editable-field-link class + data attributes. * Visitor: outputs a normal tag. * If the key is missing from JSON and an admin is browsing, {text, href} * is auto-written to the JSON file (see autoGenerateContentField). * * @param string $page JSON page name * @param string $fieldKey Dot-notation key (e.g. 'hero.cta1') * @param string $defaultText Fallback link text — becomes initial JSON value * @param string $defaultHref Fallback URL — becomes initial JSON value * @param string $class CSS classes for the tag * @param string $attrs Extra HTML attributes (e.g. 'target="_blank" rel="noopener"') * @return string HTML output */ function editableLink($page, $fieldKey, $defaultText = '', $defaultHref = '#', $class = '', $attrs = '') { $data = loadContentCached($page); $linkData = getNestedValue($data, $fieldKey); if ($linkData === null) { $linkData = ['text' => $defaultText, 'href' => $defaultHref]; if (isAdminLoggedIn()) { autoGenerateContentField($page, $fieldKey, $linkData); } } $text = $defaultText; $href = $defaultHref; $target = ''; $download = false; if (is_array($linkData)) { $text = $linkData['text'] ?? $defaultText; $href = $linkData['href'] ?? $defaultHref; $target = $linkData['target'] ?? ''; $download = !empty($linkData['download']); } elseif (is_string($linkData)) { $text = $linkData; } // Block javascript: and other dangerous schemes stored in the link target. $href = raijuSanitizeHref((string)$href); $hidden = isFieldHidden($data, $fieldKey); $classAttr = $class ? ' class="' . htmlspecialchars($class) . '"' : ''; $extraAttrs = $attrs ? ' ' . $attrs : ''; $targetAttr = ''; if ($target === '_blank') { // noopener hardens against reverse tabnabbing on new-tab links $targetAttr = ' target="_blank" rel="noopener"'; } $downloadAttr = $download ? ' download' : ''; // Decode HTML entities first (e.g. → → →), then escape for safe output. // This prevents double-encoding: → would become → without decode. $safeText = htmlspecialchars(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8')); if (isAdminLoggedIn()) { $hiddenAttr = $hidden ? ' data-hidden="true"' : ''; return '' . $safeText . ''; } if ($hidden) return ''; return '' . $safeText . ''; } /** * Render an editable image for custom layouts. * Stores src and alt in JSON (fieldKey.src and fieldKey.alt). * Admin: adds editable-field-image class + data attributes. * Visitor: outputs a normal tag. * If the key is missing from JSON and an admin is browsing, {src, alt} * is auto-written to the JSON file (see autoGenerateContentField). * * @param string $page JSON page name * @param string $fieldKey Dot-notation key (e.g. 'hero.image') * @param string $defaultSrc Fallback image path — becomes initial JSON value * @param string $defaultAlt Fallback alt text — becomes initial JSON value * @param string $class CSS classes for the tag * @return string HTML output */ function editableImage($page, $fieldKey, $defaultSrc = '', $defaultAlt = '', $class = '') { $data = loadContentCached($page); $imgData = getNestedValue($data, $fieldKey); if ($imgData === null) { $imgData = ['src' => $defaultSrc, 'alt' => $defaultAlt]; if (isAdminLoggedIn()) { autoGenerateContentField($page, $fieldKey, $imgData); } } $src = $defaultSrc; $alt = $defaultAlt; if (is_array($imgData)) { $src = $imgData['src'] ?? $defaultSrc; $alt = $imgData['alt'] ?? $defaultAlt; } elseif (is_string($imgData)) { $src = $imgData; } $hidden = isFieldHidden($data, $fieldKey); $classAttr = $class ? ' class="' . htmlspecialchars($class) . '"' : ''; if (isAdminLoggedIn()) { $hiddenAttr = $hidden ? ' data-hidden="true"' : ''; return '' . htmlspecialchars($alt) . '' . raijuAiLabelHtml((string)$src); } if ($hidden) return ''; return '' . htmlspecialchars($alt) . '' . raijuAiLabelHtml((string)$src); } /** * Render an editable image whose src and alt live at two separate * dot-notation keys (not nested inside a single {src, alt} object). * Used by block renderers where the JSON schema already stores src * and alt as sibling fields (e.g. image block: sections.N.src, sections.N.alt). * Does NOT auto-write on missing keys — schema is expected to exist. * * @param string $page JSON page name * @param string $srcFieldKey Dot-notation key for src (e.g. 'sections.3.src') * @param string $altFieldKey Dot-notation key for alt (e.g. 'sections.3.alt') * @param string $defaultSrc Fallback image path * @param string $defaultAlt Fallback alt text * @param string $class CSS classes for the tag * @return string HTML output */ function editableImageSplit($page, $srcFieldKey, $altFieldKey, $defaultSrc = '', $defaultAlt = '', $class = '') { $data = loadContentCached($page); $src = getNestedValue($data, $srcFieldKey); $alt = getNestedValue($data, $altFieldKey); if ($src === null) $src = $defaultSrc; if ($alt === null) $alt = $defaultAlt; $hidden = isFieldHidden($data, $srcFieldKey); $classAttr = $class ? ' class="' . htmlspecialchars($class) . '"' : ''; if (isAdminLoggedIn()) { $hiddenAttr = $hidden ? ' data-hidden="true"' : ''; return '' . htmlspecialchars($alt) . '' . raijuAiLabelHtml((string)$src); } if ($hidden) return ''; return '' . htmlspecialchars($alt) . '' . raijuAiLabelHtml((string)$src); } /** * Render an editable icon token for custom layouts. * The value is intentionally stored as plain text (e.g. "heart-pulse", * "check", "icon-service") so templates can map it to their own icon system. */ function editableIcon($page, $fieldKey, $default = '', $class = '') { $data = loadContentCached($page); $value = getNestedValue($data, $fieldKey); if ($value === null) { $value = $default; if (isAdminLoggedIn()) { autoGenerateContentField($page, $fieldKey, $value); } } $hidden = isFieldHidden($data, $fieldKey); $classAttr = trim('editable-field editable-field-icon ' . $class); $safeValue = htmlspecialchars((string)$value); if (isAdminLoggedIn()) { $hiddenAttr = $hidden ? ' data-hidden="true"' : ''; return '' . $safeValue . ''; } if ($hidden) return ''; return '' . $safeValue . ''; } // ============================================================ // EDITABLE GROUPS (Custom Layout Composite Editors) // ============================================================ /** * Return HTML attributes for a custom editable group. * Use on an existing component wrapper when several editable fields should be * edited together in one modal (e.g. icon + title + text + link cards). * * @param string $page JSON page name (e.g. 'en_home') * @param string $groupKey Dot-notation base key (e.g. 'features.0') * @param array $schema ['label' => 'Feature', 'fields' => [...]] * @return string HTML attributes string or empty for visitors */ function editableGroupAttrs($page, $groupKey, $schema = []) { if (!isAdminLoggedIn()) return ''; if (!raijuGroupSchemaNeedsModal($schema)) { return ''; } $schemaJson = json_encode($schema, JSON_UNESCAPED_UNICODE); if ($schemaJson === false) $schemaJson = '{}'; return ' data-editable-group' . ' data-page="' . htmlspecialchars($page) . '"' . ' data-group="' . htmlspecialchars($groupKey) . '"' . ' data-group-schema="' . htmlspecialchars($schemaJson, ENT_QUOTES, 'UTF-8') . '"'; } /** * Keep one-field plain-text groups inline-only. A group modal is worthwhile * for multiple related fields or any field with a specialized editor. */ function raijuGroupSchemaNeedsModal($schema): bool { if (!is_array($schema)) return false; $fields = $schema['fields'] ?? []; if (!is_array($fields) || empty($fields)) return false; $fieldCount = 0; $hasSpecial = false; foreach ($fields as $field) { if (!is_array($field)) continue; $key = strtolower((string)($field['key'] ?? $field['path'] ?? '')); if ($key === '' || $key === 'hidden') continue; $fieldCount++; $type = strtolower((string)($field['type'] ?? 'text')); if (in_array($type, ['image', 'link', 'icon', 'date', 'datetime', 'select', 'checkbox', 'number'], true)) { $hasSpecial = true; } if (preg_match('/^(image|imagesrc|src|photo|portrait|logo|avatar|cover|thumbnail|link|href|url|cta|button|buttonlink|icon|iconid|symbol)$/', $key)) { $hasSpecial = true; } } return $fieldCount > 1 || $hasSpecial; } /** * Start an admin-only editable group wrapper. The function echoes directly so * templates can use `` without `echo`. */ function editableGroupStart($page, $groupKey, $schema = [], $attrs = '') { if (!isAdminLoggedIn()) return ''; $extraAttrs = trim($attrs); echo '
'; return ''; } /** * Close an editable group wrapper opened with editableGroupStart(). */ function editableGroupEnd() { if (!isAdminLoggedIn()) return ''; echo '
'; return ''; } /** * Infer a structured group-editor schema from a list item/default item shape. * This is intentionally conservative: simple one-field text items stay inline * only, while cards with multiple fields or media/link/icon/date fields get a * modal editor that keeps related fields together. */ function raijuInferGroupSchemaFromDefaults($defaults, $label = 'Item') { if (!is_array($defaults)) return []; $fields = []; $languageCodes = []; if (isset($GLOBALS['SITE_LANGUAGES']) && is_array($GLOBALS['SITE_LANGUAGES'])) { $languageCodes = array_keys($GLOBALS['SITE_LANGUAGES']); } elseif (defined('SITE_LANG_DEFAULT')) { $languageCodes = [SITE_LANG_DEFAULT]; } foreach ($defaults as $key => $value) { if (!is_string($key) || $key === '' || $key === 'hidden') continue; $lower = strtolower($key); $type = 'text'; $localized = false; if (preg_match('/^(image|imagesrc|src|photo|portrait|logo|avatar|cover|thumbnail)$/', $lower)) { $type = 'image'; } elseif (preg_match('/^(link|href|url|cta|button|buttonlink)$/', $lower)) { $type = 'link'; } elseif (preg_match('/^(text|description|desc|intro|body|content|copy|summary)$/', $lower)) { $type = 'textarea'; } elseif (preg_match('/^(icon|iconid|symbol)$/', $lower)) { $type = 'icon'; } elseif (preg_match('/^(date|startdate|enddate|time|datetime)$/', $lower)) { $type = 'text'; } if (is_array($value)) { $valueKeys = array_keys($value); $localized = !empty($languageCodes) && !empty(array_intersect($languageCodes, $valueKeys)); if (array_key_exists('src', $value) || array_key_exists('alt', $value)) { $type = 'image'; } elseif (array_key_exists('href', $value) || array_key_exists('text', $value)) { $type = 'link'; } } $field = [ 'key' => $key, 'type' => $type, 'label' => ucwords(str_replace(['_', '-'], ' ', $key)), ]; if ($localized) { $field['localized'] = true; } $fields[] = $field; } $hasSpecialField = false; foreach ($fields as $field) { if (in_array($field['type'], ['image', 'link', 'icon'], true)) { $hasSpecialField = true; break; } } if (count($fields) < 2 && !$hasSpecialField) { return []; } return [ 'label' => $label, 'fields' => $fields, ]; } /** * Bind a non-editable element to a content value, so the visual editor refreshes * it live when that value changes (e.g. image previews or repeated labels that show * a field edited elsewhere). Images update their src; text and html their content. * For visitors: returns an empty string. * * @param string $page JSON page name (e.g. 'en_home') * @param string $fieldKey Dot-notation key (e.g. 'features.items.0.image') * @param string $type 'image', 'text' or 'html' * @return string HTML attributes string or empty */ function contentBindAttrs($page, $fieldKey, $type = 'text') { if (!isAdminLoggedIn()) return ''; $type = in_array($type, ['image', 'text', 'html'], true) ? $type : 'text'; return ' data-rj-bind="' . $type . '"' . ' data-rj-bind-page="' . htmlspecialchars($page) . '"' . ' data-rj-bind-field="' . htmlspecialchars($fieldKey) . '"'; } /** * Return list-item attributes plus a structured group-editor schema when the * item shape warrants a modal. Use this for card-like repeaters so editors get * both list controls and one modal for related item fields. */ function editableListGroupItemAttrs($page, $listKey, $index, $schema = [], $defaults = [], $label = 'Item') { if (!isAdminLoggedIn()) return ''; if (empty($schema) && !empty($defaults)) { $schema = raijuInferGroupSchemaFromDefaults($defaults, $label); } $attrs = editableListItemAttrs($page, $listKey, $index); if (!empty($schema)) { $attrs .= editableGroupAttrs($page, $listKey . '.' . (int)$index, $schema); } return $attrs; } // ============================================================ // EDITABLE LISTS (Repeatable Items) // ============================================================ /** * Return HTML attributes for an editable list container. * Add these to the element that wraps all list items (e.g. the grid div). * For visitors: returns empty string. * * @param string $page JSON page name (e.g. 'en_home') * @param string $listKey Dot-notation key to the items object (e.g. 'features.items') * @param array $defaults Default values for new items (e.g. ['title' => 'New', 'desc' => '']) * @return string HTML attributes string or empty */ function editableListAttrs($page, $listKey, $defaults = []) { if (!isAdminLoggedIn()) return ''; return ' data-editable-list' . ' data-list-page="' . htmlspecialchars($page) . '"' . ' data-list-key="' . htmlspecialchars($listKey) . '"' . ' data-list-defaults="' . htmlspecialchars(json_encode($defaults, JSON_UNESCAPED_UNICODE)) . '"'; } /** * Return HTML attributes for a single editable list item. * Add these to each repeating element (e.g. each card div). * For visitors: returns empty string. * * @param string $page JSON page name * @param string $listKey Dot-notation key to the items object * @param int $index Item index * @return string HTML attributes string or empty */ function editableListItemAttrs($page, $listKey, $index) { if (!isAdminLoggedIn()) return ''; // Check if list item is hidden $data = loadContentCached($page); $items = getNestedValue($data, $listKey); $hidden = ''; if (is_array($items) && isset($items[$index]) && is_array($items[$index]) && !empty($items[$index]['hidden'])) { $hidden = ' data-hidden="true"'; } return ' data-list-page="' . htmlspecialchars($page) . '"' . ' data-list-key="' . htmlspecialchars($listKey) . '"' . ' data-list-index="' . (int)$index . '"' . $hidden; } /** * Get list items from JSON content, reading count dynamically. * Returns an indexed array of item data, sorted by numeric key. * * @param string $page JSON page name * @param string $listKey Dot-notation key to the items object (e.g. 'features.items') * @return array Indexed array of item data */ function editableListItems($page, $listKey) { $data = loadContentCached($page); $items = getNestedValue($data, $listKey); if (!is_array($items)) { return []; } // Sort by numeric key to ensure correct order ksort($items, SORT_NUMERIC); // For visitors, filter out hidden items if (!isAdminLoggedIn()) { $items = array_filter($items, function($item) { return !(is_array($item) && !empty($item['hidden'])); }); // Re-index to maintain sequential keys $items = array_values($items); } return $items; } /** * Render an editable paragraph list — a list of HTML blocks that admins can * add, remove, reorder, and edit inline (with the floating toolbar). * * Uses the existing editable list system internally. Each item is a single * editableHtml() field with key "{listKey}.{index}.content". * * @param string $page Content page identifier (e.g. 'en_about') * @param string $listKey Dot-notation key to the items object (e.g. 'intro.paragraphs') * @param array $defaults Default values for new items (default: ['content' => '']) * @return string HTML output */ function editableTextList($page, $listKey, $defaults = ['content' => '']) { $items = editableListItems($page, $listKey); $output = ''; if (isAdminLoggedIn()) { // Admin: wrap in list container with editing attributes $output .= '
'; foreach ($items as $i => $item) { $content = is_array($item) ? ($item['content'] ?? '') : ''; $output .= '
'; $output .= editableHtml($page, "$listKey.$i.content", $content); $output .= '
'; } $output .= '
'; } else { // Visitors: output clean HTML content only foreach ($items as $item) { $content = is_array($item) ? ($item['content'] ?? '') : ''; if ($content !== '') { $output .= $content; } } } return $output; } /** * Return the core fallback icon set. * Sites can override or extend this via content/settings/iconset.json. * * @return array */ function getDefaultIconSet() { static $cache = null; if ($cache !== null) return $cache; $path = __DIR__ . '/default-iconset.json'; if (is_file($path)) { $raw = json_decode(file_get_contents($path), true); $icons = normalizeIconSet($raw); if (!empty($icons)) { $cache = $icons; return $cache; } } $cache = getEmergencyDefaultIconSet(); return $cache; } /** * Return a single hard-coded fallback icon for broken or missing icon files. * This keeps getIconSvg() safe even when both JSON icon sets are unavailable. * * @return array */ function getEmergencyDefaultIconSet() { return [ 'default' => [ 'label' => 'Default', 'tags' => ['fallback'], 'svg' => '', 'viewBox' => '0 0 24 24', ], ]; } /** * Path to the site-owned icon set. This lives below content/ so it survives * Raiju core updates. */ function getIconSetPath() { $contentDir = defined('SETTINGS_PATH') ? dirname(SETTINGS_PATH) : (__DIR__ . '/../content'); return $contentDir . '/settings/iconset.json'; } /** * Very small SVG fragment sanitizer for icon inner markup. * Allows common SVG shape tags/attributes and strips scripts, event handlers, * external references, and full wrappers. */ function sanitizeIconSvgMarkup($svg) { $svg = trim((string)$svg); if ($svg === '') return ''; $svg = preg_replace('#<\?xml[\s\S]*?\?>#i', '', $svg); $svg = preg_replace('##i', '', $svg); $svg = preg_replace('#<\s*/?\s*svg\b[^>]*>#i', '', $svg); $svg = preg_replace('##', '', $svg); $svg = preg_replace('#<\s*script\b[^>]*>.*?<\s*/\s*script\s*>#is', '', $svg); $svg = preg_replace('#<\s*(metadata|desc|style|sodipodi:namedview)\b[^>]*>[\s\S]*?<\s*/\s*\1\s*>#is', '', $svg); $svg = preg_replace('#<\s*defs\b[^>]*>\s*(?:<\s*style\b[^>]*>[\s\S]*?<\s*/\s*style\s*>\s*)*<\s*/\s*defs\s*>#is', '', $svg); $svg = preg_replace('/\s+on[a-z]+\s*=\s*(["\']).*?\1/is', '', $svg); $svg = preg_replace('/\s+(href|xlink:href)\s*=\s*(["\'])\s*(?!#)[^"\']*\2/is', '', $svg); $svg = preg_replace('/\s+(class|style)\s*=\s*(["\']).*?\2/is', '', $svg); $allowedTags = 'path|circle|ellipse|line|polyline|polygon|rect|g|defs|clipPath|mask|linearGradient|radialGradient|stop|title'; $svg = preg_replace_callback('#]*)?>#', function($m) use ($allowedTags) { return preg_match('/^(' . $allowedTags . ')$/i', $m[1]) ? $m[0] : ''; }, $svg); $svg = preg_replace('/^\s*(?:\.[\w-]+\s*\{[^}]*\}\s*)+/m', '', $svg); $previous = null; while ($previous !== $svg) { $previous = $svg; $svg = preg_replace('#]*>\s*#i', '', $svg); $svg = preg_replace('#]*>\s*#i', '', $svg); $svg = preg_replace('#]*>\s*#i', '', $svg); $svg = preg_replace('#]*>\s*#i', '', $svg); } return trim($svg); } /** * Normalize an SVG viewBox string. Falls back to the 24x24 icon grid used by * Raiju's bundled icons. */ function normalizeIconViewBox($viewBox) { $viewBox = trim((string)$viewBox); if ($viewBox === '') { return '0 0 24 24'; } $parts = preg_split('/[\s,]+/', $viewBox); if (count($parts) !== 4) { return '0 0 24 24'; } $normalized = []; foreach ($parts as $part) { if (!is_numeric($part)) { return '0 0 24 24'; } $normalized[] = rtrim(rtrim(sprintf('%.6F', (float)$part), '0'), '.'); } if ((float)$normalized[2] <= 0 || (float)$normalized[3] <= 0) { return '0 0 24 24'; } return implode(' ', $normalized); } /** * Extract a viewBox from a full wrapper before the wrapper is stripped. */ function extractIconViewBoxFromSvg($svg) { if (!is_string($svg)) { return ''; } if (preg_match('#<\s*svg\b[^>]*\sviewBox\s*=\s*(["\'])(.*?)\1#is', $svg, $m)) { return normalizeIconViewBox($m[2]); } if (preg_match('#<\s*svg\b[^>]*\swidth\s*=\s*(["\'])([0-9.]+)(?:px)?\1[^>]*\sheight\s*=\s*(["\'])([0-9.]+)(?:px)?\3#is', $svg, $m)) { return normalizeIconViewBox('0 0 ' . $m[2] . ' ' . $m[4]); } return ''; } /** * Normalize icon definitions from either the rich format * { "key": {"label": "...", "svg": "...", "tags": []} } * or the legacy/simple format { "key": "" }. */ function normalizeIconSet($raw) { if (!is_array($raw)) return []; $icons = []; foreach ($raw as $key => $value) { if ($key === '_deleted') continue; if (!is_string($key) || !preg_match('/^[a-zA-Z0-9_-]+$/', $key)) continue; if (is_string($value)) { $label = ucwords(str_replace(['-', '_'], ' ', $key)); $tags = []; $svg = $value; $viewBox = extractIconViewBoxFromSvg($svg); $style = ''; $createdAt = ''; $updatedAt = ''; } elseif (is_array($value)) { $label = isset($value['label']) && is_string($value['label']) ? $value['label'] : ucwords(str_replace(['-', '_'], ' ', $key)); $tags = isset($value['tags']) && is_array($value['tags']) ? array_values(array_filter($value['tags'], 'is_string')) : []; $svg = isset($value['svg']) && is_string($value['svg']) ? $value['svg'] : ''; $viewBox = isset($value['viewBox']) && is_string($value['viewBox']) ? normalizeIconViewBox($value['viewBox']) : extractIconViewBoxFromSvg($svg); $style = isset($value['style']) && is_string($value['style']) ? $value['style'] : ''; $createdAt = isset($value['createdAt']) && is_string($value['createdAt']) ? $value['createdAt'] : ''; $updatedAt = isset($value['updatedAt']) && is_string($value['updatedAt']) ? $value['updatedAt'] : ''; } else { continue; } $svg = sanitizeIconSvgMarkup($svg); if ($svg === '') continue; $icons[$key] = [ 'label' => $label, 'tags' => $tags, 'svg' => $svg, 'viewBox' => $viewBox ?: '0 0 24 24', 'style' => in_array($style ?? '', ['fill', 'stroke'], true) ? $style : 'stroke', 'createdAt' => $createdAt ?? '', 'updatedAt' => $updatedAt ?? '', ]; } return $icons; } /** * Extract hidden core icon keys from a site icon set. * * @return array */ function getDeletedIconKeys($raw) { if (!is_array($raw) || !isset($raw['_deleted']) || !is_array($raw['_deleted'])) { return []; } $deleted = []; foreach ($raw['_deleted'] as $key) { if (is_string($key) && preg_match('/^[a-zA-Z0-9_-]+$/', $key)) { $deleted[] = $key; } } return array_values(array_unique($deleted)); } /** * Return available icon definitions, merging core defaults with the site-owned * content/settings/iconset.json file when present. */ function getAvailableIconDefinitions() { static $cache = null; if ($cache !== null) return $cache; $icons = getDefaultIconSet(); $path = getIconSetPath(); if (is_file($path)) { $raw = json_decode(file_get_contents($path), true); foreach (getDeletedIconKeys($raw) as $deletedKey) { unset($icons[$deletedKey]); } $customIcons = normalizeIconSet($raw); if (!empty($customIcons)) { $icons = array_merge($icons, $customIcons); } } if (empty($icons['default'])) { $defaults = getEmergencyDefaultIconSet(); $icons['default'] = $defaults['default']; } $cache = $icons; return $cache; } /** * Return available icon SVG path markup keyed by icon identifier. * * @return array */ function getAvailableIcons() { $icons = []; foreach (getAvailableIconDefinitions() as $key => $definition) { $icons[$key] = $definition['svg']; } return $icons; } /** * Map an icon identifier to SVG path markup. * Used for feature cards and similar elements where icons are stored as IDs in JSON. * * @param string $iconId Icon identifier (e.g. 'database', 'edit', 'shield') * @return string SVG inner markup (paths, circles, etc.) */ function getIconSvg($iconId) { $icons = getAvailableIcons(); return $icons[$iconId] ?? $icons['default']; } /** * Map an icon identifier to the full normalized icon definition. * * @param string $iconId Icon identifier * @return array{label:string,tags:array,svg:string,viewBox:string} */ function getIconDefinition($iconId) { $icons = getAvailableIconDefinitions(); return $icons[$iconId] ?? $icons['default']; } /** * Render a complete SVG for an icon identifier, preserving custom viewBox * values while keeping the old getIconSvg() inner-markup helper available. */ function renderIconSvg($iconId, $attributes = '') { $icon = getIconDefinition($iconId); $viewBox = htmlspecialchars($icon['viewBox'] ?? '0 0 24 24', ENT_QUOTES, 'UTF-8'); $attributes = trim((string)$attributes); $svg = $icon['svg']; if ($attributes === '') { if (($icon['style'] ?? 'stroke') === 'fill') { $attributes = 'fill="currentColor"'; $svg = preg_replace('/\s+stroke\s*=\s*(["\'])currentColor\1/i', '', $svg); } else { $attributes = 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"'; } } return '' . $svg . ''; } // ============================================================ // COMPARISON TABLE // ============================================================ /** * Comparison table column definitions. * Reads from comparison.columns in the page JSON, falls back to a generic default. */ function getComparisonColumns($page = null) { if ($page) { $data = loadContentCached($page); $cols = getNestedValue($data, 'comparison.columns'); if (is_array($cols) && !empty($cols)) { return array_values($cols); } } // Generic fallback — two unnamed columns return [ ['id' => 'col_a', 'name' => 'Option A', 'highlight' => true], ['id' => 'col_b', 'name' => 'Option B', 'highlight' => false], ]; } /** * Render comparison table as HTML with editable fields. * Reads row data from page content JSON (comparison.rows). * * @param string $page Page name (e.g. 'en_showcase') * @return string HTML output */ function renderComparisonTable($page) { $columns = getComparisonColumns($page); // Load rows directly (not via editableListItems) to preserve original indices $data = loadContentCached($page); $rows = getNestedValue($data, 'comparison.rows'); if (!is_array($rows) || empty($rows)) { return '

No comparison data available.

'; } // Sort by numeric key ksort($rows, SORT_NUMERIC); $isAdmin = isAdminLoggedIn(); $html = '
'; $html .= ''; // Header row $html .= ''; $html .= ''; foreach ($columns as $col) { $highlightClass = !empty($col['highlight']) ? ' comparison-table__highlight' : ''; $html .= ''; } $html .= ''; // Data rows — use original index $i so editableText paths match the JSON $html .= ''; foreach ($rows as $i => $row) { $isRowHidden = is_array($row) && !empty($row['hidden']); if (!$isAdmin && $isRowHidden) continue; // Skip hidden rows for visitors $hiddenAttr = ($isAdmin && $isRowHidden) ? ' data-hidden="true"' : ''; $html .= ''; $html .= ''; foreach ($columns as $col) { $colId = $col['id']; $value = $row[$colId] ?? ''; $highlightClass = !empty($col['highlight']) ? ' comparison-table__highlight' : ''; $cellClass = 'comparison-table__cell' . $highlightClass; // Determine cell display // For admins: editable text + toggle buttons (built via JS) // For visitors: yes/no render as icons, other values as text if ($isAdmin) { $cellType = ($value === 'yes' || $value === 'no') ? $value : 'text'; $fieldKey = "comparison.rows.$i.$colId"; $display = editableText($page, $fieldKey, $value); $html .= ''; } else { if ($value === 'yes') { $display = '✓'; } elseif ($value === 'no') { $display = '✕'; } else { $display = htmlspecialchars($value); } $html .= ''; } } $html .= ''; } $html .= ''; $html .= '
'; $html .= htmlspecialchars($col['name']); $html .= '
' . editableText($page, "comparison.rows.$i.label", 'Label') . '' . $display . '' . $display . '
'; $html .= '
'; return $html; } // ============================================================ // FAQ ACCORDION // ============================================================ /** * Render FAQ accordion as HTML with editable fields. * Reads Q&A data from page content JSON (faq.entries). * * @param string $page Page name (e.g. 'en_showcase') * @return string HTML output */ function renderFaqAccordion($page) { $entries = editableListItems($page, 'faq.entries'); if (empty($entries)) { return '

No FAQ items available.

'; } $defaults = ['question' => 'New question', 'answer' => 'Answer']; $html = '
'; foreach ($entries as $i => $item) { $html .= '
'; $html .= ''; $html .= ''; $html .= '
'; } $html .= '
'; return $html; } // ============================================================ // PRICING TABLE // ============================================================ /** * Render pricing table as HTML with editable fields. * Reads plan data from page content JSON (pricing.plans). * * @param string $page Page name (e.g. 'en_showcase') * @return string HTML output */ function renderPricingTable($page) { $plans = editableListItems($page, 'pricing.plans'); if (empty($plans)) { return '

No pricing data available.

'; } $defaults = ['name' => 'Plan', 'price' => '$0', 'period' => '/mo', 'desc' => '', 'features' => "Feature 1\nFeature 2", 'cta' => ['text' => 'Get Started', 'href' => '#']]; $html = '
'; foreach ($plans as $i => $plan) { $highlighted = !empty($plan['highlight']); $cardClass = 'pricing-card' . ($highlighted ? ' pricing-card--highlight' : ''); $html .= '
'; if ($highlighted) { $html .= '' . editableText($page, "pricing.plans.$i.badge", 'Popular') . ''; } $html .= '

' . editableText($page, "pricing.plans.$i.name", 'Plan') . '

'; $html .= '
'; $html .= '' . editableText($page, "pricing.plans.$i.price", '$0') . ''; $html .= '' . editableText($page, "pricing.plans.$i.period", '/mo') . ''; $html .= '
'; $html .= '

' . editableText($page, "pricing.plans.$i.desc", '') . '

'; // Features list — stored as newline-separated string $features = $plan['features'] ?? ''; $featureList = is_string($features) ? array_filter(explode("\n", $features)) : (is_array($features) ? $features : []); $html .= '
    '; foreach ($featureList as $feature) { $html .= '
  • ' . htmlspecialchars(trim($feature)) . '
  • '; } $html .= '
'; $html .= '
'; $html .= editableLink($page, "pricing.plans.$i.cta", 'Get Started', '#', 'btn' . ($highlighted ? ' btn-gradient' : ' btn-ghost')); $html .= '
'; $html .= '
'; } $html .= '
'; // VAT footnote $data = loadContentCached($page); $vatNote = getNestedValue($data, 'pricing.vat_note'); if (!empty($vatNote)) { $html .= '

' . htmlspecialchars($vatNote) . '

'; } return $html; } // ============================================================ // TESTIMONIALS // ============================================================ /** * Render testimonials grid. * Reads from page content JSON (testimonials.items). * * @param string $page Page name * @return string HTML output */ function renderTestimonials($page) { $items = editableListItems($page, 'testimonials.items'); if (empty($items)) { return '

No testimonials available.

'; } $defaults = ['quote' => 'Quote', 'author' => 'Name', 'role' => 'Role']; $html = '
'; foreach ($items as $i => $item) { $html .= '
'; $html .= ''; $html .= '

' . editableText($page, "testimonials.items.$i.quote", 'Quote') . '

'; $html .= '
'; $html .= '' . editableText($page, "testimonials.items.$i.author", 'Author') . ''; $html .= '' . editableText($page, "testimonials.items.$i.role", 'Role') . ''; $html .= '
'; $html .= '
'; } $html .= '
'; return $html; } // ============================================================ // TEAM GRID // ============================================================ /** * Render team/contributors grid. * Reads from page content JSON (team.members). * * @param string $page Page name * @return string HTML output */ function renderTeamGrid($page) { $members = editableListItems($page, 'team.members'); if (empty($members)) { return '

No team members listed.

'; } $defaults = ['image' => ['src' => 'https://placehold.co/200x200', 'alt' => 'Team member'], 'name' => 'Name', 'role' => 'Role', 'bio' => 'Bio']; $html = '
'; foreach ($members as $i => $member) { $html .= '
'; $html .= '
'; $html .= editableImage($page, "team.members.$i.image", 'https://placehold.co/200x200', 'Team member', 'team-card__img'); $html .= '
'; $html .= '

' . editableText($page, "team.members.$i.name", 'Name') . '

'; $html .= '' . editableText($page, "team.members.$i.role", 'Role') . ''; $html .= '

' . editableText($page, "team.members.$i.bio", 'Bio') . '

'; $html .= '
'; } $html .= '
'; return $html; } // ============================================================ // FEATURE GRID WITH ICONS // ============================================================ /** * Render feature grid with icons from the built-in icon system. * Reads from page content JSON (features.items). * * @param string $page Page name * @return string HTML output */ function renderFeatureGrid($page) { $items = editableListItems($page, 'features.items'); if (empty($items)) { return '

No features listed.

'; } $defaults = ['icon' => 'star', 'title' => 'Feature', 'desc' => 'Description']; $html = '
'; foreach ($items as $i => $item) { $iconId = $item['icon'] ?? 'default'; $html .= '
'; $html .= '
' . renderIconSvg($iconId) . '
'; $html .= '

' . editableText($page, "features.items.$i.title", 'Feature') . '

'; $html .= '

' . editableText($page, "features.items.$i.desc", 'Description') . '

'; $html .= '
'; } $html .= '
'; return $html; } // ============================================================ // TIMELINE / CHANGELOG // ============================================================ /** * Render timeline/changelog. * Reads from page content JSON (timeline.entries). * * @param string $page Page name * @return string HTML output */ function renderTimeline($page) { $entries = editableListItems($page, 'timeline.entries'); if (empty($entries)) { return '

No timeline entries.

'; } $defaults = ['date' => '2026-01', 'version' => 'v1.0', 'title' => 'Release', 'desc' => 'Description', 'status' => 'released']; $html = '
'; foreach ($entries as $i => $entry) { $status = $entry['status'] ?? 'released'; $html .= '
'; $html .= '
'; $html .= '
'; $html .= '
'; $html .= '' . editableText($page, "timeline.entries.$i.date", '2026') . ''; $html .= '' . editableText($page, "timeline.entries.$i.version", 'v1.0') . ''; if ($status === 'upcoming') { $html .= 'upcoming'; } $html .= '
'; $html .= '

' . editableText($page, "timeline.entries.$i.title", 'Release') . '

'; $html .= '

' . editableText($page, "timeline.entries.$i.desc", 'Description') . '

'; $html .= '
'; $html .= '
'; } $html .= '
'; return $html; } // ============================================================ // STATS / KEY FIGURES // ============================================================ /** * Render stats/key figures section. * Reads from page content JSON (stats.items). * * @param string $page Page name * @return string HTML output */ function renderStats($page) { $items = editableListItems($page, 'stats.items'); if (empty($items)) { return '

No stats available.

'; } $defaults = ['value' => '0', 'label' => 'Label', 'desc' => 'Description']; $html = '
'; foreach ($items as $i => $item) { $html .= '
'; $html .= '' . editableText($page, "stats.items.$i.value", '0') . ''; $html .= '' . editableText($page, "stats.items.$i.label", 'Label') . ''; $html .= '

' . editableText($page, "stats.items.$i.desc", '') . '

'; $html .= '
'; } $html .= '
'; return $html; } // ============================================================ // GALLERY / IMAGE GRID // ============================================================ /** * Render gallery/image grid with lightbox support. * Reads from page content JSON (gallery.images). * * @param string $page Page name * @return string HTML output */ function renderGallery($page) { $images = editableListItems($page, 'gallery.images'); if (empty($images)) { return '

No images available.

'; } $defaults = ['src' => 'https://placehold.co/600x400', 'alt' => 'Image', 'caption' => 'Caption']; $html = ''; // Lightbox overlay (rendered once) $html .= ''; return $html; } // ============================================================ // EVENT FUNCTIONS // ============================================================ // NEWS / BLOG // ============================================================ /** * Load all published news posts, sorted by date descending. * * @param int $limit Max number of posts (0 = all) * @return array */ function loadNewsPosts($limit = 0, $lang = '') { $newsDir = __DIR__ . '/../content/news/'; if (!is_dir($newsDir)) return []; $defaultLang = defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en'; $posts = []; foreach (glob($newsDir . '*.json') as $file) { $post = json_decode(file_get_contents($file), true); if (!is_array($post)) continue; if (!empty($post['hidden'])) continue; // Posts without lang field default to primary language $postLang = $post['lang'] ?? $defaultLang; // Filter by language if specified if ($lang && $postLang !== $lang) continue; $posts[] = $post; } // Overlay (preview, render check): replaces or adds posts by id; previewed posts are shown even when hidden. $overlay = raijuNewsOverlay(); if ($overlay) { $posts = array_values(array_filter($posts, static fn($post) => !array_key_exists((string)($post['id'] ?? ''), $overlay))); foreach ($overlay as $post) { if (!is_array($post)) continue; $post['hidden'] = false; if ($lang && ($post['lang'] ?? $defaultLang) !== $lang) continue; $posts[] = $post; } } usort($posts, function($a, $b) { return strcmp($b['date'] ?? '', $a['date'] ?? ''); }); if ($limit > 0) { $posts = array_slice($posts, 0, $limit); } return $posts; } // ============================================================ // BREADCRUMBS // ============================================================ /** * Render a breadcrumb trail from the page's "breadcrumb" JSON field. * Each entry has "label" (required) and "href" (optional, omitted for current page). * Returns empty string if the page has no breadcrumb data. * * @param string $page JSON page name (e.g. 'en_about') * @param string $basePath Relative path prefix ('' or '../') * @return string HTML */ function renderBreadcrumb($page, $basePath = '') { $data = loadContentCached($page); $crumbs = $data['breadcrumb'] ?? null; if (!$crumbs || !is_array($crumbs)) return ''; $html = ''; return $html; } /** * Render a news/blog listing. * * @param int $limit Max posts to show (0 = all) * @param string $lang Language code for date formatting * @return string HTML */ function renderNewsList($limit = 0, $lang = 'en') { $posts = loadNewsPosts($limit, $lang); if (empty($posts)) { return '

' . t('news.no_posts') . '

'; } // Build base URL for post links $defaultLang = defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en'; $langPrefix = ($lang === $defaultLang) ? '' : $lang . '/'; $postBaseUrl = '/' . $langPrefix . 'news/'; $html = '
'; foreach ($posts as $post) { $title = htmlspecialchars($post['title'] ?? ''); $excerpt = htmlspecialchars($post['excerpt'] ?? ''); $date = $post['date'] ?? ''; $author = htmlspecialchars($post['author'] ?? ''); $image = htmlspecialchars($post['image'] ?? ''); $slug = htmlspecialchars($post['slug'] ?? ''); $postUrl = $postBaseUrl . $slug; // Format date try { $dateObj = new DateTime($date); $formattedDate = $dateObj->format('M j, Y'); } catch (Exception $e) { $formattedDate = htmlspecialchars($date); } $html .= ''; } $html .= '
'; return $html; } // ============================================================ if (!defined('EVENTS_FILE_PATH')) { define('EVENTS_FILE_PATH', __DIR__ . '/../content/events.json'); } /** * Load all events from JSON file * @return array Array of events, sorted by date (newest first) */ function loadEvents() { if (!file_exists(EVENTS_FILE_PATH) && !raijuEventsOverlay()) { return []; } $data = file_exists(EVENTS_FILE_PATH) ? json_decode(file_get_contents(EVENTS_FILE_PATH), true) : []; $events = is_array($data['events'] ?? null) ? $data['events'] : []; $overlay = raijuEventsOverlay(); if ($overlay) { $events = array_values(array_filter($events, static fn($event) => !array_key_exists((string)($event['id'] ?? ''), $overlay))); foreach ($overlay as $event) { if (is_array($event)) { unset($event['hidden']); // previewed events are shown $events[] = $event; } } } usort($events, function($a, $b) { return strcmp($b['date'], $a['date']); }); return $events; } /** * Get upcoming events (today and future) * @param int $limit Optional: Maximum number (0 = all) * @return array Array of upcoming events */ function getUpcomingEvents($limit = 0) { $events = loadEvents(); $today = date('Y-m-d'); $upcoming = array_filter($events, function($event) use ($today) { return $event['date'] >= $today; }); usort($upcoming, function($a, $b) { return strcmp($a['date'], $b['date']); }); if ($limit > 0) { return array_slice($upcoming, 0, $limit); } return $upcoming; } /** * Get past events * @param int $limit Optional: Maximum number (0 = all) * @return array Array of past events (newest first) */ function getPastEvents($limit = 0) { $events = loadEvents(); $today = date('Y-m-d'); $past = array_filter($events, function($event) use ($today) { return $event['date'] < $today; }); usort($past, function($a, $b) { return strcmp($b['date'], $a['date']); }); if ($limit > 0) { return array_slice($past, 0, $limit); } return $past; } /** * Get next upcoming event * @return array|null Event data or null */ function getNextEvent() { $upcoming = getUpcomingEvents(1); return !empty($upcoming) ? $upcoming[0] : null; } /** * Format a date for display * @param string $date Date in Y-m-d format * @param string $lang Language ('de' or 'en') * @return string Formatted date */ function formatEventDate($date, $lang = 'de') { $timestamp = strtotime($date); if ($lang === 'de') { $days = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']; $months = ['', 'Jänner', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; $dayName = $days[date('w', $timestamp)]; $day = date('j', $timestamp); $month = $months[(int)date('n', $timestamp)]; $year = date('Y', $timestamp); return "$dayName, $day. $month $year"; } elseif ($lang === 'es') { $days = ['domingo', 'lunes', 'martes', 'miércoles', 'jueves', 'viernes', 'sábado']; $months = ['', 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre']; $dayName = $days[date('w', $timestamp)]; $day = date('j', $timestamp); $month = $months[(int)date('n', $timestamp)]; $year = date('Y', $timestamp); return ucfirst($dayName) . ", $day de $month de $year"; } else { return date('l, F j, Y', $timestamp); } } /** * Format a short date (without weekday) for use in date ranges. */ function formatEventDateShort($date, $lang = 'de') { $timestamp = strtotime($date); if ($lang === 'de') { $months = ['', 'Jänner', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']; $day = date('j', $timestamp); $month = $months[(int)date('n', $timestamp)]; $year = date('Y', $timestamp); return "$day. $month $year"; } elseif ($lang === 'es') { $months = ['', 'enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre']; $day = date('j', $timestamp); $month = $months[(int)date('n', $timestamp)]; $year = date('Y', $timestamp); return "$day de $month de $year"; } else { return date('F j, Y', $timestamp); } } /** * Render a single event as HTML card */ function renderEvent($event, $lang = 'de', $showImage = true, $editable = false) { $dl = defined('SITE_LANG_DEFAULT') ? SITE_LANG_DEFAULT : 'en'; $title = $event['title'][$lang] ?? $event['title'][$dl] ?? ''; $location = $event['location'][$lang] ?? $event['location'][$dl] ?? ''; $description = $event['description'][$lang] ?? $event['description'][$dl] ?? ''; $admission = $event['admission'][$lang] ?? $event['admission'][$dl] ?? ''; $startDate = $event['date'] ?? ''; $startTime = $event['time'] ?? ''; $endDate = $event['end-date'] ?? ''; $endTime = $event['end-time'] ?? ''; $eventIdHtml = htmlspecialchars($event['id']); $url = $event['url'] ?? ''; $isMultiDay = !empty($endDate) && $endDate !== $startDate; $timeSuffix = $lang === 'de' ? ' Uhr' : ($lang === 'es' ? ' h' : ''); $isEventHidden = !empty($event['hidden']); $html = '
'; if ($editable) { $html .= '
'; $html .= ''; $html .= ''; $html .= ''; $html .= '
'; } // Banner image if ($showImage && (!empty($event['image']) || $editable)) { $html .= '
'; $imgSrc = htmlspecialchars($event['image']); $imgAlt = htmlspecialchars($title); if ($editable) { $html .= '' . $imgAlt . ''; } else { $html .= '' . $imgAlt . ''; } $html .= raijuAiLabelHtml((string)($event['image'] ?? '')); $html .= '
'; } $html .= '
'; // Date badge $html .= '
'; $html .= ''; if ($isMultiDay) { $fromDate = formatEventDateShort($startDate, $lang); $toDate = formatEventDateShort($endDate, $lang); $html .= htmlspecialchars($fromDate); if (!empty($startTime)) { $html .= ', ' . htmlspecialchars($startTime) . $timeSuffix; } $bisLabel = $lang === 'de' ? ' – ' : ($lang === 'es' ? ' – ' : ' – '); $html .= $bisLabel . htmlspecialchars($toDate); if (!empty($endTime)) { $html .= ', ' . htmlspecialchars($endTime) . $timeSuffix; } } else { $dateFormatted = formatEventDate($startDate, $lang); $html .= htmlspecialchars($dateFormatted); if (!empty($startTime) && !empty($endTime)) { $html .= ', ' . htmlspecialchars($startTime) . '–' . htmlspecialchars($endTime) . $timeSuffix; } elseif (!empty($startTime)) { $html .= ', ' . htmlspecialchars($startTime) . $timeSuffix; } } $html .= '
'; // Title if (!empty($url)) { $html .= '

' . htmlspecialchars($title) . '

'; } else { $html .= '

' . htmlspecialchars($title) . '

'; } // Location if (!empty($location) || $editable) { $html .= '
'; $html .= ''; $html .= htmlspecialchars($location); $html .= '
'; } // Description if (!empty($description) || $editable) { $html .= '

' . htmlspecialchars($description) . '

'; } // Footer: admission + link $html .= ''; $html .= '
'; // .event-card__body $html .= '
'; return $html; } /** * Render a list of events */ function renderEventList($events, $lang = 'de', $showImages = true, $showAddButton = true) { $isEditable = isAdminLoggedIn(); // Filter out hidden events for visitors if (!$isEditable) { $events = array_filter($events, function($event) { return empty($event['hidden']); }); } $html = ''; if (empty($events)) { $noEventsTexts = ['de' => 'Derzeit keine Termine geplant.', 'en' => 'No events scheduled at this time.', 'es' => 'No hay eventos programados en este momento.']; $noEventsText = $noEventsTexts[$lang] ?? $noEventsTexts['en']; $html .= '

' . $noEventsText . '

'; } else { $html .= '
'; foreach ($events as $event) { $html .= renderEvent($event, $lang, $showImages, $isEditable); } $html .= '
'; } if ($isEditable && $showAddButton) { $addLabels = ['de' => '+ Neues Event', 'en' => '+ New Event', 'es' => '+ Nuevo evento']; $addLabel = $addLabels[$lang] ?? $addLabels['en']; $html .= '
'; $html .= ''; $html .= '
'; } return $html; } } // RAIJU_CONTENT_LOADER_LOADED