{% extends "base.twig" %}
{% block title %}{{ t('ui.settings.title', {}, locale, ['common']) }} - {{ parent() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>
<i class="fas fa-cog"></i>
{{ t('ui.settings.title', {}, locale, ['common']) }}
</h2>
</div>
<div class="row">
{# Left nav #}
<div class="col-md-3 col-lg-2 mb-3 mb-md-0">
<div class="nav flex-column nav-pills" id="settingsTabs" role="tablist" aria-orientation="vertical">
<button class="nav-link active text-start" id="tab-display-btn"
data-bs-toggle="pill" data-bs-target="#tab-display"
type="button" role="tab">
<i class="fas fa-palette me-2"></i>{{ t('ui.settings.tab.display', {}, locale, ['common']) }}
</button>
<button class="nav-link text-start" id="tab-messaging-btn"
data-bs-toggle="pill" data-bs-target="#tab-messaging"
type="button" role="tab">
<i class="fas fa-envelope me-2"></i>{{ t('ui.settings.tab.messaging', {}, locale, ['common']) }}
</button>
<button class="nav-link text-start" id="tab-notifications-btn"
data-bs-toggle="pill" data-bs-target="#tab-notifications"
type="button" role="tab">
<i class="fas fa-bell me-2"></i>{{ t('ui.settings.tab.notifications', {}, locale, ['common']) }}
</button>
<button class="nav-link text-start" id="tab-account-btn"
data-bs-toggle="pill" data-bs-target="#tab-account"
type="button" role="tab">
<i class="fas fa-user-shield me-2"></i>{{ t('ui.settings.tab.account', {}, locale, ['common']) }}
</button>
<button class="nav-link text-start" id="tab-ai-btn"
data-bs-toggle="pill" data-bs-target="#tab-ai"
type="button" role="tab">
<i class="fas fa-robot me-2"></i>{{ t('ui.settings.tab.ai', {}, locale, ['common']) }}
</button>
<button class="nav-link text-start" id="tab-meshcore-btn"
data-bs-toggle="pill" data-bs-target="#tab-meshcore"
type="button" role="tab">
<i class="fas fa-broadcast-tower me-2"></i>{{ t('ui.settings.tab.meshcore', {}, locale, ['common']) }}
</button>
</div>
</div>
{# Right content #}
<div class="col-md-9 col-lg-10">
<form id="settingsForm" class="settings-loading" aria-busy="true">
<div class="tab-content">
{# ------------------------------------------------------------------ #}
{# Display tab #}
{# ------------------------------------------------------------------ #}
<div class="tab-pane fade show active" id="tab-display" role="tabpanel">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ t('ui.settings.display_preferences', {}, locale, ['common']) }}</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="messagesPerPage" class="form-label">{{ t('ui.settings.messages_per_page', {}, locale, ['common']) }}</label>
<select class="form-select" id="messagesPerPage" name="messages_per_page">
<option value="10">10 messages</option>
<option value="25" selected>25 messages</option>
<option value="50">50 messages</option>
<option value="100">100 messages</option>
<option value="250">250 messages</option>
<option value="500">500 messages</option>
</select>
<div class="form-text">{{ t('ui.settings.messages_per_page_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label for="timezone" class="form-label">{{ t('ui.settings.timezone', {}, locale, ['common']) }}</label>
<select class="form-select" id="timezone" name="timezone">
<option value="UTC">UTC (Coordinated Universal Time)</option>
<option value="America/New_York">Eastern Time (US/Canada)</option>
<option value="America/Chicago">Central Time (US/Canada)</option>
<option value="America/Denver">Mountain Time (US/Canada)</option>
<option value="America/Los_Angeles" selected>Pacific Time (US/Canada)</option>
<option value="Europe/London">London (GMT/BST)</option>
<option value="Europe/Berlin">Central European Time</option>
<option value="Asia/Tokyo">Japan Standard Time</option>
<option value="Australia/Sydney">Australian Eastern Time</option>
</select>
<div class="form-text">{{ t('ui.settings.timezone_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label for="locale" class="form-label">{{ t('ui.settings.language', {}, locale, ['common']) }}</label>
<select class="form-select" id="locale" name="locale">
{% for localeCode in supported_locales %}
<option value="{{ localeCode }}">{{ locale_names[localeCode] ?? localeCode }}</option>
{% endfor %}
</select>
<div class="form-text">{{ t('ui.settings.language_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label for="dateFormat" class="form-label">{{ t('ui.settings.date_format', {}, locale, ['common']) }}</label>
<select class="form-select" id="dateFormat" name="date_format">
<option value="en-US" selected>{{ t('ui.settings.date_format.option.en_us', {}, locale, ['common']) }}</option>
<option value="en-GB">{{ t('ui.settings.date_format.option.en_gb', {}, locale, ['common']) }}</option>
<option value="en-CA">{{ t('ui.settings.date_format.option.en_ca', {}, locale, ['common']) }}</option>
<option value="de-DE">{{ t('ui.settings.date_format.option.de_de', {}, locale, ['common']) }}</option>
<option value="fr-FR">{{ t('ui.settings.date_format.option.fr_fr', {}, locale, ['common']) }}</option>
<option value="es-ES">{{ t('ui.settings.date_format.option.es_es', {}, locale, ['common']) }}</option>
<option value="it-IT">{{ t('ui.settings.date_format.option.it_it', {}, locale, ['common']) }}</option>
<option value="ja-JP">{{ t('ui.settings.date_format.option.ja_jp', {}, locale, ['common']) }}</option>
<option value="zh-CN">{{ t('ui.settings.date_format.option.zh_cn', {}, locale, ['common']) }}</option>
<option value="sv-SE">{{ t('ui.settings.date_format.option.sv_se', {}, locale, ['common']) }}</option>
</select>
<div class="form-text">{{ t('ui.settings.date_format_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label for="theme" class="form-label">{{ t('ui.settings.theme', {}, locale, ['common']) }}</label>
<select class="form-select" id="theme" name="theme">
{% for name, path in available_themes %}
<option value="{{ path }}">{{ name }}</option>
{% endfor %}
</select>
<div class="form-text">{{ t('ui.settings.theme_help', {}, locale, ['common']) }}</div>
</div>
{% if not appearance.shell.lock_shell %}
<div class="mb-3">
<label for="shellPreference" class="form-label">{{ t('ui.settings.interface_style', {}, locale, ['common']) }}</label>
<select class="form-select" id="shellPreference" name="shell">
<option value="web">{{ t('ui.settings.interface_style.web', {}, locale, ['common']) }}</option>
<option value="bbs-menu">{{ t('ui.settings.interface_style.bbs_menu', {}, locale, ['common']) }}</option>
</select>
<div class="form-text">{{ t('ui.settings.interface_style_help', {}, locale, ['common']) }}</div>
</div>
{% endif %}
<div class="mb-3">
<label for="defaultEchoList" class="form-label">{{ t('ui.settings.default_echo_list', {}, locale, ['common']) }}</label>
<select class="form-select" id="defaultEchoList" name="default_echo_list">
<option value="system_choice" selected>{{ t('ui.settings.system_default', {}, locale, ['common']) }}</option>
<option value="reader">{{ t('ui.settings.default_echo_list.reader', {}, locale, ['common']) }}</option>
<option value="echolist">{{ t('ui.settings.default_echo_list.echolist', {}, locale, ['common']) }}</option>
</select>
<div class="form-text">{{ t('ui.settings.default_echo_list_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label for="fontFamily" class="form-label">{{ t('ui.settings.message_font', {}, locale, ['common']) }}</label>
<select class="form-select" id="fontFamily" name="font_family">
<option value="Courier New, Monaco, Consolas, monospace" selected>Courier New (Monospace)</option>
<option value="Monaco, Consolas, 'Courier New', monospace">Monaco (Monospace)</option>
<option value="Consolas, 'Courier New', Monaco, monospace">Consolas (Monospace)</option>
<option value="'Lucida Console', Monaco, monospace">Lucida Console (Monospace)</option>
<option value="Arial, sans-serif">Arial (Sans-serif)</option>
<option value="Helvetica, Arial, sans-serif">Helvetica (Sans-serif)</option>
<option value="Georgia, serif">Georgia (Serif)</option>
<option value="'Times New Roman', serif">Times New Roman (Serif)</option>
<option value="Verdana, sans-serif">Verdana (Sans-serif)</option>
</select>
<div class="form-text">{{ t('ui.settings.message_font_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label for="fontSize" class="form-label">{{ t('ui.settings.message_font_size', {}, locale, ['common']) }}</label>
<select class="form-select" id="fontSize" name="font_size">
<option value="12">12px (Small)</option>
<option value="14">14px (Medium)</option>
<option value="16" selected>16px (Large)</option>
<option value="18">18px (Extra Large)</option>
<option value="20">20px (Very Large)</option>
<option value="22">22px (Huge)</option>
</select>
<div class="form-text">{{ t('ui.settings.message_font_size_help', {}, locale, ['common']) }}</div>
</div>
</div>
</div>
</div>
{# ------------------------------------------------------------------ #}
{# Messaging tab #}
{# ------------------------------------------------------------------ #}
<div class="tab-pane fade" id="tab-messaging" role="tabpanel">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ t('ui.settings.tab.messaging', {}, locale, ['common']) }}</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label for="signatureText" class="form-label">{{ t('ui.settings.message_signature', {}, locale, ['common']) }}</label>
<textarea class="form-control" id="signatureText" name="signature_text" rows="4" maxlength="800" placeholder="{{ t('ui.settings.message_signature_placeholder', {}, locale, ['common']) }}"></textarea>
<div class="form-text">{{ t('ui.settings.message_signature_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label for="defaultTagline" class="form-label">{{ t('ui.settings.default_tagline', {}, locale, ['common']) }}</label>
<select class="form-select" id="defaultTagline" name="default_tagline">
<option value="">{{ t('ui.settings.no_tagline', {}, locale, ['common']) }}</option>
{% if taglines %}
<option value="__random__" {% if default_tagline == '__random__' %}selected{% endif %}>{{ t('ui.settings.random_tagline', {}, locale, ['common']) }}</option>
{% endif %}
{% for tagline in taglines %}
<option value="{{ tagline|e('html_attr') }}" {% if default_tagline == tagline %}selected{% endif %}>{{ tagline }}</option>
{% endfor %}
</select>
<div class="form-text">{{ t('ui.settings.default_tagline_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('ui.settings.threading_preferences', {}, locale, ['common']) }}</label>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="threadedView" name="threaded_view">
<label class="form-check-label" for="threadedView">
{{ t('ui.settings.threaded_view_echomail', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.settings.threaded_view_echomail_help', {}, locale, ['common']) }}</div>
</div>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="netmailThreadedView" name="netmail_threaded_view">
<label class="form-check-label" for="netmailThreadedView">
{{ t('ui.settings.threaded_view_netmail', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.settings.threaded_view_netmail_help', {}, locale, ['common']) }}</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('ui.settings.page_position_memory', {}, locale, ['common']) }}</label>
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="rememberPagePosition" name="remember_page_position">
<label class="form-check-label" for="rememberPagePosition">
{{ t('ui.settings.remember_page_position', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.settings.remember_page_position_help', {}, locale, ['common']) }}</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('ui.settings.quote_display', {}, locale, ['common']) }}</label>
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="quoteColoring" name="quote_coloring" checked>
<label class="form-check-label" for="quoteColoring">
{{ t('ui.settings.quote_coloring', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.settings.quote_coloring_help', {}, locale, ['common']) }}</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('ui.settings.media_render_mode', {}, locale, ['common']) }}</label>
{% if media_player_enabled %}
<div class="form-check mb-1">
<input class="form-check-input" type="radio" name="media_render_mode" id="mediaRenderAuto" value="auto">
<label class="form-check-label" for="mediaRenderAuto">
{{ t('ui.settings.media_render_mode.auto', {}, locale, ['common']) }}
</label>
</div>
<div class="form-check mb-1">
<input class="form-check-input" type="radio" name="media_render_mode" id="mediaRenderClick" value="click" checked>
<label class="form-check-label" for="mediaRenderClick">
{{ t('ui.settings.media_render_mode.click', {}, locale, ['common']) }}
</label>
</div>
<div class="form-text">{{ t('ui.settings.media_render_mode.help', {}, locale, ['common']) }}</div>
{% else %}
<div class="form-text">{{ t('ui.settings.media_render_mode.disabled_globally', {}, locale, ['common']) }}</div>
{% endif %}
</div>
{% if license_valid %}
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="forwardNetmailEmail" name="forward_netmail_email">
<label class="form-check-label" for="forwardNetmailEmail">
{{ t('ui.settings.forward_netmail_email', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.settings.forward_netmail_email_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-2">
<label class="form-label" for="echomailDigest">{{ t('ui.settings.echomail_digest', {}, locale, ['common']) }}</label>
<select class="form-select form-select-sm w-auto" id="echomailDigest">
<option value="none">{{ t('ui.settings.echomail_digest_none', {}, locale, ['common']) }}</option>
<option value="daily">{{ t('ui.settings.echomail_digest_daily', {}, locale, ['common']) }}</option>
<option value="weekly">{{ t('ui.settings.echomail_digest_weekly', {}, locale, ['common']) }}</option>
</select>
<div class="form-text">{{ t('ui.settings.echomail_digest_help', {}, locale, ['common']) }}</div>
</div>
{% else %}
<div class="form-check form-switch mb-2 opacity-50">
<input class="form-check-input" type="checkbox" id="forwardNetmailEmail" disabled>
<label class="form-check-label" for="forwardNetmailEmail">
{{ t('ui.settings.forward_netmail_email', {}, locale, ['common']) }}
<span class="badge bg-secondary ms-1" style="font-size:0.7em;">{{ t('ui.base.admin.registered_feature', {}, locale, ['common']) }}</span>
</label>
<div class="form-text">{{ t('ui.settings.forward_netmail_email_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-2 opacity-50">
<label class="form-label">{{ t('ui.settings.echomail_digest', {}, locale, ['common']) }}
<span class="badge bg-secondary ms-1" style="font-size:0.7em;">{{ t('ui.base.admin.registered_feature', {}, locale, ['common']) }}</span>
</label>
<select class="form-select form-select-sm w-auto" disabled>
<option>{{ t('ui.settings.echomail_digest_none', {}, locale, ['common']) }}</option>
</select>
<div class="form-text">{{ t('ui.settings.echomail_digest_help', {}, locale, ['common']) }}</div>
</div>
{% endif %}
<div class="mb-2">
<label class="form-label" for="echomailBadgeMode">{{ t('ui.settings.echomail_badge_mode', {}, locale, ['common']) }}</label>
<select class="form-select form-select-sm w-auto" id="echomailBadgeMode">
<option value="new">{{ t('ui.settings.echomail_badge_mode_new', {}, locale, ['common']) }}</option>
<option value="unread">{{ t('ui.settings.echomail_badge_mode_unread', {}, locale, ['common']) }}</option>
</select>
<div class="form-text">{{ t('ui.settings.echomail_badge_mode_help', {}, locale, ['common']) }}</div>
</div>
<hr class="mt-4 mb-3">
<div class="mb-0">
<label class="form-label">{{ t('ui.settings.echomail_ignore_rules.title', {}, locale, ['common']) }}</label>
<div class="form-text mb-3">{{ t('ui.settings.echomail_ignore_rules.help', {}, locale, ['common']) }}</div>
<div id="echomailIgnoreRulesList">
<div class="text-muted">{{ t('ui.settings.echomail_ignore_rules.loading', {}, locale, ['common']) }}</div>
</div>
</div>
</div>
</div>
</div>
{# ------------------------------------------------------------------ #}
{# Notifications tab #}
{# ------------------------------------------------------------------ #}
<div class="tab-pane fade" id="tab-notifications" role="tabpanel">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ t('ui.settings.notifications', {}, locale, ['common']) }}</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">{{ t('ui.settings.notifications', {}, locale, ['common']) }}</label>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label class="form-label" for="chatNotificationSound">{{ t('ui.settings.chat_notification_sound', {}, locale, ['common']) }}</label>
<div class="input-group">
<select class="form-select" id="chatNotificationSound" name="chat_notification_sound">
{% for sound in notification_sounds %}
<option value="{{ sound }}">{{ sound == 'disabled' ? t('ui.settings.notification_sound_disabled', {}, locale, ['common']) : sound }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-outline-secondary" onclick="previewNotificationSound($('#chatNotificationSound').val())" title="{{ t('ui.settings.preview_sound', {}, locale, ['common']) }}"><i class="fas fa-play"></i></button>
</div>
</div>
<div class="col-md-6">
<label class="form-label" for="echomailNotificationSound">{{ t('ui.settings.echomail_notification_sound', {}, locale, ['common']) }}</label>
<div class="input-group">
<select class="form-select" id="echomailNotificationSound" name="echomail_notification_sound">
{% for sound in notification_sounds %}
<option value="{{ sound }}">{{ sound == 'disabled' ? t('ui.settings.notification_sound_disabled', {}, locale, ['common']) : sound }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-outline-secondary" onclick="previewNotificationSound($('#echomailNotificationSound').val())" title="{{ t('ui.settings.preview_sound', {}, locale, ['common']) }}"><i class="fas fa-play"></i></button>
</div>
</div>
<div class="col-md-6">
<label class="form-label" for="netmailNotificationSound">{{ t('ui.settings.netmail_notification_sound', {}, locale, ['common']) }}</label>
<div class="input-group">
<select class="form-select" id="netmailNotificationSound" name="netmail_notification_sound">
{% for sound in notification_sounds %}
<option value="{{ sound }}">{{ sound == 'disabled' ? t('ui.settings.notification_sound_disabled', {}, locale, ['common']) : sound }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-outline-secondary" onclick="previewNotificationSound($('#netmailNotificationSound').val())" title="{{ t('ui.settings.preview_sound', {}, locale, ['common']) }}"><i class="fas fa-play"></i></button>
</div>
</div>
<div class="col-md-6">
<label class="form-label" for="fileNotificationSound">{{ t('ui.settings.file_notification_sound', {}, locale, ['common']) }}</label>
<div class="input-group">
<select class="form-select" id="fileNotificationSound" name="file_notification_sound">
{% for sound in notification_sounds %}
<option value="{{ sound }}">{{ sound == 'disabled' ? t('ui.settings.notification_sound_disabled', {}, locale, ['common']) : sound }}</option>
{% endfor %}
</select>
<button type="button" class="btn btn-outline-secondary" onclick="previewNotificationSound($('#fileNotificationSound').val())" title="{{ t('ui.settings.preview_sound', {}, locale, ['common']) }}"><i class="fas fa-play"></i></button>
</div>
</div>
</div>
<div class="form-text mb-3">{{ t('ui.settings.notification_sound_help', {}, locale, ['common']) }}</div>
</div>
</div>
</div>
</div>
{# ------------------------------------------------------------------ #}
{# Account tab #}
{# ------------------------------------------------------------------ #}
<div class="tab-pane fade" id="tab-account" role="tabpanel">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{{ t('ui.settings.session_security', {}, locale, ['common']) }}</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6>{{ t('ui.settings.active_sessions', {}, locale, ['common']) }}</h6>
<p class="text-muted">{{ t('ui.settings.active_sessions_help', {}, locale, ['common']) }}</p>
<button type="button" class="btn btn-outline-warning" onclick="showActiveSessions()">
<i class="fas fa-list"></i>
{{ t('ui.settings.view_sessions', {}, locale, ['common']) }}
</button>
</div>
<div class="col-md-6">
<h6>{{ t('ui.settings.security_actions', {}, locale, ['common']) }}</h6>
<p class="text-muted">{{ t('ui.settings.logout_all_help', {}, locale, ['common']) }}</p>
<button type="button" class="btn btn-outline-danger" onclick="logoutAllSessions()">
<i class="fas fa-sign-out-alt"></i>
{{ t('ui.settings.logout_all', {}, locale, ['common']) }}
</button>
</div>
</div>
</div>
</div>
{# PacketBBS TOTP Authenticator #}
<div class="card mt-4" id="packetBbsTotpCard">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-mobile-alt me-2"></i>{{ t('ui.settings.packetbbs_totp.title', {}, locale, ['common']) }}
</h5>
</div>
<div class="card-body">
<p class="text-muted">{{ t('ui.settings.packetbbs_totp.help', {}, locale, ['common']) }}</p>
<div id="totpLoading" class="text-muted fst-italic">{{ t('ui.common.loading', {}, locale, ['common']) }}</div>
{# Enrolled state #}
<div id="totpEnabled" style="display:none;">
<div class="alert alert-success d-flex align-items-center gap-2 mb-3">
<i class="fas fa-check-circle"></i>
<span>{{ t('ui.settings.packetbbs_totp.status_enabled', {}, locale, ['common']) }}</span>
</div>
<p class="font-monospace small text-muted mb-3">{{ t('ui.settings.packetbbs_totp.login_hint', {}, locale, ['common']) }}</p>
<button type="button" class="btn btn-outline-danger" onclick="disablePacketBbsTotp()">
<i class="fas fa-times me-1"></i>{{ t('ui.settings.packetbbs_totp.disable_btn', {}, locale, ['common']) }}
</button>
<div id="totpDisableStatus" class="mt-2"></div>
</div>
{# Not enrolled state #}
<div id="totpDisabled" style="display:none;">
<div class="alert alert-secondary d-flex align-items-center gap-2 mb-3">
<i class="fas fa-mobile-alt"></i>
<span>{{ t('ui.settings.packetbbs_totp.status_disabled', {}, locale, ['common']) }}</span>
</div>
<button type="button" class="btn btn-outline-primary" onclick="setupPacketBbsTotp()">
<i class="fas fa-key me-1"></i>{{ t('ui.settings.packetbbs_totp.enable_btn', {}, locale, ['common']) }}
</button>
</div>
{# Enrollment in progress #}
<div id="totpSetup" style="display:none;">
<p>{{ t('ui.settings.packetbbs_totp.setup_step1', {}, locale, ['common']) }}</p>
<div class="mb-3">
<label class="form-label fw-semibold">{{ t('ui.settings.packetbbs_totp.setup_qr_label', {}, locale, ['common']) }}</label>
<div class="border rounded bg-light p-3 d-inline-block">
<img id="totpQrCode" src="" alt="{{ t('ui.settings.packetbbs_totp.setup_qr_alt', {}, locale, ['common']) }}" width="220" height="220">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ t('ui.settings.packetbbs_totp.setup_uri_label', {}, locale, ['common']) }}</label>
<div class="input-group">
<input type="text" class="form-control font-monospace small" id="totpUri" readonly>
<button type="button" class="btn btn-outline-secondary"
onclick="navigator.clipboard.writeText(document.getElementById('totpUri').value)">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ t('ui.settings.packetbbs_totp.setup_secret_label', {}, locale, ['common']) }}</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="totpSecret" readonly>
<button type="button" class="btn btn-outline-secondary"
onclick="navigator.clipboard.writeText(document.getElementById('totpSecret').value)">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">{{ t('ui.settings.packetbbs_totp.verify_label', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="totpVerifyCode" maxlength="6"
placeholder="{{ t('ui.settings.packetbbs_totp.verify_placeholder', {}, locale, ['common']) }}"
inputmode="numeric" autocomplete="one-time-code" style="max-width: 160px;">
</div>
<div id="totpEnrollError" class="alert alert-danger" style="display:none;"></div>
<button type="button" class="btn btn-primary" onclick="verifyPacketBbsTotpEnrollment()">
<i class="fas fa-check me-1"></i>{{ t('ui.settings.packetbbs_totp.verify_btn', {}, locale, ['common']) }}
</button>
<button type="button" class="btn btn-outline-secondary ms-2" onclick="cancelPacketBbsTotpSetup()">
{{ t('ui.common.cancel', {}, locale, ['common']) }}
</button>
</div>
<div id="totpLoadError" class="alert alert-danger mt-2" style="display:none;"></div>
<div id="totpSuccessMsg" class="alert alert-success mt-2" style="display:none;"></div>
</div>
</div>
{% if interests_enabled %}
<div class="card mt-4">
<div class="card-header">
<h5 class="mb-0">{{ t('ui.settings.onboarding.title', {}, locale, ['common']) }}</h5>
</div>
<div class="card-body">
<p class="text-muted">{{ t('ui.settings.onboarding.help', {}, locale, ['common']) }}</p>
<button type="button" class="btn btn-outline-secondary" onclick="resetOnboarding()">
<i class="fas fa-redo me-1"></i>
{{ t('ui.settings.onboarding.reset_button', {}, locale, ['common']) }}
</button>
<div id="onboardingResetStatus" class="mt-2" style="display:none;"></div>
</div>
</div>
{% endif %}
</div>
{# ------------------------------------------------------------------ #}
{# AI tab #}
{# ------------------------------------------------------------------ #}
<div class="tab-pane fade" id="tab-ai" role="tabpanel">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-robot me-2"></i>{{ t('ui.settings.ai.mcp_key_title', {}, locale, ['common']) }}</h5>
</div>
<div class="card-body">
{% if not mcp_server_url %}
<div class="alert alert-secondary">
<i class="fas fa-robot me-2"></i>
{{ t('ui.settings.ai.mcp_not_enabled', {}, locale, ['common']) }}
</div>
{% elseif not mcp_service_running %}
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
{{ t('ui.settings.ai.mcp_service_unavailable', {}, locale, ['common']) }}
</div>
{% elseif not license_valid %}
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
{{ t('ui.settings.ai.registration_required', {}, locale, ['common']) }}
</div>
{% else %}
<p>{{ t('ui.settings.ai.mcp_what_is', {}, locale, ['common']) }}</p>
<p class="text-muted small">{{ t('ui.settings.ai.mcp_key_help', {}, locale, ['common']) }}</p>
<div class="mb-3">
<label class="form-label fw-semibold">{{ t('ui.settings.ai.mcp_server_url_label', {}, locale, ['common']) }}</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" value="{{ mcp_server_url }}/mcp" readonly>
<button type="button" class="btn btn-outline-secondary" onclick="navigator.clipboard.writeText('{{ mcp_server_url }}/mcp')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<div id="mcpKeySection">
<div id="mcpKeyExisting" style="display:none;">
<div class="mb-3">
<label class="form-label fw-semibold">{{ t('ui.settings.ai.mcp_current_key', {}, locale, ['common']) }}</label>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="mcpKeyPreview" readonly>
<button type="button" class="btn btn-outline-warning" onclick="generateMcpKey()">
<i class="fas fa-sync-alt me-1"></i>{{ t('ui.settings.ai.mcp_regenerate', {}, locale, ['common']) }}
</button>
<button type="button" class="btn btn-outline-danger" onclick="revokeMcpKey()">
<i class="fas fa-trash me-1"></i>{{ t('ui.settings.ai.mcp_revoke', {}, locale, ['common']) }}
</button>
</div>
</div>
</div>
<div id="mcpKeyNone" style="display:none;">
<p class="text-muted fst-italic">{{ t('ui.settings.ai.mcp_no_key', {}, locale, ['common']) }}</p>
<button type="button" class="btn btn-outline-primary" onclick="generateMcpKey()">
<i class="fas fa-key me-1"></i>{{ t('ui.settings.ai.mcp_generate', {}, locale, ['common']) }}
</button>
</div>
</div>
<div id="mcpKeyNew" class="mt-3" style="display:none;">
<div class="alert alert-warning">
<strong><i class="fas fa-exclamation-triangle me-1"></i>{{ t('ui.settings.ai.mcp_key_once_title', {}, locale, ['common']) }}</strong>
<p class="mb-2">{{ t('ui.settings.ai.mcp_key_once_help', {}, locale, ['common']) }}</p>
<div class="input-group">
<input type="text" class="form-control font-monospace" id="mcpKeyValue" readonly>
<button type="button" class="btn btn-outline-secondary" onclick="copyMcpKey()">
<i class="fas fa-copy me-1"></i>{{ t('ui.settings.ai.mcp_copy', {}, locale, ['common']) }}
</button>
</div>
</div>
</div>
<div id="mcpKeyStatus" class="mt-2" style="display:none;"></div>
<hr class="mt-4">
<button type="button" class="btn btn-outline-secondary btn-sm"
onclick="openMcpHelpModal()">
<i class="fas fa-question-circle me-1"></i>{{ t('ui.settings.ai.mcp_help_button', {}, locale, ['common']) }}
</button>
{% endif %}
</div>
</div>
</div>
{# ------------------------------------------------------------------ #}
{# MeshCore tab #}
{# ------------------------------------------------------------------ #}
<div class="tab-pane fade" id="tab-meshcore" role="tabpanel">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{{ t('ui.settings.meshcore.my_radios', {}, locale, ['common']) }}</h5>
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" onclick="showAddMeshcoreRadioModal()">
<i class="fas fa-plus me-1"></i>{{ t('ui.settings.meshcore.add_radio', {}, locale, ['common']) }}
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="meshcoreScanQrBtn"
onclick="showMeshcoreQrScanner()"
title="{{ t('ui.settings.meshcore.scan_qr_https_required', {}, locale, ['common']) }}">
<i class="fas fa-qrcode me-1"></i>{{ t('ui.settings.meshcore.scan_qr', {}, locale, ['common']) }}
</button>
</div>
</div>
<div class="card-body p-0">
<div id="meshcoreContactsContainer">
<div class="text-center py-3 text-muted">
<i class="fas fa-spinner fa-spin me-2"></i>{{ t('ui.common.loading', {}, locale, ['common']) }}
</div>
</div>
</div>
</div>
<p class="form-text mt-2">{{ t('ui.settings.meshcore.help', {}, locale, ['common']) }}</p>
</div>
</div>{# /tab-content #}
</form>
{# Save button ? not shown on Account tab which has its own actions #}
<div class="d-flex justify-content-end mt-4" id="saveButtonRow">
<button type="button" class="btn btn-fidonet" id="settingsSaveButton" onclick="saveSettings()" disabled>
<i class="fas fa-save"></i>
{{ t('ui.settings.save_settings', {}, locale, ['common']) }}
</button>
</div>
<div id="settingsStatus" class="mt-3" style="display: none;"></div>
</div>
</div>
<!-- Active Sessions Modal -->
<div class="modal fade" id="mcpHelpModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-question-circle me-2"></i>{{ t('ui.settings.ai.mcp_help_button', {}, locale, ['common']) }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="mcpHelpBody">
<div class="text-center py-3">
<i class="fas fa-spinner fa-spin"></i>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="sessionsModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('ui.settings.active_sessions', {}, locale, ['common']) }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div id="sessionsList">
<div class="text-center">
<i class="fas fa-spinner fa-spin"></i>
{{ t('ui.settings.sessions.loading', {}, locale, ['common']) }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- MeshCore Radio Add/Edit Modal -->
<div class="modal fade" id="meshcoreRadioModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="meshcoreRadioModalTitle">
{{ t('ui.settings.meshcore.add_radio_title', {}, locale, ['common']) }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<input type="hidden" id="meshcoreRadioDbId">
<div class="mb-3" id="meshcoreNodeIdGroup">
<label class="form-label fw-semibold">
{{ t('ui.settings.meshcore.node_id', {}, locale, ['common']) }}
<span class="text-danger">*</span>
</label>
<input type="text" class="form-control font-monospace" id="meshcoreNodeId"
maxlength="64" autocomplete="off"
placeholder="{{ t('ui.settings.meshcore.node_id_placeholder', {}, locale, ['common']) }}">
<div class="form-text">{{ t('ui.settings.meshcore.node_id_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ t('ui.settings.meshcore.display_name', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="meshcoreRadioName"
maxlength="64"
placeholder="{{ t('ui.settings.meshcore.display_name_placeholder', {}, locale, ['common']) }}">
<div class="form-text">{{ t('ui.settings.meshcore.display_name_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{{ t('ui.settings.meshcore.bridge', {}, locale, ['common']) }}</label>
<select class="form-select" id="meshcoreRadioBridge">
<option value="">{{ t('ui.settings.meshcore.bridge_none', {}, locale, ['common']) }}</option>
</select>
<div class="form-text">{{ t('ui.settings.meshcore.bridge_help', {}, locale, ['common']) }}</div>
</div>
<div id="meshcoreRadioError" class="alert alert-danger d-none"></div>
</div>
<div class="modal-footer">
<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="saveMeshcoreRadio()">
{{ t('ui.common.save', {}, locale, ['common']) }}
</button>
</div>
</div>
</div>
</div>
<!-- MeshCore QR Scanner Modal -->
<div class="modal fade" id="meshcoreQrModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-qrcode me-2"></i>{{ t('ui.settings.meshcore.scan_qr_title', {}, locale, ['common']) }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0 position-relative" style="background:#000; min-height:280px;">
<video id="meshcoreQrVideo" autoplay playsinline muted
style="width:100%; display:block; max-height:350px; object-fit:cover;"></video>
<canvas id="meshcoreQrCanvas" style="display:none;"></canvas>
<div style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;pointer-events:none;">
<div style="width:190px;height:190px;border:2px solid rgba(255,255,255,0.7);border-radius:6px;box-shadow:0 0 0 9999px rgba(0,0,0,0.45);"></div>
</div>
</div>
<div class="modal-footer d-block text-center py-2">
<p class="mb-2 text-muted small" id="meshcoreQrStatus">
{{ t('ui.settings.meshcore.scan_qr_prompt', {}, locale, ['common']) }}
</p>
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">
{{ t('ui.common.cancel', {}, locale, ['common']) }}
</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/jsqr.js"></script>
<script>
function uiT(key, fallback, params = {}) {
if (window.t) {
return window.t(key, params, fallback);
}
return fallback;
}
let settingsLoaded = false;
let initialSettingsSnapshot = {};
$(document).ready(function() {
setSettingsLoading('#settingsForm', true);
loadSettings();
loadEchomailIgnoreRules();
// Restore last active tab
const savedTab = UserStorage.getItem('settingsActiveTab');
if (savedTab) {
const btn = document.querySelector(`[data-bs-target="${savedTab}"]`);
if (btn) bootstrap.Tab.getOrCreateInstance(btn).show();
}
// Save active tab on change; toggle Save button visibility
document.querySelectorAll('#settingsTabs [data-bs-toggle="pill"]').forEach(function(btn) {
btn.addEventListener('shown.bs.tab', function(e) {
const target = e.target.getAttribute('data-bs-target');
UserStorage.setItem('settingsActiveTab', target);
$('#saveButtonRow').toggle(
target !== '#tab-account' && target !== '#tab-ai' && target !== '#tab-meshcore'
);
});
});
// Hide Save button if Account, AI, or MeshCore tab was restored
if (savedTab === '#tab-account' || savedTab === '#tab-ai' || savedTab === '#tab-meshcore') {
$('#saveButtonRow').hide();
}
// Load TOTP status when the Account tab is shown.
document.getElementById('tab-account-btn')?.addEventListener('shown.bs.tab', function() {
loadPacketBbsTotpStatus();
});
if (savedTab === '#tab-account') {
loadPacketBbsTotpStatus();
}
// Load MCP key status when the AI tab is shown
document.getElementById('tab-ai-btn')?.addEventListener('shown.bs.tab', function() {
loadMcpKeyStatus();
});
if (savedTab === '#tab-ai') {
loadMcpKeyStatus();
}
// Load MeshCore contacts when the MeshCore tab is shown
document.getElementById('tab-meshcore-btn')?.addEventListener('shown.bs.tab', function() {
loadMeshcoreContacts();
});
if (savedTab === '#tab-meshcore') {
loadMeshcoreContacts();
}
// Apply theme immediately when changed
$('#theme').on('change', function() {
const newTheme = $(this).val();
$('#theme-stylesheet').attr('href', newTheme + '?v=' + Date.now());
});
$('#signatureText').on('input', function() {
enforceSignatureLineLimit(this);
});
$('#chatNotificationSound, #echomailNotificationSound, #netmailNotificationSound, #fileNotificationSound').on('change', function() {
previewNotificationSound($(this).val());
});
});
function enforceSignatureLineLimit(textarea) {
const value = textarea.value || '';
const lines = value.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n');
if (lines.length > 4) {
textarea.value = lines.slice(0, 4).join('\n');
}
}
function previewNotificationSound(soundName) {
if (soundName === 'disabled') {
return;
}
const selectedSound = typeof soundName === 'string' && /^notify\d+$/.test(soundName)
? soundName
: 'notify1';
const audio = new Audio(`/sounds/${selectedSound}.mp3`);
audio.play().catch(function() {
// Ignore autoplay/playback failures during preview.
});
}
function loadSettings() {
return $.get('/api/user/settings')
.done(function(response) {
const data = response.success ? response.settings : response;
if (data.messages_per_page) $('#messagesPerPage').val(data.messages_per_page);
if (data.timezone) $('#timezone').val(data.timezone);
if (data.locale) $('#locale').val(data.locale);
if (data.date_format) $('#dateFormat').val(data.date_format);
if (data.theme) $('#theme').val(data.theme);
if (data.shell) $('#shellPreference').val(data.shell);
if (data.default_echo_list) $('#defaultEchoList').val(data.default_echo_list);
if (data.font_family) $('#fontFamily').val(data.font_family);
if (data.font_size) $('#fontSize').val(data.font_size);
if (data.signature_text !== undefined) $('#signatureText').val(data.signature_text);
if (data.default_tagline !== undefined) $('#defaultTagline').val(data.default_tagline || '');
if (typeof window.userSettings !== 'undefined') {
Object.assign(window.userSettings, data);
}
// Threading preferences
$('#threadedView').prop('checked', data.threaded_view === true);
$('#netmailThreadedView').prop('checked', data.netmail_threaded_view === true);
$('#rememberPagePosition').prop('checked', data.remember_page_position === true);
// Quote coloring (defaults to true if not set)
$('#quoteColoring').prop('checked', data.quote_coloring !== false);
// Media render mode (defaults to 'click')
$('input[name="media_render_mode"][value="' + (data.media_render_mode || 'click') + '"]').prop('checked', true);
// Notifications
$('#chatNotificationSound').val(data.chat_notification_sound || 'notify3');
$('#echomailNotificationSound').val(data.echomail_notification_sound || 'disabled');
$('#netmailNotificationSound').val(data.netmail_notification_sound || 'notify1');
$('#fileNotificationSound').val(data.file_notification_sound || 'disabled');
$('#forwardNetmailEmail').prop('checked', data.forward_netmail_email === true);
$('#echomailDigest').val(data.echomail_digest || 'none');
$('#echomailBadgeMode').val(data.echomail_badge_mode || 'new');
// Commented out settings (not yet implemented)
$('#showOrigin').prop('checked', data.show_origin !== false);
$('#showTearline').prop('checked', data.show_tearline !== false);
$('#autoRefresh').prop('checked', data.auto_refresh === true);
initialSettingsSnapshot = collectCurrentSettings();
settingsLoaded = true;
$('#settingsSaveButton').prop('disabled', false);
$('#settingsForm').attr('aria-busy', 'false');
setSettingsLoading('#settingsForm', false);
})
.fail(function() {
console.log(uiT('ui.settings.load_failed_console', 'Failed to load settings'));
$('#settingsForm').attr('aria-busy', 'false');
setSettingsLoading('#settingsForm', false);
});
}
function loadEchomailIgnoreRules() {
const container = $('#echomailIgnoreRulesList');
container.html(`<div class="text-muted"><i class="fas fa-spinner fa-spin me-2"></i>${uiT('ui.settings.echomail_ignore_rules.loading', 'Loading ignore rules...')}</div>`);
$.get('/api/user/echomail-ignore-rules')
.done(function(response) {
const rules = Array.isArray(response.rules) ? response.rules : [];
if (rules.length === 0) {
container.html(`<div class="text-muted">${uiT('ui.settings.echomail_ignore_rules.empty', 'No echomail ignore rules saved.')}</div>`);
return;
}
let html = '<div class="list-group">';
rules.forEach(function(rule) {
const senderDisplay = rule.sender_address
? `${escapeHtml(rule.sender_name)} <${escapeHtml(rule.sender_address)}>`
: escapeHtml(rule.sender_name);
html += `
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-start gap-3">
<div>
<div><strong>${senderDisplay}</strong></div>
<div class="text-muted small">${uiT('ui.settings.echomail_ignore_rules.subject_contains_prefix', 'Subject contains:')} ${escapeHtml(rule.subject_contains || '')}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="deleteEchomailIgnoreRule(${Number(rule.id)})">
<i class="fas fa-trash me-1"></i>${uiT('ui.settings.echomail_ignore_rules.remove', 'Remove')}
</button>
</div>
</div>
`;
});
html += '</div>';
container.html(html);
})
.fail(function(xhr) {
const error = window.getApiErrorMessage
? window.getApiErrorMessage(xhr.responseJSON, uiT('ui.settings.echomail_ignore_rules.load_failed', 'Failed to load ignore rules'))
: uiT('ui.settings.echomail_ignore_rules.load_failed', 'Failed to load ignore rules');
container.html(`<div class="text-danger">${escapeHtml(error)}</div>`);
});
}
function deleteEchomailIgnoreRule(ruleId) {
if (!confirm(uiT('ui.settings.echomail_ignore_rules.remove_confirm', 'Remove this echomail ignore rule?'))) {
return;
}
$.ajax({
url: `/api/user/echomail-ignore-rules/${encodeURIComponent(ruleId)}`,
method: 'DELETE',
success: function(response) {
const successMessage = window.getApiMessage
? window.getApiMessage(response, uiT('ui.echomail.ignore.removed', 'Ignore rule removed'))
: uiT('ui.echomail.ignore.removed', 'Ignore rule removed');
$('#settingsStatus').show().html(`
<div class="alert alert-success">
<i class="fas fa-check me-2"></i>
${escapeHtml(successMessage)}
</div>
`);
loadEchomailIgnoreRules();
setTimeout(function() {
$('#settingsStatus').fadeOut();
}, 3000);
},
error: function(xhr) {
const error = window.getApiErrorMessage
? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.messages.echomail.ignore.invalid_rule', 'Ignore rule not found'))
: uiT('errors.messages.echomail.ignore.invalid_rule', 'Ignore rule not found');
$('#settingsStatus').show().html(`
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
${escapeHtml(error)}
</div>
`);
}
});
}
function collectCurrentSettings() {
const settings = {
messages_per_page: parseInt($('#messagesPerPage').val()),
timezone: $('#timezone').val(),
locale: $('#locale').val(),
date_format: $('#dateFormat').val(),
theme: $('#theme').val(),
default_echo_list: $('#defaultEchoList').val(),
font_family: $('#fontFamily').val(),
font_size: parseInt($('#fontSize').val()),
signature_text: $('#signatureText').val(),
default_tagline: $('#defaultTagline').val(),
threaded_view: $('#threadedView').is(':checked'),
netmail_threaded_view: $('#netmailThreadedView').is(':checked'),
remember_page_position: $('#rememberPagePosition').is(':checked'),
quote_coloring: $('#quoteColoring').is(':checked'),
media_render_mode: $('input[name="media_render_mode"]:checked').val() || 'click',
chat_notification_sound: $('#chatNotificationSound').val() || 'notify3',
echomail_notification_sound: $('#echomailNotificationSound').val() || 'disabled',
netmail_notification_sound: $('#netmailNotificationSound').val() || 'notify1',
file_notification_sound: $('#fileNotificationSound').val() || 'disabled',
forward_netmail_email: $('#forwardNetmailEmail').is(':checked'),
echomail_digest: $('#echomailDigest').val() || 'none',
echomail_badge_mode: $('#echomailBadgeMode').val() || 'new',
show_origin: $('#showOrigin').is(':checked'),
show_tearline: $('#showTearline').is(':checked'),
auto_refresh: $('#autoRefresh').is(':checked')
};
if ($('#shellPreference').length) {
settings.shell = $('#shellPreference').val();
}
return settings;
}
function normalizedSettingValue(value) {
if (value === undefined || value === null) {
return '';
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (typeof value === 'number') {
return Number.isNaN(value) ? '' : String(value);
}
return String(value);
}
function getChangedSettings(currentSettings) {
const changed = {};
Object.keys(currentSettings).forEach(function(key) {
if (normalizedSettingValue(currentSettings[key]) !== normalizedSettingValue(initialSettingsSnapshot[key])) {
changed[key] = currentSettings[key];
}
});
return changed;
}
function saveSettings() {
if (!settingsLoaded) {
return;
}
const currentSettings = collectCurrentSettings();
const settings = getChangedSettings(currentSettings);
if (Object.keys(settings).length === 0) {
const successMessage = uiT('ui.settings.saved_successfully', 'Settings saved successfully!');
$('#settingsStatus').show().html(`
<div class="alert alert-success">
<i class="fas fa-check me-2"></i>
${escapeHtml(successMessage)}
</div>
`);
setTimeout(function() {
$('#settingsStatus').fadeOut();
}, 3000);
return;
}
$('#settingsStatus').show().html(`
<div class="alert alert-info">
<i class="fas fa-spinner fa-spin me-2"></i>
${uiT('ui.settings.saving', 'Saving settings...')}
</div>
`);
$.ajax({
url: '/api/user/settings',
method: 'POST',
data: JSON.stringify({ settings: settings }),
contentType: 'application/json',
success: function(response) {
const successMessage = window.getApiMessage
? window.getApiMessage(response, uiT('ui.settings.saved_successfully', 'Settings saved successfully!'))
: uiT('ui.settings.saved_successfully', 'Settings saved successfully!');
$('#settingsStatus').html(`
<div class="alert alert-success">
<i class="fas fa-check me-2"></i>
${escapeHtml(successMessage)}
</div>
`);
// Update global settings cache
if (typeof window.userSettings !== 'undefined') {
Object.assign(window.userSettings, settings);
}
initialSettingsSnapshot = currentSettings;
setTimeout(function() {
$('#settingsStatus').fadeOut();
}, 3000);
},
error: function(xhr) {
const error = window.getApiErrorMessage
? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.settings.update_failed', 'Failed to save settings'))
: uiT('errors.settings.update_failed', 'Failed to save settings');
$('#settingsStatus').html(`
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
${escapeHtml(error)}
</div>
`);
}
});
}
function escapeHtml(text) {
if (text === null || text === undefined) return '';
return String(text)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function showActiveSessions() {
$('#sessionsModal').modal('show');
$.get('/api/user/sessions')
.done(function(data) {
let html = '';
if (data.sessions && data.sessions.length > 0) {
data.sessions.forEach(function(session) {
const isCurrent = session.is_current;
html += `
<div class="card mb-2">
<div class="card-body py-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<strong>${escapeHtml(session.ip_address)}</strong>
${isCurrent ? `<span class="badge bg-success ms-2">${uiT('ui.settings.sessions.current', 'Current')}</span>` : ''}
<br>
<small class="text-muted">
${uiT('ui.settings.sessions.created', 'Created')}: ${formatDate(session.created_at)}<br>
${uiT('ui.settings.sessions.expires', 'Expires')}: ${formatDate(session.expires_at)}
</small>
</div>
${!isCurrent ? `<button class="btn btn-sm btn-outline-danger" onclick="revokeSession('${session.id}')">${uiT('ui.settings.sessions.revoke', 'Revoke')}</button>` : ''}
</div>
</div>
</div>
`;
});
} else {
html = `<p class="text-muted">${uiT('ui.settings.sessions.none_active', 'No active sessions found.')}</p>`;
}
$('#sessionsList').html(html);
})
.fail(function() {
$('#sessionsList').html(`<div class="alert alert-danger">${uiT('ui.settings.sessions.load_failed', 'Failed to load sessions')}</div>`);
});
}
function revokeSession(sessionId) {
if (!confirm(uiT('ui.settings.sessions.revoke_confirm', 'Are you sure you want to revoke this session?'))) return;
$.ajax({
url: `/api/user/sessions/${sessionId}`,
method: 'DELETE',
success: function(response) {
showActiveSessions();
const successMessage = window.getApiMessage
? window.getApiMessage(response, uiT('ui.settings.sessions.revoked_success', 'Session revoked successfully'))
: uiT('ui.settings.sessions.revoked_success', 'Session revoked successfully');
showSuccess(successMessage);
},
error: function() {
showError(uiT('errors.user.sessions.revoke_failed', 'Failed to revoke session'));
}
});
}
function resetOnboarding() {
$.post('/api/user/reset-onboarding')
.done(function() {
const status = $('#onboardingResetStatus');
status.html('<div class="alert alert-success py-1 mb-0">' + uiT('ui.settings.onboarding.reset_success', 'Onboarding reset. You will be taken through the guide next time you visit Echomail.') + '</div>');
status.show();
})
.fail(function() {
const status = $('#onboardingResetStatus');
status.html('<div class="alert alert-danger py-1 mb-0">' + uiT('ui.settings.onboarding.reset_error', 'Failed to reset onboarding.') + '</div>');
status.show();
});
}
function logoutAllSessions() {
if (!confirm(uiT('ui.settings.sessions.logout_all_confirm', 'Are you sure you want to logout from all devices? You will need to login again.'))) return;
$.ajax({
url: '/api/user/sessions/all',
method: 'DELETE',
success: function() {
window.location.href = '/login';
},
error: function() {
showError(uiT('errors.user.sessions.revoke_all_failed', 'Failed to logout all sessions'));
}
});
}
function loadMcpKeyStatus() {
$.get('/api/user/mcp-key')
.done(function(data) {
if (data.has_key) {
$('#mcpKeyPreview').val(data.key_preview);
$('#mcpKeyExisting').show();
$('#mcpKeyNone').hide();
} else {
$('#mcpKeyExisting').hide();
$('#mcpKeyNone').show();
}
$('#mcpKeyNew').hide();
})
.fail(function() {
$('#mcpKeyStatus').show().html('<div class="alert alert-danger">' + uiT('errors.mcp.load_failed', 'Failed to load MCP key status') + '</div>');
});
}
function generateMcpKey() {
if ($('#mcpKeyExisting').is(':visible')) {
if (!confirm(uiT('ui.settings.ai.mcp_regenerate_confirm', 'Regenerating the key will invalidate the existing key. Any MCP clients using the old key will stop working. Continue?'))) return;
}
$.post('/api/user/mcp-key/generate')
.done(function(data) {
if (data.success && data.key) {
$('#mcpKeyValue').val(data.key);
$('#mcpKeyNew').show();
$('#mcpKeyExisting').hide();
$('#mcpKeyNone').hide();
$('#mcpKeyPreview').val(data.key.slice(0, 8) + '*'.repeat(24));
}
})
.fail(function(xhr) {
const error = window.getApiErrorMessage
? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.mcp.generate_failed', 'Failed to generate MCP key'))
: uiT('errors.mcp.generate_failed', 'Failed to generate MCP key');
$('#mcpKeyStatus').show().html('<div class="alert alert-danger">' + escapeHtml(error) + '</div>');
});
}
function revokeMcpKey() {
if (!confirm(uiT('ui.settings.ai.mcp_revoke_confirm', 'Are you sure you want to revoke your MCP key? Any MCP clients using it will stop working.'))) return;
$.ajax({ url: '/api/user/mcp-key', method: 'DELETE' })
.done(function() {
$('#mcpKeyExisting').hide();
$('#mcpKeyNew').hide();
$('#mcpKeyNone').show();
$('#mcpKeyStatus').show().html('<div class="alert alert-success">' + uiT('ui.settings.ai.mcp_revoked', 'MCP key revoked.') + '</div>');
setTimeout(function() { $('#mcpKeyStatus').fadeOut(); }, 3000);
})
.fail(function(xhr) {
const error = window.getApiErrorMessage
? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.mcp.revoke_failed', 'Failed to revoke MCP key'))
: uiT('errors.mcp.revoke_failed', 'Failed to revoke MCP key');
$('#mcpKeyStatus').show().html('<div class="alert alert-danger">' + escapeHtml(error) + '</div>');
});
}
function copyMcpKey() {
const val = $('#mcpKeyValue').val();
if (!val) return;
navigator.clipboard.writeText(val).then(function() {
$('#mcpKeyStatus').show().html('<div class="alert alert-success py-1">' + uiT('ui.settings.ai.mcp_copied', 'Key copied to clipboard.') + '</div>');
setTimeout(function() { $('#mcpKeyStatus').fadeOut(); }, 2000);
});
}
let mcpHelpLoaded = false;
function openMcpHelpModal() {
const modal = new bootstrap.Modal(document.getElementById('mcpHelpModal'));
modal.show();
if (mcpHelpLoaded) return;
$.get('/api/docs/mcp-client-help/claude')
.done(function(data) {
mcpHelpLoaded = true;
$('#mcpHelpBody').html(data.html);
})
.fail(function() {
$('#mcpHelpBody').html('<div class="alert alert-danger">Failed to load help content.</div>');
});
}
// --- PacketBBS TOTP ---
function loadPacketBbsTotpStatus() {
$('#totpLoading').show();
$('#totpEnabled, #totpDisabled, #totpSetup, #totpLoadError, #totpSuccessMsg').hide();
$.get('/api/user/packetbbs-totp/status')
.done(function(data) {
$('#totpLoading').hide();
if (data.enabled) {
$('#totpEnabled').show();
} else {
$('#totpDisabled').show();
}
})
.fail(function() {
$('#totpLoading').hide();
$('#totpLoadError').show().text(uiT('ui.settings.packetbbs_totp.load_failed', 'Failed to load authenticator status.'));
});
}
function setupPacketBbsTotp() {
$('#totpDisabled, #totpEnabled, #totpSuccessMsg, #totpLoadError').hide();
$('#totpLoading').show();
$.post('/api/user/packetbbs-totp/setup')
.done(function(data) {
$('#totpLoading').hide();
if (data.success) {
$('#totpUri').val(data.uri);
$('#totpSecret').val(data.secret);
$('#totpQrCode').attr('src', data.qr_code || '');
$('#totpVerifyCode').val('');
$('#totpEnrollError').hide();
$('#totpSetup').show();
}
})
.fail(function(xhr) {
$('#totpLoading').hide();
const error = window.getApiErrorMessage
? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.packetbbs_totp.setup_failed', 'Failed to set up authenticator. Please try again.'))
: uiT('errors.packetbbs_totp.setup_failed', 'Failed to set up authenticator. Please try again.');
$('#totpLoadError').show().text(error);
$('#totpDisabled').show();
});
}
function cancelPacketBbsTotpSetup() {
$('#totpSetup, #totpEnrollError').hide();
$('#totpQrCode').attr('src', '');
$('#totpDisabled').show();
}
function verifyPacketBbsTotpEnrollment() {
const code = $('#totpVerifyCode').val().trim();
$('#totpEnrollError').hide();
$.ajax({
url: '/api/user/packetbbs-totp/verify-enrollment',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ code: code })
})
.done(function(data) {
if (data.success) {
$('#totpSetup').hide();
$('#totpQrCode').attr('src', '');
$('#totpSuccessMsg').show().text(uiT('ui.settings.packetbbs_totp.verify_success', 'Authenticator enrolled. Log in via radio with: LOGIN <username> <6-digit-code>'));
$('#totpEnabled').show();
}
})
.fail(function(xhr) {
const error = window.getApiErrorMessage
? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.packetbbs_totp.invalid_code', 'Invalid code. Check your authenticator app and try again.'))
: uiT('errors.packetbbs_totp.invalid_code', 'Invalid code. Check your authenticator app and try again.');
$('#totpEnrollError').show().text(error);
});
}
function disablePacketBbsTotp() {
if (!confirm(uiT('ui.settings.packetbbs_totp.disable_confirm', 'Disable PacketBBS authenticator? You will no longer be able to log in from radio devices.'))) return;
$('#totpDisableStatus').html('');
$.post('/api/user/packetbbs-totp/disable')
.done(function(data) {
if (data.success) {
$('#totpEnabled').hide();
$('#totpSuccessMsg').show().text(uiT('ui.settings.packetbbs_totp.disable_success', 'PacketBBS authenticator disabled.'));
$('#totpDisabled').show();
}
})
.fail(function(xhr) {
const error = window.getApiErrorMessage
? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.packetbbs_totp.disable_failed', 'Failed to disable authenticator. Please try again.'))
: uiT('errors.packetbbs_totp.disable_failed', 'Failed to disable authenticator. Please try again.');
$('#totpDisableStatus').html('<div class="alert alert-danger mt-2">' + escapeHtml(error) + '</div>');
});
}
function pollUplinks() {
showSuccess(uiT('ui.settings.polling_uplinks', 'Polling uplinks... (this may take a moment)'));
$.post('/api/binkp/poll')
.done(function(data) {
const pollMessage = window.getApiMessage
? window.getApiMessage(data, uiT('ui.api.binkp.poll_triggered', 'BinkP poll triggered'))
: uiT('ui.api.binkp.poll_triggered', 'BinkP poll triggered');
const exitCode = data && data.result && data.result.exit_code !== undefined && data.result.exit_code !== null
? data.result.exit_code
: null;
if (exitCode !== null) {
showSuccess(uiT('ui.settings.poll_complete_exit', 'Uplink poll completed (exit {code})', { code: exitCode }));
} else {
showSuccess(pollMessage);
}
})
.fail(function(xhr) {
const error = window.getApiErrorMessage
? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.binkp.poll_failed', 'Poll failed'))
: uiT('errors.binkp.poll_failed', 'Poll failed');
showError(uiT('ui.settings.poll_failed_prefix', 'Poll failed: ') + error);
});
}
// ---- MeshCore Radio management ----
let meshcoreRadioModal;
let meshcoreRadioDbId = null;
let meshcoreBridges = null;
function loadMeshcoreContacts() {
const container = document.getElementById('meshcoreContactsContainer');
if (!container) return;
container.innerHTML = `<div class="text-center py-3 text-muted"><i class="fas fa-spinner fa-spin me-2"></i>${uiT('ui.common.loading', 'Loading?')}</div>`;
$.get('/api/user/meshcore/contacts')
.done(function(data) {
const contacts = data.contacts || [];
if (!contacts.length) {
container.innerHTML = `<p class="text-muted text-center py-3 mb-0">${uiT('ui.settings.meshcore.no_radios', 'No radios registered.')}</p>`;
return;
}
let html = `<div class="table-responsive"><table class="table table-hover mb-0">
<thead><tr>
<th>${uiT('ui.settings.meshcore.col_node_id', 'Node ID')}</th>
<th>${uiT('ui.settings.meshcore.col_name', 'Name')}</th>
<th>${uiT('ui.settings.meshcore.col_type', 'Type')}</th>
<th>${uiT('ui.settings.meshcore.col_bridge', 'Bridge')}</th>
<th>${uiT('ui.settings.meshcore.col_last_seen', 'Last Seen')}</th>
<th>${uiT('ui.settings.meshcore.col_actions', 'Actions')}</th>
</tr></thead><tbody>`;
contacts.forEach(function(c) {
const keyDisplay = c.pub_key_full
? `<code title="${escHtmlSettings(c.pub_key_full)}">${escHtmlSettings(c.pub_key_prefix)}</code>`
: `<code>${escHtmlSettings(c.pub_key_prefix)}</code> <small class="text-muted">(prefix only)</small>`;
const lastSeen = c.last_seen_at
? new Date(c.last_seen_at).toLocaleString()
: `<span class="text-muted">${uiT('ui.settings.meshcore.never', 'Never')}</span>`;
const typeCell = c.adv_type
? `<span class="badge bg-secondary">${escHtmlSettings(c.adv_type)}</span>`
: '<span class="text-muted">?</span>';
const bridgeCell = c.bridge_handle || c.bridge_node_str
? escHtmlSettings(c.bridge_handle || c.bridge_node_str)
: `<span class="text-muted">?</span>`;
html += `<tr>
<td>${keyDisplay}</td>
<td>${escHtmlSettings(c.name || '?')}</td>
<td>${typeCell}</td>
<td>${bridgeCell}</td>
<td>${lastSeen}</td>
<td>
<button type="button" class="btn btn-sm btn-outline-secondary me-1 js-mc-edit"
data-contact="${escHtmlSettings(JSON.stringify(c))}"
title="${uiT('ui.common.edit', 'Edit')}">
<i class="fas fa-edit"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger js-mc-delete"
data-id="${c.id}"
title="${uiT('ui.common.delete', 'Delete')}">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>`;
});
html += '</tbody></table></div>';
container.innerHTML = html;
})
.fail(function() {
container.innerHTML = `<p class="text-danger text-center py-3 mb-0">${uiT('errors.generic.load_failed', 'Failed to load.')}</p>`;
});
}
document.getElementById('meshcoreContactsContainer').addEventListener('click', function(e) {
const editBtn = e.target.closest('.js-mc-edit');
if (editBtn) {
editMeshcoreRadio(JSON.parse(editBtn.dataset.contact));
return;
}
const deleteBtn = e.target.closest('.js-mc-delete');
if (deleteBtn) {
deleteMeshcoreRadio(parseInt(deleteBtn.dataset.id, 10));
}
});
function loadMeshcoreBridges(callback) {
if (meshcoreBridges !== null) { callback(meshcoreBridges); return; }
$.get('/api/user/meshcore/bridges')
.done(function(data) {
meshcoreBridges = data.bridges || [];
callback(meshcoreBridges);
})
.fail(function() {
meshcoreBridges = [];
callback([]);
});
}
function populateBridgeSelect(selectedId) {
const sel = document.getElementById('meshcoreRadioBridge');
const none = uiT('ui.settings.meshcore.bridge_none', '? None ?');
const bridges = meshcoreBridges || [];
sel.innerHTML = `<option value="">${none}</option>` +
bridges.map(b => {
const label = b.handle ? `${escHtmlSettings(b.handle)} (${escHtmlSettings(b.node_id)})` : escHtmlSettings(b.node_id);
const sel = parseInt(b.id) === parseInt(selectedId) ? ' selected' : '';
return `<option value="${b.id}"${sel}>${label}</option>`;
}).join('');
}
function showAddMeshcoreRadioModal() {
meshcoreRadioDbId = null;
if (!meshcoreRadioModal) meshcoreRadioModal = new bootstrap.Modal(document.getElementById('meshcoreRadioModal'));
document.getElementById('meshcoreRadioModalTitle').textContent =
uiT('ui.settings.meshcore.add_radio_title', 'Add Radio');
document.getElementById('meshcoreRadioDbId').value = '';
document.getElementById('meshcoreNodeId').value = '';
document.getElementById('meshcoreRadioName').value = '';
document.getElementById('meshcoreRadioError').classList.add('d-none');
document.getElementById('meshcoreNodeIdGroup').style.display = '';
loadMeshcoreBridges(function() { populateBridgeSelect(null); });
meshcoreRadioModal.show();
}
function editMeshcoreRadio(c) {
meshcoreRadioDbId = c.id;
if (!meshcoreRadioModal) meshcoreRadioModal = new bootstrap.Modal(document.getElementById('meshcoreRadioModal'));
document.getElementById('meshcoreRadioModalTitle').textContent =
uiT('ui.settings.meshcore.edit_radio_title', 'Edit Radio');
document.getElementById('meshcoreRadioDbId').value = c.id;
document.getElementById('meshcoreNodeId').value = c.pub_key_full || c.pub_key_prefix;
document.getElementById('meshcoreRadioName').value = c.name || '';
document.getElementById('meshcoreRadioError').classList.add('d-none');
// Hide node ID field when editing ? the key cannot be changed
document.getElementById('meshcoreNodeIdGroup').style.display = 'none';
loadMeshcoreBridges(function() { populateBridgeSelect(c.bridge_node_id); });
meshcoreRadioModal.show();
}
function saveMeshcoreRadio() {
const dbId = document.getElementById('meshcoreRadioDbId').value;
const isEdit = dbId !== '';
const name = document.getElementById('meshcoreRadioName').value.trim();
const bridgeVal = document.getElementById('meshcoreRadioBridge').value;
const bridgeId = bridgeVal !== '' ? parseInt(bridgeVal, 10) : null;
if (!isEdit) {
const nodeId = document.getElementById('meshcoreNodeId').value.trim();
if (!nodeId) {
showMeshcoreRadioError(uiT('errors.meshcore.invalid_node_id', 'Node ID is required.'));
return;
}
$.ajax({
url: '/api/user/meshcore/contacts',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ node_id: nodeId, name, bridge_id: bridgeId }),
success: function() {
meshcoreRadioModal.hide();
loadMeshcoreContacts();
},
error: function(xhr) {
showMeshcoreRadioError(window.getApiErrorMessage
? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.meshcore.contact_exists', 'Save failed.'))
: (xhr.responseJSON && xhr.responseJSON.error) || uiT('errors.meshcore.contact_exists', 'Save failed.'));
}
});
} else {
$.ajax({
url: `/api/user/meshcore/contacts/${dbId}`,
method: 'PUT',
contentType: 'application/json',
data: JSON.stringify({ name, bridge_id: bridgeId }),
success: function() {
meshcoreRadioModal.hide();
loadMeshcoreContacts();
},
error: function(xhr) {
showMeshcoreRadioError(window.getApiErrorMessage
? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.generic.save_failed', 'Save failed.'))
: (xhr.responseJSON && xhr.responseJSON.error) || uiT('errors.generic.save_failed', 'Save failed.'));
}
});
}
}
function deleteMeshcoreRadio(id) {
if (!confirm(uiT('ui.settings.meshcore.delete_confirm', 'Remove this radio from your account?'))) return;
$.ajax({
url: `/api/user/meshcore/contacts/${id}`,
method: 'DELETE',
success: function() { loadMeshcoreContacts(); },
error: function() { alert(uiT('errors.generic.delete_failed', 'Delete failed.')); }
});
}
function showMeshcoreRadioError(msg) {
const el = document.getElementById('meshcoreRadioError');
el.textContent = msg;
el.classList.remove('d-none');
}
function escHtmlSettings(s) {
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
// ---- MeshCore QR scanner ----
(function() {
let qrModal = null;
let qrStream = null;
let qrScanTimer = null;
function stopQrCamera() {
if (qrScanTimer) { clearTimeout(qrScanTimer); qrScanTimer = null; }
if (qrStream) { qrStream.getTracks().forEach(t => t.stop()); qrStream = null; }
}
function parseMeshcoreQr(raw) {
raw = raw.trim();
try {
const url = new URL(raw);
if (url.protocol === 'meshcore:') {
const key = (url.searchParams.get('public_key') || '').toLowerCase();
const name = url.searchParams.get('name') || '';
if (/^[0-9a-f]{64}$/.test(key)) return { key, name };
}
} catch (_) {}
if (/^[0-9a-f]{64}$/i.test(raw)) return { key: raw.toLowerCase(), name: '' };
return null;
}
function setQrStatus(msg, isError) {
const el = document.getElementById('meshcoreQrStatus');
if (!el) return;
el.textContent = msg;
el.className = 'mb-2 small ' + (isError ? 'text-danger' : 'text-muted');
}
function startQrScanLoop() {
const video = document.getElementById('meshcoreQrVideo');
const canvas = document.getElementById('meshcoreQrCanvas');
if (!video || !canvas || !window.jsQR) return;
const ctx = canvas.getContext('2d');
function tick() {
if (!qrStream) return;
if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const code = window.jsQR(imageData.data, imageData.width, imageData.height);
if (code) {
const parsed = parseMeshcoreQr(code.data);
stopQrCamera();
if (parsed) {
if (qrModal) qrModal.hide();
showAddMeshcoreRadioModal();
document.getElementById('meshcoreNodeId').value = parsed.key;
document.getElementById('meshcoreRadioName').value = parsed.name;
} else {
setQrStatus(uiT('errors.meshcore.qr_unrecognized', 'Unrecognized QR code format.') + ' ' + code.data, true);
return;
}
return;
}
}
qrScanTimer = setTimeout(tick, 100);
}
tick();
}
window.showMeshcoreQrScanner = function() {
if (!window.isSecureContext) return;
if (!qrModal) {
qrModal = new bootstrap.Modal(document.getElementById('meshcoreQrModal'));
document.getElementById('meshcoreQrModal').addEventListener('hidden.bs.modal', function() {
stopQrCamera();
});
}
setQrStatus(uiT('ui.settings.meshcore.scan_qr_prompt', 'Point camera at a MeshCore QR code'), false);
qrModal.show();
navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } } })
.then(function(stream) {
qrStream = stream;
const video = document.getElementById('meshcoreQrVideo');
video.srcObject = stream;
video.play();
startQrScanLoop();
})
.catch(function() {
setQrStatus(uiT('errors.meshcore.qr_camera_denied', 'Camera access denied.'), true);
});
};
document.addEventListener('DOMContentLoaded', function() {
const btn = document.getElementById('meshcoreScanQrBtn');
if (!btn) return;
if (!window.isSecureContext || !navigator.mediaDevices) {
btn.style.display = 'none';
} else {
btn.removeAttribute('title');
}
});
}());
</script>
{% endblock %}
|