{% 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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 %}
|