PHP Classes

File: templates/user_subscriptions.twig

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

Contents

Class file image Download
{% extends "base.twig" %} {% block title %}{{ t('ui.user_subscriptions.page_title', {}, locale, ['common']) }} - {{ parent() }}{% endblock %} {% block content %} <div class="container-fluid"> <div class="row"> <div class="col-12"> <div class="card"> <div class="card-header d-flex justify-content-between align-items-center"> <h5 class="mb-0"> <i class="bi bi-bookmark-star me-2"></i> {{ t('ui.user_subscriptions.heading', {}, locale, ['common']) }} </h5> </div> <div class="card-body"> <style> #subscription-filters .dropdown-menu { min-width: 220px; max-height: 260px; overflow-y: auto; } </style> {% set subscribed_count = echoareas|filter(e => e.subscribed)|length %} {% set total_count = echoareas|length %} {% if interests is defined and interests|length > 0 %} <div class="alert alert-info d-flex align-items-center gap-2 mb-3"> <i class="fas fa-layer-group flex-shrink-0" aria-hidden="true"></i> <div> {{ t('ui.user_subscriptions.interests_blurb', {}, locale, ['common']) }} <a href="/interests" class="alert-link">{{ t('ui.user_subscriptions.review_interests', {}, locale, ['common']) }}</a> </div> </div> {% endif %} <div class="d-flex justify-content-end mb-3"> <div class="flex-shrink-0 text-end" style="min-width:160px"> <small class="text-muted d-block">{{ t('ui.user_subscriptions.subscribed_label', {}, locale, ['common']) }} <strong><span id="subscribedCountValue">{{ subscribed_count }}</span> / {{ total_count }}</strong></small> <div class="progress mt-1" style="height: 5px;"> <div class="progress-bar" id="subscribedProgressBar" style="width: {{ total_count > 0 ? (subscribed_count / total_count * 100) : 0 }}%"></div> </div> </div> </div> <div class="card bg-light border-0 mb-3" id="subscription-filters"> <div class="card-body py-2"> <div class="d-flex flex-column flex-lg-row align-items-lg-center gap-2 mb-2"> <span class="text-muted small fw-semibold flex-shrink-0"> <i class="fas fa-filter me-1"></i>{{ t('ui.user_subscriptions.filter_heading', {}, locale, ['common']) }} </span> <div class="dropdown"> <button class="btn btn-outline-secondary btn-sm dropdown-toggle 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.user_subscriptions.all_networks', {}, locale, ['common']) }}</span> </button> <ul class="dropdown-menu p-2" id="networkDropdown"></ul> </div> {% if interests is defined and interests|length > 0 %} <div class="dropdown"> <button class="btn btn-outline-secondary btn-sm dropdown-toggle 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.user_subscriptions.all_interests', {}, locale, ['common']) }}</span> </button> <ul class="dropdown-menu p-2" id="interestDropdown"></ul> </div> <div class="form-check form-check-inline mb-0"> <input class="form-check-input" type="checkbox" id="groupsWithMessagesOnlyCheck"> <label class="form-check-label small" for="groupsWithMessagesOnlyCheck"> {{ t('ui.user_subscriptions.only_groups_with_messages', {}, locale, ['common']) }} </label> </div> {% endif %} </div> <div class="d-flex flex-column flex-lg-row align-items-lg-center gap-2"> <input type="text" class="form-control" id="search-echoareas" placeholder="{{ t('ui.user_subscriptions.search_placeholder', {}, locale, ['common']) }}"> <div class="d-flex align-items-center gap-2 flex-shrink-0"> <label for="sort-echoareas" class="form-label mb-0 text-muted small text-nowrap"> {{ t('ui.user_subscriptions.sort_by', {}, locale, ['common']) }} </label> <select class="form-select form-select-sm" id="sort-echoareas" style="min-width: 160px;"> <option value="name">{{ t('ui.user_subscriptions.sort_name', {}, locale, ['common']) }}</option> <option value="created">{{ t('ui.user_subscriptions.sort_created', {}, locale, ['common']) }}</option> <option value="messages">{{ t('ui.user_subscriptions.sort_messages', {}, locale, ['common']) }}</option> </select> <button type="button" class="btn btn-sm btn-outline-secondary" id="sort-direction" data-direction="asc" data-asc-label="{{ t('ui.user_subscriptions.sort_direction_asc', {}, locale, ['common']) }}" data-desc-label="{{ t('ui.user_subscriptions.sort_direction_desc', {}, locale, ['common']) }}" aria-label="{{ t('ui.user_subscriptions.sort_direction_asc', {}, locale, ['common']) }}" title="{{ t('ui.user_subscriptions.sort_direction_asc', {}, locale, ['common']) }}"> <i class="fas fa-sort-alpha-down" aria-hidden="true"></i> </button> </div> </div> </div> </div> <div id="echoareas-list"> {% for echoarea in echoareas %} <div class="card mb-3 echoarea-card" data-echoarea-id="{{ echoarea.id }}" data-tag="{{ echoarea.tag|lower }}" data-name="{{ echoarea.tag|lower }}" data-created="{{ echoarea.created_at ? echoarea.created_at|date('U') : 0 }}" data-messages="{{ echoarea.message_count|default(0) }}" data-network-key="{{ echoarea.is_local ? '__local__' : (echoarea.domain|default('')|lower) }}" data-network-label="{{ echoarea.is_local ? '__local__' : (echoarea.domain|default('')) }}" data-view-href="/echomail/{{ echoarea.tag }}@{{ echoarea.domain }}" data-interest-ids="{{ (echoarea_interest_map is defined and echoarea_interest_map[echoarea.id] is defined) ? echoarea_interest_map[echoarea.id]|join(',') : '' }}"> <div class="card-body"> <div class="row align-items-center"> <div class="col-md-1"> <div class="form-check form-switch"> <input class="form-check-input subscription-toggle" type="checkbox" id="sub-{{ echoarea.id }}" data-echoarea-id="{{ echoarea.id }}" data-subscription-type="{{ echoarea.subscription_type|default('') }}" {{ echoarea.subscribed ? 'checked' : '' }}> </div> </div> <div class="col-md-3" style="min-width:0"> <div class="d-flex align-items-center" style="min-width:0"> <span class="me-2 flex-shrink-0" style="display: inline-block; width: 20px; height: 20px; background-color: {{ echoarea.color }}; border-radius: 3px;"></span> <h6 class="mb-0 text-truncate" style="color: #ffc107;">{{ echoarea.tag }}</h6> {% if echoarea.is_new %} <span class="badge bg-success ms-2 flex-shrink-0" title="{{ t('ui.user_subscriptions.new_area_title', {}, locale, ['common']) }}"> {{ t('ui.user_subscriptions.new_area_badge', {}, locale, ['common']) }} </span> {% endif %} </div> {% if echoarea.domain or echoarea.is_default_subscription %} <div class="ms-4 mt-1"> {% if echoarea.domain %} <span class="badge bg-secondary" style="font-size: 0.7em;">{{ echoarea.domain }}</span> {% endif %} {% if echoarea.is_default_subscription %} <i class="bi bi-star-fill text-warning ms-1" title="{{ t('ui.user_subscriptions.default_subscription_title', {}, locale, ['common']) }}"></i> {% endif %} </div> {% endif %} </div> <div class="col-md-5"> <p class="mb-1">{{ echoarea.description }}</p> <small class="text-muted subscription-meta"> {{ t('ui.user_subscriptions.message_count', {'count': echoarea.message_count}, locale, ['common']) }} {% if echoarea.subscribed and echoarea.subscription_type == 'auto' %} {{ t('ui.user_subscriptions.dot_subscribed', {}, locale, ['common']) }} {{ t('ui.user_subscriptions.automatic', {}, locale, ['common']) }} {% endif %} </small> </div> <div class="col-md-3 text-end"> <a href="/echomail/{{ echoarea.tag }}@{{ echoarea.domain }}" data-base-class="btn btn-sm" data-active-class="{{ echoarea.subscribed ? 'btn-outline-primary' : 'btn-outline-secondary' }}" class="btn btn-sm {{ echoarea.subscribed ? 'btn-outline-primary' : 'btn-outline-secondary' }}"> <i class="bi bi-eye me-1"></i> {{ t('ui.user_subscriptions.view_messages', {}, locale, ['common']) }} </a> </div> </div> </div> </div> {% endfor %} </div> </div> </div> </div> </div> </div> <!-- Subscription toast notification --> <div class="position-fixed bottom-0 end-0 p-3" style="z-index: 1100"> <div id="subscriptionToast" class="toast align-items-center border-0" role="alert" aria-live="assertive" aria-atomic="true" data-bs-delay="3000"> <div class="d-flex"> <div class="toast-body" id="subscriptionToastBody"></div> <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button> </div> </div> </div> <script> function uiT(key, fallback, params = {}) { if (window.t) { return window.t(key, params, fallback); } return fallback; } document.addEventListener('DOMContentLoaded', function() { const searchInput = document.getElementById('search-echoareas'); const sortSelect = document.getElementById('sort-echoareas'); const sortDirectionButton = document.getElementById('sort-direction'); const echoareasList = document.getElementById('echoareas-list'); const echoareaCards = Array.from(document.querySelectorAll('.echoarea-card')); const subscriptionToggles = document.querySelectorAll('.subscription-toggle'); const toastEl = document.getElementById('subscriptionToast'); const toastBody = document.getElementById('subscriptionToastBody'); const toast = new bootstrap.Toast(toastEl); const subscribedCountValue = document.getElementById('subscribedCountValue'); const subscribedProgressBar = document.getElementById('subscribedProgressBar'); const networkDropdown = document.getElementById('networkDropdown'); const networkPickerLabel = document.getElementById('networkPickerLabel'); const interestDropdown = document.getElementById('interestDropdown'); const interestPickerLabel = document.getElementById('interestPickerLabel'); const groupsWithMessagesOnlyCheck = document.getElementById('groupsWithMessagesOnlyCheck'); const allInterests = {{ (interests is defined ? interests : [])|json_encode|raw }}; const totalCount = {{ total_count }}; const activeInterestIds = new Set(); const activeNetworkKeys = new Set(); const interestMessageAvailability = new Map(); echoareaCards.forEach(card => { const messageCount = parseInt(card.dataset.messages, 10) || 0; if (messageCount < 1) { return; } const ids = card.dataset.interestIds ? card.dataset.interestIds.split(',').filter(Boolean) : []; ids.forEach(id => interestMessageAvailability.set(id, true)); }); function escapeHtml(value) { return String(value) .replace(/&/g, '&amp;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;'); } function getNetworkLabel(rawLabel) { return rawLabel === '__local__' ? uiT('ui.user_subscriptions.local', 'Local') : (rawLabel || uiT('ui.user_subscriptions.unknown', 'Unknown')); } function updateNetworkPickerLabel() { if (!networkPickerLabel) { return; } if (activeNetworkKeys.size === 0) { networkPickerLabel.textContent = uiT('ui.user_subscriptions.all_networks', 'All Networks'); return; } if (activeNetworkKeys.size === 1) { const selectedKey = Array.from(activeNetworkKeys)[0]; const selectedCard = echoareaCards.find(card => card.dataset.networkKey === selectedKey); networkPickerLabel.textContent = getNetworkLabel(selectedCard ? selectedCard.dataset.networkLabel : selectedKey); return; } networkPickerLabel.textContent = uiT( 'ui.user_subscriptions.networks_selected', '{count} networks', { count: activeNetworkKeys.size } ); } function updateInterestPickerLabel() { if (!interestPickerLabel) { return; } if (activeInterestIds.size === 0) { interestPickerLabel.textContent = uiT('ui.user_subscriptions.all_interests', 'All Interests'); return; } if (activeInterestIds.size === 1) { const selectedId = Array.from(activeInterestIds)[0]; const selectedInterest = allInterests.find(interest => String(interest.id) === selectedId); interestPickerLabel.textContent = selectedInterest ? selectedInterest.name : uiT('ui.user_subscriptions.all_interests', 'All Interests'); return; } interestPickerLabel.textContent = uiT( 'ui.user_subscriptions.interests_selected', '{count} interests', { count: activeInterestIds.size } ); } function populateNetworkFilter() { if (!networkDropdown) { return; } const seen = new Map(); echoareaCards.forEach(card => { const key = card.dataset.networkKey || ''; if (!seen.has(key)) { seen.set(key, card.dataset.networkLabel || ''); } }); const items = Array.from(seen.entries()).sort((a, b) => { return getNetworkLabel(a[1]).localeCompare(getNetworkLabel(b[1]), undefined, { sensitivity: 'base' }); }); networkDropdown.innerHTML = ''; items.forEach(([key, rawLabel]) => { const checked = activeNetworkKeys.has(key) ? ' checked' : ''; const label = escapeHtml(getNetworkLabel(rawLabel)); networkDropdown.insertAdjacentHTML('beforeend', `<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(key)}"${checked}> <span class="small">${label}</span> </label></li>`); }); networkDropdown.querySelectorAll('.network-cb').forEach(input => { input.addEventListener('change', function() { if (this.checked) { activeNetworkKeys.add(this.value); } else { activeNetworkKeys.delete(this.value); } updateNetworkPickerLabel(); applyFilters(); }); }); updateNetworkPickerLabel(); } function populateInterestFilter() { if (!interestDropdown) { return; } const onlyWithMessages = groupsWithMessagesOnlyCheck && groupsWithMessagesOnlyCheck.checked; const visibleInterests = allInterests.filter(interest => { return !onlyWithMessages || interestMessageAvailability.has(String(interest.id)); }); activeInterestIds.forEach(id => { if (!visibleInterests.some(interest => String(interest.id) === id)) { activeInterestIds.delete(id); } }); interestDropdown.innerHTML = ''; if (visibleInterests.length === 0) { interestDropdown.innerHTML = `<li><span class="dropdown-item-text text-muted small">${escapeHtml(uiT('ui.user_subscriptions.no_interest_groups', 'No interest groups match this filter'))}</span></li>`; updateInterestPickerLabel(); applyFilters(); return; } visibleInterests.forEach(interest => { const interestId = String(interest.id); const checked = activeInterestIds.has(interestId) ? ' checked' : ''; const dot = `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${escapeHtml(interest.color || '#6c757d')};flex-shrink:0"></span>`; const icon = interest.icon ? `<i class="fas ${escapeHtml(interest.icon)} text-muted"></i>` : ''; interestDropdown.insertAdjacentHTML('beforeend', `<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(interestId)}"${checked}> ${dot} ${icon} <span class="small">${escapeHtml(interest.name)}</span> </label></li>`); }); interestDropdown.querySelectorAll('.interest-cb').forEach(input => { input.addEventListener('change', function() { if (this.checked) { activeInterestIds.add(this.value); } else { activeInterestIds.delete(this.value); } updateInterestPickerLabel(); applyFilters(); }); }); updateInterestPickerLabel(); } function matchesGroupsWithMessages(card) { if (!groupsWithMessagesOnlyCheck || !groupsWithMessagesOnlyCheck.checked) { return true; } const ids = card.dataset.interestIds ? card.dataset.interestIds.split(',').filter(Boolean) : []; if (ids.length === 0) { return false; } return ids.some(id => interestMessageAvailability.has(id)); } function applyFilters() { const searchTerm = searchInput.value.toLowerCase(); echoareaCards.forEach(card => { const tag = card.dataset.tag; const description = card.querySelector('p').textContent.toLowerCase(); const matchesSearch = !searchTerm || tag.includes(searchTerm) || description.includes(searchTerm); const matchesNetwork = activeNetworkKeys.size === 0 || activeNetworkKeys.has(card.dataset.networkKey || ''); const matchesMessageGroups = matchesGroupsWithMessages(card); let matchesInterest = true; if (activeInterestIds.size > 0) { const ids = card.dataset.interestIds ? card.dataset.interestIds.split(',') : []; matchesInterest = ids.some(id => activeInterestIds.has(id)); } card.style.display = (matchesSearch && matchesNetwork && matchesInterest && matchesMessageGroups) ? 'block' : 'none'; }); } function updateSubscribedSummary(delta) { if (!subscribedCountValue || !subscribedProgressBar) { return; } const currentValue = parseInt(subscribedCountValue.textContent, 10) || 0; const nextValue = Math.max(0, Math.min(totalCount, currentValue + delta)); subscribedCountValue.textContent = String(nextValue); const percent = totalCount > 0 ? (nextValue / totalCount) * 100 : 0; subscribedProgressBar.style.width = `${percent}%`; } function updateEchoareaCardState(toggle, subscribed) { const card = toggle.closest('.echoarea-card'); if (!card) { return; } card.dataset.subscribed = subscribed ? 'true' : 'false'; const meta = card.querySelector('.subscription-meta'); if (meta) { const messageCount = parseInt(card.dataset.messages, 10) || 0; let text = uiT('ui.user_subscriptions.message_count', '{count} messages', { count: messageCount }); if (subscribed && toggle.dataset.subscriptionType === 'auto') { text += ' ' + uiT('ui.user_subscriptions.dot_subscribed', '- Subscribed'); text += ' ' + uiT('ui.user_subscriptions.automatic', '(automatic)'); } meta.textContent = text; } const viewLink = card.querySelector('a[href]'); if (viewLink) { viewLink.className = `btn btn-sm ${subscribed ? 'btn-outline-primary' : 'btn-outline-secondary'}`; } } // Search functionality searchInput.addEventListener('input', applyFilters); function sortEchoareas() { const sortKey = sortSelect.value; const direction = sortDirectionButton.dataset.direction === 'desc' ? -1 : 1; const sortedCards = [...echoareaCards].sort((a, b) => { if (sortKey === 'created') { return ((parseInt(a.dataset.created, 10) || 0) - (parseInt(b.dataset.created, 10) || 0)) * direction; } if (sortKey === 'messages') { return ((parseInt(a.dataset.messages, 10) || 0) - (parseInt(b.dataset.messages, 10) || 0)) * direction; } return a.dataset.name.localeCompare(b.dataset.name, undefined, { sensitivity: 'base' }) * direction; }); sortedCards.forEach(card => echoareasList.appendChild(card)); } function updateSortDirectionButton() { const descending = sortDirectionButton.dataset.direction === 'desc'; const label = descending ? sortDirectionButton.dataset.descLabel : sortDirectionButton.dataset.ascLabel; sortDirectionButton.setAttribute('aria-label', label); sortDirectionButton.setAttribute('title', label); sortDirectionButton.querySelector('i').className = descending ? 'fas fa-sort-alpha-up' : 'fas fa-sort-alpha-down'; } sortSelect.addEventListener('change', sortEchoareas); sortDirectionButton.addEventListener('click', function() { this.dataset.direction = this.dataset.direction === 'asc' ? 'desc' : 'asc'; updateSortDirectionButton(); sortEchoareas(); }); populateNetworkFilter(); populateInterestFilter(); sortEchoareas(); updateSortDirectionButton(); if (groupsWithMessagesOnlyCheck) { groupsWithMessagesOnlyCheck.addEventListener('change', function() { populateInterestFilter(); applyFilters(); }); } // Subscription toggle functionality subscriptionToggles.forEach(toggle => { toggle.addEventListener('change', function() { const echoareaId = this.dataset.echoareaId; const action = this.checked ? 'subscribe' : 'unsubscribe'; const originalState = !this.checked; // Disable toggle while processing this.disabled = true; fetch('/api/subscriptions/user', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ action: action, echoarea_id: parseInt(echoareaId) }) }) .then(response => response.json()) .then(data => { if (data.success) { const fallbackMessage = action === 'subscribe' ? uiT('ui.user_subscriptions.subscribed_success', 'Subscribed successfully!') : uiT('ui.user_subscriptions.unsubscribed_success', 'Unsubscribed successfully!'); const successMessage = window.getApiMessage ? window.getApiMessage(data, fallbackMessage) : fallbackMessage; updateEchoareaCardState(this, action === 'subscribe'); updateSubscribedSummary(action === 'subscribe' ? 1 : -1); applyFilters(); showToast(successMessage, 'success'); } else { // Revert toggle state this.checked = originalState; showToast(uiT('ui.user_subscriptions.update_failed', 'Failed to update subscription. Please try again.'), 'danger'); } }) .catch(error => { // Revert toggle state this.checked = originalState; showToast(uiT('ui.user_subscriptions.network_error', 'Network error. Please try again.'), 'danger'); }) .finally(() => { this.disabled = false; }); }); }); function showToast(message, type) { toastEl.className = `toast align-items-center border-0 text-bg-${type}`; toastBody.textContent = message; toast.show(); } }); </script> {% endblock %}