PHP Classes

File: templates/admin/appearance.twig

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   templates/admin/appearance.twig   Download  
File: templates/admin/appearance.twig
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 116,650 bytes
 

Contents

Class file image Download
{% extends "base.twig" %} {% block title %}{{ t('ui.admin.appearance.page_title', {}, locale, ['common']) }}{% endblock %} {% block head %} <style> .dash-chip { cursor: grab; user-select: none; transition: opacity 0.15s; font-size: 0.875rem; } .dash-chip.opacity-50 { cursor: grabbing; } .dash-drop-zone { border: 2px dashed transparent; border-radius: 6px; transition: border-color 0.15s; } .dash-drop-zone.drag-active { border-color: var(--bs-primary); background-color: var(--bs-light); } </style> {% endblock %} {% block content %} <div class="d-flex justify-content-between align-items-center mb-4"> <h2><i class="fas fa-palette me-2"></i>{{ t('ui.admin.appearance.heading', {}, locale, ['common']) }}</h2> </div> <div id="appearanceAlert"></div> <ul class="nav nav-tabs mb-3" id="appearanceTabs" role="tablist"> <li class="nav-item" role="presentation"> <button class="nav-link active" id="tab-dashboard-btn" data-bs-toggle="tab" data-bs-target="#tab-dashboard" type="button" role="tab"> <i class="fas fa-th-large me-1"></i> {{ t('ui.admin.appearance.tab_dashboard', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-branding-btn" data-bs-toggle="tab" data-bs-target="#tab-branding" type="button" role="tab"> <i class="fas fa-brush me-1"></i> {{ t('ui.admin.appearance.tab_branding', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-content-btn" data-bs-toggle="tab" data-bs-target="#tab-content" type="button" role="tab"> <i class="fas fa-newspaper me-1"></i> {{ t('ui.admin.appearance.tab_content', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-navigation-btn" data-bs-toggle="tab" data-bs-target="#tab-navigation" type="button" role="tab"> <i class="fas fa-link me-1"></i> {{ t('ui.admin.appearance.tab_navigation', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-seo-btn" data-bs-toggle="tab" data-bs-target="#tab-seo" type="button" role="tab"> <i class="fas fa-search me-1"></i> {{ t('ui.admin.appearance.tab_seo', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-shell-btn" data-bs-toggle="tab" data-bs-target="#tab-shell" type="button" role="tab"> <i class="fas fa-desktop me-1"></i> {{ t('ui.admin.appearance.tab_shell', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-login-btn" data-bs-toggle="tab" data-bs-target="#tab-login" type="button" role="tab"> <i class="fas fa-right-to-bracket me-1"></i> {{ t('ui.admin.appearance.tab_login', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-term-server-btn" data-bs-toggle="tab" data-bs-target="#tab-term-server" type="button" role="tab"> <i class="fas fa-terminal me-1"></i> {{ t('ui.admin.appearance.tab_term_server', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-message-reader-btn" data-bs-toggle="tab" data-bs-target="#tab-message-reader" type="button" role="tab"> <i class="fas fa-envelope-open-text me-1"></i> {{ t('ui.admin.appearance.tab_message_reader', {}, locale, ['common']) }} </button> </li> <li class="nav-item" role="presentation"> <button class="nav-link" id="tab-file-areas-btn" data-bs-toggle="tab" data-bs-target="#tab-file-areas" type="button" role="tab"> <i class="fas fa-folder-open me-1"></i> {{ t('ui.admin.appearance.tab_file_areas', {}, locale, ['common']) }} </button> </li> </ul> <div class="tab-content" id="appearanceTabContent"> {# ====== TAB: DASHBOARD ====== #} <div class="tab-pane fade show active" id="tab-dashboard" role="tabpanel"> <div class="card"> <div class="card-header"> <h5 class="mb-0"><i class="fas fa-th-large me-2"></i>{{ t('ui.admin.appearance.dashboard.title', {}, locale, ['common']) }}</h5> </div> <div class="card-body"> <p class="text-muted mb-3">{{ t('ui.admin.appearance.dashboard.description', {}, locale, ['common']) }}</p> <p class="text-muted small mb-4"><i class="fas fa-grip-vertical me-1"></i>{{ t('ui.admin.appearance.dashboard.drag_hint', {}, locale, ['common']) }}</p> <div class="row g-3 mb-4"> <div class="col-md-4"> <h6 class="fw-semibold mb-2"><i class="fas fa-columns me-1 text-primary"></i>{{ t('ui.admin.appearance.dashboard.main_column', {}, locale, ['common']) }}</h6> <div id="adminDashMainList" class="dash-drop-zone border rounded p-2" style="min-height:80px;"></div> </div> <div class="col-md-4"> <h6 class="fw-semibold mb-2"><i class="fas fa-sidebar me-1 text-secondary"></i>{{ t('ui.admin.appearance.dashboard.sidebar_column', {}, locale, ['common']) }}</h6> <div id="adminDashSidebarList" class="dash-drop-zone border rounded p-2" style="min-height:80px;"></div> </div> <div class="col-md-4"> <h6 class="fw-semibold mb-2"><i class="fas fa-eye-slash me-1 text-muted"></i>{{ t('ui.admin.appearance.dashboard.hidden_column', {}, locale, ['common']) }}</h6> <div id="adminDashHiddenList" class="dash-drop-zone border rounded p-2" style="min-height:80px;"></div> </div> </div> <button type="button" class="btn btn-primary me-2" onclick="saveAdminDashboardLayout()"> <i class="fas fa-save me-1"></i> {{ t('ui.admin.appearance.dashboard.save', {}, locale, ['common']) }} </button> <button type="button" class="btn btn-outline-secondary" onclick="resetAdminDashboardLayout()"> <i class="fas fa-undo me-1"></i> {{ t('ui.admin.appearance.dashboard.reset', {}, locale, ['common']) }} </button> <span id="adminDashboardStatus" class="ms-2 text-muted small"></span> </div> </div> </div> {# ====== TAB: BRANDING ====== #} <div class="tab-pane fade" id="tab-branding" role="tabpanel"> <div class="card"> <div class="card-header"> <h5 class="mb-0">{{ t('ui.admin.appearance.branding.title', {}, locale, ['common']) }}</h5> </div> <div class="card-body"> <div class="mb-3"> <label class="form-label">{{ t('ui.admin.appearance.branding.accent_color', {}, locale, ['common']) }}</label> <div class="d-flex align-items-center gap-2"> <input type="color" class="form-control form-control-color" id="accentColorPicker" title="{{ t('ui.admin.appearance.branding.pick_accent_color', {}, locale, ['common']) }}"> <input type="text" class="form-control" id="accentColorHex" placeholder="#0d6efd" maxlength="7" style="max-width:120px;"> <button type="button" class="btn btn-outline-secondary btn-sm" onclick="resetAccentColor()">{{ t('ui.admin.appearance.branding.reset', {}, locale, ['common']) }}</button> </div> <div class="form-text">{{ t('ui.admin.appearance.branding.accent_help', {}, locale, ['common']) }}</div> </div> <div class="mb-3"> <label class="form-label" for="defaultTheme">{{ t('ui.admin.appearance.branding.default_theme', {}, locale, ['common']) }}</label> <select class="form-select" id="defaultTheme" style="max-width:350px;"> <option value="">{{ t('ui.admin.appearance.branding.system_default', {}, locale, ['common']) }}</option> {% for name, path in available_themes %} <option value="{{ path | e }}">{{ name | e }}</option> {% endfor %} </select> <div class="form-text">{{ t('ui.admin.appearance.branding.default_theme_help', {}, locale, ['common']) }}</div> </div> <div class="mb-3"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="lockTheme"> <label class="form-check-label" for="lockTheme">{{ t('ui.admin.appearance.branding.lock_theme', {}, locale, ['common']) }}</label> </div> </div> <div class="mb-3"> <label class="form-label" for="logoUrl">{{ t('ui.admin.appearance.branding.logo_url', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="logoUrl" placeholder="/images/logo.png"> <div class="form-text">{{ t('ui.admin.appearance.branding.logo_url_help', {}, locale, ['common']) }}</div> </div> {% if license_valid %} <div class="mb-3"> <label class="form-label" for="footerText">{{ t('ui.admin.appearance.branding.footer_text', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="footerText" maxlength="500" placeholder="{{ t('ui.admin.appearance.branding.footer_placeholder', {}, locale, ['common']) }}"> <div class="form-text">{{ t('ui.admin.appearance.branding.footer_help', {}, locale, ['common']) }}</div> </div> <div class="mb-3"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="hidePoweredBy"> <label class="form-check-label" for="hidePoweredBy">{{ t('ui.admin.appearance.branding.hide_powered_by', {}, locale, ['common']) }}</label> </div> <div class="form-text">{{ t('ui.admin.appearance.branding.hide_powered_by_help', {}, locale, ['common']) }}</div> </div> {% if 'registered_badge' in license_features %} <div class="mb-3"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="showRegistrationBadge"> <label class="form-check-label" for="showRegistrationBadge">{{ t('ui.admin.appearance.branding.show_registration_badge', {}, locale, ['common']) }}</label> </div> <div class="form-text">{{ t('ui.admin.appearance.branding.show_registration_badge_help', {}, locale, ['common']) }}</div> </div> {% endif %} {% else %} <div class="alert alert-secondary"> <i class="fas fa-lock me-1"></i> {{ t('ui.admin.appearance.branding.premium_branding_locked', {}, locale, ['common']) }} <a href="/admin/licensing" class="ms-2">{{ t('ui.admin.appearance.branding.premium_branding_register', {}, locale, ['common']) }}</a> </div> {% endif %} <button type="button" class="btn btn-primary" onclick="saveBranding()"> <i class="fas fa-save me-1"></i> {{ t('ui.admin.appearance.branding.save', {}, locale, ['common']) }} </button> <span id="brandingStatus" class="ms-2 text-muted small"></span> </div> </div> </div> {# ====== TAB: CONTENT ====== #} <div class="tab-pane fade" id="tab-content" role="tabpanel"> <div class="row g-3"> <div class="col-12"> <div class="card"> <div class="card-header d-flex justify-content-between align-items-center"> <h5 class="mb-0">{{ t('ui.admin.appearance.content.system_news_title', {}, locale, ['common']) }}</h5> <button type="button" class="btn btn-outline-secondary btn-sm" onclick="previewMarkdown('motd', 'motdText', 'motdPreview')"> <i class="fas fa-eye me-1"></i> {{ t('ui.common.preview', {}, locale, ['common']) }} </button> </div> <div class="card-body"> <textarea class="form-control font-monospace" id="motdText" rows="10" placeholder="{{ t('ui.admin.appearance.content.system_news_placeholder', {}, locale, ['common']) }}"></textarea> <div class="form-text">{{ t('ui.admin.appearance.content.system_news_help', {}, locale, ['common']) }}</div> <div id="motdPreview" class="border rounded p-3 mt-2" style="display:none; min-height:80px;"></div> </div> </div> </div> <div class="col-12"> <div class="card"> <div class="card-header d-flex justify-content-between align-items-center"> <h5 class="mb-0">{{ t('ui.admin.appearance.content.house_rules_title', {}, locale, ['common']) }}</h5> <button type="button" class="btn btn-outline-secondary btn-sm" onclick="previewMarkdown('rules', 'rulesText', 'rulesPreview')"> <i class="fas fa-eye me-1"></i> {{ t('ui.common.preview', {}, locale, ['common']) }} </button> </div> <div class="card-body"> <textarea class="form-control font-monospace" id="rulesText" rows="10" placeholder="{{ t('ui.admin.appearance.content.house_rules_placeholder', {}, locale, ['common']) }}"></textarea> <div class="form-text">{{ t('ui.admin.appearance.content.house_rules_help', {}, locale, ['common']) }}</div> <div id="rulesPreview" class="border rounded p-3 mt-2" style="display:none; min-height:80px;"></div> </div> </div> </div> <div class="col-12"> <div class="card"> <div class="card-header"> <h5 class="mb-0">{{ t('ui.admin.appearance.content.site_announcement_title', {}, locale, ['common']) }}</h5> </div> <div class="card-body"> <div class="mb-3"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="annEnabled"> <label class="form-check-label" for="annEnabled">{{ t('ui.admin.appearance.content.show_announcement_banner', {}, locale, ['common']) }}</label> </div> </div> <div class="mb-3"> <label class="form-label" for="annText">{{ t('ui.admin.appearance.content.announcement_text', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="annText" maxlength="500"> </div> <div class="row mb-3"> <div class="col-md-4"> <label class="form-label" for="annType">{{ t('ui.admin.appearance.content.type', {}, locale, ['common']) }}</label> <select class="form-select" id="annType"> <option value="info">{{ t('ui.admin.appearance.content.type_info', {}, locale, ['common']) }}</option> <option value="success">{{ t('ui.admin.appearance.content.type_success', {}, locale, ['common']) }}</option> <option value="warning">{{ t('ui.admin.appearance.content.type_warning', {}, locale, ['common']) }}</option> <option value="danger">{{ t('ui.admin.appearance.content.type_danger', {}, locale, ['common']) }}</option> <option value="primary">{{ t('ui.admin.appearance.content.type_primary', {}, locale, ['common']) }}</option> </select> </div> <div class="col-md-4"> <label class="form-label" for="annExpires">{{ t('ui.admin.appearance.content.expires_at_optional', {}, locale, ['common']) }}</label> <input type="datetime-local" class="form-control" id="annExpires"> <div class="form-text">{{ t('ui.admin.appearance.content.expires_help', {}, locale, ['common']) }}</div> </div> <div class="col-md-4 d-flex align-items-end"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="annDismissible" checked> <label class="form-check-label" for="annDismissible">{{ t('ui.admin.appearance.content.dismissible', {}, locale, ['common']) }}</label> </div> </div> </div> </div> </div> </div> {% if license_valid %} <div class="col-12"> <div class="card"> <div class="card-header"> <h5 class="mb-0">{{ t('ui.admin.appearance.splash.register_label', {}, locale, ['common']) }}</h5> </div> <div class="card-body"> <textarea class="form-control font-monospace" id="registerSplash" rows="8" placeholder="{{ t('ui.admin.appearance.splash.placeholder', {}, locale, ['common']) }}"></textarea> <div class="form-text">{{ t('ui.admin.appearance.splash.register_help', {}, locale, ['common']) }}</div> </div> </div> </div> {% endif %} <div class="col-12"> <div class="d-flex align-items-center gap-2"> <button type="button" class="btn btn-primary" onclick="saveContent()"> <i class="fas fa-save me-1"></i> {{ t('ui.admin.appearance.content.save', {}, locale, ['common']) }} </button> <span id="contentStatus" class="text-muted small"></span> </div> </div> </div> </div> {# ====== TAB: NAVIGATION ====== #} <div class="tab-pane fade" id="tab-navigation" role="tabpanel"> <div class="card"> <div class="card-header d-flex justify-content-between align-items-center"> <h5 class="mb-0">{{ t('ui.admin.appearance.navigation.title', {}, locale, ['common']) }}</h5> <button type="button" class="btn btn-outline-primary btn-sm" onclick="addNavLink()"> <i class="fas fa-plus me-1"></i> {{ t('ui.admin.appearance.navigation.add_link', {}, locale, ['common']) }} </button> </div> <div class="card-body"> <div class="form-text mb-3">{{ t('ui.admin.appearance.navigation.help', {}, locale, ['common']) }}</div> <table class="table table-sm" id="navLinksTable"> <thead> <tr> <th>{{ t('ui.common.label', {}, locale, ['common']) }}</th> <th>{{ t('ui.common.url', {}, locale, ['common']) }}</th> <th>{{ t('ui.common.new_tab', {}, locale, ['common']) }}</th> <th></th> </tr> </thead> <tbody id="navLinksBody"> </tbody> </table> <button type="button" class="btn btn-primary mt-2" onclick="saveNavigation()"> <i class="fas fa-save me-1"></i> {{ t('ui.admin.appearance.navigation.save', {}, locale, ['common']) }} </button> <span id="navStatus" class="ms-2 text-muted small"></span> </div> </div> </div> {# ====== TAB: SEO ====== #} <div class="tab-pane fade" id="tab-seo" role="tabpanel"> <div class="card"> <div class="card-header"> <h5 class="mb-0">{{ t('ui.admin.appearance.seo.title', {}, locale, ['common']) }}</h5> </div> <div class="card-body"> <div class="mb-3"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="aboutPageEnabled"> <label class="form-check-label" for="aboutPageEnabled">{{ t('ui.admin.appearance.seo.enable_about_page', {}, locale, ['common']) }}</label> </div> <div class="form-text">{{ t('ui.admin.appearance.seo.about_page_help', {}, locale, ['common']) }}</div> </div> <div class="mb-3"> <label class="form-label" for="seoDescription">{{ t('ui.admin.appearance.seo.site_description', {}, locale, ['common']) }} <small class="text-muted">{{ t('ui.admin.appearance.seo.max_300_chars', {}, locale, ['common']) }}</small></label> <textarea class="form-control" id="seoDescription" rows="3" maxlength="300"></textarea> <div class="form-text d-flex justify-content-between"> <span>{{ t('ui.admin.appearance.seo.description_help', {}, locale, ['common']) }}</span> <span id="seoDescLen">0/300</span> </div> </div> <div class="mb-3"> <label class="form-label" for="ogImageUrl">{{ t('ui.admin.appearance.seo.og_image_url', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="ogImageUrl" placeholder="{{ t('ui.admin.appearance.seo.og_image_placeholder', {}, locale, ['common']) }}"> <div class="form-text">{{ t('ui.admin.appearance.seo.og_image_help', {}, locale, ['common']) }}</div> </div> <button type="button" class="btn btn-primary" onclick="saveSeo()"> <i class="fas fa-save me-1"></i> {{ t('ui.admin.appearance.seo.save', {}, locale, ['common']) }} </button> <span id="seoStatus" class="ms-2 text-muted small"></span> </div> </div> </div> {# ====== TAB: SHELL ====== #} <div class="tab-pane fade" id="tab-shell" role="tabpanel"> <div class="card"> <div class="card-header"> <h5 class="mb-0">{{ t('ui.admin.appearance.shell.title', {}, locale, ['common']) }}</h5> </div> <div class="card-body"> <div class="mb-3"> <label class="form-label">{{ t('ui.admin.appearance.shell.default_interface_style', {}, locale, ['common']) }}</label> <div class="row g-3"> <div class="col-md-5"> <div class="card border-primary h-100" id="shellCardWeb" style="cursor:pointer;" onclick="selectShell('web')"> <div class="card-body text-center py-4"> <i class="fas fa-globe fa-2x text-primary mb-2"></i> <h6 class="mb-1">{{ t('ui.admin.appearance.shell.web_interface', {}, locale, ['common']) }}</h6> <small class="text-muted">{{ t('ui.admin.appearance.shell.web_interface_help', {}, locale, ['common']) }}</small> <div class="mt-2"><span class="badge bg-primary" id="shellBadgeWeb" style="display:none;">{{ t('ui.common.selected', {}, locale, ['common']) }}</span></div> </div> </div> </div> <div class="col-md-5"> <div class="card h-100" id="shellCardBbs" style="cursor:pointer;" onclick="selectShell('bbs-menu')"> <div class="card-body text-center py-4"> <i class="fas fa-terminal fa-2x text-secondary mb-2"></i> <h6 class="mb-1">{{ t('ui.admin.appearance.shell.bbs_menu', {}, locale, ['common']) }}</h6> <small class="text-muted">{{ t('ui.admin.appearance.shell.bbs_menu_help', {}, locale, ['common']) }}</small> <div class="mt-2"><span class="badge bg-primary" id="shellBadgeBbs" style="display:none;">{{ t('ui.common.selected', {}, locale, ['common']) }}</span></div> </div> </div> </div> </div> <input type="hidden" id="activeShell" value="web"> </div> <div class="mb-3"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="lockShell"> <label class="form-check-label" for="lockShell">{{ t('ui.admin.appearance.shell.lock_shell', {}, locale, ['common']) }}</label> </div> </div> <div id="bbsMenuConfig" style="display:none;"> <hr> <h6>{{ t('ui.admin.appearance.shell.bbs_menu_config', {}, locale, ['common']) }}</h6> <div class="mb-3"> <label class="form-label">{{ t('ui.admin.appearance.shell.menu_variant', {}, locale, ['common']) }}</label> <div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="bbsVariant" id="variantCards" value="cards"> <label class="form-check-label" for="variantCards">{{ t('ui.admin.appearance.shell.variant_cards', {}, locale, ['common']) }}</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="bbsVariant" id="variantText" value="text"> <label class="form-check-label" for="variantText">{{ t('ui.admin.appearance.shell.variant_text', {}, locale, ['common']) }}</label> </div> <div class="form-check form-check-inline"> <input class="form-check-input" type="radio" name="bbsVariant" id="variantAnsi" value="ansi"> <label class="form-check-label" for="variantAnsi">{{ t('ui.admin.appearance.shell.variant_ansi', {}, locale, ['common']) }}</label> </div> </div> </div> <div class="mb-3" id="ansiFileRow" style="display:none;"> <label class="form-label">{{ t('ui.admin.appearance.shell.ansi_art_file', {}, locale, ['common']) }}</label> <input type="hidden" id="ansiFile"> <div class="d-flex align-items-start gap-3 flex-wrap"> <div> <select class="form-select" id="ansiFileSelect" style="min-width:220px;" onchange="$('#ansiFile').val($(this).val());"> <option value="">{{ t('ui.admin.appearance.shell.none_selected', {}, locale, ['common']) }}</option> </select> <div class="form-text">{{ t('ui.admin.appearance.shell.ansi_file_help', {}, locale, ['common']) }}</div> </div> <div> <label class="btn btn-outline-secondary btn-sm mb-0" for="ansiFileUploadInput"> <i class="fas fa-upload me-1"></i> {{ t('ui.admin.appearance.shell.upload_ans', {}, locale, ['common']) }} </label> <input type="file" id="ansiFileUploadInput" accept=".ans,.asc,.txt" style="display:none;" onchange="uploadShellArtFile(this)"> <div class="form-text">{{ t('ui.admin.appearance.shell.max_512_kb', {}, locale, ['common']) }}</div> </div> <button type="button" class="btn btn-outline-danger btn-sm" onclick="deleteSelectedShellArt()" id="deleteShellArtBtn" style="display:none;"> <i class="fas fa-trash me-1"></i> {{ t('ui.admin.appearance.shell.delete_selected', {}, locale, ['common']) }} </button> </div> <div id="shellArtUploadStatus" class="mt-1 small"></div> </div> <div class="mb-3" id="ansiSizeRow" style="display:none;"> <label class="form-label" for="ansiSizeSelect">{{ t('ui.admin.appearance.shell.ansi_art_size', {}, locale, ['common']) }}</label> <select class="form-select" id="ansiSizeSelect" style="max-width: 240px;"> <option value="80x25">80x25</option> <option value="132x24">132x24</option> <option value="132x43">132x43</option> <option value="132x50">132x50</option> <option value="full">{{ t('ui.admin.appearance.shell.ansi_art_size_full', {}, locale, ['common']) }}</option> </select> <div class="form-text">{{ t('ui.admin.appearance.shell.ansi_art_size_help', {}, locale, ['common']) }}</div> </div> <div class="mb-3"> <label class="form-label">{{ t('ui.admin.appearance.shell.menu_items', {}, locale, ['common']) }}</label> <div class="form-text mb-2">{{ t('ui.admin.appearance.shell.menu_items_help', {}, locale, ['common']) }}</div> <table class="table table-sm table-bordered" id="menuItemsTable"> <thead class="table-light"> <tr> <th style="width:60px;">{{ t('ui.common.key', {}, locale, ['common']) }}</th> <th>{{ t('ui.common.label', {}, locale, ['common']) }}</th> <th>{{ t('ui.admin.appearance.shell.icon', {}, locale, ['common']) }} <small class="fw-normal text-muted">{{ t('ui.admin.appearance.shell.icon_help', {}, locale, ['common']) }}</small></th> <th>{{ t('ui.common.url', {}, locale, ['common']) }}</th> <th></th> </tr> </thead> <tbody id="menuItemsBody"> </tbody> </table> <button type="button" class="btn btn-outline-secondary btn-sm" onclick="addMenuItem()"> <i class="fas fa-plus me-1"></i> {{ t('ui.admin.appearance.shell.add_item', {}, locale, ['common']) }} </button> <button type="button" class="btn btn-outline-danger btn-sm ms-2" onclick="resetMenuItems()"> <i class="fas fa-undo me-1"></i> {{ t('ui.admin.appearance.shell.reset_to_defaults', {}, locale, ['common']) }} </button> </div> </div> <button type="button" class="btn btn-primary" onclick="saveShell()"> <i class="fas fa-save me-1"></i> {{ t('ui.admin.appearance.shell.save', {}, locale, ['common']) }} </button> <span id="shellStatus" class="ms-2 text-muted small"></span> </div> </div> </div> {# ====== TAB: LOGIN ====== #} <div class="tab-pane fade" id="tab-login" role="tabpanel"> <div class="card"> <div class="card-header"> <h5 class="mb-0">{{ t('ui.admin.appearance.login.title', {}, locale, ['common']) }}</h5> </div> <div class="card-body"> <p class="text-muted mb-3">{{ t('ui.admin.appearance.login.help', {}, locale, ['common']) }}</p> <div class="mb-3"> <label class="form-label" for="loginDisplayMode">{{ t('ui.admin.appearance.login.display_mode', {}, locale, ['common']) }}</label> <select class="form-select" id="loginDisplayMode" style="max-width: 320px;"> <option value="standard">{{ t('ui.admin.appearance.login.mode_standard', {}, locale, ['common']) }}</option> <option value="ansi_prompt">{{ t('ui.admin.appearance.login.mode_ansi_prompt', {}, locale, ['common']) }}</option> </select> <div class="form-text">{{ t('ui.admin.appearance.login.display_mode_help', {}, locale, ['common']) }}</div> </div> {% if license_valid %} <div class="mb-4" id="loginSplashSettings"> <label class="form-label fw-semibold" for="loginSplash">{{ t('ui.admin.appearance.splash.login_label', {}, locale, ['common']) }}</label> <textarea class="form-control font-monospace" id="loginSplash" rows="8" placeholder="{{ t('ui.admin.appearance.splash.placeholder', {}, locale, ['common']) }}"></textarea> <div class="form-text">{{ t('ui.admin.appearance.splash.login_help', {}, locale, ['common']) }}</div> </div> {% endif %} <div id="loginAnsiSettings" style="display:none;"> <div class="mb-3"> <label class="form-label" for="loginAnsiSizeSelect">{{ t('ui.admin.appearance.login.ansi_art_size', {}, locale, ['common']) }}</label> <select class="form-select" id="loginAnsiSizeSelect" style="max-width: 240px;"> <option value="80x25">80x25</option> <option value="132x24">132x24</option> <option value="132x43">132x43</option> <option value="132x50">132x50</option> <option value="full">{{ t('ui.admin.appearance.shell.ansi_art_size_full', {}, locale, ['common']) }}</option> </select> <div class="form-text">{{ t('ui.admin.appearance.login.ansi_art_size_help', {}, locale, ['common']) }}</div> </div> <div class="mb-3"> <label class="form-label fw-semibold" for="loginAnsiContent">{{ t('ui.admin.appearance.login.ansi_content', {}, locale, ['common']) }}</label> <div id="loginAnsiEditorWrap"> <textarea class="form-control font-monospace" id="loginAnsiContent" rows="18" placeholder="{{ t('ui.admin.appearance.login.ansi_placeholder', {}, locale, ['common']) }}"></textarea> </div> <div class="form-text">{{ t('ui.admin.appearance.login.ansi_help', {}, locale, ['common']) }}</div> </div> </div> <button type="button" class="btn btn-primary" onclick="saveLoginAppearance()"> <i class="fas fa-save me-1"></i> {{ t('ui.common.save', {}, locale, ['common']) }} </button> <span id="loginAppearanceStatus" class="ms-2 text-muted small"></span> </div> </div> </div> {# ====== TAB: TERMINAL SERVER ====== #} <div class="tab-pane fade" id="tab-term-server" role="tabpanel"> <div class="card"> <div class="card-header"> <h5 class="mb-0">{{ t('ui.admin.appearance.term_server.title', {}, locale, ['common']) }}</h5> </div> <div class="card-body"> <p class="text-muted mb-3">{{ t('ui.admin.appearance.term_server.help', {}, locale, ['common']) }}</p> <div class="row g-3 align-items-end"> <div class="col-lg-4"> <label class="form-label" for="termScreenSelect">{{ t('ui.admin.appearance.term_server.screen_slot', {}, locale, ['common']) }}</label> <select class="form-select" id="termScreenSelect"></select> </div> <div class="col-lg-8"> <div class="d-flex flex-wrap gap-2"> <label class="btn btn-outline-secondary btn-sm mb-0" for="termScreenUploadInput"> <i class="fas fa-upload me-1"></i> {{ t('ui.admin.appearance.term_server.upload', {}, locale, ['common']) }} </label> <input type="file" id="termScreenUploadInput" accept=".ans,.asc,.txt" style="display:none;" onchange="uploadTerminalScreenFile(this)"> <button type="button" class="btn btn-outline-danger btn-sm" id="deleteTerminalScreenBtn" onclick="deleteTerminalScreen()" style="display:none;"> <i class="fas fa-undo me-1"></i> {{ t('ui.admin.appearance.term_server.reset_default', {}, locale, ['common']) }} </button> </div> </div> </div> <div class="mt-3"> <div class="small text-muted mb-1" id="termScreenDescription"></div> <div class="small text-muted mb-2" id="termScreenMeta"></div> <div id="termScreenContentEditor"> <textarea class="form-control font-monospace" id="termScreenContent" rows="16" placeholder="{{ t('ui.admin.appearance.term_server.placeholder', {}, locale, ['common']) }}"></textarea> </div> <div class="form-text">{{ t('ui.admin.appearance.term_server.editor_help', {}, locale, ['common']) }}</div> </div> <div class="mt-3"> <button type="button" class="btn btn-primary" onclick="saveTerminalScreen()"> <i class="fas fa-save me-1"></i> {{ t('ui.common.save', {}, locale, ['common']) }} </button> <span id="termScreenStatus" class="ms-2 text-muted small"></span> </div> </div> </div> <div class="card mt-4"> <div class="card-header"> <h5 class="mb-0">{{ t('ui.admin.appearance.sixel.title', {}, locale, ['common']) }}</h5> </div> <div class="card-body"> <p class="text-muted mb-3">{{ t('ui.admin.appearance.sixel.help', {}, locale, ['common']) }}</p> <div class="row g-3 align-items-end"> <div class="col-lg-4"> <label class="form-label" for="sixelScreenSelect">{{ t('ui.admin.appearance.term_server.screen_slot', {}, locale, ['common']) }}</label> <select class="form-select" id="sixelScreenSelect"></select> </div> <div class="col-lg-8"> <div class="d-flex flex-wrap gap-2"> <label class="btn btn-outline-secondary btn-sm mb-0" for="sixelScreenUploadInput"> <i class="fas fa-upload me-1"></i> {{ t('ui.admin.appearance.sixel.upload', {}, locale, ['common']) }} </label> <input type="file" id="sixelScreenUploadInput" accept=".sixel,.six" style="display:none;" onchange="uploadSixelScreen(this)"> <button type="button" class="btn btn-outline-danger btn-sm" id="deleteSixelScreenBtn" onclick="deleteSixelScreen()" style="display:none;"> <i class="fas fa-trash me-1"></i> {{ t('ui.admin.appearance.sixel.delete', {}, locale, ['common']) }} </button> </div> </div> </div> <div class="mt-3"> <div class="small text-muted mb-1" id="sixelScreenDescription"></div> <div class="small text-muted" id="sixelScreenMeta"></div> </div> <span id="sixelScreenStatus" class="mt-2 d-block small"></span> </div> </div> <div class="card mt-4" id="termBorderStyleCard"> <div class="card-header"> <h5 class="mb-0">{{ t('ui.admin.appearance.term_border.title', {}, locale, ['common']) }}</h5> </div> <div class="card-body"> <p class="text-muted mb-3">{{ t('ui.admin.appearance.term_border.help', {}, locale, ['common']) }}</p> <div class="row g-3 align-items-end"> <div class="col-lg-4"> <label class="form-label" for="termBorderStyleSelect">{{ t('ui.admin.appearance.term_border.label', {}, locale, ['common']) }}</label> <select class="form-select" id="termBorderStyleSelect"> <option value="classic">{{ t('ui.admin.appearance.term_border.style.classic', {}, locale, ['common']) }}</option> <option value="double">{{ t('ui.admin.appearance.term_border.style.double', {}, locale, ['common']) }}</option> <option value="single">{{ t('ui.admin.appearance.term_border.style.single', {}, locale, ['common']) }}</option> <option value="heavy">{{ t('ui.admin.appearance.term_border.style.heavy', {}, locale, ['common']) }}</option> <option value="rounded">{{ t('ui.admin.appearance.term_border.style.rounded', {}, locale, ['common']) }}</option> <option value="minimal">{{ t('ui.admin.appearance.term_border.style.minimal', {}, locale, ['common']) }}</option> <option value="mixed">{{ t('ui.admin.appearance.term_border.style.mixed', {}, locale, ['common']) }}</option> <option value="shadow">{{ t('ui.admin.appearance.term_border.style.shadow', {}, locale, ['common']) }}</option> <option value="ascii">{{ t('ui.admin.appearance.term_border.style.ascii', {}, locale, ['common']) }}</option> </select> </div> <div class="col-lg-8"> <div class="form-text">{{ t('ui.admin.appearance.term_border.charset_note', {}, locale, ['common']) }}</div> </div> </div> <div class="mt-3"> <button type="button" class="btn btn-primary" onclick="saveTermBorderStyle()"> <i class="fas fa-save me-1"></i> {{ t('ui.common.save', {}, locale, ['common']) }} </button> <span id="termBorderStyleStatus" class="ms-2 text-muted small"></span> </div> </div> </div> <div class="card mt-4" id="termMenuKeysCard"> <div class="card-header d-flex justify-content-between align-items-center"> <h5 class="mb-0">{{ t('ui.admin.appearance.term_menu_keys.title', {}, locale, ['common']) }}</h5> <button type="button" class="btn btn-sm btn-outline-secondary" onclick="resetTermMenuKeysToDefaults()"> <i class="fas fa-undo me-1"></i> {{ t('ui.admin.appearance.term_menu_keys.reset_defaults', {}, locale, ['common']) }} </button> </div> <div class="card-body"> <p class="text-muted mb-3">{{ t('ui.admin.appearance.term_menu_keys.help', {}, locale, ['common']) }}</p> <div class="alert alert-warning py-2 small mb-3" id="termMenuKeysQuitWarning" style="display:none;"> <i class="fas fa-exclamation-triangle me-1"></i> {{ t('ui.admin.appearance.term_menu_keys.quit_required_notice', {}, locale, ['common']) }} </div> <div class="alert alert-danger py-2 small mb-3" id="termMenuKeysDupeWarning" style="display:none;"> <i class="fas fa-exclamation-triangle me-1"></i> <span id="termMenuKeysDupeText"></span> </div> <table class="table table-sm table-bordered w-auto"> <thead> <tr> <th>{{ t('ui.admin.appearance.term_menu_keys.col_action', {}, locale, ['common']) }}</th> <th class="text-center" style="width:70px;">{{ t('ui.admin.appearance.term_menu_keys.col_default', {}, locale, ['common']) }}</th> <th style="width:80px;">{{ t('ui.admin.appearance.term_menu_keys.col_key', {}, locale, ['common']) }}</th> </tr> </thead> <tbody id="termMenuKeysBody"> </tbody> </table> <button type="button" class="btn btn-primary" id="saveTermMenuKeysBtn" onclick="saveTermMenuKeys()"> <i class="fas fa-save me-1"></i> {{ t('ui.common.save', {}, locale, ['common']) }} </button> <span id="termMenuKeysStatus" class="ms-2 text-muted small"></span> </div> </div> </div> {# ====== TAB: MESSAGE READER ====== #} <div class="tab-pane fade" id="tab-message-reader" role="tabpanel"> <div class="card"> <div class="card-header"> <h5 class="mb-0"><i class="fas fa-envelope-open-text me-2"></i>{{ t('ui.admin.appearance.message_reader.title', {}, locale, ['common']) }}</h5> </div> <div class="card-body"> <h6 class="text-muted mb-3">{{ t('ui.admin.appearance.message_reader.layout', {}, locale, ['common']) }}</h6> <div class="form-check form-switch mb-1"> <input class="form-check-input" type="checkbox" id="mrScrollableBody"> <label class="form-check-label" for="mrScrollableBody"> <strong>{{ t('ui.admin.appearance.message_reader.scrollable_body', {}, locale, ['common']) }}</strong> </label> </div> <div class="form-text mb-3"> {{ t('ui.admin.appearance.message_reader.scrollable_body_help', {}, locale, ['common']) }} </div> <div class="mb-3"> <label class="form-label" for="mrEmailLinkUrl">{{ t('ui.admin.appearance.message_reader.email_link_url', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="mrEmailLinkUrl" maxlength="1000" placeholder="{{ t('ui.admin.appearance.message_reader.email_link_url_placeholder', {}, locale, ['common']) }}"> <div class="form-text">{{ t('ui.admin.appearance.message_reader.email_link_url_help', {}, locale, ['common']) }}</div> </div> <div class="mb-3"> <label class="form-label" for="mrDiscordUrl">{{ t('ui.admin.appearance.message_reader.discord_url', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="mrDiscordUrl" maxlength="1000" placeholder="{{ t('ui.admin.appearance.message_reader.discord_url_placeholder', {}, locale, ['common']) }}"> <div class="form-text">{{ t('ui.admin.appearance.message_reader.discord_url_help', {}, locale, ['common']) }}</div> </div> <hr> <h6 class="text-muted mb-3">{{ t('ui.admin.bbs_settings.media_player.title', {}, locale, ['common']) }}</h6> <p class="text-muted small">{{ t('ui.admin.bbs_settings.media_player.description', {}, locale, ['common']) }}</p> <div class="form-check form-switch mb-3"> <input class="form-check-input" type="checkbox" id="mediaPlayerEnabled"> <label class="form-check-label" for="mediaPlayerEnabled">{{ t('ui.admin.bbs_settings.media_player.enabled', {}, locale, ['common']) }}</label> </div> <p class="text-muted small">{{ t('ui.admin.bbs_settings.media_player.providers_label', {}, locale, ['common']) }}</p> <div class="row g-2 mb-3"> {% set providers = ['youtube','odysee','rumble','bitchute','brighteon','peertube','soundcloud','twitter','tiktok','minds','bastyon','reverbnation','raw_media'] %} {% set providersWithSettings = ['soundcloud', 'twitter'] %} {% for provider in providers %} <div class="col-sm-4 col-md-3"> <div class="d-flex align-items-center"> <div class="form-check mb-0"> <input class="form-check-input media-provider-toggle" type="checkbox" id="mp_{{ provider }}" data-provider="{{ provider }}"> <label class="form-check-label" for="mp_{{ provider }}">{{ provider|capitalize }}</label> </div> {% if provider in providersWithSettings %} <button type="button" class="btn btn-link btn-sm p-0 ms-2 lh-1 text-muted" onclick="openProviderSettings('{{ provider }}')" title="{{ t('ui.admin.bbs_settings.media_player.provider_settings', {}, locale, ['common']) }}"> <i class="fas fa-pen fa-xs"></i> </button> {% endif %} </div> </div> {% endfor %} </div> <button type="button" class="btn btn-primary" onclick="saveMessageReader()"> <i class="fas fa-save me-1"></i> {{ t('ui.admin.appearance.message_reader.save', {}, locale, ['common']) }} </button> <span id="messageReaderStatus" class="ms-2 text-muted small"></span> </div> </div> </div> {# ====== TAB: FILE AREAS ====== #} <div class="tab-pane fade" id="tab-file-areas" role="tabpanel"> <div class="card"> <div class="card-header"> <h5 class="mb-0"><i class="fas fa-folder-open me-2"></i>{{ t('ui.admin.appearance.file_areas.title', {}, locale, ['common']) }}</h5> </div> <div class="card-body"> <p class="text-muted mb-4">{{ t('ui.admin.appearance.file_areas.help', {}, locale, ['common']) }}</p> <h6 class="fw-semibold mb-3">{{ t('ui.admin.appearance.file_areas.sidebar_section', {}, locale, ['common']) }}</h6> <div class="mb-3"> <label class="form-label" for="faInfoTitle">{{ t('ui.admin.appearance.file_areas.sidebar_info_title', {}, locale, ['common']) }}</label> <input type="text" class="form-control" id="faInfoTitle" maxlength="200" placeholder="{{ t('ui.admin.appearance.file_areas.sidebar_info_title_placeholder', {}, locale, ['common']) }}"> <div class="form-text">{{ t('ui.admin.appearance.file_areas.sidebar_info_title_help', {}, locale, ['common']) }}</div> </div> <div class="mb-4"> <label class="form-label" for="faInfoMarkdown">{{ t('ui.admin.appearance.file_areas.sidebar_info_markdown', {}, locale, ['common']) }}</label> <textarea class="form-control" id="faInfoMarkdown" rows="8" maxlength="10000" placeholder="{{ t('ui.admin.appearance.file_areas.sidebar_info_markdown_placeholder', {}, locale, ['common']) }}"></textarea> <div class="form-text">{{ t('ui.admin.appearance.file_areas.sidebar_info_markdown_help', {}, locale, ['common']) }}</div> <button type="button" class="btn btn-outline-secondary btn-sm mt-2" onclick="previewMarkdown('sidebar', 'faInfoMarkdown', 'faInfoMarkdownPreview')"> <i class="fas fa-eye me-1"></i> {{ t('ui.admin.appearance.preview', {}, locale, ['common']) }} </button> <div id="faInfoMarkdownPreview" class="border rounded p-3 mt-2 small" style="display:none;"></div> </div> <hr> <h6 class="fw-semibold mb-3">{{ t('ui.admin.appearance.file_areas.footer_section', {}, locale, ['common']) }}</h6> <div class="mb-3"> <label class="form-label" for="faFooterMarkdown">{{ t('ui.admin.appearance.file_areas.footer_markdown', {}, locale, ['common']) }}</label> <textarea class="form-control" id="faFooterMarkdown" rows="8" maxlength="10000" placeholder="{{ t('ui.admin.appearance.file_areas.footer_markdown_placeholder', {}, locale, ['common']) }}"></textarea> <div class="form-text">{{ t('ui.admin.appearance.file_areas.footer_markdown_help', {}, locale, ['common']) }}</div> <button type="button" class="btn btn-outline-secondary btn-sm mt-2" onclick="previewMarkdown('footer', 'faFooterMarkdown', 'faFooterMarkdownPreview')"> <i class="fas fa-eye me-1"></i> {{ t('ui.admin.appearance.preview', {}, locale, ['common']) }} </button> <div id="faFooterMarkdownPreview" class="border rounded p-3 mt-2 small" style="display:none;"></div> </div> <button type="button" class="btn btn-primary" onclick="saveFileAreas()"> <i class="fas fa-save me-1"></i> {{ t('ui.admin.appearance.file_areas.save', {}, locale, ['common']) }} </button> <span id="fileAreasStatus" class="ms-2 text-muted small"></span> </div> </div> </div> </div> {# Provider settings modal #} <div class="modal fade" id="providerSettingsModal" tabindex="-1" aria-labelledby="providerSettingsModalLabel" aria-hidden="true"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="providerSettingsModalLabel"> <span id="providerSettingsTitle"></span> </h5> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ t('ui.common.close', {}, locale, ['common']) }}"></button> </div> <div class="modal-body"> <div id="providerSettingsPanel_soundcloud" style="display:none"> <div class="mb-3"> <label class="form-label fw-semibold" for="mpApiKey_soundcloud">{{ t('ui.admin.bbs_settings.media_player.soundcloud_client_id', {}, locale, ['common']) }}</label> <input type="text" class="form-control font-monospace" id="mpApiKey_soundcloud" maxlength="500" placeholder="{{ t('ui.admin.bbs_settings.media_player.soundcloud_client_id_placeholder', {}, locale, ['common']) }}" autocomplete="off"> <div class="form-text">{{ t('ui.admin.bbs_settings.media_player.soundcloud_client_id_help', {}, locale, ['common']) }}</div> </div> </div> <div id="providerSettingsPanel_twitter" style="display:none"> <div class="mb-3"> <label class="form-label fw-semibold" for="mpApiKey_twitter">{{ t('ui.admin.bbs_settings.media_player.twitter_bearer_token', {}, locale, ['common']) }}</label> <input type="password" class="form-control font-monospace" id="mpApiKey_twitter" maxlength="500" placeholder="{{ t('ui.admin.bbs_settings.media_player.twitter_bearer_token_placeholder', {}, locale, ['common']) }}" autocomplete="off"> <div class="form-text">{{ t('ui.admin.bbs_settings.media_player.twitter_bearer_token_help', {}, locale, ['common']) }}</div> </div> </div> </div> <div class="modal-footer"> <span class="text-muted small me-auto">{{ t('ui.admin.bbs_settings.media_player.provider_settings_hint', {}, locale, ['common']) }}</span> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel', {}, locale, ['common']) }}</button> <button type="button" class="btn btn-primary" onclick="applyProviderSettings()">{{ t('ui.common.apply', {}, locale, ['common']) }}</button> </div> </div> </div> </div> {% endblock %} {% block scripts %} <script src="/js/ansisys.js"></script> <script src="/js/sixel.js"></script> <script src="/js/pcboard.js"></script> <script src="/js/ansi-editor.js"></script> <script> const defaultMenuItems = [ { key: 'M', label: uiT('ui.admin.appearance.default_menu.messages', 'Messages'), label_key: 'ui.admin.appearance.default_menu.messages', icon: 'envelope', url: '/echomail' }, { key: 'N', label: uiT('ui.admin.appearance.default_menu.netmail', 'Netmail'), label_key: 'ui.admin.appearance.default_menu.netmail', icon: 'at', url: '/netmail' }, { key: 'F', label: uiT('ui.admin.appearance.default_menu.files', 'Files'), label_key: 'ui.admin.appearance.default_menu.files', icon: 'folder', url: '/files' }, { key: 'G', label: uiT('ui.admin.appearance.default_menu.games_doors', 'Games & Doors'), label_key: 'ui.admin.appearance.default_menu.games_doors', icon: 'gamepad', url: '/games' }, { key: 'S', label: uiT('ui.admin.appearance.default_menu.settings', 'Settings'), label_key: 'ui.admin.appearance.default_menu.settings', icon: 'cog', url: '/settings' }, ]; let appearanceData = {}; let terminalScreens = []; let currentTerminalScreenKey = ''; let termScreenAnsiEditor = null; let loginAnsiEditor = null; let mediaApiKeys = {}; let currentEditingProvider = null; function uiT(key, fallback, params = {}) { if (window.t) { return window.t(key, params, fallback); } return fallback; } const termScreenFilenames = { welcome: 'login.ans', main_menu: 'mainmenu.ans', goodbye: 'bye.ans' }; const sixelScreenFilenames = { welcome: 'login.sixel', main_menu: 'mainmenu.sixel', goodbye: 'bye.sixel' }; function getTerminalScreenLabel(key) { const labels = { welcome: uiT('ui.admin.appearance.term_server.screen.welcome', 'Welcome'), main_menu: uiT('ui.admin.appearance.term_server.screen.main_menu', 'Main Menu'), goodbye: uiT('ui.admin.appearance.term_server.screen.goodbye', 'Goodbye') }; const label = labels[key] || key; const filename = termScreenFilenames[key]; return filename ? label + ' (' + filename + ')' : label; } function getSixelScreenLabel(key) { const labels = { welcome: uiT('ui.admin.appearance.term_server.screen.welcome', 'Welcome'), main_menu: uiT('ui.admin.appearance.term_server.screen.main_menu', 'Main Menu'), goodbye: uiT('ui.admin.appearance.term_server.screen.goodbye', 'Goodbye') }; const label = labels[key] || key; const filename = sixelScreenFilenames[key]; return filename ? label + ' (' + filename + ')' : label; } function getTerminalScreenDescription(key) { const map = { welcome: uiT('ui.admin.appearance.term_server.screen.welcome_help', 'Shown when a user first connects to the terminal server.'), main_menu: uiT('ui.admin.appearance.term_server.screen.main_menu_help', 'Shown behind the terminal main menu after login.'), goodbye: uiT('ui.admin.appearance.term_server.screen.goodbye_help', 'Shown when a user disconnects from the terminal server.') }; return map[key] || ''; } $(document).ready(function() { termScreenAnsiEditor = window.AnsiEditor ? window.AnsiEditor.create(document.getElementById('termScreenContentEditor'), { previewTitleResolver: function() { if (!currentTerminalScreenKey) { return uiT('ui.common.preview', 'Preview'); } return getTerminalScreenLabel(currentTerminalScreenKey); } }) : null; loginAnsiEditor = window.AnsiEditor ? window.AnsiEditor.create(document.getElementById('loginAnsiEditorWrap'), { previewTitleResolver: function() { return uiT('ui.admin.appearance.login.preview_title', 'Login Screen Preview'); } }) : null; loadAppearanceConfig(); // Accent color sync $('#accentColorPicker').on('input', function() { $('#accentColorHex').val($(this).val()); }); $('#accentColorHex').on('input', function() { const v = $(this).val(); if (/^#[0-9A-Fa-f]{6}$/.test(v)) { $('#accentColorPicker').val(v); } }); // SEO char counter $('#seoDescription').on('input', function() { $('#seoDescLen').text($(this).val().length + '/300'); }); // BBS variant toggle $('input[name="bbsVariant"]').on('change', function() { if ($(this).val() === 'ansi') { $('#ansiFileRow').show(); $('#ansiSizeRow').show(); loadShellArtList({ selectedFile: $('#ansiFile').val(), selectId: 'ansiFileSelect', hiddenInputId: 'ansiFile', deleteButtonId: 'deleteShellArtBtn', statusId: 'shellArtUploadStatus' }); } else { $('#ansiFileRow').hide(); $('#ansiSizeRow').hide(); } }); $('#loginDisplayMode').on('change', function() { updateLoginAppearanceDisplay(); }); // Show delete button when a file is selected $('#ansiFileSelect').on('change', function() { $('#ansiFile').val($(this).val()); $('#deleteShellArtBtn').toggle($(this).val() !== ''); }); $('#termScreenSelect').on('change', function() { currentTerminalScreenKey = $(this).val() || ''; if (currentTerminalScreenKey) { loadTerminalScreen(currentTerminalScreenKey); } }); // Active shell toggle (show/hide BBS menu config) updateShellDisplay(); }); function loadAppearanceConfig() { setSettingsLoading('#appearanceTabContent', true); $.get('/admin/api/appearance') .done(function(response) { if (!response.success) return; const d = response.data || {}; appearanceData = d; const config = d.config || {}; // Branding const b = config.branding || {}; const color = b.accent_color || ''; if (color) { $('#accentColorPicker').val(color); $('#accentColorHex').val(color); } $('#defaultTheme').val(b.default_theme || ''); $('#lockTheme').prop('checked', !!b.lock_theme); $('#logoUrl').val(b.logo_url || ''); $('#footerText').val(b.footer_text || ''); $('#hidePoweredBy').prop('checked', !!b.hide_powered_by); $('#showRegistrationBadge').prop('checked', b.show_registration_badge !== false); // Content const ann = (config.content || {}).announcement || {}; $('#annEnabled').prop('checked', !!ann.enabled); $('#annText').val(ann.text || ''); $('#annType').val(ann.type || 'info'); if (ann.expires_at) { // Convert to local datetime-local format try { const dt = new Date(ann.expires_at); const iso = dt.toISOString().slice(0, 16); $('#annExpires').val(iso); } catch(e) {} } $('#annDismissible').prop('checked', ann.dismissible !== false); // MOTD / House Rules / Registration Splash $('#motdText').val(d.system_news || ''); $('#rulesText').val(d.house_rules || ''); if (d.register_splash !== null && d.register_splash !== undefined) { $('#registerSplash').val(d.register_splash); } // Navigation const links = (config.navigation || {}).custom_links || []; $('#navLinksBody').empty(); links.forEach(link => addNavLinkRow(link)); // SEO const seo = config.seo || {}; $('#aboutPageEnabled').prop('checked', !!seo.about_page_enabled); const desc = seo.description || ''; $('#seoDescription').val(desc); $('#seoDescLen').text(desc.length + '/300'); $('#ogImageUrl').val(seo.og_image_url || ''); // Shell const shell = config.shell || {}; const activeShell = shell.active || 'web'; $('#activeShell').val(activeShell); $('#lockShell').prop('checked', !!shell.lock_shell); selectShell(activeShell); const bbsMenu = shell.bbs_menu || {}; const variant = bbsMenu.variant || 'cards'; $('input[name="bbsVariant"][value="' + variant + '"]').prop('checked', true); if (variant === 'ansi') { $('#ansiFileRow').show(); $('#ansiSizeRow').show(); loadShellArtList({ selectedFile: bbsMenu.ansi_file || '', selectId: 'ansiFileSelect', hiddenInputId: 'ansiFile', deleteButtonId: 'deleteShellArtBtn', statusId: 'shellArtUploadStatus' }); } else { $('#ansiFileRow').hide(); $('#ansiSizeRow').hide(); } $('#ansiFile').val(bbsMenu.ansi_file || ''); $('#ansiSizeSelect').val(bbsMenu.ansi_size || '80x25'); const items = bbsMenu.menu_items || defaultMenuItems; $('#menuItemsBody').empty(); items.forEach(item => addMenuItemRow(item)); const loginConfig = config.login || {}; $('#loginDisplayMode').val(loginConfig.display_mode || 'standard'); $('#loginAnsiSizeSelect').val(loginConfig.ansi_size || '80x25'); $('#loginAnsiContent').val(d.login_ansi || ''); updateLoginAppearanceDisplay(); loginAnsiEditor?.refreshPreview(); // Message Reader const mr = config.message_reader || {}; $('#mrScrollableBody').prop('checked', !!mr.scrollable_body); $('#mrEmailLinkUrl').val(mr.email_link_url || ''); $('#mrDiscordUrl').val(mr.discord_url || ''); const mp = mr.media_player || {}; $('#mediaPlayerEnabled').prop('checked', !!mp.enabled); const mpProviders = mp.providers || {}; document.querySelectorAll('.media-provider-toggle').forEach(function(cb) { cb.checked = mpProviders[cb.dataset.provider] !== false; }); const mpApiKeysRaw = mp.api_keys || {}; mediaApiKeys = { soundcloud: mpApiKeysRaw.soundcloud || '', twitter: mpApiKeysRaw.twitter || '', }; // File Areas const fa = config.file_areas || {}; $('#faInfoTitle').val(fa.sidebar_info_title || ''); $('#faInfoMarkdown').val(fa.sidebar_info_markdown || ''); $('#faFooterMarkdown').val(fa.footer_markdown || ''); // Dashboard default layout initAdminDashboardTab(config.dashboard || {}); // Login if (d.login_splash !== null && d.login_splash !== undefined) { $('#loginSplash').val(d.login_splash); } loadTerminalScreenList(); loadSixelScreenList(); setSettingsLoading('#appearanceTabContent', false); }) .fail(function(xhr) { showAlert('danger', uiT('ui.admin.appearance.load_failed_prefix', 'Failed to load appearance config: ') + apiError(xhr.responseJSON, uiT('ui.common.unknown_error', 'Unknown error'))); }); } function selectShell(shell) { $('#activeShell').val(shell); if (shell === 'web') { $('#shellCardWeb').addClass('border-primary').removeClass('border'); $('#shellCardBbs').addClass('border').removeClass('border-primary'); $('#shellBadgeWeb').show(); $('#shellBadgeBbs').hide(); $('#bbsMenuConfig').hide(); } else { $('#shellCardBbs').addClass('border-primary').removeClass('border'); $('#shellCardWeb').addClass('border').removeClass('border-primary'); $('#shellBadgeBbs').show(); $('#shellBadgeWeb').hide(); $('#bbsMenuConfig').show(); } } function updateShellDisplay() { selectShell($('#activeShell').val() || 'web'); } function updateLoginAppearanceDisplay() { const ansiMode = ($('#loginDisplayMode').val() || 'standard') === 'ansi_prompt'; $('#loginSplashSettings').toggle(!ansiMode); $('#loginAnsiSettings').toggle(ansiMode); } function resetAccentColor() { $('#accentColorHex').val(''); $('#accentColorPicker').val('#0d6efd'); } // ---- BRANDING ---- function saveBranding() { const color = $('#accentColorHex').val().trim(); if (color && !/^#[0-9A-Fa-f]{6}$/.test(color)) { showStatus('brandingStatus', 'danger', uiT('ui.admin.appearance.invalid_hex_color', 'Invalid hex color')); return; } const payload = { branding: { accent_color: color, default_theme: $('#defaultTheme').val(), lock_theme: $('#lockTheme').is(':checked'), logo_url: $('#logoUrl').val().trim(), footer_text: ($('#footerText').val() || '').trim(), hide_powered_by: $('#hidePoweredBy').is(':checked'), show_registration_badge: $('#showRegistrationBadge').is(':checked') } }; showStatus('brandingStatus', 'info', uiT('ui.common.saving', 'Saving...')); $.ajax({ url: '/admin/api/appearance/branding', method: 'POST', contentType: 'application/json', data: JSON.stringify(payload), success: function(response) { const successMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.common.saved', 'Saved!')) : uiT('ui.common.saved', 'Saved!'); showStatus('brandingStatus', 'success', successMessage); }, error: function(xhr) { showStatus('brandingStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.save_failed', 'Save failed'))); } }); } // ---- CONTENT ---- function saveContent() { const expiresVal = $('#annExpires').val(); const payload = { system_news: $('#motdText').val(), house_rules: $('#rulesText').val(), register_splash: $('#registerSplash').val(), announcement: { enabled: $('#annEnabled').is(':checked'), text: $('#annText').val().trim(), type: $('#annType').val(), expires_at: expiresVal ? new Date(expiresVal).toISOString() : null, dismissible: $('#annDismissible').is(':checked') } }; showStatus('contentStatus', 'info', uiT('ui.common.saving', 'Saving...')); $.ajax({ url: '/admin/api/appearance/content', method: 'POST', contentType: 'application/json', data: JSON.stringify(payload), success: function(response) { const successMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.common.saved', 'Saved!')) : uiT('ui.common.saved', 'Saved!'); showStatus('contentStatus', 'success', successMessage); }, error: function(xhr) { showStatus('contentStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.save_failed', 'Save failed'))); } }); } function previewMarkdown(type, textareaId, previewId) { const md = $('#' + textareaId).val(); $.ajax({ url: '/admin/api/appearance/preview-markdown', method: 'POST', contentType: 'application/json', data: JSON.stringify({ markdown: md }), success: function(response) { const $preview = $('#' + previewId); $preview.html(response.html || '<em>' + uiT('ui.admin.appearance.no_content', 'No content') + '</em>').show(); }, error: function() { $('#' + previewId).html('<span class="text-danger">' + uiT('ui.admin.appearance.preview_failed', 'Preview failed') + '</span>').show(); } }); } // ---- NAVIGATION ---- function addNavLink(link) { addNavLinkRow(link || { label: '', url: '', new_tab: false }); } function addNavLinkRow(link) { const row = `<tr> <td><input type="text" class="form-control form-control-sm nav-label" value="${escHtml(link.label || '')}" placeholder="${escHtml(uiT('ui.common.label', 'Label'))}" maxlength="100"></td> <td><input type="text" class="form-control form-control-sm nav-url" value="${escHtml(link.url || '')}" placeholder="${escHtml(uiT('ui.admin.appearance.navigation.url_placeholder', '/page or https://...'))}" maxlength="500"></td> <td class="text-center"><input type="checkbox" class="form-check-input nav-newtab" ${link.new_tab ? 'checked' : ''}></td> <td><button type="button" class="btn btn-outline-danger btn-sm" onclick="$(this).closest('tr').remove()"><i class="fas fa-trash"></i></button></td> </tr>`; $('#navLinksBody').append(row); } function saveNavigation() { const links = []; $('#navLinksBody tr').each(function() { const label = $(this).find('.nav-label').val().trim(); const url = $(this).find('.nav-url').val().trim(); const newTab = $(this).find('.nav-newtab').is(':checked'); if (label && url) { links.push({ label, url, new_tab: newTab }); } }); showStatus('navStatus', 'info', uiT('ui.common.saving', 'Saving...')); $.ajax({ url: '/admin/api/appearance/navigation', method: 'POST', contentType: 'application/json', data: JSON.stringify({ custom_links: links }), success: function(response) { const successMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.common.saved', 'Saved!')) : uiT('ui.common.saved', 'Saved!'); showStatus('navStatus', 'success', successMessage); }, error: function(xhr) { showStatus('navStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.save_failed', 'Save failed'))); } }); } // ---- SEO ---- function saveSeo() { const payload = { seo: { about_page_enabled: $('#aboutPageEnabled').is(':checked'), description: $('#seoDescription').val().trim(), og_image_url: $('#ogImageUrl').val().trim() } }; showStatus('seoStatus', 'info', uiT('ui.common.saving', 'Saving...')); $.ajax({ url: '/admin/api/appearance/seo', method: 'POST', contentType: 'application/json', data: JSON.stringify(payload), success: function(response) { const successMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.common.saved', 'Saved!')) : uiT('ui.common.saved', 'Saved!'); showStatus('seoStatus', 'success', successMessage); }, error: function(xhr) { showStatus('seoStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.save_failed', 'Save failed'))); } }); } // ---- SHELL ---- function addMenuItemRow(item) { const row = `<tr> <td><input type="text" class="form-control form-control-sm mi-key" value="${escHtml(item.key || '')}" maxlength="1" style="width:50px;"></td> <td><input type="text" class="form-control form-control-sm mi-label" value="${escHtml(item.label || '')}" maxlength="100"></td> <td class="d-none"><input type="hidden" class="mi-label-key" value="${escHtml(item.label_key || '')}"></td> <td><input type="text" class="form-control form-control-sm mi-icon" value="${escHtml(item.icon || '')}" maxlength="50" placeholder="${escHtml(uiT('ui.admin.appearance.shell.icon_placeholder', 'envelope'))}"></td> <td><input type="text" class="form-control form-control-sm mi-url" value="${escHtml(item.url || '')}" maxlength="500"></td> <td><button type="button" class="btn btn-outline-danger btn-sm" onclick="$(this).closest('tr').remove()"><i class="fas fa-trash"></i></button></td> </tr>`; $('#menuItemsBody').append(row); } function addMenuItem() { addMenuItemRow({ key: '', label: '', icon: 'circle', url: '/' }); } function resetMenuItems() { if (!confirm(uiT('ui.admin.appearance.reset_menu_confirm', 'Reset menu items to default?'))) return; $('#menuItemsBody').empty(); defaultMenuItems.forEach(item => addMenuItemRow(item)); } function saveShell() { const menuItems = []; $('#menuItemsBody tr').each(function() { const key = $(this).find('.mi-key').val().trim().toUpperCase(); const label = $(this).find('.mi-label').val().trim(); const labelKey = $(this).find('.mi-label-key').val().trim(); const icon = $(this).find('.mi-icon').val().trim(); const url = $(this).find('.mi-url').val().trim(); if (key.length === 1 && label && url) { const item = { key, label, icon: icon || 'circle', url }; if (labelKey && label === uiT(labelKey, label)) { item.label_key = labelKey; } menuItems.push(item); } }); const variant = $('input[name="bbsVariant"]:checked').val() || 'cards'; const payload = { shell: { active: $('#activeShell').val(), lock_shell: $('#lockShell').is(':checked'), bbs_menu: { variant: variant, ansi_file: $('#ansiFile').val().trim(), ansi_size: $('#ansiSizeSelect').val() || '80x25', menu_items: menuItems.length > 0 ? menuItems : defaultMenuItems } } }; showStatus('shellStatus', 'info', uiT('ui.common.saving', 'Saving...')); $.ajax({ url: '/admin/api/appearance/shell', method: 'POST', contentType: 'application/json', data: JSON.stringify(payload), success: function(response) { const successMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.admin.appearance.shell_saved_reload', 'Saved! Reload the page to see the new shell.')) : uiT('ui.admin.appearance.shell_saved_reload', 'Saved! Reload the page to see the new shell.'); showStatus('shellStatus', 'success', successMessage); }, error: function(xhr) { showStatus('shellStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.save_failed', 'Save failed'))); } }); } function saveLoginAppearance() { showStatus('loginAppearanceStatus', 'info', uiT('ui.common.saving', 'Saving...')); $.ajax({ url: '/admin/api/appearance/login', method: 'POST', contentType: 'application/json', data: JSON.stringify({ login: { display_mode: $('#loginDisplayMode').val() || 'standard', ansi_size: $('#loginAnsiSizeSelect').val() || '80x25' }, login_ansi: $('#loginAnsiContent').val(), login_splash: $('#loginSplash').val() }), success: function(response) { const successMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.common.saved', 'Saved!')) : uiT('ui.common.saved', 'Saved!'); showStatus('loginAppearanceStatus', 'success', successMessage); }, error: function(xhr) { showStatus('loginAppearanceStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.save_failed', 'Save failed'))); } }); } // ---- Shell Art Management ---- function loadShellArtList(options) { const config = options || {}; const selectId = config.selectId || 'ansiFileSelect'; const hiddenInputId = config.hiddenInputId || 'ansiFile'; const deleteButtonId = config.deleteButtonId || 'deleteShellArtBtn'; const statusId = config.statusId || 'shellArtUploadStatus'; $.get('/admin/api/shell-art', function(data) { const files = data.files || []; const current = config.selectedFile !== undefined ? config.selectedFile : $('#' + hiddenInputId).val(); const $sel = $('#' + selectId); $sel.empty().append('<option value="">' + escHtml(uiT('ui.admin.appearance.shell.none_selected', '(none selected)')) + '</option>'); files.forEach(function(f) { const opt = $('<option>').val(f.name).text(f.name + ' (' + formatBytes(f.size) + ')'); if (f.name === current) opt.prop('selected', true); $sel.append(opt); }); $('#' + hiddenInputId).val($sel.val() || ''); $('#' + deleteButtonId).toggle($sel.val() !== ''); }).fail(function() { $('#' + statusId).text(uiT('ui.admin.appearance.shell.could_not_load_file_list', 'Could not load file list.')).addClass('text-danger'); }); } function uploadShellArtFile(input, options) { if (!input.files || !input.files[0]) return; const config = options || {}; const $status = $('#' + (config.statusId || 'shellArtUploadStatus')); $status.text(uiT('ui.admin.appearance.shell.uploading', 'Uploading...')).removeClass('text-danger text-success'); const formData = new FormData(); formData.append('file', input.files[0]); $.ajax({ url: '/admin/api/shell-art/upload', method: 'POST', data: formData, processData: false, contentType: false, success: function(data) { const fallback = uiT('ui.admin.appearance.shell.uploaded_prefix', 'Uploaded: ') + data.name; const successMessage = window.getApiMessage ? window.getApiMessage(data, fallback) : fallback; $status.text(successMessage).addClass('text-success'); loadShellArtList({ selectedFile: data.name, selectId: config.selectId || 'ansiFileSelect', hiddenInputId: config.hiddenInputId || 'ansiFile', deleteButtonId: config.deleteButtonId || 'deleteShellArtBtn', statusId: config.statusId || 'shellArtUploadStatus' }); input.value = ''; }, error: function(xhr) { $status.text(uiT('ui.admin.appearance.upload_failed_prefix', 'Upload failed: ') + apiError(xhr.responseJSON, uiT('ui.common.unknown_error', 'Unknown error'))).addClass('text-danger'); input.value = ''; } }); } function deleteSelectedShellArt(options) { const config = options || {}; const selectId = config.selectId || 'ansiFileSelect'; const hiddenInputId = config.hiddenInputId || 'ansiFile'; const deleteButtonId = config.deleteButtonId || 'deleteShellArtBtn'; const statusId = config.statusId || 'shellArtUploadStatus'; const name = $('#' + selectId).val(); if (!name) return; if (!confirm(uiT('ui.admin.appearance.delete_shell_art_confirm', 'Delete {name}?', { name }))) return; $.ajax({ url: '/admin/api/shell-art/' + encodeURIComponent(name), method: 'DELETE', success: function(response) { const fallback = uiT('ui.admin.appearance.shell.deleted_prefix', 'Deleted: ') + name; const successMessage = window.getApiMessage ? window.getApiMessage(response, fallback) : fallback; $('#' + statusId).text(successMessage).addClass('text-success'); loadShellArtList({ selectedFile: '', selectId: selectId, hiddenInputId: hiddenInputId, deleteButtonId: deleteButtonId, statusId: statusId }); }, error: function(xhr) { $('#' + statusId).text(uiT('ui.admin.appearance.delete_failed_prefix', 'Delete failed: ') + apiError(xhr.responseJSON, uiT('ui.common.unknown_error', 'Unknown error'))).addClass('text-danger'); } }); } function formatBytes(b) { if (b < 1024) return b + ' B'; return Math.round(b / 1024) + ' KB'; } // ---- Terminal Server Screens ---- function loadTerminalScreenList(selectedKey) { $.get('/admin/api/appearance/terminal-screens', function(data) { terminalScreens = data.screens || []; const $sel = $('#termScreenSelect'); const preferred = selectedKey || currentTerminalScreenKey || (terminalScreens[0] ? terminalScreens[0].key : ''); $sel.empty(); terminalScreens.forEach(function(screen) { const suffix = screen.exists ? '' : ' [' + uiT('ui.admin.appearance.term_server.default_active', 'default') + ']'; $sel.append($('<option>').val(screen.key).text(getTerminalScreenLabel(screen.key) + suffix)); }); $sel.val(preferred); currentTerminalScreenKey = $sel.val() || ''; if (currentTerminalScreenKey) { loadTerminalScreen(currentTerminalScreenKey); } else { $('#termScreenDescription').text(''); $('#termScreenMeta').text(''); $('#termScreenContent').val(''); termScreenAnsiEditor?.refreshPreview(); $('#deleteTerminalScreenBtn').hide(); } }).fail(function(xhr) { showStatus('termScreenStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.term_server.list_failed', 'Failed to load terminal screens'))); }); } function loadTerminalScreen(key) { $.get('/admin/api/appearance/terminal-screens/' + encodeURIComponent(key), function(data) { const screen = data.screen || {}; currentTerminalScreenKey = screen.key || key; $('#termScreenDescription').text(getTerminalScreenDescription(currentTerminalScreenKey)); const metaParts = []; if (screen.filename) metaParts.push(screen.filename); if (screen.exists && screen.size) metaParts.push(formatBytes(screen.size)); if (screen.updated_at) metaParts.push(screen.updated_at); if (!screen.exists) metaParts.push(uiT('ui.admin.appearance.term_server.default_active', 'default')); $('#termScreenMeta').text(metaParts.join(' ? ')); $('#termScreenContent').val(screen.content || ''); termScreenAnsiEditor?.refreshPreview(); $('#deleteTerminalScreenBtn').toggle(!!screen.exists); $('#termScreenStatus').text(''); }).fail(function(xhr) { showStatus('termScreenStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.term_server.load_failed', 'Failed to load terminal screen'))); }); } function saveTerminalScreen() { if (!currentTerminalScreenKey) return; showStatus('termScreenStatus', 'info', uiT('ui.common.saving', 'Saving...')); $.ajax({ url: '/admin/api/appearance/terminal-screens/' + encodeURIComponent(currentTerminalScreenKey), method: 'POST', contentType: 'application/json', data: JSON.stringify({ content: $('#termScreenContent').val() }), success: function(response) { const successMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.common.saved', 'Saved!')) : uiT('ui.common.saved', 'Saved!'); showStatus('termScreenStatus', 'success', successMessage); loadTerminalScreenList(currentTerminalScreenKey); }, error: function(xhr) { showStatus('termScreenStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.term_server.save_failed', 'Failed to save terminal screen'))); } }); } function uploadTerminalScreenFile(input) { if (!currentTerminalScreenKey || !input.files || !input.files[0]) return; showStatus('termScreenStatus', 'info', uiT('ui.admin.appearance.term_server.uploading', 'Uploading...')); const formData = new FormData(); formData.append('file', input.files[0]); $.ajax({ url: '/admin/api/appearance/terminal-screens/' + encodeURIComponent(currentTerminalScreenKey) + '/upload', method: 'POST', data: formData, processData: false, contentType: false, success: function(response) { const successMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.common.saved', 'Saved!')) : uiT('ui.common.saved', 'Saved!'); showStatus('termScreenStatus', 'success', successMessage); loadTerminalScreenList(currentTerminalScreenKey); input.value = ''; }, error: function(xhr) { showStatus('termScreenStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.term_server.upload_failed', 'Failed to upload terminal screen'))); input.value = ''; } }); } function deleteTerminalScreen() { if (!currentTerminalScreenKey) return; if (!confirm(uiT('ui.admin.appearance.term_server.reset_confirm', 'Reset this terminal screen to the default behavior?'))) return; $.ajax({ url: '/admin/api/appearance/terminal-screens/' + encodeURIComponent(currentTerminalScreenKey), method: 'DELETE', success: function(response) { const successMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.common.saved', 'Saved!')) : uiT('ui.common.saved', 'Saved!'); showStatus('termScreenStatus', 'success', successMessage); loadTerminalScreenList(currentTerminalScreenKey); }, error: function(xhr) { showStatus('termScreenStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.term_server.delete_failed', 'Failed to reset terminal screen'))); } }); } // ---- Sixel Screens ---- let currentSixelScreenKey = ''; function loadSixelScreenList(selectedKey) { $.get('/admin/api/appearance/sixel-screens', function(data) { const screens = data.screens || []; const $sel = $('#sixelScreenSelect'); const preferred = selectedKey || currentSixelScreenKey || (screens[0] ? screens[0].key : ''); $sel.empty(); screens.forEach(function(screen) { const suffix = screen.exists ? '' : ' [' + uiT('ui.admin.appearance.sixel.not_installed', 'not installed') + ']'; $sel.append($('<option>').val(screen.key).text(getSixelScreenLabel(screen.key) + suffix)); }); $sel.val(preferred); currentSixelScreenKey = $sel.val() || ''; if (currentSixelScreenKey) { loadSixelScreen(currentSixelScreenKey, screens); } else { $('#sixelScreenDescription').text(''); $('#sixelScreenMeta').text(''); $('#deleteSixelScreenBtn').hide(); } }).fail(function(xhr) { showStatus('sixelScreenStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.sixel.list_failed', 'Failed to load sixel screens'))); }); } function loadSixelScreen(key, cachedScreens) { const screens = cachedScreens || []; const screen = screens.find(s => s.key === key) || {}; currentSixelScreenKey = key; const descriptions = { welcome: uiT('ui.admin.appearance.term_server.screen.welcome_help', 'Shown when a user first connects to the terminal server.'), main_menu: uiT('ui.admin.appearance.term_server.screen.main_menu_help', 'Shown behind the terminal main menu after login.'), goodbye: uiT('ui.admin.appearance.term_server.screen.goodbye_help', 'Shown when a user disconnects from the terminal server.') }; $('#sixelScreenDescription').text( (descriptions[key] || '') + ' ' + uiT('ui.admin.appearance.sixel.sixel_clients_only', '(Sixel-capable clients only.)') ); const metaParts = []; if (screen.filename) metaParts.push(screen.filename); if (screen.exists && screen.size) metaParts.push(formatBytes(screen.size)); if (screen.updated_at) metaParts.push(screen.updated_at); if (!screen.exists) metaParts.push(uiT('ui.admin.appearance.sixel.not_installed', 'not installed')); $('#sixelScreenMeta').text(metaParts.join(' \u2022 ')); $('#deleteSixelScreenBtn').toggle(!!screen.exists); $('#sixelScreenStatus').text(''); } function uploadSixelScreen(input) { if (!currentSixelScreenKey || !input.files || !input.files[0]) return; showStatus('sixelScreenStatus', 'info', uiT('ui.admin.appearance.term_server.uploading', 'Uploading...')); const formData = new FormData(); formData.append('file', input.files[0]); $.ajax({ url: '/admin/api/appearance/sixel-screens/' + encodeURIComponent(currentSixelScreenKey) + '/upload', method: 'POST', data: formData, processData: false, contentType: false, success: function(response) { const successMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.common.saved', 'Saved!')) : uiT('ui.common.saved', 'Saved!'); showStatus('sixelScreenStatus', 'success', successMessage); loadSixelScreenList(currentSixelScreenKey); input.value = ''; }, error: function(xhr) { showStatus('sixelScreenStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.sixel.upload_failed', 'Failed to upload sixel screen'))); input.value = ''; } }); } function deleteSixelScreen() { if (!currentSixelScreenKey) return; if (!confirm(uiT('ui.admin.appearance.sixel.delete_confirm', 'Remove this sixel screen?'))) return; $.ajax({ url: '/admin/api/appearance/sixel-screens/' + encodeURIComponent(currentSixelScreenKey), method: 'DELETE', success: function(response) { const successMessage = window.getApiMessage ? window.getApiMessage(response, uiT('ui.common.saved', 'Saved!')) : uiT('ui.common.saved', 'Saved!'); showStatus('sixelScreenStatus', 'success', successMessage); loadSixelScreenList(currentSixelScreenKey); }, error: function(xhr) { showStatus('sixelScreenStatus', 'danger', apiError(xhr.responseJSON, uiT('ui.admin.appearance.sixel.delete_failed', 'Failed to delete sixel screen'))); } }); } $('#sixelScreenSelect').on('change', function() { currentSixelScreenKey = $(this).val() || ''; if (currentSixelScreenKey) { loadSixelScreen(currentSixelScreenKey); } }); // ---- Terminal Main Menu Keys ---- const TERM_MENU_KEY_DEFAULTS = { netmail: 'n', echomail: 'e', shoutbox: 's', bulletins: 'u', polls: 'p', doors: 'd', files: 'f', settings: 't', interests: 'i', whosonline: 'w', qwk: 'k', bbslist: 'b', nodelist: 'l', localchat: 'c', quit: 'q' }; const TERM_MENU_KEY_LABELS = { netmail: uiT('ui.admin.appearance.term_menu_keys.action.netmail', 'Netmail'), echomail: uiT('ui.admin.appearance.term_menu_keys.action.echomail', 'Echomail'), shoutbox: uiT('ui.admin.appearance.term_menu_keys.action.shoutbox', 'Shoutbox'), bulletins: uiT('ui.admin.appearance.term_menu_keys.action.bulletins', 'Bulletins'), polls: uiT('ui.admin.appearance.term_menu_keys.action.polls', 'Polls'), doors: uiT('ui.admin.appearance.term_menu_keys.action.doors', 'Door Games'), files: uiT('ui.admin.appearance.term_menu_keys.action.files', 'Files'), settings: uiT('ui.admin.appearance.term_menu_keys.action.settings', 'Settings'), interests: uiT('ui.admin.appearance.term_menu_keys.action.interests', 'Interests'), whosonline: uiT('ui.admin.appearance.term_menu_keys.action.whosonline', "Who's Online"), qwk: uiT('ui.admin.appearance.term_menu_keys.action.qwk', 'QWK Offline Mail'), bbslist: uiT('ui.admin.appearance.term_menu_keys.action.bbslist', 'BBS Directory'), nodelist: uiT('ui.admin.appearance.term_menu_keys.action.nodelist', 'Node List'), localchat: uiT('ui.admin.appearance.term_menu_keys.action.localchat', 'Local Chat'), quit: uiT('ui.admin.appearance.term_menu_keys.action.quit', 'Quit'), }; function buildTermMenuKeysTable(keys) { const $body = $('#termMenuKeysBody').empty(); Object.keys(TERM_MENU_KEY_DEFAULTS).forEach(function(action) { const val = keys[action] || ''; const required = action === 'quit' ? ' required' : ''; const dflt = TERM_MENU_KEY_DEFAULTS[action] || ''; $body.append( `<tr data-action="${escHtml(action)}"> <td>${escHtml(TERM_MENU_KEY_LABELS[action] || action)}</td> <td class="text-center font-monospace text-muted">${escHtml(dflt.toUpperCase())}</td> <td><input type="text" class="form-control form-control-sm term-menu-key-input font-monospace text-uppercase" maxlength="1" value="${escHtml(val)}" data-action="${escHtml(action)}"${required} style="width:60px; text-transform:uppercase;" oninput="validateTermMenuKeys()"></td> </tr>` ); }); validateTermMenuKeys(); } function validateTermMenuKeys() { const seen = {}; let dupes = []; let quitMissing = false; $('.term-menu-key-input').each(function() { const action = $(this).data('action'); const val = $(this).val().trim().toLowerCase(); $(this).removeClass('is-invalid'); if (val === '') { if (action === 'quit') quitMissing = true; return; } if (!/^[a-z0-9]$/.test(val)) { $(this).addClass('is-invalid'); return; } if (seen[val]) { dupes.push(val.toUpperCase()); $(this).addClass('is-invalid'); $('.term-menu-key-input[data-action="' + seen[val] + '"]').addClass('is-invalid'); } else { seen[val] = action; } }); const valid = dupes.length === 0 && !quitMissing; $('#saveTermMenuKeysBtn').prop('disabled', !valid); $('#termMenuKeysQuitWarning').toggle(quitMissing); if (dupes.length > 0) { $('#termMenuKeysDupeText').text( uiT('errors.admin.appearance.term_menu_keys.duplicate_key', 'Each menu key must be unique') + ': ' + [...new Set(dupes)].join(', ') ); $('#termMenuKeysDupeWarning').show(); } else { $('#termMenuKeysDupeWarning').hide(); } } function resetTermMenuKeysToDefaults() { buildTermMenuKeysTable(TERM_MENU_KEY_DEFAULTS); } function saveTermMenuKeys() { const keys = {}; $('.term-menu-key-input').each(function() { const action = $(this).data('action'); const val = $(this).val().trim().toLowerCase(); if (val !== '') keys[action] = val; }); showStatus('termMenuKeysStatus', 'info', uiT('ui.common.saving', 'Saving...')); $.ajax({ url: '/admin/api/appearance/term-menu-keys', method: 'POST', contentType: 'application/json', data: JSON.stringify({ term_menu_keys: keys }), success: function() { showStatus('termMenuKeysStatus', 'success', uiT('ui.common.saved', 'Saved')); }, error: function(xhr) { showStatus('termMenuKeysStatus', 'danger', apiError(xhr.responseJSON, uiT('errors.admin.appearance.term_menu_keys.save_failed', 'Failed to save menu key settings'))); } }); } // Load existing keys from the appearance config (function initTermMenuKeys() { $.get('/admin/api/appearance', function(data) { const stored = data?.data?.config?.shell?.term_menu_keys; buildTermMenuKeysTable(stored && typeof stored === 'object' ? stored : TERM_MENU_KEY_DEFAULTS); }).fail(function() { buildTermMenuKeysTable(TERM_MENU_KEY_DEFAULTS); }); })(); // ---- Terminal Border Style ---- function saveTermBorderStyle() { const style = $('#termBorderStyleSelect').val(); showStatus('termBorderStyleStatus', 'info', uiT('ui.common.saving', 'Saving...')); $.ajax({ url: '/admin/api/appearance/term-border-style', method: 'POST', contentType: 'application/json', data: JSON.stringify({ term_border_style: style }), success: function() { showStatus('termBorderStyleStatus', 'success', uiT('ui.common.saved', 'Saved')); }, error: function(xhr) { showStatus('termBorderStyleStatus', 'danger', getApiErrorMessage(xhr.responseJSON, uiT('errors.admin.appearance.term_border.save_failed', 'Failed to save border style'))); } }); } (function initTermBorderStyle() { $.get('/admin/api/appearance', function(data) { const stored = data?.data?.config?.shell?.term_border_style; if (stored) { $('#termBorderStyleSelect').val(stored); } }); })(); // ---- Helpers ---- function showStatus(id, type, msg) { const icons = { success: 'check', danger: 'exclamation-triangle', info: 'spinner fa-spin', warning: 'exclamation-circle' }; const icon = icons[type] || 'info-circle'; const colors = { success: 'text-success', danger: 'text-danger', info: 'text-muted', warning: 'text-warning' }; const color = colors[type] || 'text-muted'; $('#' + id).html(`<i class="fas fa-${icon} me-1"></i><span class="${color}">${escHtml(msg)}</span>`); if (type === 'success') { setTimeout(() => $('#' + id).html(''), 3000); } } function openProviderSettings(provider) { currentEditingProvider = provider; document.querySelectorAll('[id^="providerSettingsPanel_"]').forEach(function(el) { el.style.display = 'none'; }); const panel = document.getElementById('providerSettingsPanel_' + provider); if (panel) { panel.style.display = ''; } const titleMap = { soundcloud: 'SoundCloud', twitter: 'Twitter / X', }; document.getElementById('providerSettingsTitle').textContent = (titleMap[provider] || provider) + ' ? ' + uiT('ui.admin.bbs_settings.media_player.provider_settings', 'Provider Settings'); if (provider === 'soundcloud') { document.getElementById('mpApiKey_soundcloud').value = mediaApiKeys.soundcloud || ''; } else if (provider === 'twitter') { document.getElementById('mpApiKey_twitter').value = mediaApiKeys.twitter || ''; } new bootstrap.Modal(document.getElementById('providerSettingsModal')).show(); } function applyProviderSettings() { if (currentEditingProvider === 'soundcloud') { mediaApiKeys.soundcloud = document.getElementById('mpApiKey_soundcloud').value.trim(); } else if (currentEditingProvider === 'twitter') { mediaApiKeys.twitter = document.getElementById('mpApiKey_twitter').value.trim(); } bootstrap.Modal.getInstance(document.getElementById('providerSettingsModal')).hide(); } function saveMessageReader() { const providers = {}; document.querySelectorAll('.media-provider-toggle').forEach(function(cb) { providers[cb.dataset.provider] = cb.checked; }); const payload = { message_reader: { scrollable_body: $('#mrScrollableBody').is(':checked'), email_link_url: $('#mrEmailLinkUrl').val().trim(), discord_url: $('#mrDiscordUrl').val().trim(), media_player: { enabled: $('#mediaPlayerEnabled').is(':checked'), providers: providers, api_keys: mediaApiKeys } } }; $.post('/admin/api/appearance/message-reader', JSON.stringify(payload), null, 'json') .done(function(r) { if (r.success) { const successMessage = window.getApiMessage ? window.getApiMessage(r, uiT('ui.common.saved_short', 'Saved')) : uiT('ui.common.saved_short', 'Saved'); $('#messageReaderStatus').text(successMessage).removeClass('text-danger').addClass('text-success'); setTimeout(() => $('#messageReaderStatus').text(''), 3000); } else { $('#messageReaderStatus').text(uiT('ui.admin.appearance.error_prefix', 'Error: ') + apiError(r, uiT('ui.common.unknown_error', 'Unknown error'))).addClass('text-danger'); } }) .fail(function(xhr) { $('#messageReaderStatus').text(uiT('ui.admin.appearance.error_prefix', 'Error: ') + apiError(xhr.responseJSON, uiT('ui.admin.appearance.request_failed', 'Request failed'))).addClass('text-danger'); }); } function saveFileAreas() { const payload = { file_areas: { sidebar_info_title: $('#faInfoTitle').val().trim(), sidebar_info_markdown: $('#faInfoMarkdown').val(), footer_markdown: $('#faFooterMarkdown').val() } }; $.post('/admin/api/appearance/file-areas', JSON.stringify(payload), null, 'json') .done(function(r) { if (r.success) { const successMessage = window.getApiMessage ? window.getApiMessage(r, uiT('ui.common.saved_short', 'Saved')) : uiT('ui.common.saved_short', 'Saved'); $('#fileAreasStatus').text(successMessage).removeClass('text-danger').addClass('text-success'); setTimeout(() => $('#fileAreasStatus').text(''), 3000); } else { $('#fileAreasStatus').text(uiT('ui.admin.appearance.error_prefix', 'Error: ') + apiError(r, uiT('ui.common.unknown_error', 'Unknown error'))).addClass('text-danger'); } }) .fail(function(xhr) { $('#fileAreasStatus').text(uiT('ui.admin.appearance.error_prefix', 'Error: ') + apiError(xhr.responseJSON, uiT('ui.admin.appearance.request_failed', 'Request failed'))).addClass('text-danger'); }); } // ---- Admin Dashboard Default Layout ---- const ADMIN_DASHBOARD_CARDS = {{ dashboard_cards | json_encode | raw }}; // Built-in defaults (zone assignments from card definitions) function getAdminDashBuiltinDefault() { const layout = { main: [], sidebar: [], hidden: [] }; Object.entries(ADMIN_DASHBOARD_CARDS).forEach(([id, card]) => { if (card.default_zone === 'main') { layout.main.push(id); } else { layout.sidebar.push(id); } }); return layout; } let adminDragSrcId = null; function initAdminDashboardTab(dashConfig) { const layout = (dashConfig && dashConfig.default_layout) ? dashConfig.default_layout : getAdminDashBuiltinDefault(); buildAdminDashZone('adminDashMainList', layout.main || []); buildAdminDashZone('adminDashSidebarList', layout.sidebar || []); buildAdminDashZone('adminDashHiddenList', layout.hidden || []); // Cards in main/sidebar but not in any list should be appended to their default zone const allIds = Object.keys(ADMIN_DASHBOARD_CARDS); const placed = new Set([...(layout.main || []), ...(layout.sidebar || []), ...(layout.hidden || [])]); allIds.forEach(function(id) { if (!placed.has(id)) { const zone = ADMIN_DASHBOARD_CARDS[id].default_zone === 'main' ? 'adminDashMainList' : 'adminDashSidebarList'; appendAdminDashChip(document.getElementById(zone), id); } }); } function buildAdminDashZone(containerId, cardIds) { const container = document.getElementById(containerId); if (!container) return; container.innerHTML = ''; cardIds.forEach(function(id) { if (ADMIN_DASHBOARD_CARDS[id]) { appendAdminDashChip(container, id); } }); container.addEventListener('dragover', onAdminDashZoneDragOver); container.addEventListener('dragleave', onAdminDashZoneDragLeave); container.addEventListener('drop', onAdminDashZoneDrop); } function appendAdminDashChip(container, cardId) { const card = ADMIN_DASHBOARD_CARDS[cardId]; if (!card) return; const label = window.t ? window.t(card.label_key || '', {}, cardId) : cardId; let badges = ''; if (card.required) { badges += ` <span class="badge bg-secondary ms-1">${escHtml(uiT('ui.admin.appearance.dashboard.required_badge', 'Required'))}</span>`; } if (card.admin_only) { badges += ` <span class="badge bg-warning text-dark ms-1">${escHtml(uiT('ui.admin.appearance.dashboard.admin_badge', 'Admin Only'))}</span>`; } if (card.feature) { badges += ` <span class="badge bg-info text-dark ms-1">${escHtml(card.feature)}</span>`; } const chip = document.createElement('div'); chip.className = 'dash-chip d-flex align-items-center border rounded px-2 py-1 mb-1 bg-light'; chip.setAttribute('data-card-id', cardId); chip.setAttribute('draggable', 'true'); chip.innerHTML = `<i class="fas fa-grip-vertical text-muted me-2"></i><span class="flex-grow-1">${escHtml(label)}</span>${badges}`; chip.addEventListener('dragstart', onAdminDashChipDragStart); chip.addEventListener('dragend', onAdminDashChipDragEnd); chip.addEventListener('dragover', onAdminDashChipDragOver); chip.addEventListener('drop', onAdminDashChipDrop); container.appendChild(chip); } function onAdminDashChipDragStart(e) { adminDragSrcId = this.getAttribute('data-card-id'); this.classList.add('opacity-50'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', adminDragSrcId); } function onAdminDashChipDragEnd() { this.classList.remove('opacity-50'); document.querySelectorAll('#tab-dashboard .dash-drop-zone.drag-active').forEach(el => el.classList.remove('drag-active')); } function onAdminDashChipDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } function onAdminDashChipDrop(e) { e.preventDefault(); e.stopPropagation(); const srcId = e.dataTransfer.getData('text/plain'); if (srcId === this.getAttribute('data-card-id')) return; const srcChip = document.querySelector(`#tab-dashboard .dash-chip[data-card-id="${srcId}"]`); if (!srcChip) return; // Required cards cannot be moved to hidden const targetZone = this.closest('.dash-drop-zone'); if (targetZone && targetZone.id === 'adminDashHiddenList') { const srcCard = ADMIN_DASHBOARD_CARDS[srcId] || {}; if (srcCard.required) return; } this.closest('.dash-drop-zone').insertBefore(srcChip, this); } function onAdminDashZoneDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; this.classList.add('drag-active'); } function onAdminDashZoneDragLeave(e) { if (!this.contains(e.relatedTarget)) { this.classList.remove('drag-active'); } } function onAdminDashZoneDrop(e) { e.preventDefault(); this.classList.remove('drag-active'); const srcId = e.dataTransfer.getData('text/plain'); const srcChip = document.querySelector(`#tab-dashboard .dash-chip[data-card-id="${srcId}"]`); if (!srcChip) return; // Required cards cannot be moved to hidden if (this.id === 'adminDashHiddenList') { const srcCard = ADMIN_DASHBOARD_CARDS[srcId] || {}; if (srcCard.required) return; } this.appendChild(srcChip); } function readAdminDashboardLayout() { const layout = { main: [], sidebar: [], hidden: [] }; [['adminDashMainList', 'main'], ['adminDashSidebarList', 'sidebar'], ['adminDashHiddenList', 'hidden']].forEach(function([listId, zone]) { const el = document.getElementById(listId); if (!el) return; el.querySelectorAll('.dash-chip[data-card-id]').forEach(function(chip) { layout[zone].push(chip.getAttribute('data-card-id')); }); }); return layout; } function saveAdminDashboardLayout() { const layout = readAdminDashboardLayout(); showStatus('adminDashboardStatus', 'info', uiT('ui.admin.appearance.dashboard.saving', 'Saving...')); $.ajax({ url: '/admin/api/appearance/dashboard', method: 'POST', contentType: 'application/json', data: JSON.stringify({ layout: layout }), success: function(r) { if (r.success) { const msg = window.getApiMessage ? window.getApiMessage(r, uiT('ui.common.saved_short', 'Saved')) : uiT('ui.common.saved_short', 'Saved'); showStatus('adminDashboardStatus', 'success', msg); } else { showStatus('adminDashboardStatus', 'danger', apiError(r, uiT('ui.common.unknown_error', 'Unknown error'))); } }, error: function(xhr) { showStatus('adminDashboardStatus', 'danger', apiError(xhr.responseJSON, uiT('errors.admin.appearance.dashboard.save_failed', 'Failed to save dashboard layout'))); } }); } function resetAdminDashboardLayout() { if (!confirm(uiT('ui.admin.appearance.dashboard.reset_confirm', 'Reset dashboard layout to system defaults?'))) return; showStatus('adminDashboardStatus', 'info', uiT('ui.admin.appearance.dashboard.saving', 'Saving...')); $.ajax({ url: '/admin/api/appearance/dashboard', method: 'POST', contentType: 'application/json', data: JSON.stringify({ reset: true }), success: function(r) { if (r.success) { const defaultLayout = getAdminDashBuiltinDefault(); initAdminDashboardTab({ default_layout: null }); const msg = window.getApiMessage ? window.getApiMessage(r, uiT('ui.common.saved_short', 'Saved')) : uiT('ui.common.saved_short', 'Saved'); showStatus('adminDashboardStatus', 'success', msg); } else { showStatus('adminDashboardStatus', 'danger', apiError(r, uiT('ui.common.unknown_error', 'Unknown error'))); } }, error: function(xhr) { showStatus('adminDashboardStatus', 'danger', apiError(xhr.responseJSON, uiT('errors.admin.appearance.dashboard.save_failed', 'Failed to save dashboard layout'))); } }); } function showAppearanceAlert(type, msg) { showAlert(type, msg); } function showAlert(type, msg) { $('#appearanceAlert').html(`<div class="alert alert-${type} alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>${escHtml(msg)}</div>`); } function escHtml(s) { if (!s) return ''; return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); } function apiError(payload, fallback) { if (window.getApiErrorMessage) { return window.getApiErrorMessage(payload, fallback); } if (payload && payload.error) { return String(payload.error); } return fallback; } </script> {% endblock %}