{% extends "base.twig" %}
{% block title %}{{ t('ui.echolist.page_title') }} - {{ parent() }}{% endblock %}
{% block content %}
<style>
/* Minimal scoped styles for echolist page */
#echolist-page .echoarea-icon {
width: 48px;
height: 48px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
flex-shrink: 0;
}
#echolist-page .echoarea-title {
font-weight: 600;
font-size: 1.05rem;
margin-bottom: 0.25rem;
}
#echolist-page .echoarea-desc {
font-size: 0.9rem;
color: #6c757d;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
white-space: normal !important;
display: block !important;
}
#echolist-page .list-group-item {
overflow: hidden !important;
}
#echolist-page .d-flex {
min-width: 0 !important;
}
#echolist-page .echoarea-mod {
font-size: 0.85rem;
color: #868e96;
}
#echolist-page .network-badge {
font-size: 0.85rem;
}
@media (max-width: 767.98px) {
#echolist-page .echoarea-icon {
width: 40px;
height: 40px;
font-size: 1.2rem;
}
#echolist-page .d-none-mobile {
display: none !important;
}
}
</style>
<div id="echolist-page">
<div class="page-header">
<h2>
<i class="fas fa-comments"></i>
{{ t('ui.echolist.heading') }}
</h2>
</div>
<div class="row mb-3 g-3">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="fas fa-filter me-2"></i>{{ t('ui.echolist.filter_heading') }}</h6>
</div>
<div class="card-body">
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" id="subscribedOnlyCheck" onchange="toggleSubscribedFilter()">
<label class="form-check-label" for="subscribedOnlyCheck">
{{ t('ui.echolist.show_subscribed_only') }}
</label>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="unreadOnlyCheck" onchange="toggleUnreadFilter()">
<label class="form-check-label" for="unreadOnlyCheck">
{{ t('ui.echolist.show_unread_only') }}
</label>
</div>
<input type="text" class="form-control mb-2" id="areaSearch" placeholder="{{ t('ui.echolist.area_filter_placeholder') }}" onkeyup="searchEchoAreas(this.value)">
<div class="dropdown" id="networkPickerWrap">
<button class="btn btn-outline-secondary dropdown-toggle w-100 text-start text-truncate"
type="button" id="networkPickerBtn"
data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
<span id="networkPickerLabel">{{ t('ui.echolist.all_networks') }}</span>
</button>
<ul class="dropdown-menu p-2" id="networkDropdown" style="min-width:220px;max-height:260px;overflow-y:auto;">
<li><span class="dropdown-item-text text-muted small" id="networkDropdownEmpty">{{ t('ui.echolist.loading') }}</span></li>
</ul>
</div>
{% if interests_enabled %}
<div class="dropdown mt-2" id="interestPickerWrap">
<button class="btn btn-outline-secondary dropdown-toggle w-100 text-start text-truncate"
type="button" id="interestPickerBtn"
data-bs-toggle="dropdown" data-bs-auto-close="outside" aria-expanded="false">
<span id="interestPickerLabel">{{ t('ui.echolist.all_interests') }}</span>
</button>
<ul class="dropdown-menu p-2" id="interestDropdown" style="min-width:220px;max-height:260px;overflow-y:auto;">
<li><span class="dropdown-item-text text-muted small">{{ t('ui.echolist.loading') }}</span></li>
</ul>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h6 class="mb-0"><i class="fas fa-search me-2"></i>{{ t('ui.echolist.search_heading') }}</h6>
</div>
<div class="card-body">
<form onsubmit="searchMessages(); return false;">
<div class="input-group">
<input type="text" class="form-control" id="messageSearch" placeholder="{{ t('ui.echolist.search_placeholder') }}">
<button class="btn btn-primary" type="submit">
<i class="fas fa-search"></i>
</button>
</div>
<small class="text-muted d-block mt-1">{{ t('ui.echolist.search_help') }}</small>
</form>
</div>
</div>
</div>
</div>
<div id="forumsContainer">
<div class="loading">
<i class="fas fa-spinner fa-spin me-2"></i>
{{ t('ui.echolist.loading') }}
</div>
</div>
<div class="stats-bar">
<div class="card">
<div class="card-body">
<div class="row">
<div class="col-md-3 stat-box">
<h5 id="totalAreas">-</h5>
<small>{{ t('ui.echolist.stats.total_echos') }}</small>
</div>
<div class="col-md-3 stat-box">
<h5 id="totalMessages">-</h5>
<small>{{ t('ui.echolist.stats.total_messages') }}</small>
</div>
<div class="col-md-3 stat-box">
<h5 id="totalNetworks">-</h5>
<small>{{ t('ui.echolist.stats.networks') }}</small>
</div>
<div class="col-md-3 stat-box">
<h5 id="recentMessages">-</h5>
<small>{{ t('ui.echolist.stats.recent_messages') }}</small>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let allEchoAreas = [];
let searchQuery = '';
function uiT(key, fallback, params = {}) {
if (window.t) {
return window.t(key, params, fallback);
}
return fallback;
}
function saveEcholistPreferences() {
$.ajax({
url: '/api/user/echolist-preference',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({
subscribed_only: $('#subscribedOnlyCheck').is(':checked'),
unread_only: $('#unreadOnlyCheck').is(':checked'),
})
});
}
$(document).ready(function() {
// Load saved preferences from database
loadI18nNamespaces(['common', 'errors']).then(function() {
loadInterestFilter();
$.get('/api/user/echolist-preference')
.done(function(data) {
if (data.subscribed_only) $('#subscribedOnlyCheck').prop('checked', true);
if (data.unread_only) $('#unreadOnlyCheck').prop('checked', true);
loadEchoAreas();
loadStatistics();
})
.fail(function() {
loadEchoAreas();
loadStatistics();
});
});
// Auto refresh every 2 minutes
setInterval(function() {
loadEchoAreas();
loadStatistics();
}, 120000);
});
let allInterests = [];
function loadInterestFilter() {
if (!document.getElementById('interestDropdown')) return;
$.get('/api/interests')
.done(function(data) {
allInterests = data.interests || [];
populateInterestFilter();
});
}
function populateInterestFilter() {
const ul = $('#interestDropdown');
const prevChecked = new Set(
$('#interestDropdown .interest-cb:checked').map(function() { return this.value; }).get()
);
ul.empty();
const subscribedOnly = $('#subscribedOnlyCheck').is(':checked');
const interests = subscribedOnly ? allInterests.filter(i => i.subscribed) : allInterests;
if (!interests.length) {
ul.append('<li><span class="dropdown-item-text text-muted small">' + uiT('ui.interests.no_interests', 'No interests defined') + '</span></li>');
return;
}
interests.forEach(interest => {
const checked = prevChecked.has(String(interest.id)) ? ' checked' : '';
const dot = `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${escapeHtml(interest.color || '#6c757d')};flex-shrink:0"></span>`;
ul.append($(`<li><label class="dropdown-item d-flex align-items-center gap-2 py-1 px-2">
<input type="checkbox" class="form-check-input interest-cb flex-shrink-0" value="${escapeHtml(String(interest.id))}"${checked}>
${dot}
<span class="small">${escapeHtml(interest.name)}</span>
</label></li>`));
});
ul.find('.interest-cb').on('change', function() {
updateInterestPickerLabel();
searchEchoAreas($('#areaSearch').val());
});
updateInterestPickerLabel();
}
function updateInterestPickerLabel() {
const allLabel = uiT('ui.echolist.all_interests', 'All Interests');
const checked = $('#interestDropdown .interest-cb:checked').map(function() {
return $(this).closest('label').find('span.small').text().trim();
}).get();
$('#interestPickerLabel').text(checked.length > 0 ? checked.join(', ') : allLabel);
}
function loadEchoAreas() {
const subscribedOnly = $('#subscribedOnlyCheck').is(':checked');
const url = '/api/echoareas?subscribed_only=' + subscribedOnly;
$.get(url)
.done(function(data) {
allEchoAreas = data.echoareas || [];
populateNetworkFilter();
if (document.getElementById('interestDropdown')) populateInterestFilter();
searchEchoAreas(searchQuery);
const networks = new Set();
allEchoAreas.forEach(f => {
networks.add(f.is_local ? uiT('ui.echolist.local', 'Local') : (f.domain || uiT('ui.echolist.unknown', 'Unknown')));
});
$('#totalNetworks').text(networks.size);
})
.fail(function() {
$('#forumsContainer').html(`<div class="alert alert-danger">${uiT('ui.echolist.load_failed', 'Failed to load echo areas')}</div>`);
});
}
function populateNetworkFilter() {
const localLabel = uiT('ui.echolist.local', 'Local');
const networks = new Set();
let hasLocal = false;
allEchoAreas.forEach(f => {
if (f.is_local) {
hasLocal = true;
} else {
networks.add(f.domain || uiT('ui.echolist.unknown', 'Unknown'));
}
});
// Remember current selections before rebuilding
const prevChecked = new Set(
$('#networkDropdown .network-cb:checked').map(function() { return this.value; }).get()
);
const ul = $('#networkDropdown');
ul.empty();
const makeItem = (value, label) => {
const checked = prevChecked.has(value) ? ' checked' : '';
return $(`<li><label class="dropdown-item d-flex align-items-center gap-2 py-1 px-2">
<input type="checkbox" class="form-check-input network-cb flex-shrink-0" value="${escapeHtml(value)}"${checked}>
<span class="small">${escapeHtml(label)}</span>
</label></li>`);
};
if (hasLocal) ul.append(makeItem('__local__', localLabel));
[...networks].sort().forEach(net => ul.append(makeItem(net, net.toUpperCase())));
if (!hasLocal && networks.size === 0) {
ul.append('<li><span class="dropdown-item-text text-muted small">No networks</span></li>');
}
ul.find('.network-cb').on('change', function() {
updateNetworkPickerLabel();
searchEchoAreas($('#areaSearch').val());
});
updateNetworkPickerLabel();
}
function updateNetworkPickerLabel() {
const allNetworksLabel = uiT('ui.echolist.all_networks', 'All Networks');
const checked = $('#networkDropdown .network-cb:checked').map(function() {
return $(this).closest('label').find('span').text().trim();
}).get();
$('#networkPickerLabel').text(checked.length > 0 ? checked.join(', ') : allNetworksLabel);
}
function toggleSubscribedFilter() {
saveEcholistPreferences();
loadEchoAreas();
loadStatistics();
}
function toggleUnreadFilter() {
saveEcholistPreferences();
searchEchoAreas(searchQuery);
}
function displayEchoAreas(areas) {
if (areas.length === 0) {
$('#forumsContainer').html(`<div class="empty-state"><i class="fas fa-inbox fa-3x mb-3"></i><p>${uiT('ui.echolist.none_available', 'No echo areas available.')}</p></div>`);
return;
}
// Group by network
const localLabel = uiT('ui.echolist.local', 'Local');
const groups = { [localLabel]: [] };
areas.forEach(area => {
if (area.is_local) {
groups[localLabel].push(area);
} else {
const net = area.domain || uiT('ui.echolist.unknown', 'Unknown');
if (!groups[net]) groups[net] = [];
groups[net].push(area);
}
});
let html = '';
html += '<div class="d-flex justify-content-end mb-2 gap-2">';
html += '<button class="btn btn-fidonet" onclick="composeMessage(\'echomail\')">';
html += `<i class="fas fa-plus"></i> ${uiT('ui.echolist.new_post', 'New Post')}`;
html += '</button>';
html += '<a class="btn btn-fidonet" href="/echomail">';
html += `<i class="fas fa-envelope-open-text"></i> ${uiT('ui.echolist.new_messages', 'New Messages')}`;
html += '</a>';
html += '</div>';
// Local first
if (groups[localLabel] && groups[localLabel].length > 0) {
html += buildCategory(localLabel, uiT('ui.echolist.local_areas', 'Local Areas'), groups[localLabel], true);
delete groups[localLabel];
}
// Lovlynet second
if (groups['lovlynet'] && groups['lovlynet'].length > 0) {
html += buildCategory('lovlynet', uiT('ui.echolist.lovlynet_network', 'LOVLYNET Network'), groups['lovlynet'], false);
delete groups['lovlynet'];
}
// Other networks alphabetically
Object.keys(groups).sort().forEach(net => {
if (groups[net].length > 0) {
html += buildCategory(net, net.toUpperCase() + ' ' + uiT('ui.echolist.network_suffix', 'Network'), groups[net], false);
}
});
$('#forumsContainer').html(html);
}
function buildCategory(id, name, areas, isLocal) {
const badgeColor = isLocal ? 'bg-success' : 'bg-primary';
let html = '<div class="card mb-3">';
html += '<div class="card-header d-flex justify-content-between align-items-center">';
html += `<h6 class="mb-0"><i class="fas fa-folder me-2"></i>${escapeHtml(name)}</h6>`;
html += `<span class="badge ${badgeColor}">${areas.length} ${uiT('ui.echolist.area_count', 'area')}${areas.length !== 1 ? uiT('ui.echolist.plural_suffix', 's') : ''}</span>`;
html += '</div>';
html += '<div class="list-group list-group-flush">';
areas.forEach(area => {
html += buildEchoAreaRow(area);
});
html += '</div></div>';
return html;
}
function buildEchoAreaRow(area) {
const color = area.color || '#28a745';
let url = '/echomail?echoarea=' + encodeURIComponent(area.tag);
if (area.domain) {
url += '&domain=' + encodeURIComponent(area.domain);
}
let html = `<a href="${url}" class="list-group-item list-group-item-action">`;
html += '<div class="d-flex align-items-start gap-3">';
// Icon
html += `<div class="echoarea-icon" style="background-color: ${color};">`;
html += '<i class="fas fa-comments"></i>';
html += '</div>';
// Info
html += '<div class="flex-grow-1" style="min-width: 0; overflow: hidden;">';
html += '<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">';
html += '<div class="flex-grow-1" style="min-width: 0; width: 0;">';
html += '<div class="echoarea-title">';
html += escapeHtml(area.tag);
if (area.is_sysop_only) {
html += ` <span class="badge bg-warning text-dark">${uiT('ui.echolist.sysop_badge', 'Sysop')}</span>`;
}
html += '</div>';
html += `<div class="echoarea-desc">${escapeHtml(area.description || uiT('ui.echolist.no_description', 'No description'))}</div>`;
if (area.moderator) {
html += `<div class="echoarea-mod"><i class="fas fa-user-shield"></i> ${uiT('ui.echolist.moderator_prefix', 'Moderator:')} ${escapeHtml(area.moderator)}</div>`;
}
html += '</div>';
// Stats and last post on the right
html += '<div class="text-end flex-shrink-0">';
const unreadCount = area.unread_count || 0;
const totalCount = area.message_count || 0;
if (unreadCount > 0) {
html += `<div><span class="badge bg-secondary">${uiT('ui.echolist.unread_of_posts', '{unread} unread of {total} posts', { unread: unreadCount, total: totalCount })}</span></div>`;
} else {
html += `<div><span class="badge bg-secondary">${uiT('ui.echolist.post_count', '{total} posts', { total: totalCount })}</span></div>`;
}
if (area.last_subject) {
html += `<div class="small text-muted mt-1 d-none d-md-block">`;
html += `<div class="text-truncate" style="max-width: 200px;" title="${escapeHtml(area.last_subject)}">${escapeHtml(area.last_subject)}</div>`;
html += `<div>${uiT('ui.echolist.by_author', 'by {author}', { author: escapeHtml(area.last_author || uiT('ui.echolist.unknown', 'Unknown')) })}</div>`;
html += `<div>${formatDate(area.last_date)}</div>`;
html += '</div>';
}
html += '</div>';
html += '</div></div>'; // Close flex container and info div
html += '</div></a>';
return html;
}
function searchEchoAreas(query) {
searchQuery = query.toLowerCase().trim();
let filtered = allEchoAreas;
// Apply unread filter
if ($('#unreadOnlyCheck').is(':checked')) {
filtered = filtered.filter(f => (f.unread_count || 0) > 0);
}
// Apply network filter
const selectedNets = new Set($('#networkDropdown .network-cb:checked').map(function() { return this.value; }).get());
if (selectedNets.size > 0) {
filtered = filtered.filter(f => {
if (f.is_local) return selectedNets.has('__local__');
return selectedNets.has(f.domain || uiT('ui.echolist.unknown', 'Unknown'));
});
}
// Apply interest filter
const selectedInterests = new Set($('#interestDropdown .interest-cb:checked').map(function() { return this.value; }).get());
if (selectedInterests.size > 0) {
filtered = filtered.filter(f => {
const ids = (f.interest_ids || []).map(String);
return ids.some(id => selectedInterests.has(id));
});
}
// Apply search query
if (searchQuery !== '') {
filtered = filtered.filter(f => {
return f.tag.toLowerCase().includes(searchQuery) ||
(f.description && f.description.toLowerCase().includes(searchQuery)) ||
(f.moderator && f.moderator.toLowerCase().includes(searchQuery));
});
}
displayEchoAreas(filtered);
}
function searchMessages() {
const query = $('#messageSearch').val().trim();
if (query.length < 2) {
alert(uiT('ui.echolist.search_min_chars', 'Please enter at least 2 characters to search'));
return;
}
// Redirect to echomail page with search query
window.location.href = '/echomail?search=' + encodeURIComponent(query);
}
function loadStatistics() {
$.get('/api/messages/echomail/stats')
.done(function(data) {
$('#totalMessages').text(data.total || 0);
$('#recentMessages').text(data.recent || 0);
$('#totalAreas').text(data.areas || 0);
});
}
function formatDate(dateStr) {
if (!dateStr) return uiT('ui.echolist.time.never', 'Never');
// Parse the date - if no timezone info, assume UTC
let date = new Date(dateStr);
// If the date string doesn't contain 'Z' or timezone offset, treat it as UTC
if (!dateStr.includes('Z') && !dateStr.match(/[+-]\d{2}:\d{2}$/)) {
date = new Date(dateStr + 'Z');
}
const now = new Date();
const diff = Math.floor((now - date) / 1000);
// Handle negative differences (future dates or clock skew)
if (diff < 0) {
return uiT('ui.echolist.time.just_now', 'Just now');
}
if (diff < 60) return uiT('ui.echolist.time.just_now', 'Just now');
if (diff < 3600) return uiT('ui.echolist.time.minutes_ago', '{count}m ago', { count: Math.floor(diff / 60) });
if (diff < 86400) return uiT('ui.echolist.time.hours_ago', '{count}h ago', { count: Math.floor(diff / 3600) });
if (diff < 2592000) return uiT('ui.echolist.time.days_ago', '{count}d ago', { count: Math.floor(diff / 86400) });
return date.toLocaleDateString();
}
</script>
{% endblock %}
|