PHP Classes

File: templates/settings.twig

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

Contents

Class file image Download
{% 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)} &lt;${escapeHtml(rule.sender_address)}&gt;` : 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, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); } 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); } // ---- 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 %}