PHP Classes

File: templates/admin/lovlynet.twig

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

Contents

Class file image Download
{% extends "base.twig" %} {% block title %}{{ t('ui.admin.lovlynet.page_title') }} - {{ parent() }}{% endblock %} {% block content %} <div class="d-flex justify-content-between align-items-center mb-4"> <h2> <i class="fas fa-network-wired"></i> {{ t('ui.admin.lovlynet.heading') }} </h2> <button class="btn btn-outline-secondary" onclick="loadAreas()"> <i class="fas fa-sync-alt"></i> {{ t('ui.common.refresh') }} </button> </div> {% if not lovlynet_configured %} <div class="alert alert-warning"> <i class="fas fa-exclamation-triangle me-2"></i> <strong>{{ t('ui.admin.lovlynet.not_configured_title') }}</strong> <div class="mt-2">{{ t('ui.admin.lovlynet.not_configured_body') }}</div> <div class="mt-2">{{ t('ui.admin.lovlynet.not_configured_synopsis') }}</div> <div class="mt-2">{{ t('ui.admin.lovlynet.not_configured_setup_intro') }}</div> <pre class="mb-2 mt-2"><code>php scripts/lovlynet_setup.php</code></pre> <div>{{ t('ui.admin.lovlynet.not_configured_docs_intro') }} <code>docs/LovlyNet.md</code>.</div> </div> {% else %} <div class="row mb-4"> <div class="col-md-3"> <div class="card"> <div class="card-body"> <h6 class="card-title text-muted small text-uppercase mb-1">{{ t('ui.admin.lovlynet.node_number') }}</h6> <p class="mb-0 fw-bold font-monospace">{{ lovlynet_node_number }}</p> </div> </div> </div> <div class="col-md-3"> <div class="card"> <div class="card-body"> <h6 class="card-title text-muted small text-uppercase mb-1">{{ t('ui.admin.lovlynet.hub_address') }}</h6> <p class="mb-0 fw-bold font-monospace">{{ lovlynet_hub_address }}</p> </div> </div> </div> <div class="col-md-3"> <div class="card"> <div class="card-body"> <h6 class="card-title text-muted small text-uppercase mb-1">{{ t('ui.admin.lovlynet.server') }}</h6> <p class="mb-0 small text-truncate font-monospace">{{ lovlynet_base_url }}</p> </div> </div> </div> <div class="col-md-3"> <div class="card"> <div class="card-body"> <h6 class="card-title text-muted small text-uppercase mb-1">{{ t('ui.admin.lovlynet.subscribed_areas') }}</h6> <p class="mb-0 fw-bold" id="subscribedCount">-</p> </div> </div> </div> </div> <ul class="nav nav-tabs mb-3" id="areasTabs"> <li class="nav-item"> <a class="nav-link active" data-bs-toggle="tab" href="#echoTab"> <i class="fas fa-comments me-1"></i> {{ t('ui.admin.lovlynet.tab_echo') }} <span class="badge bg-secondary ms-1" id="echoCount">-</span> </a> </li> <li class="nav-item"> <a class="nav-link" data-bs-toggle="tab" href="#fileTab"> <i class="fas fa-folder-open me-1"></i> {{ t('ui.admin.lovlynet.tab_file') }} <span class="badge bg-secondary ms-1" id="fileCount">-</span> </a> </li> <li class="nav-item"> <a class="nav-link" data-bs-toggle="tab" href="#setupTab" id="setupTabLink"> <i class="fas fa-cog me-1"></i> {{ t('ui.admin.lovlynet.tab_setup') }} </a> </li> </ul> <div class="tab-content"> <div class="tab-pane fade show active" id="echoTab"> <div class="card"> <div class="card-body border-bottom d-flex justify-content-end"> <button type="button" class="btn btn-outline-primary btn-sm" onclick="openRequestModal('echo')"> <i class="fas fa-paper-plane me-1"></i> {{ t('ui.admin.lovlynet.request_button_echo') }} </button> </div> <div class="card-body p-0" id="echoAreasContainer"> <div class="text-center py-5 text-muted"> <i class="fas fa-spinner fa-spin fa-2x"></i> </div> </div> </div> </div> <div class="tab-pane fade" id="fileTab"> <div class="card"> <div class="card-body border-bottom d-flex justify-content-end"> <button type="button" class="btn btn-outline-primary btn-sm" onclick="openRequestModal('file')"> <i class="fas fa-paper-plane me-1"></i> {{ t('ui.admin.lovlynet.request_button_file') }} </button> </div> <div class="card-body p-0" id="fileAreasContainer"> <div class="text-center py-5 text-muted"> <i class="fas fa-spinner fa-spin fa-2x"></i> </div> </div> </div> </div> <div class="tab-pane fade" id="setupTab"> <div class="row g-4"> <div class="col-lg-7"> <div class="card mb-4"> <div class="card-header"> <strong>{{ t('ui.admin.lovlynet.setup_status_title') }}</strong> </div> <div class="card-body" id="setupStatusBody"> <div class="text-center py-3 text-muted"> <i class="fas fa-spinner fa-spin"></i> </div> </div> </div> <div class="card"> <div class="card-header"> <strong>{{ t('ui.admin.lovlynet.setup_update_title') }}</strong> </div> <div class="card-body"> <form id="lovlynetUpdateForm"> <div class="row g-3"> <div class="col-md-6"> <label for="setupSystemName" class="form-label">{{ t('ui.admin.lovlynet.setup_system_name') }}</label> <input type="text" class="form-control" id="setupSystemName" maxlength="60" required> </div> <div class="col-md-6"> <label for="setupSysopName" class="form-label">{{ t('ui.admin.lovlynet.setup_sysop_name') }}</label> <input type="text" class="form-control" id="setupSysopName" maxlength="60" required> </div> <div class="col-md-8"> <label for="setupHostname" class="form-label">{{ t('ui.admin.lovlynet.setup_hostname') }}</label> <input type="text" class="form-control" id="setupHostname" maxlength="255" required> </div> <div class="col-md-4"> <label for="setupBinkpPort" class="form-label">{{ t('ui.admin.lovlynet.setup_binkp_port') }}</label> <input type="number" class="form-control" id="setupBinkpPort" min="1" max="65535"> </div> <div class="col-md-12"> <label for="setupSiteUrl" class="form-label">{{ t('ui.admin.lovlynet.setup_site_url') }}</label> <input type="url" class="form-control" id="setupSiteUrl" maxlength="255"> </div> <div class="col-md-12"> <div class="form-check"> <input class="form-check-input" type="checkbox" id="setupIsPassive"> <label class="form-check-label" for="setupIsPassive"> {{ t('ui.admin.lovlynet.setup_is_passive') }} </label> </div> </div> </div> <div class="alert alert-warning mt-3 mb-0 py-2 small"> <i class="fas fa-exclamation-triangle me-1"></i> {{ t('ui.admin.lovlynet.setup_update_warning') }} </div> <div id="setupFormAlert" class="mt-3 d-none"></div> <div class="mt-3 d-flex gap-2"> <button type="submit" class="btn btn-primary" id="setupUpdateBtn"> <i class="fas fa-save me-1"></i> {{ t('ui.admin.lovlynet.setup_update_btn') }} </button> </div> </form> </div> </div> </div> <div class="col-lg-5"> <div class="card"> <div class="card-header"> <strong>{{ t('ui.admin.lovlynet.checklist_title') }}</strong> </div> <div class="card-body p-0" id="checklistBody"> <div class="text-center py-3 text-muted"> <i class="fas fa-spinner fa-spin"></i> </div> </div> </div> </div> </div> </div> </div> <div class="modal fade" id="lovlynetRequestModal" tabindex="-1" aria-hidden="true"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="lovlynetRequestModalTitle">{{ t('ui.admin.lovlynet.request_modal_title_echo') }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ t('ui.common.close') }}"></button> </div> <div class="modal-body"> <p class="text-muted mb-3" id="lovlynetRequestModalHelp">{{ t('ui.admin.lovlynet.request_modal_help_echo') }}</p> <input type="hidden" id="lovlynetRequestType" value="echo"> <div class="mb-3 d-flex justify-content-end"> <button type="button" class="btn btn-outline-secondary btn-sm" id="lovlynetRequestHelpButton" onclick="loadLovlyNetHelp()"> <i class="fas fa-circle-info me-1"></i> {{ t('ui.admin.lovlynet.request_help_button') }} </button> </div> <div class="border rounded p-3 d-none" id="lovlynetRequestHelpPanel"> <div class="d-flex justify-content-between align-items-center mb-2"> <strong>{{ t('ui.admin.lovlynet.request_help_title') }}</strong> <button type="button" class="btn btn-sm btn-outline-secondary" onclick="hideLovlyNetHelp()"> {{ t('ui.common.close') }} </button> </div> <pre class="mb-0 small font-monospace overflow-auto" id="lovlynetRequestHelpText" style="max-height: 45vh; white-space: pre; background: transparent; color: inherit; border: 0;"></pre> </div> <div class="mb-0"> <label for="lovlynetRequestMessage" class="form-label">{{ t('ui.admin.lovlynet.request_message_label') }}</label> <textarea class="form-control font-monospace" id="lovlynetRequestMessage" rows="10" placeholder="{{ t('ui.admin.lovlynet.request_message_placeholder') }}"></textarea> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel') }}</button> <button type="button" class="btn btn-primary" id="lovlynetRequestSubmit" onclick="submitLovlynetRequest()"> <i class="fas fa-paper-plane me-1"></i> {{ t('ui.admin.lovlynet.request_send') }} </button> </div> </div> </div> </div> <div class="modal fade" id="lovlynetRescanModal" tabindex="-1" aria-hidden="true"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">{{ t('ui.admin.lovlynet.rescan_modal_title') }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ t('ui.common.close') }}"></button> </div> <div class="modal-body"> <div class="text-muted mb-3">{{ t('ui.admin.lovlynet.rescan_modal_help') }}</div> <input type="hidden" id="lovlynetRescanAreaTag" value=""> <div class="mb-3"> <label for="lovlynetRescanAreaDisplay" class="form-label">{{ t('ui.admin.lovlynet.rescan_area_label') }}</label> <input type="text" class="form-control font-monospace" id="lovlynetRescanAreaDisplay" readonly> </div> <div class="mb-3"> <label for="lovlynetRescanMode" class="form-label">{{ t('ui.admin.lovlynet.rescan_mode_label') }}</label> <select class="form-select" id="lovlynetRescanMode"> <option value="days">{{ t('ui.admin.lovlynet.rescan_mode_days') }}</option> <option value="messages">{{ t('ui.admin.lovlynet.rescan_mode_messages') }}</option> <option value="all">{{ t('ui.admin.lovlynet.rescan_mode_all') }}</option> </select> </div> <div class="mb-0" id="lovlynetRescanAmountGroup"> <label for="lovlynetRescanAmount" class="form-label" id="lovlynetRescanAmountLabel">{{ t('ui.admin.lovlynet.rescan_amount_days_label') }}</label> <input type="number" class="form-control" id="lovlynetRescanAmount" min="1" step="1" value="7"> <div class="form-text" id="lovlynetRescanAmountHelp">{{ t('ui.admin.lovlynet.rescan_amount_days_help') }}</div> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel') }}</button> <button type="button" class="btn btn-primary" id="lovlynetRescanSubmit" onclick="submitLovlynetRescan()"> <i class="fas fa-paper-plane me-1"></i> {{ t('ui.admin.lovlynet.rescan_send') }} </button> </div> </div> </div> </div> <div class="modal fade" id="lovlynetResendModal" tabindex="-1" aria-hidden="true"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">{{ t('ui.admin.lovlynet.resend_modal_title') }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ t('ui.common.close') }}"></button> </div> <div class="modal-body"> <p class="text-muted mb-3">{{ t('ui.admin.lovlynet.resend_modal_help') }}</p> <input type="hidden" id="lovlynetResendAreaTag" value=""> <div class="mb-3"> <label class="form-label">{{ t('ui.admin.lovlynet.resend_area_label') }}</label> <input type="text" class="form-control font-monospace" id="lovlynetResendAreaDisplay" readonly> </div> <div id="lovlynetResendFilesWrapper"> <div class="text-center py-4 text-muted" id="lovlynetResendFilesLoading"> <i class="fas fa-spinner fa-spin me-1"></i> {{ t('ui.admin.lovlynet.resend_files_loading') }} </div> </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel') }}</button> <button type="button" class="btn btn-primary" id="lovlynetResendSubmit" onclick="submitLovlynetResend()" disabled> <i class="fas fa-paper-plane me-1"></i> {{ t('ui.admin.lovlynet.resend_send') }} </button> </div> </div> </div> </div> <div class="modal fade" id="lovlynetMessageViewModal" tabindex="-1" aria-hidden="true"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title" id="lovlynetMessageViewTitle">{{ t('ui.admin.lovlynet.message_view_title') }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ t('ui.common.close') }}"></button> </div> <div class="modal-body"> <div class="small text-muted mb-3" id="lovlynetMessageViewMeta"></div> <pre class="mb-0 small font-monospace overflow-auto" id="lovlynetMessageViewBody" style="max-height: 55vh; white-space: pre-wrap; background: transparent; color: inherit; border: 0;"></pre> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.close') }}</button> </div> </div> </div> </div> {% endif %} {% endblock %} {% block scripts %} <script> let lovlynetRequestModal = null; let lovlynetRescanModal = null; let lovlynetResendModal = null; let lovlynetMessageViewModal = null; let lovlynetHelpCache = { echo: null, file: null }; let _lovlynetRequests = []; function uiT(key, fallback, params = {}) { return window.t ? window.t(key, params, fallback) : fallback; } $(document).ready(function() { const requestModalEl = document.getElementById('lovlynetRequestModal'); if (requestModalEl && typeof bootstrap !== 'undefined' && bootstrap.Modal) { lovlynetRequestModal = new bootstrap.Modal(requestModalEl); } const rescanModalEl = document.getElementById('lovlynetRescanModal'); if (rescanModalEl && typeof bootstrap !== 'undefined' && bootstrap.Modal) { lovlynetRescanModal = new bootstrap.Modal(rescanModalEl); } const resendModalEl = document.getElementById('lovlynetResendModal'); if (resendModalEl && typeof bootstrap !== 'undefined' && bootstrap.Modal) { lovlynetResendModal = new bootstrap.Modal(resendModalEl); } const messageViewModalEl = document.getElementById('lovlynetMessageViewModal'); if (messageViewModalEl && typeof bootstrap !== 'undefined' && bootstrap.Modal) { lovlynetMessageViewModal = new bootstrap.Modal(messageViewModalEl); } const init = function() { loadAreas(); }; if (typeof loadUserSettings === 'function') { loadUserSettings().finally(init); } else { init(); } $('#lovlynetRescanMode').on('change', updateLovlyNetRescanForm); // Load setup tab content when shown; checklist refreshes on every visit document.getElementById('setupTabLink').addEventListener('shown.bs.tab', function() { if (!document.getElementById('setupStatusBody').dataset.loaded) { loadRegistrationStatus(); } loadChecklist(); }); $('#lovlynetUpdateForm').on('submit', function(e) { e.preventDefault(); submitRegistrationUpdate(); }); }); function loadRegistrationStatus() { $.get('/admin/api/lovlynet/registration') .done(function(data) { document.getElementById('setupStatusBody').dataset.loaded = '1'; let html = '<dl class="row mb-0">'; html += `<dt class="col-sm-3">${uiT('ui.admin.lovlynet.setup_ftn_address', 'FTN Address')}</dt>`; html += `<dd class="col-sm-9 font-monospace">${escapeHtml(data.ftn_address)}</dd>`; html += `<dt class="col-sm-3">${uiT('ui.admin.lovlynet.setup_hub_address', 'Hub Address')}</dt>`; html += `<dd class="col-sm-9 font-monospace">${escapeHtml(data.hub_address)}</dd>`; html += `<dt class="col-sm-3">${uiT('ui.admin.lovlynet.setup_registered_at', 'Registered')}</dt>`; html += `<dd class="col-sm-9">${escapeHtml(formatLovlyNetDate(data.registered_at))}</dd>`; if (data.updated_at) { html += `<dt class="col-sm-3">${uiT('ui.admin.lovlynet.setup_updated_at', 'Last Updated')}</dt>`; html += `<dd class="col-sm-9">${escapeHtml(formatLovlyNetDate(data.updated_at))}</dd>`; } html += '</dl>'; $('#setupStatusBody').html(html); // Pre-fill form with defaults const d = data.defaults || {}; $('#setupSystemName').val(d.system_name || ''); $('#setupSysopName').val(d.sysop_name || ''); $('#setupHostname').val(d.hostname || ''); $('#setupBinkpPort').val(d.binkp_port || 24554); $('#setupSiteUrl').val(d.site_url || ''); // is_passive: use saved value if present, otherwise infer from hub hostname const isPassive = data.is_passive !== null ? data.is_passive : (data.hub_address || '').toLowerCase().includes('passive'); $('#setupIsPassive').prop('checked', !!isPassive); }) .fail(function(xhr) { const payload = xhr.responseJSON; const msg = (payload && payload.error) ? payload.error : uiT('ui.admin.lovlynet.setup_load_failed', 'Failed to load registration status'); $('#setupStatusBody').html(`<div class="alert alert-danger mb-0">${escapeHtml(msg)}</div>`); }); } function submitRegistrationUpdate() { const $btn = $('#setupUpdateBtn'); const originalHtml = $btn.html(); $btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i>' + uiT('ui.common.loading', 'Loading...')); const payload = { system_name: $('#setupSystemName').val().trim(), sysop_name: $('#setupSysopName').val().trim(), hostname: $('#setupHostname').val().trim(), binkp_port: parseInt($('#setupBinkpPort').val(), 10) || 24554, site_url: $('#setupSiteUrl').val().trim(), is_passive: $('#setupIsPassive').is(':checked'), }; $.ajax({ url: '/admin/api/lovlynet/update-registration', method: 'POST', contentType: 'application/json', data: JSON.stringify(payload), }).done(function() { $btn.prop('disabled', false).html(originalHtml); showSetupAlert('success', uiT('ui.admin.lovlynet.setup_update_ok', 'Registration updated successfully')); delete document.getElementById('setupStatusBody').dataset.loaded; loadRegistrationStatus(); }).fail(function(xhr) { $btn.prop('disabled', false).html(originalHtml); const payload = xhr.responseJSON; const msg = (payload && payload.error) ? payload.error : uiT('ui.admin.lovlynet.setup_update_failed', 'Failed to update registration'); showSetupAlert('danger', msg); }); } function showSetupAlert(type, msg) { const $el = $('#setupFormAlert'); $el.removeClass('d-none alert-success alert-danger') .addClass('alert alert-' + type) .html(escapeHtml(msg)); setTimeout(function() { $el.addClass('d-none').removeClass('alert alert-success alert-danger'); }, 6000); } // Ordered list of checklist item IDs and their label keys (for skeleton rendering) var checklistItems = [ { id: 'registration', labelKey: 'ui.admin.lovlynet.checklist_registration', labelFallback: 'Registered with LovlyNet' }, { id: 'uplink_configured', labelKey: 'ui.admin.lovlynet.checklist_uplink_configured', labelFallback: 'LovlyNet uplink configured in BinkP' }, { id: 'binkp_auth_test', labelKey: 'ui.admin.lovlynet.checklist_binkp_auth_test', labelFallback: 'BinkP authentication to LovlyNet hub' }, { id: 'nodelist_rule', labelKey: 'ui.admin.lovlynet.checklist_nodelist_rule', labelFallback: 'LVLY_NODELIST file area rule' }, { id: 'default_areas', labelKey: 'ui.admin.lovlynet.checklist_default_areas', labelFallback: 'Default area subscriptions' }, ]; function loadChecklist() { // Render all 5 items immediately with spinners, then fire each request in parallel let html = '<ul class="list-group list-group-flush">'; checklistItems.forEach(function(def) { html += `<li class="list-group-item" id="checklist-row-${def.id}">`; html += '<div class="d-flex align-items-center gap-2">'; html += '<i class="fas fa-spinner fa-spin text-muted flex-shrink-0"></i>'; html += `<strong>${escapeHtml(uiT(def.labelKey, def.labelFallback))}</strong>`; html += '</div></li>'; }); html += '</ul>'; $('#checklistBody').html(html); checklistItems.forEach(function(def) { $.get('/admin/api/lovlynet/checklist/' + def.id) .done(function(data) { if (data && data.item) { renderChecklistRow(data.item); } }) .fail(function() { $('#checklist-row-' + def.id).html( '<div class="d-flex align-items-center gap-2">' + '<i class="fas fa-circle-xmark text-danger flex-shrink-0"></i>' + `<strong>${escapeHtml(uiT(def.labelKey, def.labelFallback))}</strong>` + `<span class="text-danger small ms-1">${escapeHtml(uiT('ui.admin.lovlynet.checklist_load_failed', 'Failed to load'))}</span>` + '</div>' ); }); }); } function renderChecklistRow(item) { const $row = $('#checklist-row-' + item.id); if (!$row.length) { return; } $row.html(getChecklistItemHtml(item)); } function getChecklistItemHtml(item) { if (item.id === 'registration') { let h = '<div class="d-flex align-items-center gap-2">'; h += '<i class="fas fa-circle-check text-success"></i>'; h += `<strong>${escapeHtml(uiT('ui.admin.lovlynet.checklist_registration', 'Registered with LovlyNet'))}</strong>`; h += `<span class="text-success small ms-1">${escapeHtml(uiT('ui.admin.lovlynet.checklist_registration_ok', 'Node is registered'))}</span>`; h += '</div>'; return h; } if (item.id === 'uplink_configured') { const ok = item.ok; const iconClass = ok ? 'fa-circle-check text-success' : 'fa-circle-xmark text-danger'; const label = uiT('ui.admin.lovlynet.checklist_uplink_configured', 'LovlyNet uplink configured in BinkP'); const statusMsg = ok ? uiT('ui.admin.lovlynet.checklist_uplink_configured_ok', 'Uplink is configured') : uiT('ui.admin.lovlynet.checklist_uplink_configured_missing', 'No LovlyNet uplink found in BinkP configuration'); const hint = ok ? '' : uiT('ui.admin.lovlynet.checklist_uplink_configured_hint', 'Add a LovlyNet uplink under Admin \u2192 BinkP Configuration with domain set to "lovlynet".'); let h = '<div class="d-flex align-items-start gap-2">'; h += `<i class="fas ${escapeHtmlAttr(iconClass)} mt-1 flex-shrink-0"></i>`; h += '<div>'; h += `<strong>${escapeHtml(label)}</strong>`; h += `<div class="small mt-1 ${ok ? 'text-success' : 'text-danger'}">${escapeHtml(statusMsg)}</div>`; if (hint) { h += `<div class="text-muted small mt-1">${escapeHtml(hint)}</div>`; } h += '</div></div>'; return h; } if (item.id === 'binkp_auth_test') { const ok = item.ok; const daemonErr = !!item.daemon_error; const iconClass = daemonErr ? 'fa-circle-question text-warning' : (ok ? 'fa-circle-check text-success' : 'fa-circle-xmark text-danger'); const label = uiT('ui.admin.lovlynet.checklist_binkp_auth_test', 'BinkP authentication to LovlyNet hub'); let statusMsg; if (daemonErr) { statusMsg = uiT('ui.admin.lovlynet.checklist_binkp_auth_test_daemon_error', 'Cannot check ? admin daemon is not running'); } else if (ok) { statusMsg = uiT('ui.admin.lovlynet.checklist_binkp_auth_test_ok', 'Authenticated successfully ({method})', { method: item.auth_method || '?' }); } else { statusMsg = uiT('ui.admin.lovlynet.checklist_binkp_auth_test_failed', 'Authentication failed: {error}', { error: item.error || '' }); } const statusClass = daemonErr ? 'text-warning' : (ok ? 'text-success' : 'text-danger'); let h = '<div class="d-flex align-items-start gap-2">'; h += `<i class="fas ${escapeHtmlAttr(iconClass)} mt-1 flex-shrink-0"></i>`; h += '<div>'; h += `<strong>${escapeHtml(label)}</strong>`; h += `<div class="small mt-1 ${statusClass}">${escapeHtml(statusMsg)}</div>`; h += '</div></div>'; return h; } if (item.id === 'default_areas') { const ok = item.ok && !item.fetch_error; const iconClass = ok ? 'fa-circle-check text-success' : 'fa-circle-xmark text-danger'; const label = uiT('ui.admin.lovlynet.checklist_default_areas', 'Default area subscriptions'); let statusMsg, hint = ''; if (item.fetch_error) { statusMsg = uiT('ui.admin.lovlynet.checklist_default_areas_error', 'Could not retrieve area data from LovlyNet'); } else if (ok) { statusMsg = uiT('ui.admin.lovlynet.checklist_default_areas_ok', 'All default areas subscribed'); } else { const missing = [].concat(item.unsubscribed_echo || [], item.unsubscribed_file || []); statusMsg = uiT('ui.admin.lovlynet.checklist_default_areas_missing', 'Missing subscriptions: {list}', { list: missing.join(', ') }); hint = uiT('ui.admin.lovlynet.checklist_default_areas_hint', 'Subscribe through the Echo Areas or File Areas tab.'); } let h = '<div class="d-flex align-items-start gap-2">'; h += `<i class="fas ${escapeHtmlAttr(iconClass)} mt-1 flex-shrink-0"></i>`; h += '<div>'; h += `<strong>${escapeHtml(label)}</strong>`; h += `<div class="small mt-1 ${ok ? 'text-success' : 'text-danger'}">${escapeHtml(statusMsg)}</div>`; if (hint) { h += `<div class="text-muted small mt-1">${escapeHtml(hint)}</div>`; } h += '</div></div>'; return h; } if (item.id === 'nodelist_rule') { const ok = item.ok; const daemonError = !!item.daemon_error; const iconClass = daemonError ? 'fa-circle-question text-warning' : (ok ? 'fa-circle-check text-success' : 'fa-circle-xmark text-danger'); let statusMsg; if (daemonError) { statusMsg = uiT('ui.admin.lovlynet.checklist_nodelist_rule_daemon_error', 'Cannot check ? admin daemon is not running'); } else if (ok) { statusMsg = uiT('ui.admin.lovlynet.checklist_nodelist_rule_ok', 'Rule configured correctly'); } else if (!item.has_rule) { statusMsg = uiT('ui.admin.lovlynet.checklist_nodelist_rule_missing', 'Rule missing'); } else { statusMsg = uiT('ui.admin.lovlynet.checklist_nodelist_rule_wrong_pattern', 'Rule exists but pattern does not match recommended value'); } const label = uiT('ui.admin.lovlynet.checklist_nodelist_rule', 'LVLY_NODELIST file area rule'); const desc = uiT('ui.admin.lovlynet.checklist_nodelist_rule_desc', 'A file area rule must exist for LVLY_NODELIST@LOVLYNET with the recommended import pattern.'); const statusClass = daemonError ? 'text-warning' : (ok ? 'text-success' : 'text-danger'); let h = '<div class="d-flex align-items-start justify-content-between gap-2">'; h += '<div class="flex-grow-1 min-w-0">'; h += `<div><i class="fas ${escapeHtmlAttr(iconClass)} me-1"></i><strong>${escapeHtml(label)}</strong></div>`; h += `<div class="text-muted small mt-1">${escapeHtml(desc)}</div>`; h += `<div class="small mt-1 ${statusClass}">${escapeHtml(statusMsg)}</div>`; h += '</div>'; if (!ok && !daemonError) { h += `<button class="btn btn-sm btn-outline-warning flex-shrink-0" id="checklistFixNodelistBtn" onclick="fixNodelistRule(this)">`; h += `<i class="fas fa-wrench me-1"></i>${escapeHtml(uiT('ui.admin.lovlynet.checklist_fix', 'Fix'))}`; h += '</button>'; } h += '</div>'; return h; } return ''; } function fixNodelistRule(btn) { const $btn = $(btn); const originalHtml = $btn.html(); $btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>'); $.ajax({ url: '/admin/api/lovlynet/checklist/fix-nodelist-rule', method: 'POST', contentType: 'application/json', data: JSON.stringify({}), }).done(function() { loadChecklist(); }).fail(function(xhr) { $btn.prop('disabled', false).html(originalHtml); const payload = xhr.responseJSON; const msg = (payload && payload.error) ? payload.error : uiT('errors.admin.lovlynet.checklist_fix_failed', 'Failed to apply fix'); showSetupAlert('danger', msg); }); } let _allAreas = { echoareas: [], fileareas: [] }; function loadAreas() { ['echoAreasContainer', 'fileAreasContainer', 'lovlynetRequestsContainer'].forEach(id => { $('#' + id).html('<div class="text-center py-5 text-muted"><i class="fas fa-spinner fa-spin fa-2x"></i></div>'); }); $("#requestCount").text('-'); $.get('/admin/api/lovlynet/areas') .done(function(data) { _allAreas = data; renderAreas('echo', data.echoareas || [], '#echoAreasContainer', '#echoCount'); renderAreas('file', data.fileareas || [], '#fileAreasContainer', '#fileCount'); updateSubscribedCount(); }) .fail(function(xhr) { const msg = (xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : uiT('ui.admin.lovlynet.load_failed', 'Failed to load areas from LovlyNet'); ['echoAreasContainer', 'fileAreasContainer', 'lovlynetRequestsContainer'].forEach(id => { $('#' + id).html(`<div class="alert alert-danger m-3">${escapeHtml(msg)}</div>`); }); }); } function renderAreas(type, areas, containerSel, countSel) { const $c = $(containerSel); $(countSel).text(areas.length); if (areas.length === 0) { $c.html(`<p class="text-muted text-center py-4">${uiT('ui.admin.lovlynet.no_areas', 'No areas available')}</p>`); return; } let html = `<div class="table-responsive"> <table class="table table-hover mb-0"> <thead> <tr> <th>${uiT('ui.admin.lovlynet.col_tag', 'Tag')}</th> <th>${uiT('ui.admin.lovlynet.col_description', 'Description')}</th> <th class="text-center" style="width:120px;">${uiT('ui.admin.lovlynet.col_status', 'Status')}</th> <th style="width:220px;"></th> </tr> </thead> <tbody>`; areas.forEach(function(area) { const tag = escapeHtml(area.tag); const rawTag = String(area.tag || '').trim(); const desc = escapeHtml(area.description || ''); const sub = area.subscribed; const syncNeeded = sub && (!area.local_exists || !!area.description_mismatch || !!area.has_setting_issues); const issueIcon = buildLovlyNetSettingIssueIcon(area); const isDefaultUnsubscribed = !sub && !!area.is_default; const defaultIcon = isDefaultUnsubscribed ? `<i class="fas fa-exclamation-triangle text-warning ms-2" title="${escapeHtmlAttr(uiT('ui.admin.lovlynet.default_area_not_subscribed', 'Recommended default area ? not subscribed'))}"></i>` : ''; const statusBadge = sub ? `<span class="badge bg-success">${uiT('ui.admin.lovlynet.subscribed', 'Subscribed')}</span>` : `<span class="badge bg-secondary">${uiT('ui.admin.lovlynet.not_subscribed', 'Not subscribed')}</span>`; const btnClass = sub ? 'btn-outline-danger' : 'btn-outline-success'; const btnLabel = sub ? uiT('ui.admin.lovlynet.btn_unsubscribe', 'Unsubscribe') : uiT('ui.admin.lovlynet.btn_subscribe', 'Subscribe'); const btnAction = sub ? 'unsubscribe' : 'subscribe'; const rescanButton = type === 'echo' ? ` <button class="btn btn-sm btn-outline-secondary rescan-btn" data-tag="${tag}" title="${escapeHtmlAttr(uiT('ui.admin.lovlynet.btn_rescan_title', 'Request rescan of messages'))}" ${sub ? '' : 'disabled'}> ${uiT('ui.admin.lovlynet.btn_rescan', 'Rescan')} </button>` : ''; const resendButton = type === 'file' ? ` <button class="btn btn-sm btn-outline-secondary resend-btn" data-tag="${tag}" title="${escapeHtmlAttr(uiT('ui.admin.lovlynet.btn_resend_title', 'Request resend of files'))}" ${sub ? '' : 'disabled'}> ${uiT('ui.admin.lovlynet.btn_resend', 'Resend File(s)')} </button>` : ''; const syncTitle = type === 'echo' ? uiT('ui.admin.lovlynet.sync_button_title', 'Sync settings') : (!area.local_exists ? uiT('ui.admin.lovlynet.sync_title_create_and_description', 'Create group and sync description') : (area.has_setting_issues ? uiT('ui.admin.lovlynet.sync_title_settings_and_description', 'Sync recommended settings and description') : uiT('ui.admin.lovlynet.sync_title_description', 'Sync description'))); const syncButton = syncNeeded ? ` <button class="btn btn-sm btn-outline-primary sync-btn" data-type="${escapeHtmlAttr(type)}" data-tag="${escapeHtmlAttr(rawTag)}" title="${escapeHtmlAttr(syncTitle)}"> ${uiT('ui.echoareas.lovlynet_sync_button', 'Sync')} </button>` : ''; const editTagTitle = uiT('ui.admin.lovlynet.edit_tag_title', 'Edit {tag}', { tag: rawTag }); let tagCell = `<code>${tag}</code>${issueIcon}${defaultIcon}`; if (area.local_exists) { tagCell = type === 'echo' ? `<a href="/echoareas?tag=${encodeURIComponent(rawTag)}&domain=lovlynet" title="${escapeHtmlAttr(editTagTitle)}"><code>${tag}</code></a>${issueIcon}${defaultIcon}` : `<a href="/fileareas?tag=${encodeURIComponent(rawTag)}&domain=lovlynet" title="${escapeHtmlAttr(editTagTitle)}"><code>${tag}</code></a>${issueIcon}${defaultIcon}`; } html += `<tr> <td>${tagCell}</td> <td class="text-muted small">${desc}</td> <td class="text-center">${statusBadge}</td> <td class="text-end"> <div class="d-inline-flex gap-2"> ${syncButton} ${rescanButton} ${resendButton} <button class="btn btn-sm ${btnClass} toggle-btn" data-tag="${tag}" data-type="${type}" data-action="${btnAction}"> ${btnLabel} </button> </div> </td> </tr>`; }); html += '</tbody></table></div>'; $c.html(html); $c.find('.toggle-btn').on('click', function() { const $btn = $(this); const tag = $btn.data('tag'); const atype = $btn.data('type'); const action = $btn.data('action'); toggleSubscription($btn, atype, tag, action); }); $c.find('.rescan-btn').on('click', function() { openRescanModal($(this).data('tag') || ''); }); $c.find('.resend-btn').on('click', function() { openResendModal($(this).data('tag') || ''); }); $c.find('.sync-btn').on('click', function() { const $btn = $(this); syncLovlyNetArea($btn, $btn.data('type') || '', $btn.data('tag') || ''); }); } function buildLovlyNetSettingIssueMessages(area) { const issues = Array.isArray(area.setting_issues) ? area.setting_issues : []; const messages = []; issues.forEach(function(issue) { if (!issue) { return; } if (issue.setting === 'sysop_only') { messages.push( issue.recommended ? uiT('ui.admin.lovlynet.issue_sysop_only_on', 'LovlyNet recommends enabling Sysop Access Only') : uiT('ui.admin.lovlynet.issue_sysop_only_off', 'LovlyNet recommends disabling Sysop Access Only') ); return; } if (issue.setting === 'readonly') { messages.push( issue.recommended ? uiT('ui.admin.lovlynet.issue_readonly_on', 'LovlyNet recommends making this area read-only') : uiT('ui.admin.lovlynet.issue_readonly_off', 'LovlyNet recommends allowing uploads in this area') ); return; } if (issue.setting === 'replace') { messages.push( issue.recommended ? uiT('ui.admin.lovlynet.issue_replace_on', 'LovlyNet recommends enabling file replacement in this area') : uiT('ui.admin.lovlynet.issue_replace_off', 'LovlyNet recommends disabling file replacement in this area') ); return; } if (issue.setting === 'allow_media') { messages.push( issue.recommended ? uiT('ui.admin.lovlynet.issue_allow_media_on', 'LovlyNet recommends enabling inline media for this area') : uiT('ui.admin.lovlynet.issue_allow_media_off', 'LovlyNet recommends disabling inline media for this area') ); } }); return messages; } function buildLovlyNetSettingIssueIcon(area) { const messages = buildLovlyNetSettingIssueMessages(area); if (!messages.length) { return ''; } const tooltip = `${uiT('ui.admin.lovlynet.issue_title', 'LovlyNet recommended settings mismatch')}: ${messages.join('; ')}`; return `<i class="fas fa-exclamation-triangle text-warning ms-2" title="${escapeHtmlAttr(tooltip)}"></i>`; } function toggleSubscription($btn, areaType, areaTag, action) { $btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>'); $.ajax({ url: '/admin/api/lovlynet/subscription', method: 'POST', contentType: 'application/json', data: JSON.stringify({ action: action, area_type: areaType, area_tag: areaTag }), success: function(data) { _allAreas.echoareas = data.echoareas || _allAreas.echoareas; _allAreas.fileareas = data.fileareas || _allAreas.fileareas; renderAreas('echo', _allAreas.echoareas, '#echoAreasContainer', '#echoCount'); renderAreas('file', _allAreas.fileareas, '#fileAreasContainer', '#fileCount'); updateSubscribedCount(); const msg = action === 'subscribe' ? uiT('ui.admin.lovlynet.subscribed_ok', 'Subscribed to {tag}', { tag: areaTag }) : uiT('ui.admin.lovlynet.unsubscribed_ok', 'Unsubscribed from {tag}', { tag: areaTag }); showSuccess(msg); }, error: function(xhr) { $btn.prop('disabled', false); const msg = (xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : uiT('ui.admin.lovlynet.toggle_failed', 'Operation failed'); showError(msg); // Restore button label const newLabel = action === 'subscribe' ? uiT('ui.admin.lovlynet.btn_subscribe', 'Subscribe') : uiT('ui.admin.lovlynet.btn_unsubscribe', 'Unsubscribe'); $btn.html(newLabel); } }); } function updateSubscribedCount() { const total = (_allAreas.echoareas || []).filter(a => a.subscribed).length + (_allAreas.fileareas || []).filter(a => a.subscribed).length; $('#subscribedCount').text(total); } function loadLovlyNetRequests() { $.get('/admin/api/lovlynet/requests') .done(function(data) { renderLovlyNetRequests(data.requests || []); }) .fail(function(xhr) { const fallback = uiT('ui.admin.lovlynet.requests_load_failed', 'Failed to load request status'); const msg = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON || {}, fallback) : ((xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : fallback); $('#lovlynetRequestsContainer').html(`<div class="alert alert-danger m-3">${escapeHtml(msg)}</div>`); $('#requestCount').text('-'); }); } function renderLovlyNetRequests(requests) { const $container = $('#lovlynetRequestsContainer'); _lovlynetRequests = Array.isArray(requests) ? requests : []; const visibleRequests = _lovlynetRequests.filter(function(item) { return item.direction === 'outgoing'; }); $('#requestCount').text(visibleRequests.length); if (!visibleRequests.length) { $container.html(`<p class="text-muted text-center py-4">${uiT('ui.admin.lovlynet.no_requests', 'No matching requests or responses found')}</p>`); return; } let html = `<div class="table-responsive"> <table class="table table-hover mb-0 align-middle"> <thead> <tr> <th>${uiT('ui.admin.lovlynet.requests_col_type', 'Type')}</th> <th>${uiT('ui.admin.lovlynet.requests_col_direction', 'Direction')}</th> <th>${uiT('ui.admin.lovlynet.requests_col_status', 'Status')}</th> <th>${uiT('ui.admin.lovlynet.requests_col_message', 'Message')}</th> <th>${uiT('ui.admin.lovlynet.requests_col_date', 'Date')}</th> </tr> </thead> <tbody>`; visibleRequests.forEach(function(item) { const typeLabel = item.request_type === 'file' ? uiT('ui.admin.lovlynet.type_filefix', 'FILEFIX') : uiT('ui.admin.lovlynet.type_areafix', 'AREAFIX'); const statusBadge = getLovlyNetStatusBadge(item.status); const title = escapeHtml(getLovlyNetMessageTitle(item)); const excerpt = escapeHtml(item.excerpt || ''); const route = `${escapeHtml(item.to_name || '')} @ ${escapeHtml(item.to_address || '')}`; const bodyHtml = excerpt && excerpt !== title ? `<div class="small text-muted mt-1">${excerpt}</div>` : ''; html += `<tr class="lovlynet-message-row" role="button" tabindex="0" data-id="${escapeHtmlAttr(item.id)}"> <td><span class="badge bg-dark-subtle text-body border">${typeLabel}</span></td> <td>${uiT('ui.admin.lovlynet.direction_request', 'Request')}</td> <td>${statusBadge}</td> <td> <div class="fw-semibold">${title}</div> <div class="small text-muted">${route}</div> ${bodyHtml} </td> <td class="text-nowrap small text-muted">${escapeHtml(formatLovlyNetDate(item.timestamp))}</td> </tr>`; }); html += '</tbody></table></div>'; $container.html(html); $container.find('.lovlynet-message-row').on('click keydown', function(event) { if (event.type === 'keydown' && event.key !== 'Enter' && event.key !== ' ') { return; } if (event.type === 'keydown') { event.preventDefault(); } const itemId = Number($(this).data('id') || 0); const item = _lovlynetRequests.find(function(requestItem) { return Number(requestItem.id) === itemId; }); if (item) { openLovlyNetMessageView(item); } }); } function getLovlyNetStatusBadge(status) { if (status === 'responded') { return `<span class="badge bg-success">${uiT('ui.admin.lovlynet.status_responded', 'Responded')}</span>`; } if (status === 'response') { return `<span class="badge bg-info text-dark">${uiT('ui.admin.lovlynet.status_response', 'Response received')}</span>`; } return `<span class="badge bg-warning text-dark">${uiT('ui.admin.lovlynet.status_pending', 'Pending response')}</span>`; } function formatLovlyNetDate(value) { if (!value) { return ''; } const m = String(value).match(/^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/); if (m) { const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; return months[parseInt(m[2], 10) - 1] + ' ' + parseInt(m[3], 10) + ', ' + m[1] + ' ' + m[4] + ':' + m[5]; } return String(value); } function getLovlyNetMessageTitle(item) { if (item.direction === 'incoming') { return item.excerpt || uiT('ui.admin.lovlynet.requests_response_label', 'Hub response'); } return item.excerpt || uiT('ui.admin.lovlynet.requests_request_label', 'Request sent'); } function openLovlyNetMessageView(item) { const linkedItem = getLinkedLovlyNetMessage(item); const viewItems = []; const typeLabel = item.request_type === 'file' ? uiT('ui.admin.lovlynet.type_filefix', 'FILEFIX') : uiT('ui.admin.lovlynet.type_areafix', 'AREAFIX'); if (item.direction === 'incoming' && linkedItem) { viewItems.push(linkedItem); viewItems.push(item); } else { viewItems.push(item); if (linkedItem) { viewItems.push(linkedItem); } } if (viewItems.length > 1) { $('#lovlynetMessageViewTitle').text(uiT('ui.admin.lovlynet.message_view_title', 'Message')); } else if (item.direction === 'incoming') { $('#lovlynetMessageViewTitle').text(uiT('ui.admin.lovlynet.message_view_title_response', 'Response Message')); } else { $('#lovlynetMessageViewTitle').text(uiT('ui.admin.lovlynet.message_view_title_request', 'Request Message')); } $('#lovlynetMessageViewMeta').text(typeLabel); $('#lovlynetMessageViewBody').text(buildLovlyNetMessageViewBody(viewItems)); if (lovlynetMessageViewModal) { lovlynetMessageViewModal.show(); } } function getLinkedLovlyNetMessage(item) { if (!item) { return null; } if (item.direction === 'incoming' && item.linked_request_id) { return _lovlynetRequests.find(function(requestItem) { return Number(requestItem.id) === Number(item.linked_request_id); }) || null; } if (item.response_message_id) { return _lovlynetRequests.find(function(requestItem) { return Number(requestItem.id) === Number(item.response_message_id); }) || null; } return null; } function buildLovlyNetMessageViewBody(items) { return items.map(function(item) { const isIncoming = item.direction === 'incoming'; const heading = isIncoming ? uiT('ui.admin.lovlynet.message_view_title_response', 'Response Message') : uiT('ui.admin.lovlynet.message_view_title_request', 'Request Message'); const route = isIncoming ? `${item.from_name || ''} @ ${item.from_address || ''}` : `${item.to_name || ''} @ ${item.to_address || ''}`; const sectionLines = [heading]; if (route.trim() !== '') { sectionLines.push(route); } if (item.timestamp) { sectionLines.push(formatLovlyNetDate(item.timestamp)); } sectionLines.push(''); sectionLines.push(item.message_text || ''); return sectionLines.join('\n'); }).join('\n\n'); } function escapeHtmlAttr(value) { return String(value) .replace(/&/g, '&amp;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;'); } function openRequestModal(areaType) { const isFile = areaType === 'file'; $('#lovlynetRequestType').val(isFile ? 'file' : 'echo'); $('#lovlynetRequestModalTitle').text( isFile ? uiT('ui.admin.lovlynet.request_modal_title_file', 'FileFix Request') : uiT('ui.admin.lovlynet.request_modal_title_echo', 'AreaFix Request') ); $('#lovlynetRequestModalHelp').text( isFile ? uiT('ui.admin.lovlynet.request_modal_help_file', 'Enter the FileFix command text to send. The destination and password are filled in automatically. A response will be sent to you by netmail.') : uiT('ui.admin.lovlynet.request_modal_help_echo', 'Enter the AreaFix command text to send. The destination and password are filled in automatically. A response will be sent to you by netmail.') ); $('#lovlynetRequestMessage').val('').trigger('focus'); hideLovlyNetHelp(); $('#lovlynetRequestSubmit').prop('disabled', false).html( `<i class="fas fa-paper-plane me-1"></i>${uiT('ui.admin.lovlynet.request_send', 'Send request')}` ); $('#lovlynetRequestHelpButton').prop('disabled', false).html( `<i class="fas fa-circle-info me-1"></i>${uiT('ui.admin.lovlynet.request_help_button', 'Help')}` ); if (lovlynetRequestModal) { lovlynetRequestModal.show(); } } function openRescanModal(areaTag) { $('#lovlynetRescanAreaTag').val(areaTag); $('#lovlynetRescanAreaDisplay').val(areaTag); $('#lovlynetRescanMode').val('days'); $('#lovlynetRescanAmount').val('7'); $('#lovlynetRescanSubmit').prop('disabled', false).html( `<i class="fas fa-paper-plane me-1"></i>${uiT('ui.admin.lovlynet.rescan_send', 'Send rescan request')}` ); updateLovlyNetRescanForm(); if (lovlynetRescanModal) { lovlynetRescanModal.show(); } } function updateLovlyNetRescanForm() { const mode = $('#lovlynetRescanMode').val() || 'days'; const isAll = mode === 'all'; $('#lovlynetRescanAmountGroup').toggleClass('d-none', isAll); if (mode === 'messages') { $('#lovlynetRescanAmountLabel').text(uiT('ui.admin.lovlynet.rescan_amount_messages_label', 'Number of messages')); $('#lovlynetRescanAmountHelp').text(uiT('ui.admin.lovlynet.rescan_amount_messages_help', 'Send the last N messages from this echo area.')); if (!$('#lovlynetRescanAmount').val()) { $('#lovlynetRescanAmount').val('100'); } } else { $('#lovlynetRescanAmountLabel').text(uiT('ui.admin.lovlynet.rescan_amount_days_label', 'Number of days')); $('#lovlynetRescanAmountHelp').text(uiT('ui.admin.lovlynet.rescan_amount_days_help', 'Send messages from the last N days from this echo area.')); if (!$('#lovlynetRescanAmount').val()) { $('#lovlynetRescanAmount').val('7'); } } } function buildLovlyNetRescanCommand() { const areaTag = String($('#lovlynetRescanAreaTag').val() || '').trim(); const mode = String($('#lovlynetRescanMode').val() || 'days'); if (!areaTag) { throw new Error(uiT('ui.admin.lovlynet.rescan_area_required', 'An echo area is required')); } if (mode === 'all') { return `%RESCAN ${areaTag}`; } const rawAmount = String($('#lovlynetRescanAmount').val() || '').trim(); const amount = Number(rawAmount); if (!Number.isInteger(amount) || amount <= 0) { throw new Error(uiT('ui.admin.lovlynet.rescan_amount_required', 'Enter a whole number greater than zero')); } if (mode === 'messages') { return `%RESCAN ${areaTag} ${amount}`; } return `%RESCAN ${areaTag} ${amount} days`; } function submitLovlynetRescan() { let command = ''; try { command = buildLovlyNetRescanCommand(); } catch (error) { showError(error.message || uiT('ui.admin.lovlynet.rescan_send_failed', 'Failed to send rescan request')); return; } const $submit = $('#lovlynetRescanSubmit'); $submit.prop('disabled', true).html(`<i class="fas fa-spinner fa-spin me-1"></i>${uiT('ui.common.loading', 'Loading...')}`); $.ajax({ url: '/admin/api/lovlynet/request', method: 'POST', contentType: 'application/json', data: JSON.stringify({ area_type: 'echo', message_text: command }), success: function() { if (lovlynetRescanModal) { lovlynetRescanModal.hide(); } showSuccess(uiT('ui.admin.lovlynet.rescan_sent', 'AreaFix rescan request sent')); }, error: function(xhr) { const fallback = uiT('ui.admin.lovlynet.rescan_send_failed', 'Failed to send rescan request'); const msg = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON || {}, fallback) : ((xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : fallback); showError(msg); }, complete: function() { $submit.prop('disabled', false).html( `<i class="fas fa-paper-plane me-1"></i>${uiT('ui.admin.lovlynet.rescan_send', 'Send rescan request')}` ); } }); } function syncLovlyNetArea($btn, areaType, areaTag) { if (!areaType || !areaTag) { return; } const originalHtml = $btn.html(); $btn.prop('disabled', true).html(`<i class="fas fa-spinner fa-spin"></i>`); $.ajax({ url: '/admin/api/lovlynet/area-sync', method: 'POST', contentType: 'application/json', data: JSON.stringify({ area_type: areaType, area_tag: areaTag }), success: function(response) { const fallbackMessage = uiT('ui.echoareas.lovlynet_sync_success', 'Description updated from LovlyNet'); const successMessage = window.getApiMessage ? window.getApiMessage(response, fallbackMessage) : fallbackMessage; showSuccess(successMessage); loadAreas(); }, error: function(xhr) { const fallback = uiT('ui.echoareas.lovlynet_sync_failed', 'Failed to sync description from LovlyNet'); const msg = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON || {}, fallback) : ((xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : fallback); showError(msg); }, complete: function() { $btn.prop('disabled', false).html(originalHtml); } }); } function loadLovlyNetHelp() { const areaType = $('#lovlynetRequestType').val() === 'file' ? 'file' : 'echo'; const cached = lovlynetHelpCache[areaType]; const $button = $('#lovlynetRequestHelpButton'); if (cached) { showLovlyNetHelp(cached); return; } $button.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i>'); $.get('/admin/api/lovlynet/help', { type: areaType }) .done(function(data) { const helpText = data.help || ''; lovlynetHelpCache[areaType] = helpText; showLovlyNetHelp(helpText); }) .fail(function(xhr) { const fallback = uiT('ui.admin.lovlynet.request_help_failed', 'Failed to load help text'); const msg = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON || {}, fallback) : ((xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : fallback); showError(msg); }) .always(function() { $button.prop('disabled', false).html( `<i class="fas fa-circle-info me-1"></i>${uiT('ui.admin.lovlynet.request_help_button', 'Help')}` ); }); } function showLovlyNetHelp(helpText) { $('#lovlynetRequestHelpText').text(helpText || uiT('ui.admin.lovlynet.request_help_empty', 'No help text available.')); $('#lovlynetRequestHelpPanel').removeClass('d-none'); } function hideLovlyNetHelp() { $('#lovlynetRequestHelpPanel').addClass('d-none'); $('#lovlynetRequestHelpText').text(''); } function submitLovlynetRequest() { const areaType = $('#lovlynetRequestType').val(); const messageText = ($('#lovlynetRequestMessage').val() || '').trim(); const $submit = $('#lovlynetRequestSubmit'); if (!messageText) { showError(uiT('ui.admin.lovlynet.request_message_required', 'Request message is required')); $('#lovlynetRequestMessage').trigger('focus'); return; } $submit.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i>'); $.ajax({ url: '/admin/api/lovlynet/request', method: 'POST', contentType: 'application/json', headers: getCsrfHeaders(), data: JSON.stringify({ area_type: areaType, message_text: messageText }), success: function() { if (lovlynetRequestModal) { lovlynetRequestModal.hide(); } $('#lovlynetRequestMessage').val(''); showSuccess( areaType === 'file' ? uiT('ui.admin.lovlynet.request_sent_file', 'FileFix request sent') : uiT('ui.admin.lovlynet.request_sent_echo', 'AreaFix request sent') ); $submit.prop('disabled', false).html( `<i class="fas fa-paper-plane me-1"></i>${uiT('ui.admin.lovlynet.request_send', 'Send request')}` ); }, error: function(xhr) { $submit.prop('disabled', false).html( `<i class="fas fa-paper-plane me-1"></i>${uiT('ui.admin.lovlynet.request_send', 'Send request')}` ); const fallback = uiT('ui.admin.lovlynet.request_send_failed', 'Failed to send request'); const msg = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON || {}, fallback) : ((xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : fallback); showError(msg); } }); } function getCsrfHeaders() { const token = document.querySelector('meta[name="csrf-token"]'); return token && token.content ? { 'X-CSRF-Token': token.content } : {}; } const RESEND_PAGE_SIZE = 25; let _resendAllFiles = []; let _resendCurrentPage = 0; let _resendSelectedFiles = new Set(); let _localOnlyFiles = []; function openResendModal(areaTag) { if (!areaTag) { return; } _resendAllFiles = []; _resendCurrentPage = 0; _resendSelectedFiles = new Set(); _localOnlyFiles = []; $('#lovlynetResendAreaTag').val(areaTag); $('#lovlynetResendAreaDisplay').val(areaTag); $('#lovlynetResendSubmit').prop('disabled', true).html( `<i class="fas fa-paper-plane me-1"></i>${uiT('ui.admin.lovlynet.resend_send', 'Send resend request')}` ); renderResendFilesLoading(); if (lovlynetResendModal) { lovlynetResendModal.show(); } loadFileAreaFiles(areaTag); } function renderResendFilesLoading() { $('#lovlynetResendFilesWrapper').html( `<div class="text-center py-4 text-muted"> <i class="fas fa-spinner fa-spin me-1"></i> ${uiT('ui.admin.lovlynet.resend_files_loading', 'Loading files...')} </div>` ); } function loadFileAreaFiles(areaTag) { $.get('/admin/api/lovlynet/filearea-files', { tag: areaTag }) .done(function(data) { _resendAllFiles = data.files || []; _localOnlyFiles = data.local_only_files || []; _resendCurrentPage = 0; renderResendFileList(); }) .fail(function(xhr) { const fallback = uiT('ui.admin.lovlynet.resend_files_error', 'Failed to load files from LovlyNet'); const msg = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON || {}, fallback) : ((xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : fallback); $('#lovlynetResendFilesWrapper').html( `<div class="alert alert-danger">${escapeHtml(msg)}</div>` ); }); } function renderResendFileList() { if (_resendAllFiles.length === 0 && _localOnlyFiles.length === 0) { $('#lovlynetResendFilesWrapper').html( `<p class="text-muted text-center py-3">${uiT('ui.admin.lovlynet.resend_files_none', 'No files found in this area')}</p>` ); return; } let html = ''; // --- Local-only section (files present locally but not in LovlyNet) --- html += `<h6 class="text-muted fw-semibold small text-uppercase mt-2 mb-2"> ${uiT('ui.admin.lovlynet.sync_local_only_section', 'Local Files (not in LovlyNet)')} </h6>`; if (_localOnlyFiles.length === 0) { html += `<p class="text-muted small mb-3">${uiT('ui.admin.lovlynet.sync_no_local_only', 'All local files are already in LovlyNet')}</p>`; } else { html += `<div class="border rounded mb-3" style="overflow-y: auto; max-height: 25vh;"> <table class="table table-sm table-hover mb-0"> <thead class="table-light"> <tr> <th>${uiT('ui.admin.lovlynet.resend_col_filename', 'Filename')}</th> <th>${uiT('ui.admin.lovlynet.resend_col_description', 'Description')}</th> <th></th> </tr> </thead> <tbody>`; _localOnlyFiles.forEach(function(file) { const filename = escapeHtml(file.filename || ''); const desc = escapeHtml(file.description || ''); html += `<tr> <td class="font-monospace small">${filename}</td> <td class="text-muted small">${desc}</td> <td class="text-end pe-3"> <button type="button" class="btn btn-sm btn-outline-secondary" onclick="hatchLocalFile(${file.id}, this)" title="${escapeHtmlAttr(uiT('ui.admin.lovlynet.sync_hatch_btn_title', 'Hatch this file to LovlyNet'))}"> ${uiT('ui.admin.lovlynet.sync_hatch_btn', 'Hatch')} </button> </td> </tr>`; }); html += '</tbody></table></div>'; } // --- LovlyNet files section (checkboxes for %RESEND) --- if (_resendAllFiles.length > 0) { html += `<h6 class="text-muted fw-semibold small text-uppercase mb-2"> ${uiT('ui.admin.lovlynet.sync_lovlynet_section', 'LovlyNet Files')} </h6>`; const totalPages = Math.ceil(_resendAllFiles.length / RESEND_PAGE_SIZE); const page = Math.max(0, Math.min(_resendCurrentPage, totalPages - 1)); const start = page * RESEND_PAGE_SIZE; const pageFiles = _resendAllFiles.slice(start, start + RESEND_PAGE_SIZE); const allSelected = _resendAllFiles.every(function(f) { return _resendSelectedFiles.has(f.filename); }); const selectAllLabel = allSelected ? uiT('ui.admin.lovlynet.resend_files_deselect_all', 'Deselect all') : uiT('ui.admin.lovlynet.resend_files_select_all', 'Select all'); html += `<div class="mb-2 d-flex justify-content-between align-items-center"> <span class="small text-muted"> ${_resendSelectedFiles.size} ${uiT('ui.admin.lovlynet.resend_selected_label', 'selected')} &nbsp;&bull;&nbsp; ${_resendAllFiles.length} ${uiT('ui.admin.lovlynet.resend_file_count_label', 'file(s) available')} </span> <button type="button" class="btn btn-sm btn-outline-secondary" onclick="toggleResendSelectAll()"> ${selectAllLabel} </button> </div> <div class="border rounded" style="overflow-y: auto; max-height: 30vh;"> <table class="table table-sm table-hover mb-0"> <thead class="table-light"> <tr> <th style="width:1px;"></th> <th>${uiT('ui.admin.lovlynet.resend_col_filename', 'Filename')}</th> <th>${uiT('ui.admin.lovlynet.resend_col_description', 'Description')}</th> <th class="text-center" style="width:90px;">${uiT('ui.admin.lovlynet.resend_col_local', 'Local')}</th> </tr> </thead> <tbody>`; pageFiles.forEach(function(file, idx) { const filename = escapeHtml(file.filename || ''); const desc = escapeHtml(file.description || ''); const globalIdx = start + idx; const checked = _resendSelectedFiles.has(file.filename) ? 'checked' : ''; const localBadge = file.local_exists ? `<span class="badge bg-success" title="${escapeHtmlAttr(uiT('ui.admin.lovlynet.resend_local_yes_title', 'File is in your local file area'))}"><i class="fas fa-check"></i></span>` : `<span class="badge bg-secondary" title="${escapeHtmlAttr(uiT('ui.admin.lovlynet.resend_local_no_title', 'File is not in your local file area'))}"><i class="fas fa-minus"></i></span>`; html += `<tr> <td class="ps-3" style="width: 1px; white-space: nowrap;"> <input class="form-check-input resend-file-check" type="checkbox" id="resendFile_${globalIdx}" value="${filename}" ${checked} onchange="onResendCheckChange(this)"> </td> <td><label class="form-check-label font-monospace mb-0" for="resendFile_${globalIdx}">${filename}</label></td> <td class="text-muted small">${desc}</td> <td class="text-center">${localBadge}</td> </tr>`; }); html += '</tbody></table></div>'; if (totalPages > 1) { const prevDisabled = page === 0 ? 'disabled' : ''; const nextDisabled = page >= totalPages - 1 ? 'disabled' : ''; html += `<div class="d-flex justify-content-between align-items-center mt-2"> <button class="btn btn-sm btn-outline-secondary" onclick="resendGoToPage(${page - 1})" ${prevDisabled}> <i class="fas fa-chevron-left"></i> </button> <span class="small text-muted"> ${uiT('ui.admin.lovlynet.resend_page_label', 'Page')} ${page + 1} / ${totalPages} </span> <button class="btn btn-sm btn-outline-secondary" onclick="resendGoToPage(${page + 1})" ${nextDisabled}> <i class="fas fa-chevron-right"></i> </button> </div>`; } } $('#lovlynetResendFilesWrapper').html(html); updateResendSubmitState(); } function onResendCheckChange(el) { if (el.checked) { _resendSelectedFiles.add(el.value); } else { _resendSelectedFiles.delete(el.value); } updateResendSubmitState(); // Refresh just the header stats without re-rendering the whole list renderResendFileList(); } function resendGoToPage(page) { const totalPages = Math.ceil(_resendAllFiles.length / RESEND_PAGE_SIZE); _resendCurrentPage = Math.max(0, Math.min(page, totalPages - 1)); renderResendFileList(); } function toggleResendSelectAll() { const allSelected = _resendAllFiles.every(function(f) { return _resendSelectedFiles.has(f.filename); }); if (allSelected) { _resendSelectedFiles.clear(); } else { _resendAllFiles.forEach(function(f) { _resendSelectedFiles.add(f.filename); }); } renderResendFileList(); updateResendSubmitState(); } function updateResendSubmitState() { $('#lovlynetResendSubmit').prop('disabled', _resendSelectedFiles.size === 0); } function hatchLocalFile(fileId, btn) { const $btn = $(btn); $btn.prop('disabled', true).html(`<i class="fas fa-spinner fa-spin"></i>`); $.ajax({ url: '/admin/api/lovlynet/hatch-file', method: 'POST', contentType: 'application/json', headers: getCsrfHeaders(), data: JSON.stringify({ file_id: fileId }), success: function() { $btn.removeClass('btn-outline-secondary').addClass('btn-success') .html(`<i class="fas fa-check me-1"></i>${uiT('ui.admin.lovlynet.sync_hatch_btn', 'Hatch')}`); showSuccess(uiT('ui.admin.lovlynet.sync_hatch_success', 'File hatched to LovlyNet')); }, error: function(xhr) { const fallback = uiT('ui.admin.lovlynet.sync_hatch_failed', 'Failed to hatch file'); const msg = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON || {}, fallback) : ((xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : fallback); showError(msg); $btn.prop('disabled', false).html(uiT('ui.admin.lovlynet.sync_hatch_btn', 'Hatch')); } }); } function submitLovlynetResend() { const areaTag = String($('#lovlynetResendAreaTag').val() || '').trim(); if (!areaTag) { return; } const selectedFiles = Array.from(_resendSelectedFiles); if (selectedFiles.length === 0) { showError(uiT('ui.admin.lovlynet.resend_select_required', 'Select at least one file')); return; } const messageText = selectedFiles.map(function(filename) { return `%RESEND ${filename} ${areaTag}`; }).join('\n'); const $submit = $('#lovlynetResendSubmit'); $submit.prop('disabled', true).html(`<i class="fas fa-spinner fa-spin me-1"></i>${uiT('ui.common.loading', 'Loading...')}`); $.ajax({ url: '/admin/api/lovlynet/request', method: 'POST', contentType: 'application/json', headers: getCsrfHeaders(), data: JSON.stringify({ area_type: 'file', message_text: messageText }), success: function() { if (lovlynetResendModal) { lovlynetResendModal.hide(); } showSuccess(uiT('ui.admin.lovlynet.resend_sent', 'FileFix resend request sent')); }, error: function(xhr) { const fallback = uiT('ui.admin.lovlynet.resend_send_failed', 'Failed to send resend request'); const msg = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON || {}, fallback) : ((xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : fallback); showError(msg); }, complete: function() { $submit.prop('disabled', false).html( `<i class="fas fa-paper-plane me-1"></i>${uiT('ui.admin.lovlynet.resend_send', 'Send resend request')}` ); } }); } </script> {% endblock %}