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