PHP Classes

File: templates/echolist.twig

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

Contents

Class file image Download
{% 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 %}