{% 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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
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 %}
|