{% 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
}
function apiError(payload, fallback) {
if (window.getApiErrorMessage) {
return window.getApiErrorMessage(payload, fallback);
}
if (payload && payload.error) {
return String(payload.error);
}
return fallback;
}
</script>
{% endblock %}
|