PHP Classes

File: templates/dashboard.twig

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   templates/dashboard.twig   Download  
File: templates/dashboard.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: 63,827 bytes
 

Contents

Class file image Download
{% extends "base.twig" %} {% block title %}{{ t('ui.dashboard.title', {}, locale, ['common']) }} - {{ parent() }}{% endblock %} {% block head %} {{ parent() }} <style> #dashboardAdContainer, #dashboardAdContainer .ansi-art-container, #dashboardAdContainer pre.ansi-art { overflow: hidden !important; scrollbar-width: none; -ms-overflow-style: none; } #dashboardAdContainer::-webkit-scrollbar, #dashboardAdContainer .ansi-art-container::-webkit-scrollbar, #dashboardAdContainer pre.ansi-art::-webkit-scrollbar, #dashboardAdContainer .dashboard-ad-scaled::-webkit-scrollbar { display: none; } .dash-card-wrapper + .dash-card-wrapper { margin-top: 1.5rem; } .packetbbs-card .card-body { display: flex; flex-direction: column; gap: 0.85rem; } .packetbbs-card-intro { color: var(--text-color, #e0e0e0) !important; font-size: 0.875rem; line-height: 1.45; margin: 0; opacity: 0.92; } .packetbbs-card-list { display: flex; flex-direction: column; gap: 0.75rem; margin: 0; padding: 0; } .packetbbs-card-item { align-items: flex-start; background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(157, 78, 221, 0.32); border-radius: 0.75rem; display: flex; gap: 0.75rem; justify-content: space-between; padding: 0.8rem 0.9rem; } .packetbbs-card-item-main { min-width: 0; } .packetbbs-card-item-link { color: var(--cyber-cyan, #05d9e8) !important; font-size: 1rem; font-weight: 600; line-height: 1.2; text-decoration: none; word-break: break-word; } .packetbbs-card-item-link:hover, .packetbbs-card-item-link:focus { color: var(--cyber-cyan-bright, #6eeef8) !important; text-decoration: underline; } .packetbbs-card-item-location { color: var(--text-color, #e0e0e0) !important; font-size: 0.9rem; line-height: 1.35; margin-top: 0.35rem; opacity: 0.88; word-break: break-word; } .packetbbs-card-item-meta { display: flex; flex-shrink: 0; } .packetbbs-card-item-badge { background: rgba(157, 78, 221, 0.18); border: 1px solid rgba(157, 78, 221, 0.45); border-radius: 999px; color: var(--cyber-cyan-bright, #6eeef8) !important; display: inline-flex; font-size: 0.72rem; font-weight: 700; letter-spacing: 0.02em; line-height: 1; padding: 0.35rem 0.55rem; text-transform: uppercase; white-space: nowrap; } .packetbbs-card-loading, .packetbbs-card-empty, .packetbbs-card-error { color: var(--text-color, #e0e0e0) !important; font-size: 0.875rem; margin: 0; opacity: 0.88; padding: 0.35rem 0; text-align: center; } .packetbbs-card-footer { border-top: 1px solid var(--bs-card-border-color); margin-top: 0.15rem; padding-top: 0.85rem; text-align: right; } .packetbbs-card-footer a, .packetbbs-card-footer .small, .packetbbs-card-footer small { color: var(--cyber-cyan-bright, #6eeef8) !important; } @media (max-width: 575.98px) { .packetbbs-card-item { flex-direction: column; } .packetbbs-card-item-meta { align-self: flex-start; } } /* Customize modal drag styles */ .dash-chip { cursor: grab; user-select: none; transition: opacity 0.15s; } .dash-chip.dragging { opacity: 0.4; cursor: grabbing; } .dash-chip.drag-over { border-top: 2px solid var(--bs-primary) !important; } .dash-drop-zone { min-height: 60px; border: 2px dashed transparent; border-radius: 6px; transition: border-color 0.15s; } .dash-drop-zone.drag-active { border-color: var(--bs-primary); } </style> {% endblock %} {% block content %} <div class="d-flex justify-content-between align-items-center mb-3"> <h5 class="mb-0"> <i class="fas fa-tachometer-alt me-2"></i>{{ t('ui.dashboard.title', {}, locale, ['common']) }} </h5> <button class="btn btn-sm btn-outline-secondary" type="button" onclick="openDashboardCustomize()" title="{{ t('ui.dashboard.customize.title', {}, locale, ['common']) }}"> <i class="fas fa-sliders-h me-1"></i>{{ t('ui.dashboard.customize.title', {}, locale, ['common']) }} </button> </div> <div class="row"> <div class="col-lg-8" id="dashMainCards"> <div class="dash-card-wrapper" data-card-id="unread"> <div class="card"> <div class="card-header"> <h5 class="mb-0"> <i class="fas fa-tachometer-alt"></i> {{ t('ui.dashboard.mail_and_areas', {}, locale, ['common']) }} </h5> </div> <div class="card-body"> <div class="row g-3"> <div class="col-md-6"> <a href="/netmail" class="text-decoration-none"> <div class="card bg-primary text-white"> <div class="card-body"> <h4 id="netmailCount" title="{{ t('ui.dashboard.unread_netmail_tooltip', {}, locale, ['common']) }}"><i class="fas fa-spinner fa-spin"></i></h4> <p class="mb-0"> <i class="fas fa-envelope"></i> {{ t('ui.dashboard.unread_netmail', {}, locale, ['common']) }} </p> </div> </div> </a> </div> <div class="col-md-6"> <a href="{{ default_echo_list == 'echolist' ? '/echolist' : '/echomail' }}" class="text-decoration-none"> <div class="card bg-info text-white"> <div class="card-body"> <h4 id="echomailCount"><i class="fas fa-spinner fa-spin"></i></h4> <p class="mb-0" title="{{ echomail_badge_mode == 'unread' ? t('ui.dashboard.echomail_tooltip_unread', {}, locale, ['common']) : t('ui.dashboard.echomail_tooltip_new', {}, locale, ['common']) }}"> <i class="fas fa-comments"></i> {{ echomail_badge_mode == 'unread' ? t('ui.dashboard.total_unread_echomail', {}, locale, ['common']) : t('ui.dashboard.unread_echomail', {}, locale, ['common']) }} </p> </div> </div> </a> </div> </div> <div class="mt-3 pt-3 border-top"> <div class="d-flex justify-content-between align-items-center mb-2"> <div class="fw-semibold small text-uppercase"> <i class="fas fa-map-signs me-1"></i> {{ t('ui.dashboard.new_echoareas', {}, locale, ['common']) }} </div> <div class="d-flex align-items-center gap-2"> <div class="small text-muted" id="newEchoareaCount"> <i class="fas fa-spinner fa-spin"></i> </div> <button type="button" class="btn btn-sm btn-outline-secondary py-0 px-2" id="toggleNewEchoareas" onclick="toggleNewEchoareasSection()" aria-expanded="true" aria-controls="newEchoareasBody" title="{{ t('ui.dashboard.new_echoareas_hide', {}, locale, ['common']) }}"> <i class="fas fa-chevron-up" aria-hidden="true"></i> <span class="visually-hidden">{{ t('ui.dashboard.new_echoareas_hide', {}, locale, ['common']) }}</span> </button> </div> </div> <div id="newEchoareasBody"> <div id="newEchoareaList"> <div class="text-center text-muted small"> <i class="fas fa-spinner fa-spin"></i> </div> </div> <div class="d-flex justify-content-between align-items-center mt-2"> <button class="btn btn-sm btn-outline-secondary" type="button" id="newEchoareasLoadMore" onclick="loadMoreNewEchoareas()" style="display:none;"> {{ t('ui.dashboard.new_echoareas_load_more', {}, locale, ['common']) }} </button> <div class="d-flex gap-2"> <a class="btn btn-sm btn-outline-secondary" href="/subscriptions"> <i class="fas fa-bookmark me-1"></i>{{ t('ui.dashboard.manage_subscriptions', {}, locale, ['common']) }} </a> {% if interests_enabled %} <a class="btn btn-sm btn-outline-secondary" href="/interests"> <i class="fas fa-layer-group me-1"></i>{{ t('ui.dashboard.manage_interests', {}, locale, ['common']) }} </a> {% endif %} </div> </div> </div> </div> </div> </div> </div>{# /dash-card-wrapper unread #} <div class="dash-card-wrapper" data-card-id="system_news"> <div class="card"> <div class="card-header"> <h6 class="mb-0"> <i class="fas fa-newspaper me-2"></i> {{ t('ui.dashboard.system_news', {}, locale, ['common']) }} </h6> </div> <div class="card-body"> <div id="systemNews"> {{ system_news_content|raw }} </div> </div> </div> </div>{# /dash-card-wrapper system_news #} {% if bbs_feature_enabled('shoutbox') %} <div class="dash-card-wrapper" data-card-id="shoutbox"> <div class="card"> <div class="card-header"> <h6 class="mb-0"> <i class="fas fa-bullhorn me-2"></i> {{ t('ui.shoutbox.title', {}, locale, ['common']) }} </h6> </div> <div class="card-body"> <div id="shoutboxMessages" class="mb-3"> <div class="text-center"> <i class="fas fa-spinner fa-spin"></i> {{ t('ui.shoutbox.loading', {}, locale, ['common']) }} </div> </div> <div class="d-flex justify-content-center mb-3"> <button class="btn btn-outline-secondary btn-sm" type="button" id="shoutboxLoadMore" onclick="loadMoreShouts()"> {{ t('ui.shoutbox.load_older', {}, locale, ['common']) }} </button> </div> <div class="input-group"> <input type="text" class="form-control" id="shoutboxInput" maxlength="280" placeholder="{{ t('ui.shoutbox.leave_shout', {}, locale, ['common']) }}"> <button class="btn btn-primary" type="button" onclick="postShout()">{{ t('ui.shoutbox.post', {}, locale, ['common']) }}</button> </div> <div class="form-text text-muted">{{ t('ui.shoutbox.max_chars', {}, locale, ['common']) }}</div> </div> </div> </div>{# /dash-card-wrapper shoutbox #} {% endif %} {% if dashboard_ad and bbs_feature_enabled('advertising') %} <div class="dash-card-wrapper" data-card-id="advertising"> <div class="card"> <div class="card-header d-flex justify-content-between align-items-center"> <h6 class="mb-0"> <i class="fas fa-ad me-2"></i> {{ t('ui.dashboard.advertisement', {}, locale, ['common']) }} </h6> {% if dashboard_ads|length > 1 %} <div class="btn-group btn-group-sm" role="group" aria-label="{{ t('ui.dashboard.advertisement_controls', {}, locale, ['common']) }}"> <button type="button" class="btn btn-outline-secondary" onclick="showDashboardAd(-1)" aria-label="{{ t('ui.common.previous', {}, locale, ['common']) }}" title="{{ t('ui.common.previous', {}, locale, ['common']) }}"> <i class="fas fa-chevron-left" aria-hidden="true"></i> </button> <button type="button" class="btn btn-outline-secondary" onclick="showDashboardAd(1)" aria-label="{{ t('ui.common.next', {}, locale, ['common']) }}" title="{{ t('ui.common.next', {}, locale, ['common']) }}"> <i class="fas fa-chevron-right" aria-hidden="true"></i> </button> </div> {% endif %} </div> <div class="card-body d-flex flex-column align-items-center"> <div class="fw-semibold mb-2 text-center w-100 d-flex justify-content-center align-items-center gap-2"> <span id="dashboardAdTitle">{{ dashboard_ad.title }}</span> <a id="dashboardAdLink" href="#" target="_blank" rel="noopener noreferrer" class="badge bg-primary text-decoration-none d-none" title="{{ t('ui.dashboard.advertisement_visit', {}, locale, ['common']) }}" onclick="return handleAdClick(event)"> <i class="fas fa-external-link-alt"></i> {{ t('ui.dashboard.advertisement_visit', {}, locale, ['common']) }} </a> </div> <div id="dashboardAdContainer" class="w-100" style="overflow: hidden;"></div> {% if dashboard_ads|length > 1 %} <div class="small text-muted mt-2" id="dashboardAdPosition"></div> {% endif %} </div> </div> </div>{# /dash-card-wrapper advertising #} {% endif %} </div> <div class="col-lg-4" id="dashSidebarCards"> <div class="dash-card-wrapper" data-card-id="bulletins"> <div class="card"> <div class="card-header"> <h6 class="mb-0"><i class="fas fa-clipboard-list me-2"></i>{{ t('ui.bulletins.title', {}, locale, ['common']) }}</h6> </div> <div class="card-body"> {% if bulletin_unread_count > 0 %} <div class="d-flex justify-content-between align-items-center"> <div> <div class="h4 mb-0">{{ bulletin_unread_count }}</div> <div class="text-muted small">{{ t('ui.dashboard.bulletins.unread', {'count': bulletin_unread_count}, locale, ['common']) }}</div> </div> <a class="btn btn-primary btn-sm" href="/bulletins">{{ t('ui.bulletins.title', {}, locale, ['common']) }}</a> </div> {% else %} <div class="text-muted small">{{ t('ui.bulletins.no_new', {}, locale, ['common']) }}</div> {% endif %} </div> </div> </div>{# /dash-card-wrapper bulletins #} <div class="dash-card-wrapper" data-card-id="system_info"> <div class="card"> <div class="card-header"> <h6 class="mb-0">{{ t('ui.dashboard.system_information', {}, locale, ['common']) }}</h6> </div> <div class="card-body"> <dl class="row mb-0"> <dt class="col-sm-5">{{ t('ui.dashboard.sysop', {}, locale, ['common']) }}:</dt> <dd class="col-sm-7">{{ sysop_name }}</dd> <dt class="col-sm-5">{{ t('ui.dashboard.user', {}, locale, ['common']) }}:</dt> <dd class="col-sm-7">{{ current_user.username }}</dd> {% if network_addresses|length > 0 %} <dt class="col-sm-5">{{ t('ui.dashboard.networks', {}, locale, ['common']) }}:</dt> <dd class="col-sm-7"> {% for addr in network_addresses %} <span class="badge bg-secondary me-1 mb-1">{{ addr.domain }}</span> {% endfor %} </dd> {% endif %} </dl> </div> </div> </div>{# /dash-card-wrapper system_info #} {% if current_user.is_admin and todays_callers is not null %} <div class="dash-card-wrapper" data-card-id="todays_callers"> <div class="card"> <div class="card-header"> <h6 class="mb-0">{{ t('ui.dashboard.todays_callers', {}, locale, ['common']) }}</h6> </div> <div class="card-body p-0"> {% if todays_callers|length > 0 %} <table class="table table-sm table-borderless mb-0 small callers-table w-100"> <thead> <tr> <th class="ps-2">{{ t('ui.dashboard.callers_col_user', {}, locale, ['common']) }}</th> <th>{{ t('ui.dashboard.callers_col_time', {}, locale, ['common']) }}</th> <th class="text-center pe-2">{{ t('ui.dashboard.callers_col_online', {}, locale, ['common']) }}</th> </tr> </thead> <tbody> {% for caller in todays_callers %} <tr> <td class="ps-2"><a href="/profile/{{ caller.username|url_encode }}" class="text-decoration-none">{{ caller.username }}</a></td> <td class="text-muted">{{ caller.last_activity|date('H:i', 'UTC') }}</td> <td class="text-center pe-2"> {% if caller.is_online %} <span class="badge bg-success" title="{{ t('ui.dashboard.callers_online_now', {}, locale, ['common']) }}">?</span> {% endif %} </td> </tr> {% endfor %} </tbody> </table> {% else %} <div class="p-3 text-muted small">{{ t('ui.dashboard.no_callers_today', {}, locale, ['common']) }}</div> {% endif %} </div> </div> </div>{# /dash-card-wrapper todays_callers #} {% endif %} {% if bbs_feature_enabled('voting_booth') %} <div class="dash-card-wrapper" data-card-id="voting_booth"> <div class="card" id="votingBoothCard"> <div class="card-header d-flex justify-content-between align-items-center"> <h6 class="mb-0"> <i class="fas fa-vote-yea me-2"></i> {{ t('ui.dashboard.voting_booth', {}, locale, ['common']) }} </h6> <div class="btn-group btn-group-sm" role="group"> <button type="button" class="btn btn-outline-secondary btn-sm" id="pollPrevBtn" onclick="changeVotingPoll(-1)" title="{{ t('ui.polls.previous', {}, locale, ['common']) }}"> <i class="fas fa-chevron-left"></i> </button> <button type="button" class="btn btn-outline-secondary btn-sm" id="pollNextBtn" onclick="changeVotingPoll(1)" title="{{ t('ui.polls.next', {}, locale, ['common']) }}"> <i class="fas fa-chevron-right"></i> </button> </div> </div> <div class="card-body" id="votingBoothBody"> <div class="text-center"> <i class="fas fa-spinner fa-spin"></i> {{ t('ui.polls.loading', {}, locale, ['common']) }} </div> </div> <div class="card-footer text-end py-2"> <a href="/polls/create" title="{{ t('ui.dashboard.create_poll_title', {}, locale, ['common']) }}" style="text-decoration: none; font-size: 1.2rem;"> <i class="fas fa-file-circle-plus"></i> </a> </div> </div> </div>{# /dash-card-wrapper voting_booth #} {% endif %} {% if referral_enabled %} <div class="dash-card-wrapper" data-card-id="referral"> <div class="card"> <div class="card-header"> <h6 class="mb-0"><i class="fas fa-user-plus me-2"></i>{{ t('ui.dashboard.my_referral_link', {}, locale, ['common']) }}</h6> </div> <div class="card-body"> <p class="text-muted small">{{ t('ui.dashboard.referral_invite_prefix', {}, locale, ['common']) }} <strong id="referralBonus">25</strong> {{ t('ui.dashboard.referral_invite_suffix', {}, locale, ['common']) }}</p> <div class="input-group mb-3"> <input type="text" class="form-control form-control-sm" id="referralUrl" readonly> <button class="btn btn-primary btn-sm" type="button" onclick="copyReferralLink(this, event)"> <i class="fas fa-copy"></i> </button> </div> <div id="referralStats" class="mt-3"> <div class="row text-center"> <div class="col-6"> <h5 id="referralCount" class="mb-0">-</h5> <small class="text-muted">{{ t('ui.dashboard.referrals.label', {}, locale, ['common']) }}</small> </div> <div class="col-6"> <h5 id="referralEarnings" class="mb-0">-</h5> <small class="text-muted">{{ t('ui.dashboard.referrals.earned', {}, locale, ['common']) }}</small> </div> </div> </div> <div id="referralList" class="mt-3"></div> </div> </div> </div>{# /dash-card-wrapper referral #} {% endif %} {% if packetbbs_nodes_exist %} <div class="dash-card-wrapper" data-card-id="packetbbs_status"> <div class="card packetbbs-card"> <div class="card-header"> <h6 class="mb-0"> <i class="fas fa-broadcast-tower me-2"></i>{{ t('ui.dashboard.card.packetbbs_status', {}, locale, ['common']) }} </h6> </div> <div class="card-body"> <p class="packetbbs-card-intro">{{ t('ui.meshcore_nodes.preamble', {}, locale, ['common']) }}</p> <div id="packetBbsStatusList"> <div class="packetbbs-card-loading"> <i class="fas fa-spinner fa-spin me-1"></i>{{ t('ui.meshcore_nodes.loading', {}, locale, ['common']) }} </div> </div> <div class="packetbbs-card-footer"> <a href="/packetbbs-nodes" class="small">{{ t('ui.meshcore_nodes.view_all', {}, locale, ['common']) }}</a> </div> </div> </div> </div>{# /dash-card-wrapper packetbbs_status #} {% endif %} <div class="dash-card-wrapper" data-card-id="echo_areas"> <div class="card"> <div class="card-header d-flex justify-content-between align-items-center"> <h6 class="mb-0">{{ t('ui.echomail.echo_areas', {}, locale, ['common']) }}</h6> <a href="/subscriptions"><i class="fa-solid fa-wrench"></i></a> </div> <div class="card-body"> <div id="echoareaList"> <div class="text-center"> <i class="fas fa-spinner fa-spin"></i> {{ t('ui.echomail.loading_areas', {}, locale, ['common']) }} </div> </div> </div> </div> </div>{# /dash-card-wrapper echo_areas #} </div> </div> {# Dashboard Customize Modal #} <div class="modal fade" id="dashboardCustomizeModal" tabindex="-1" aria-labelledby="dashboardCustomizeModalLabel" aria-hidden="true"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="dashboardCustomizeModalLabel"> <i class="fas fa-sliders-h me-2"></i>{{ t('ui.dashboard.customize.title', {}, locale, ['common']) }} </h5> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ t('ui.common.close', {}, locale, ['common']) }}"></button> </div> <div class="modal-body"> <p class="text-muted small">{{ t('ui.dashboard.customize.hint', {}, locale, ['common']) }}</p> <div class="row g-3"> <div class="col-md-6"> <div class="fw-semibold mb-2"><i class="fas fa-columns me-1"></i>{{ t('ui.dashboard.customize.zone_main', {}, locale, ['common']) }}</div> <div id="customizeMainList" class="dash-drop-zone border rounded p-2"></div> </div> <div class="col-md-6"> <div class="fw-semibold mb-2"><i class="fas fa-sidebar me-1"></i>{{ t('ui.dashboard.customize.zone_sidebar', {}, locale, ['common']) }}</div> <div id="customizeSidebarList" class="dash-drop-zone border rounded p-2"></div> </div> </div> </div> <div class="modal-footer d-flex justify-content-between"> <button type="button" class="btn btn-outline-secondary btn-sm" onclick="resetDashboardLayout()"> <i class="fas fa-undo me-1"></i>{{ t('ui.dashboard.customize.reset', {}, locale, ['common']) }} </button> <div> <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 ms-2" onclick="saveDashboardLayout()"> <i class="fas fa-save me-1"></i>{{ t('ui.common.save', {}, locale, ['common']) }} </button> </div> </div> </div> </div> </div> {% endblock %} {% block scripts %} <script src="/js/ansisys.js"></script> <script src="/js/sixel.js"></script> <script src="/js/pcboard.js"></script> <script> const DASHBOARD_LAYOUT = {{ dashboard_layout|json_encode|raw }}; const DASHBOARD_CARDS = {{ dashboard_available_cards|json_encode|raw }}; let votingPolls = []; let votingPollIndex = 0; let shoutboxOffset = 0; const shoutboxPageSize = 5; const NEW_ECHOAREAS_COLLAPSED_KEY = 'dashboardNewEchoareasCollapsed'; let latestDashboardStats = null; let newEchoareasOffset = 0; const newEchoareasPageSize = 8; function apiError(payload, fallback) { if (window.getApiErrorMessage) { return window.getApiErrorMessage(payload, fallback); } return payload && payload.error ? payload.error : fallback; } function uiT(key, fallback, params = {}) { if (window.t) { return window.t(key, params, fallback); } return fallback; } const dashboardAds = {{ (dashboard_ads|default([]))|json_encode|raw }}; const DASHBOARD_AD_ROTATE_INTERVAL_MS = {{ (dashboard_ad_rotate_interval_seconds|default(20) * 1000)|json_encode|raw }}; let dashboardAdIndex = 0; const dashboardAdImpressionsSent = new Set(); $(document).ready(function() { applyDashboardLayout(DASHBOARD_LAYOUT); applyNewEchoareasCollapsedState(); loadI18nNamespaces(['common', 'errors']).then(function() { updateNewEchoareasToggle(); loadDashboardData(); {% if bbs_feature_enabled('voting_booth') %} loadVotingBooth(); {% endif %} loadEchoareas(); {% if bbs_feature_enabled('shoutbox') %} loadShoutbox(); {% endif %} {% if referral_enabled %} loadReferralStats(); {% endif %} {% if packetbbs_nodes_exist %} loadPacketBbsStatus(); {% endif %} renderDashboardAd(); updateDashboardAdPosition(); window.addEventListener('resize', scaleDashboardAd); }); // Refresh dashboard data every 30 seconds (but not system news) setInterval(function() { loadDashboardData(); }, 30000); if (Array.isArray(dashboardAds) && dashboardAds.length > 1) { setInterval(function() { if (document.hidden) { return; } showDashboardAd(1); }, DASHBOARD_AD_ROTATE_INTERVAL_MS); } document.addEventListener('keydown', function(event) { if (!Array.isArray(dashboardAds) || dashboardAds.length <= 1) { return; } const tag = (event.target && event.target.tagName ? event.target.tagName : '').toUpperCase(); if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') { return; } if (event.key === 'ArrowLeft') { event.preventDefault(); showDashboardAd(-1); } else if (event.key === 'ArrowRight') { event.preventDefault(); showDashboardAd(1); } }); }); function loadDashboardData() { $.get('/api/dashboard/stats') .done(function(data) { latestDashboardStats = data; $('#netmailCount').text(data.total_netmail || 0); $('#echomailCount').text(data.new_echomail || 0); renderNewEchoareas(data); }); } function isNewEchoareasCollapsed() { try { return UserStorage.getItem(NEW_ECHOAREAS_COLLAPSED_KEY) === '1'; } catch (_) { return false; } } function setNewEchoareasCollapsed(collapsed) { try { UserStorage.setItem(NEW_ECHOAREAS_COLLAPSED_KEY, collapsed ? '1' : '0'); } catch (_) {} } function applyNewEchoareasCollapsedState() { const collapsed = isNewEchoareasCollapsed(); $('#newEchoareasBody').toggleClass('d-none', collapsed); $('#toggleNewEchoareas').attr('aria-expanded', collapsed ? 'false' : 'true'); updateNewEchoareasToggle(); } function updateNewEchoareasToggle() { const collapsed = isNewEchoareasCollapsed(); const label = collapsed ? uiT('ui.dashboard.new_echoareas_show', 'Show new echo areas') : uiT('ui.dashboard.new_echoareas_hide', 'Hide new echo areas'); $('#toggleNewEchoareas') .attr('title', label) .attr('aria-label', label) .find('i') .toggleClass('fa-chevron-down', collapsed) .toggleClass('fa-chevron-up', !collapsed); $('#toggleNewEchoareas .visually-hidden').text(label); } function toggleNewEchoareasSection() { const collapsed = !isNewEchoareasCollapsed(); setNewEchoareasCollapsed(collapsed); applyNewEchoareasCollapsedState(); if (!collapsed && latestDashboardStats) { renderNewEchoareas(latestDashboardStats); } } function buildEchoareaHtml(area) { const tag = area.tag || ''; const domain = area.domain || ''; const areaUrl = domain ? `/echomail/${encodeURIComponent(tag)}@${encodeURIComponent(domain)}` : `/echomail/${encodeURIComponent(tag)}`; const createdAtRaw = area.created_at ? String(area.created_at).replace(' ', 'T') : ''; const createdDate = createdAtRaw ? new Date(createdAtRaw) : null; const createdAt = createdDate && !Number.isNaN(createdDate.getTime()) ? createdDate.toLocaleDateString() : ''; const dateLabel = createdAt ? `<span class="text-muted small">${uiT('ui.dashboard.new_echoareas_added', 'Added')} ${escapeHtml(createdAt)}</span>` : ''; return ` <div class="d-flex justify-content-between align-items-start py-1"> <div class="me-2"> <a href="${areaUrl}" class="text-decoration-none fw-semibold">${escapeHtml(tag)}</a> ${domain ? `<span class="badge bg-secondary ms-1" style="font-size: 0.65em; vertical-align: middle;">${escapeHtml(domain)}</span>` : ''} ${area.description ? `<div class="text-muted small">${escapeHtml(area.description)}</div>` : ''} </div> <div class="text-nowrap">${dateLabel}</div> </div> `; } function renderNewEchoareas(data) { const areas = Array.isArray(data.recent_echoareas) ? data.recent_echoareas : []; const count = Number(data.new_echoareas || areas.length || 0); $('#newEchoareaCount').text(uiT('ui.dashboard.new_echoareas_count', '{count} added in past 30 days', { count })); if (isNewEchoareasCollapsed()) { return; } if (count === 0) { $('#newEchoareaList').html(`<div class="text-muted small">${uiT('ui.dashboard.new_echoareas_none', 'No new echo areas in the past 30 days.')}</div>`); $('#newEchoareasLoadMore').hide(); return; } $('#newEchoareaList').html(areas.map(buildEchoareaHtml).join('')); newEchoareasOffset = areas.length; $('#newEchoareasLoadMore').toggle(count > areas.length); } function loadMoreNewEchoareas() { const button = document.getElementById('newEchoareasLoadMore'); if (!button || button.disabled) { return; } button.disabled = true; $.get(`/api/echoareas/recent?limit=${newEchoareasPageSize}&offset=${newEchoareasOffset}`) .done(function(data) { const areas = Array.isArray(data.areas) ? data.areas : []; if (areas.length === 0) { $('#newEchoareasLoadMore').hide(); return; } $('#newEchoareaList').append(areas.map(buildEchoareaHtml).join('')); newEchoareasOffset += areas.length; button.disabled = areas.length < newEchoareasPageSize; if (areas.length < newEchoareasPageSize) { $('#newEchoareasLoadMore').hide(); } }) .fail(function() { button.disabled = false; }); } function loadVotingBooth(keepPollId = null) { $.get('/api/polls/active') .done(function(data) { votingPolls = data.polls || []; if (votingPolls.length === 0) { $('#votingBoothBody').html(`<p class="text-muted mb-0">${uiT('ui.dashboard.polls.none_active', 'No active polls right now.')}</p>`); updateVotingPollNav(); return; } if (keepPollId) { const idx = votingPolls.findIndex(p => p.id === keepPollId); votingPollIndex = idx >= 0 ? idx : 0; } else { const unansweredIndex = votingPolls.findIndex(p => !p.has_voted); votingPollIndex = unansweredIndex >= 0 ? unansweredIndex : 0; } renderVotingBooth(); }) .fail(function() { $('#votingBoothBody').html(`<p class="text-danger mb-0">${uiT('ui.dashboard.polls.load_failed', 'Failed to load poll.')}</p>`); updateVotingPollNav(); }); } function renderVotingBooth() { if (votingPolls.length === 0) { $('#votingBoothBody').html(`<p class="text-muted mb-0">${uiT('ui.dashboard.polls.none_active', 'No active polls right now.')}</p>`); updateVotingPollNav(); return; } const poll = votingPolls[votingPollIndex]; const hasVoted = !!poll.has_voted; if (hasVoted) { const totalVotes = poll.total_votes || 0; let resultsHtml = `<h6 class="mb-3">${escapeHtml(poll.question)}</h6>`; resultsHtml += `<div class="small text-muted mb-2">${uiT('ui.dashboard.polls.results', 'Results')}</div>`; if (totalVotes === 0) { resultsHtml += `<p class="text-muted mb-0">${uiT('ui.dashboard.polls.no_votes', 'No votes yet.')}</p>`; } else { (poll.results || []).forEach(result => { const percent = totalVotes > 0 ? Math.round((result.votes / totalVotes) * 100) : 0; resultsHtml += ` <div class="mb-2"> <div class="d-flex justify-content-between small"> <span>${escapeHtml(result.option_text)}</span> <span>${result.votes} (${percent}%)</span> </div> <div class="progress" style="height: 6px;"> <div class="progress-bar" role="progressbar" style="width: ${percent}%"></div> </div> </div> `; }); } $('#votingBoothBody').html(resultsHtml); updateVotingPollNav(); return; } let optionsHtml = `<h6 class="mb-3">${escapeHtml(poll.question)}</h6>`; optionsHtml += `<div class="small text-muted mb-2">${uiT('ui.dashboard.polls.vote_to_see_results', 'Vote to see results')}</div>`; optionsHtml += '<form id="votingBoothForm">'; (poll.options || []).forEach(option => { optionsHtml += ` <div class="form-check mb-2"> <input class="form-check-input" type="radio" name="poll_option" id="pollOption${option.id}" value="${option.id}"> <label class="form-check-label" for="pollOption${option.id}"> ${escapeHtml(option.option_text)} </label> </div> `; }); optionsHtml += ` <button type="button" class="btn btn-sm btn-primary mt-2" onclick="submitPollVote(${poll.id})"> ${uiT('ui.dashboard.polls.submit_vote', 'Submit Vote')} </button> `; optionsHtml += '</form>'; $('#votingBoothBody').html(optionsHtml); updateVotingPollNav(); } function changeVotingPoll(delta) { if (votingPolls.length <= 1) { return; } const nextIndex = votingPollIndex + delta; if (nextIndex < 0 || nextIndex >= votingPolls.length) { return; } votingPollIndex = nextIndex; renderVotingBooth(); } function updateVotingPollNav() { const hasMultiple = votingPolls.length > 1; const prevBtn = document.getElementById('pollPrevBtn'); const nextBtn = document.getElementById('pollNextBtn'); if (!prevBtn || !nextBtn) { return; } prevBtn.disabled = !hasMultiple || votingPollIndex === 0; nextBtn.disabled = !hasMultiple || votingPollIndex === (votingPolls.length - 1); } function submitPollVote(pollId) { const selected = document.querySelector('input[name="poll_option"]:checked'); if (!selected) { alert(uiT('errors.polls.option_required', 'Please select an option.')); return; } fetch(`/api/polls/${pollId}/vote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ option_id: selected.value }) }) .then(response => response.json()) .then(data => { if (data.success) { loadVotingBooth(pollId); } else { alert(apiError(data, uiT('errors.polls.vote_failed', 'Failed to submit vote.'))); } }) .catch(() => alert(uiT('errors.polls.vote_failed', 'Failed to submit vote.'))); } function escapeHtml(text) { const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }; return String(text).replace(/[&<>"']/g, function(m) { return map[m]; }); } function loadEchoareas() { $.get('/api/echoareas?subscribed_only=true') .done(function(data) { let html = ''; if (data.echoareas && data.echoareas.length > 0) { // Add header row html += ` <div class="d-flex justify-content-between align-items-center py-1 border-bottom mb-2"> <div class="fw-bold text-muted small">${uiT('ui.dashboard.echoareas.header_area', 'Echo Area')}</div> <div class="fw-bold text-muted small">${uiT('ui.dashboard.echoareas.header_unread_total', 'Unread Total')}</div> </div> `; data.echoareas.forEach(function(area) { const unreadCount = area.unread_count || 0; const totalCount = area.message_count || 0; const areaUrl = area.domain ? `/echomail/${encodeURIComponent(area.tag)}@${encodeURIComponent(area.domain)}` : `/echomail/${encodeURIComponent(area.tag)}`; html += ` <div class="d-flex justify-content-between align-items-center py-1"> <div> <a href="${areaUrl}" class="text-decoration-none"> ${area.tag} </a> ${area.domain ? `<span class="badge bg-secondary ms-1" style="font-size: 0.65em; vertical-align: middle;">${area.domain}</span>` : ''} </div> <div class="d-flex gap-1 align-items-center"> <span class="badge ${unreadCount > 0 ? 'bg-warning text-dark' : 'bg-secondary'} text-center" style="min-width: 35px;">${unreadCount}</span> <span class="badge bg-secondary text-center" style="min-width: 35px;">${totalCount}</span> </div> </div> `; }); } else { html = `<p class="text-muted">${uiT('ui.dashboard.echoareas.none_available', 'No echo areas available')}</p>`; } $('#echoareaList').html(html); }); } function loadShoutbox() { shoutboxOffset = 0; $.get(`/api/shoutbox?limit=${shoutboxPageSize}&offset=${shoutboxOffset}`) .done(function(data) { const items = data.messages || []; if (items.length === 0) { $('#shoutboxMessages').html(`<p class="text-muted mb-0">${uiT('ui.dashboard.shoutbox.none_yet', 'No shouts yet. Be the first!')}</p>`); $('#shoutboxLoadMore').prop('disabled', true); return; } let html = ''; items.forEach(function(msg) { html += ` <div class="mb-2"> <strong><a href="/profile/${encodeURIComponent(msg.username)}">${escapeHtml(msg.username)}</a></strong> <span class="text-muted small ms-2">${new Date(msg.created_at).toLocaleString([], { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span> <div>${formatMessageText(msg.message)}</div> </div> `; }); $('#shoutboxMessages').html(html); shoutboxOffset += items.length; $('#shoutboxLoadMore').prop('disabled', items.length < shoutboxPageSize); }) .fail(function() { $('#shoutboxMessages').html(`<p class="text-danger mb-0">${uiT('ui.dashboard.shoutbox.load_failed', 'Failed to load shouts.')}</p>`); $('#shoutboxLoadMore').prop('disabled', true); }); } function loadMoreShouts() { const button = document.getElementById('shoutboxLoadMore'); if (button.disabled) { return; } button.disabled = true; $.get(`/api/shoutbox?limit=${shoutboxPageSize}&offset=${shoutboxOffset}`) .done(function(data) { const items = data.messages || []; if (items.length === 0) { return; } let html = ''; items.forEach(function(msg) { html += ` <div class="mb-2"> <strong><a href="/profile/${encodeURIComponent(msg.username)}">${escapeHtml(msg.username)}</a></strong> <span class="text-muted small ms-2">${new Date(msg.created_at).toLocaleString([], { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</span> <div>${formatMessageText(msg.message)}</div> </div> `; }); $('#shoutboxMessages').append(html); shoutboxOffset += items.length; button.disabled = items.length < shoutboxPageSize; }) .fail(function() { button.disabled = false; }); } function postShout() { const input = document.getElementById('shoutboxInput'); const message = input.value.trim(); if (!message) { return; } fetch('/api/shoutbox', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message }) }) .then(response => response.json()) .then(data => { if (data.success) { input.value = ''; loadShoutbox(); } else { alert(apiError(data, uiT('ui.dashboard.shoutbox.post_failed', 'Failed to post shout.'))); } }) .catch(() => alert(uiT('ui.dashboard.shoutbox.post_failed', 'Failed to post shout.'))); } function loadPacketBbsStatus() { fetch('/api/meshcore/nodes') .then(r => r.json()) .then(function (data) { const el = document.getElementById('packetBbsStatusList'); if (!el) return; const nodes = data.nodes || []; if (nodes.length === 0) { el.innerHTML = '<p class="packetbbs-card-empty">' + window.t('ui.meshcore_nodes.none_registered', {}, 'No nodes registered.') + '</p>'; return; } el.innerHTML = '<div class="packetbbs-card-list">' + nodes.map(function (n) { const location = n.location ? escapeHtml(n.location) : '<span class="fst-italic">' + window.t('ui.meshcore_nodes.no_location', {}, 'No location set') + '</span>'; const iface = n.interface_type ? '<span class="packetbbs-card-item-badge">' + escapeHtml(n.interface_type) + '</span>' : ''; return '<div class="packetbbs-card-item">' + '<div class="packetbbs-card-item-main">' + '<a href="/packetbbs-nodes#node-' + n.id + '" class="packetbbs-card-item-link">' + escapeHtml(n.handle || '?') + '</a>' + '<div class="packetbbs-card-item-location">' + location + '</div>' + '</div>' + (iface ? '<div class="packetbbs-card-item-meta">' + iface + '</div>' : '') + '</div>'; }).join('') + '</div>'; }) .catch(function () { const el = document.getElementById('packetBbsStatusList'); if (el) el.innerHTML = '<p class="packetbbs-card-error">' + window.t('ui.meshcore_nodes.load_error', {}, 'Failed to load.') + '</p>'; }); } function loadReferralStats() { fetch('/api/referrals/my-stats') .then(r => r.json()) .then(data => { if (data['error_code'] || ('error' in data)) { console.error(uiT('ui.dashboard.referrals.error_prefix', 'Referral stats error:'), apiError(data, uiT('errors.referrals.stats_failed', 'Failed to load referral stats'))); return; } document.getElementById('referralUrl').value = data.referral_url; document.getElementById('referralCount').textContent = data.total_count; document.getElementById('referralEarnings').textContent = data.total_earned; document.getElementById('referralBonus').textContent = data.referral_bonus; if (data.referrals && data.referrals.length > 0) { const list = data.referrals.slice(0, 5).map(r => { const date = new Date(r.created_at); return `<div class="border-bottom py-2 small"> <strong>${escapeHtml(r.username)}</strong> <br><small class="text-muted">${date.toLocaleDateString()}</small> </div>`; }).join(''); document.getElementById('referralList').innerHTML = `<h6 class="mt-2 mb-2 small">${uiT('ui.dashboard.referrals.recent', 'Recent Referrals')}</h6>` + list; } }) .catch(err => { console.error(uiT('errors.referrals.stats_failed', 'Failed to load referral stats:'), err); }); } function copyReferralLink(button, evt) { const input = document.getElementById('referralUrl'); input.select(); input.setSelectionRange(0, 99999); // For mobile devices try { document.execCommand('copy'); // Show temporary success feedback const btn = button || (evt && evt.target.closest('button')); if (btn) { const originalHTML = btn.innerHTML; btn.innerHTML = '<i class="fas fa-check"></i>'; setTimeout(() => { btn.innerHTML = originalHTML; }, 2000); } } catch (err) { console.error(uiT('ui.dashboard.referrals.copy_failed_prefix', 'Failed to copy:'), err); } } function scaleDashboardAd() { const outer = document.getElementById('dashboardAdContainer'); if (!outer) { return; } const inner = outer.querySelector('.ansi-art-container'); if (!inner) { return; } const pre = inner.querySelector('pre.ansi-art'); if (!pre) { return; } pre.style.removeProperty('font-size'); pre.style.removeProperty('line-height'); inner.style.removeProperty('transform'); inner.style.removeProperty('transform-origin'); inner.style.removeProperty('width'); inner.style.removeProperty('overflow'); inner.style.removeProperty('scrollbar-width'); inner.style.removeProperty('-ms-overflow-style'); inner.classList.remove('dashboard-ad-scaled'); outer.style.removeProperty('height'); const availableWidth = outer.clientWidth; const contentWidth = pre.scrollWidth; if (!availableWidth || !contentWidth || contentWidth <= availableWidth) { return; } const scale = availableWidth / contentWidth; inner.style.transform = `scale(${scale})`; inner.style.transformOrigin = 'top left'; inner.style.width = `${contentWidth}px`; inner.style.overflow = 'hidden'; inner.style.scrollbarWidth = 'none'; inner.style.msOverflowStyle = 'none'; inner.classList.add('dashboard-ad-scaled'); outer.style.height = `${Math.ceil(inner.getBoundingClientRect().height * scale || pre.scrollHeight * scale)}px`; } function renderDashboardAd() { const container = document.getElementById('dashboardAdContainer'); if (!container) { return; } const current = dashboardAds[dashboardAdIndex]; if (!current) { return; } renderAdContent(container, current.content || ''); requestAnimationFrame(scaleDashboardAd); // Update click-through link visibility const link = document.getElementById('dashboardAdLink'); if (link) { if (current.click_url) { link.href = '#'; link.dataset.adId = String(current.id); link.dataset.clickUrl = current.click_url; link.classList.remove('d-none'); } else { link.classList.add('d-none'); } } // Record impression once per ad per page load if (current.id && !dashboardAdImpressionsSent.has(current.id)) { dashboardAdImpressionsSent.add(current.id); fetch(`/api/ads/${current.id}/impression`, { method: 'POST' }).catch(() => {}); } } function handleAdClick(event) { event.preventDefault(); const link = event.currentTarget; const adId = link.dataset.adId; const url = link.dataset.clickUrl; if (!adId || !url) { return false; } fetch(`/api/ads/${adId}/click`, { method: 'POST' }).catch(() => {}); window.open(url, '_blank', 'noopener,noreferrer'); return false; } function updateDashboardAdPosition() { const label = document.getElementById('dashboardAdPosition'); if (!label || !Array.isArray(dashboardAds) || dashboardAds.length <= 1) { return; } label.textContent = uiT('ui.dashboard.advertisement_position', 'Ad {current} of {total}', { current: dashboardAdIndex + 1, total: dashboardAds.length }); } function showDashboardAd(delta) { if (!Array.isArray(dashboardAds) || dashboardAds.length <= 1) { return; } dashboardAdIndex = (dashboardAdIndex + delta + dashboardAds.length) % dashboardAds.length; const current = dashboardAds[dashboardAdIndex]; const title = document.getElementById('dashboardAdTitle'); if (!current) { return; } if (title) { title.textContent = current.title || ''; } renderDashboardAd(); updateDashboardAdPosition(); } /* ?? Dashboard layout ????????????????????????????????????????????????? */ /** * Applies a saved layout to the live DOM. * Hidden cards are hidden; cards are reordered within their zone containers. */ function applyDashboardLayout(layout) { if (!layout || typeof layout !== 'object') return; const mainEl = document.getElementById('dashMainCards'); const sidebarEl = document.getElementById('dashSidebarCards'); if (!mainEl || !sidebarEl) return; const hiddenIds = new Set(layout.hidden || []); function applyZone(containerEl, orderedIds) { if (!Array.isArray(orderedIds)) return; orderedIds.forEach(function(cardId) { const wrapper = document.querySelector(`[data-card-id="${cardId}"]`); if (!wrapper) return; containerEl.appendChild(wrapper); wrapper.style.display = hiddenIds.has(cardId) ? 'none' : ''; }); } applyZone(mainEl, layout.main); applyZone(sidebarEl, layout.sidebar); } /* ?? Customize modal ?????????????????????????????????????????????????? */ let customizeModal = null; let dragSrcId = null; let dragSrcList = null; function openDashboardCustomize() { customizeModal = customizeModal || new bootstrap.Modal(document.getElementById('dashboardCustomizeModal')); buildCustomizeList('customizeMainList', 'main'); buildCustomizeList('customizeSidebarList', 'sidebar'); customizeModal.show(); } function buildCustomizeList(containerId, zone) { const mainEl = document.getElementById('dashMainCards'); const sidebarEl = document.getElementById('dashSidebarCards'); const container = document.getElementById(containerId); if (!container) return; container.innerHTML = ''; // Derive current order from the live DOM const sourceEl = zone === 'main' ? mainEl : sidebarEl; const wrappers = Array.from(sourceEl.querySelectorAll(':scope > .dash-card-wrapper')); wrappers.forEach(function(wrapper) { const cardId = wrapper.getAttribute('data-card-id'); if (!cardId) return; const cardDef = DASHBOARD_CARDS[cardId] || {}; const isRequired = !!cardDef.required; const isHidden = wrapper.style.display === 'none'; const label = window.t ? window.t(cardDef.label_key || '', {}, cardId) : cardId; const chip = document.createElement('div'); chip.className = 'dash-chip d-flex align-items-center justify-content-between border rounded px-3 py-2 mb-2 bg-light'; chip.setAttribute('data-card-id', cardId); chip.setAttribute('data-hidden', isHidden ? '1' : '0'); chip.setAttribute('draggable', 'true'); chip.innerHTML = ` <span class="me-2"><i class="fas fa-grip-vertical text-muted me-2"></i>${escapeHtml(label)}</span> ${isRequired ? '<span class="badge bg-secondary">' + (window.t ? window.t('ui.dashboard.customize.required', {}, 'Required') : 'Required') + '</span>' : `<button type="button" class="btn btn-sm p-0 border-0 bg-transparent" onclick="toggleCardVisibility(this,'${cardId}')" title="${isHidden ? (window.t ? window.t('ui.dashboard.customize.show', {}, 'Show') : 'Show') : (window.t ? window.t('ui.dashboard.customize.hide', {}, 'Hide') : 'Hide')}"> <i class="fas ${isHidden ? 'fa-eye-slash text-muted' : 'fa-eye text-primary'}"></i> </button>`} `; chip.addEventListener('dragstart', onChipDragStart); chip.addEventListener('dragend', onChipDragEnd); chip.addEventListener('dragover', onChipDragOver); chip.addEventListener('drop', onChipDrop); container.appendChild(chip); }); // Wire drop zone events on the container itself for empty-list drops container.addEventListener('dragover', onZoneDragOver); container.addEventListener('dragleave', onZoneDragLeave); container.addEventListener('drop', onZoneDrop); } function toggleCardVisibility(btn, cardId) { const icon = btn.querySelector('i'); const isNowHidden = icon.classList.contains('fa-eye'); icon.classList.toggle('fa-eye', !isNowHidden); icon.classList.toggle('fa-eye-slash', isNowHidden); icon.classList.toggle('text-primary', !isNowHidden); icon.classList.toggle('text-muted', isNowHidden); btn.title = isNowHidden ? (window.t ? window.t('ui.dashboard.customize.show', {}, 'Show') : 'Show') : (window.t ? window.t('ui.dashboard.customize.hide', {}, 'Hide') : 'Hide'); // Store hidden state on the chip itself for readback btn.closest('.dash-chip').setAttribute('data-hidden', isNowHidden ? '1' : '0'); } /* ?? Drag-and-drop helpers ???????????????????????????????????????????? */ function onChipDragStart(e) { dragSrcId = this.getAttribute('data-card-id'); dragSrcList = this.closest('.dash-drop-zone'); this.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragSrcId); } function onChipDragEnd() { this.classList.remove('dragging'); document.querySelectorAll('.dash-chip.drag-over').forEach(el => el.classList.remove('drag-over')); document.querySelectorAll('.dash-drop-zone.drag-active').forEach(el => el.classList.remove('drag-active')); } function onChipDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; document.querySelectorAll('.dash-chip.drag-over').forEach(el => { if (el !== this) el.classList.remove('drag-over'); }); this.classList.add('drag-over'); } function onZoneDragOver(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; this.classList.add('drag-active'); } function onZoneDragLeave(e) { if (!this.contains(e.relatedTarget)) { this.classList.remove('drag-active'); } } function onZoneDrop(e) { e.preventDefault(); this.classList.remove('drag-active'); const srcId = e.dataTransfer.getData('text/plain'); const srcChip = document.querySelector(`.dash-chip[data-card-id="${srcId}"]`); if (!srcChip) return; // Dropped onto the zone itself (not a chip) ? append to end this.appendChild(srcChip); } function onChipDrop(e) { e.preventDefault(); e.stopPropagation(); this.classList.remove('drag-over'); const srcId = e.dataTransfer.getData('text/plain'); if (srcId === this.getAttribute('data-card-id')) return; const srcChip = document.querySelector(`.dash-chip[data-card-id="${srcId}"]`); if (!srcChip) return; const targetList = this.closest('.dash-drop-zone'); targetList.insertBefore(srcChip, this); } /* ?? Save / Reset ????????????????????????????????????????????????????? */ function readCustomizeLayout() { const layout = { main: [], sidebar: [], hidden: [] }; ['main', 'sidebar'].forEach(function(zone) { const listId = zone === 'main' ? 'customizeMainList' : 'customizeSidebarList'; const listEl = document.getElementById(listId); if (!listEl) return; listEl.querySelectorAll('.dash-chip[data-card-id]').forEach(function(chip) { const cardId = chip.getAttribute('data-card-id'); layout[zone].push(cardId); if (chip.getAttribute('data-hidden') === '1') { layout.hidden.push(cardId); } }); }); return layout; } function saveDashboardLayout() { const layout = readCustomizeLayout(); fetch('/api/dashboard/layout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(layout) }) .then(r => r.json()) .then(function(data) { if (data.success) { applyDashboardLayout(layout); if (customizeModal) customizeModal.hide(); } else { alert(window.getApiErrorMessage ? window.getApiErrorMessage(data, uiT('ui.dashboard.customize.save_failed', 'Failed to save layout.')) : 'Failed to save layout.'); } }) .catch(function() { alert(uiT('ui.dashboard.customize.save_failed', 'Failed to save layout.')); }); } function resetDashboardLayout() { fetch('/api/dashboard/layout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ main: [], sidebar: [], hidden: [], reset: true }) }) .then(r => r.json()) .then(function(data) { if (data.success) { if (customizeModal) customizeModal.hide(); window.location.reload(); } }) .catch(function() { alert(uiT('ui.dashboard.customize.save_failed', 'Failed to reset layout.')); }); } </script> <style> /* Dashboard-specific styling */ #systemNews .card { border: none; box-shadow: none; } #systemNews .card-header { background-color: #f8f9fa; border-bottom: 1px solid #dee2e6; } #systemNews .alert { border-radius: 0.5rem; } #systemNews code { font-size: 0.875em; background-color: #f8f9fa; padding: 0.125rem 0.25rem; border-radius: 0.25rem; } </style> {% endblock %}