{% extends "base.twig" %}
{% block title %}{{ t('ui.fileareas.page_title', {}, locale, ['common']) }} - {{ parent() }}{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>
<i class="fas fa-folder-open"></i>
{{ t('ui.fileareas.heading', {}, locale, ['common']) }}
</h2>
<button class="btn btn-fidonet" onclick="showAddFileareaModal()">
<i class="fas fa-plus"></i>
{{ t('ui.fileareas.add_file_area', {}, locale, ['common']) }}
</button>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h6 class="mb-0">{{ t('ui.fileareas.list_title', {}, locale, ['common']) }}</h6>
<div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check" name="filter" id="active" value="active" checked>
<label class="btn btn-outline-secondary" for="active">{{ t('ui.common.active', {}, locale, ['common']) }}</label>
<input type="radio" class="btn-check" name="filter" id="all" value="all">
<label class="btn btn-outline-secondary" for="all">{{ t('ui.common.all', {}, locale, ['common']) }}</label>
<input type="radio" class="btn-check" name="filter" id="inactive" value="inactive">
<label class="btn btn-outline-secondary" for="inactive">{{ t('ui.common.inactive', {}, locale, ['common']) }}</label>
</div>
</div>
<input type="text" class="form-control form-control-sm mt-2" id="fileareaSearch"
placeholder="{{ t('ui.common.search_placeholder', {}, locale, ['common']) }}" onkeyup="searchFileareas(this.value)">
<div class="mt-2" id="fileareaDomainFilters"></div>
</div>
<div class="card-body p-0">
<div id="fileareasContainer">
<div class="loading-spinner">
<i class="fas fa-spinner fa-spin me-2"></i>
{{ t('ui.fileareas.loading_file_areas', {}, locale, ['common']) }}
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h6 class="mb-0">{{ t('ui.common.statistics', {}, locale, ['common']) }}</h6>
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-8">{{ t('ui.fileareas.active_areas', {}, locale, ['common']) }}</dt>
<dd class="col-4" id="activeCount">-</dd>
<dt class="col-8">{{ t('ui.fileareas.total_files', {}, locale, ['common']) }}</dt>
<dd class="col-4" id="totalFiles">-</dd>
<dt class="col-8">{{ t('ui.fileareas.total_size', {}, locale, ['common']) }}</dt>
<dd class="col-4" id="totalSize">-</dd>
</dl>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h6 class="mb-0">{{ t('ui.fileareas.settings_title', {}, locale, ['common']) }}</h6>
</div>
<div class="card-body">
<small class="text-muted">
<strong>{{ t('ui.fileareas.replace_existing_label', {}, locale, ['common']) }}</strong> {{ t('ui.fileareas.replace_existing_help', {}, locale, ['common']) }}
<br><br>
<strong>{{ t('ui.fileareas.versioning_label', {}, locale, ['common']) }}</strong> {{ t('ui.fileareas.versioning_help', {}, locale, ['common']) }}
</small>
</div>
</div>
</div>
</div>
<!-- Add/Edit Filearea Modal -->
<div class="modal fade" id="fileareaModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="fileareaModalTitle">{{ t('ui.fileareas.add_file_area', {}, locale, ['common']) }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="fileareaForm">
<input type="hidden" id="fileareaId">
<div class="row g-4">
<div class="col-lg-7">
<div class="mb-3">
<label for="fileareaTag" class="form-label">{{ t('ui.fileareas.tag_required', {}, locale, ['common']) }}</label>
<input type="text" class="form-control text-uppercase" id="fileareaTag" name="tag" required maxlength="255">
<div class="form-text" id="fileareaTagHelp">{{ t('ui.fileareas.tag_help', {}, locale, ['common']) }}</div>
<div class="form-text text-warning" id="fileareaTagReadonlyNote" style="display:none;"><i class="fas fa-lock me-1"></i>{{ t('ui.fileareas.tag_readonly', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label for="fileareaDescription" class="form-label">{{ t('ui.fileareas.description_required', {}, locale, ['common']) }}</label>
<textarea class="form-control" id="fileareaDescription" name="description" rows="2" required></textarea>
<div class="form-text">{{ t('ui.fileareas.description_help', {}, locale, ['common']) }}</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="fileareaDomain" class="form-label">{{ t('ui.common.domain', {}, locale, ['common']) }}</label>
<select class="form-select" id="fileareaDomain" name="domain">
<option value="">{{ t('ui.fileareas.local', {}, locale, ['common']) }}</option>
{% for network in networks|default([]) %}
<option value="{{ network.domain }}">{{ network.name }} ({{ network.domain }})</option>
{% endfor %}
</select>
<div class="form-text">{{ t('ui.fileareas.network_domain', {}, locale, ['common']) }}</div>
</div>
<div class="col-md-6">
<label for="fileareaMaxSize" class="form-label">{{ t('ui.fileareas.max_file_size_mb', {}, locale, ['common']) }}</label>
<input type="number" class="form-control" id="fileareaMaxSize" name="max_file_size" value="10" min="1" max="1024">
<div class="form-text">{{ t('ui.fileareas.maximum_file_size', {}, locale, ['common']) }}</div>
</div>
</div>
<div class="mb-3">
<label for="fileareaPassword" class="form-label">{{ t('ui.fileareas.tic_password_optional', {}, locale, ['common']) }}</label>
<div class="input-group">
<input type="password" class="form-control" id="fileareaPassword" name="password" maxlength="255" autocomplete="new-password">
<button class="btn btn-outline-secondary" type="button" onclick="toggleFileareaPasswordVisibility(this)" title="{{ t('ui.common.show_password', {}, locale, ['common']) }}" aria-label="{{ t('ui.common.show_password', {}, locale, ['common']) }}">
<i class="fas fa-eye"></i>
</button>
</div>
<div class="form-text">{{ t('ui.fileareas.tic_password_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-3">
<label for="fileareaAllowedExt" class="form-label">{{ t('ui.fileareas.allowed_extensions', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="fileareaAllowedExt" name="allowed_extensions" placeholder="{{ t('ui.fileareas.extensions_placeholder_allowed', {}, locale, ['common']) }}">
<div class="form-text">{{ t('ui.fileareas.allowed_extensions_help', {}, locale, ['common']) }}</div>
</div>
<div class="mb-0">
<label for="fileareaBlockedExt" class="form-label">{{ t('ui.fileareas.blocked_extensions', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="fileareaBlockedExt" name="blocked_extensions" placeholder="{{ t('ui.fileareas.extensions_placeholder_blocked', {}, locale, ['common']) }}">
<div class="form-text">{{ t('ui.fileareas.blocked_extensions_help', {}, locale, ['common']) }}</div>
</div>
</div>
<div class="col-lg-5">
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="fileareaReplaceExisting" name="replace_existing">
<label class="form-check-label" for="fileareaReplaceExisting">
{{ t('ui.fileareas.replace_existing_files', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.fileareas.replace_existing_files_help', {}, locale, ['common']) }}</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="fileareaAllowDuplicateHash" name="allow_duplicate_hash">
<label class="form-check-label" for="fileareaAllowDuplicateHash">
{{ t('ui.fileareas.allow_duplicate_content', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.fileareas.allow_duplicate_content_help', {}, locale, ['common']) }}</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="fileareaIsLocal" name="is_local">
<label class="form-check-label" for="fileareaIsLocal">
{{ t('ui.fileareas.local_only', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.fileareas.local_only_help', {}, "Don't forward files to uplinks") }}</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="fileareaGeminiPublic" name="gemini_public">
<label class="form-check-label" for="fileareaGeminiPublic">
{{ t('ui.fileareas.gemini_public', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.fileareas.gemini_public_help', {}, 'List and serve files in this area via the Gemini capsule server') }}</div>
</div>
{% if license_valid %}
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="fileareaIsPublic" name="is_public">
<label class="form-check-label" for="fileareaIsPublic">
{{ t('ui.fileareas.is_public', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.fileareas.is_public_help', {}, 'Allow unauthenticated visitors to browse and download files in this area') }}</div>
</div>
{% else %}
<div class="alert alert-secondary py-2 small mb-3">
<i class="fas fa-lock me-1"></i>
{{ t('ui.fileareas.is_public_requires_license', {}, locale, ['common']) }}
</div>
{% endif %}
{% if freq_experimental_enabled %}
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="fileareaFreqEnabled" name="freq_enabled">
<label class="form-check-label" for="fileareaFreqEnabled">
{{ t('ui.fileareas.freq_enabled', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.fileareas.freq_enabled_help', {}, 'Allow any FidoNet node to request files from this area via FREQ') }}</div>
</div>
<div class="mb-3" id="freqPasswordGroup" style="display:none;">
<label for="fileareaFreqPassword" class="form-label small">{{ t('ui.fileareas.freq_password', {}, locale, ['common']) }}</label>
<input type="text" class="form-control form-control-sm" id="fileareaFreqPassword" name="freq_password" placeholder="{{ t('ui.fileareas.freq_password_placeholder', {}, locale, ['common']) }}">
<div class="form-text">{{ t('ui.fileareas.freq_password_help', {}, 'Leave blank for open access') }}</div>
</div>
{% endif %}
<div class="mb-3">
<label class="form-label">{{ t('ui.fileareas.area_type', {}, locale, ['common']) }}</label>
<div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="area_type" id="areaTypeNormal" value="normal" checked>
<label class="form-check-label" for="areaTypeNormal">{{ t('ui.fileareas.area_type_normal', {}, locale, ['common']) }}</label>
</div>
<div class="form-check form-check-inline">
<input class="form-check-input" type="radio" name="area_type" id="areaTypeIso" value="iso">
<label class="form-check-label" for="areaTypeIso">{{ t('ui.fileareas.area_type_iso', {}, locale, ['common']) }}</label>
</div>
</div>
</div>
<div id="isoFieldsGroup" style="display:none;">
<div class="mb-3">
<label for="fileareaIsoMountPoint" class="form-label">{{ t('ui.fileareas.iso_mount_point', {}, locale, ['common']) }}</label>
<input type="text" class="form-control" id="fileareaIsoMountPoint" name="iso_mount_point" placeholder="{{ is_windows_host ? 'D:\\' : iso_mounts_path }}">
<div class="form-text">{{ t('ui.fileareas.iso_mount_point_help', {}, locale, ['common']) }}</div>
</div>
<div id="isoMountControls" style="display:none;" class="mb-3">
<label class="form-label">{{ t('ui.fileareas.iso_mount_status', {}, locale, ['common']) }}</label>
<div class="d-flex align-items-center gap-2 flex-wrap">
<span id="isoMountStatusBadge" class="badge bg-secondary">?</span>
<button type="button" class="btn btn-sm btn-outline-primary" id="isoReindexBtn" onclick="openIsoPreview()">
<i class="fas fa-search"></i> {{ t('ui.fileareas.reindex', {}, locale, ['common']) }}
</button>
</div>
<div id="isoLastIndexed" class="form-text mt-1"></div>
</div>
</div>
<div class="mb-3">
<label for="fileareaUploadPermission" class="form-label">{{ t('ui.fileareas.upload_permission', {}, locale, ['common']) }}</label>
<select class="form-select" id="fileareaUploadPermission" name="upload_permission">
<option value="1">{{ t('ui.fileareas.upload_users_can_upload', {}, locale, ['common']) }}</option>
<option value="0">{{ t('ui.fileareas.upload_admin_only', {}, locale, ['common']) }}</option>
<option value="2">{{ t('ui.fileareas.upload_read_only', {}, locale, ['common']) }}</option>
</select>
<div class="form-text">{{ t('ui.fileareas.upload_permission_help', {}, locale, ['common']) }}</div>
</div>
<div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" id="fileareaScanVirus" name="scan_virus" checked>
<label class="form-check-label" for="fileareaScanVirus">
{{ t('ui.fileareas.virus_scanning', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.fileareas.virus_scanning_help', {}, locale, ['common']) }}</div>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="fileareaIsActive" name="is_active" checked>
<label class="form-check-label" for="fileareaIsActive">
{{ t('ui.common.active', {}, locale, ['common']) }}
</label>
<div class="form-text">{{ t('ui.fileareas.file_area_is_active', {}, locale, ['common']) }}</div>
</div>
</div>
</div>
<hr>
<div class="mb-0">
<label class="form-label" for="fileareaCommentEchoareaSelect">
<i class="fas fa-comments me-1"></i>
{{ t('ui.fileareas.comment_area', {}, locale, ['common']) }}
</label>
<select class="form-select" id="fileareaCommentEchoareaSelect">
<option value="">? {{ t('ui.common.none', {}, locale, ['common']) }} ({{ t('ui.common.disabled', {}, locale, ['common']) }}) ?</option>
<option value="__new__" id="fileareaCommentCreateOpt">? {{ t('ui.fileareas.comment_area_create_new', {}, locale, ['common']) }}?</option>
</select>
<div id="fileareaCommentNewTagGroup" class="mt-2" style="display:none;">
<input type="text" class="form-control form-control-sm text-uppercase" id="fileareaCommentTagInput"
placeholder="{{ t('ui.fileareas.comment_area_placeholder', {}, locale, ['common']) }}"
maxlength="40">
<div class="form-text">{{ t('ui.fileareas.comment_area_create_help', {}, locale, ['common']) }}</div>
</div>
<div class="form-text mt-1">{{ t('ui.fileareas.comment_area_help', {}, locale, ['common']) }}</div>
</div>
</form>
</div>
<div class="modal-footer">
<a href="/admin/filearea-rules" class="btn btn-outline-secondary me-auto">{{ t('ui.fileareas.edit_rules', {}, locale, ['common']) }}</a>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel', {}, locale, ['common']) }}</button>
<button type="button" class="btn btn-primary" onclick="saveFilearea()">{{ t('ui.common.save', {}, locale, ['common']) }}</button>
</div>
</div>
</div>
</div>
<!-- ISO Import Preview Modal -->
<div class="modal fade" id="isoPreviewModal" tabindex="-1">
<div class="modal-dialog modal-xl modal-fullscreen-lg-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-search me-2"></i><span id="isoPreviewTitle">ISO Import Preview</span></h5>
<div class="ms-3 form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" id="isoPreviewFlat">
<label class="form-check-label small" for="isoPreviewFlat" title="Import all files into the root of the area, ignoring the ISO directory structure.">Flat import</label>
</div>
<div class="ms-3 form-check form-check-inline mb-0">
<input class="form-check-input" type="checkbox" id="isoPreviewCatalogueOnly">
<label class="form-check-label small" for="isoPreviewCatalogueOnly" title="Only import files listed in FILES.BBS / DESCRIPT.ION. Files not in the catalogue are skipped.">Catalogue only</label>
</div>
<button type="button" class="btn-close ms-auto" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body p-0">
<div id="isoPreviewLoading" class="text-center p-5">
<i class="fas fa-spinner fa-spin fa-2x mb-3 d-block"></i>
<span id="isoPreviewLoadingMsg">Scanning ISO?</span>
</div>
<div id="isoPreviewContent" style="display:none;">
<div id="isoPreviewSummary" class="px-3 py-2 border-bottom small text-muted"></div>
<div class="px-3 py-2 border-bottom d-flex gap-2">
<button class="btn btn-sm btn-outline-secondary" onclick="isoPreviewSelectAll(true)">
<i class="fas fa-check-square me-1"></i>Select All
</button>
<button class="btn btn-sm btn-outline-secondary" onclick="isoPreviewSelectAll(false)">
<i class="fas fa-square me-1"></i>Deselect All
</button>
</div>
<div class="table-responsive" style="max-height:55vh; overflow-y:auto;">
<table class="table table-sm table-hover mb-0" id="isoPreviewTable">
<thead class="sticky-top">
<tr>
<th width="4%"></th>
<th width="22%">Directory</th>
<th width="40%">Description</th>
<th width="12%">Files</th>
<th width="12%">Status</th>
</tr>
</thead>
<tbody id="isoPreviewBody"></tbody>
</table>
</div>
</div>
<div id="isoPreviewError" class="alert alert-danger m-3" style="display:none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="isoPreviewSubmitBtn" onclick="submitIsoImport()" style="display:none;">
<i class="fas fa-download me-1"></i>Apply Import
</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{{ t('ui.common.confirm_delete', {}, locale, ['common']) }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>{{ t('ui.fileareas.delete_confirm_prefix', {}, locale, ['common']) }} <strong id="deleteFileareaTag"></strong>?</p>
<p class="text-danger">{{ t('ui.fileareas.delete_confirm_warning', {}, locale, ['common']) }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel', {}, locale, ['common']) }}</button>
<button type="button" class="btn btn-danger" id="confirmDelete">{{ t('ui.common.delete', {}, locale, ['common']) }}</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
let currentFileareaId = null;
let currentFilter = 'active';
let allFileareas = [];
let searchQuery = '';
let selectedDomains = [];
let availableDomains = [];
let pendingFileareaTag = null;
let pendingFileareaDomain = null;
function apiError(payload, fallback) {
if (window.getApiErrorMessage) {
return window.getApiErrorMessage(payload, fallback);
}
return payload && payload.error ? payload.error : fallback;
}
function uiT(key, fallback, params = {}) {
if (window.t) {
return window.t(key, params, fallback);
}
return fallback;
}
$(document).ready(function() {
const params = new URLSearchParams(window.location.search);
pendingFileareaTag = (params.get('tag') || '').trim().toUpperCase();
pendingFileareaDomain = (params.get('domain') || '').trim().toLowerCase();
loadI18nNamespaces(['common', 'errors']).then(function() {
loadFileareas();
loadStats();
});
$('input[name="filter"]').change(function() {
currentFilter = $(this).val();
loadFileareas();
});
$('#fileareaTag').on('input', function() {
this.value = this.value.toUpperCase().replace(/[^A-Z0-9_]/g, '');
});
$('#fileareaDomain').on('change', function() {
rebuildCommentEchoareaOptions($(this).val());
});
});
function loadFileareas() {
$('#fileareasContainer').html(`<div class="loading-spinner"><i class="fas fa-spinner fa-spin me-2"></i>${uiT('ui.common.loading', 'Loading...')}</div>`);
let url = '/api/fileareas?filter=' + currentFilter;
$.get(url)
.done(function(data) {
allFileareas = data.fileareas || [];
updateAvailableDomains();
renderDomainFilters();
applySearchFilter();
openPendingFileareaFromUrl(allFileareas);
})
.fail(function() {
$('#fileareasContainer').html(`<div class="alert alert-danger m-3">${uiT('ui.fileareas.load_failed', 'Failed to load file areas')}</div>`);
});
}
function searchFileareas(query) {
searchQuery = query.toLowerCase();
applySearchFilter();
}
function updateAvailableDomains() {
const domains = new Set();
allFileareas.forEach(function(area) {
const domain = String(area.domain || '').trim().toLowerCase();
domains.add(domain);
});
availableDomains = Array.from(domains).sort();
selectedDomains = selectedDomains.filter(function(domain) {
return availableDomains.includes(domain);
});
}
function renderDomainFilters() {
const $container = $('#fileareaDomainFilters');
if (!availableDomains.length) {
$container.empty();
return;
}
let html = `<div class="small text-muted mb-1">${uiT('ui.common.domain', 'Domain')}</div>`;
html += '<div class="d-flex flex-wrap gap-2">';
html += `<button type="button" class="btn btn-sm ${selectedDomains.length === 0 ? 'btn-primary' : 'btn-outline-secondary'}" onclick="clearDomainFilters()">${uiT('ui.common.all', 'All')}</button>`;
availableDomains.forEach(function(domain) {
const selected = selectedDomains.includes(domain);
const label = domain === '' ? uiT('ui.fileareas.local', 'Local') : domain;
html += `<button type="button" class="btn btn-sm ${selected ? 'btn-primary' : 'btn-outline-secondary'}" onclick="toggleDomainFilter('${escapeJsString(domain)}')">${escapeHtml(label)}</button>`;
});
html += '</div>';
$container.html(html);
}
function toggleDomainFilter(domain) {
if (selectedDomains.includes(domain)) {
selectedDomains = selectedDomains.filter(function(value) { return value !== domain; });
} else {
selectedDomains.push(domain);
selectedDomains.sort();
}
renderDomainFilters();
applySearchFilter();
}
function clearDomainFilters() {
selectedDomains = [];
renderDomainFilters();
applySearchFilter();
}
function escapeJsString(value) {
return String(value)
.replace(/\\/g, '\\\\')
.replace(/'/g, "\\'")
.replace(/"/g, '\\"')
.replace(/\r/g, '\\r')
.replace(/\n/g, '\\n')
.replace(/</g, '\\x3C')
.replace(/>/g, '\\x3E');
}
function buildLovlyNetFileareaIssueMessages(area) {
const issues = Array.isArray(area.lovlynet_setting_issues) ? area.lovlynet_setting_issues : [];
const messages = [];
issues.forEach(function(issue) {
if (!issue || issue.setting !== 'readonly') {
return;
}
if (issue.recommended) {
messages.push(uiT('ui.fileareas.lovlynet_issue_readonly_on', 'LovlyNet recommends making this area read-only'));
} else {
messages.push(uiT('ui.fileareas.lovlynet_issue_readonly_off', 'LovlyNet recommends allowing uploads in this area'));
}
});
return messages;
}
function buildLovlyNetFileareaIssueIcon(area) {
const messages = buildLovlyNetFileareaIssueMessages(area);
if (!messages.length) {
return '';
}
const tooltip = `${uiT('ui.fileareas.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 applySearchFilter() {
let filtered = allFileareas;
if (selectedDomains.length > 0) {
filtered = filtered.filter(function(area) {
const domain = String(area.domain || '').trim().toLowerCase();
return selectedDomains.includes(domain);
});
}
if (searchQuery.length > 0) {
filtered = filtered.filter(function(area) {
const tag = String(area.tag || '').toLowerCase();
const domain = String(area.domain || '').toLowerCase();
const description = String(area.description || '').toLowerCase();
return tag.includes(searchQuery)
|| domain.includes(searchQuery)
|| description.includes(searchQuery);
});
}
displayFileareas(filtered);
}
function openPendingFileareaFromUrl(fileareas) {
if (!pendingFileareaTag) {
return;
}
const match = (fileareas || []).find(function(area) {
const areaTag = String(area.tag || '').trim().toUpperCase();
const areaDomain = String(area.domain || '').trim().toLowerCase();
return areaTag === pendingFileareaTag
&& (pendingFileareaDomain === '' || areaDomain === pendingFileareaDomain);
});
if (!match) {
return;
}
pendingFileareaTag = null;
pendingFileareaDomain = null;
editFilearea(match.id);
}
function displayFileareas(fileareas) {
if (fileareas.length === 0) {
$('#fileareasContainer').html(`<div class="alert alert-info m-3">${uiT('ui.fileareas.no_file_areas_found', 'No file areas found')}</div>`);
return;
}
let html = `
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th width="18%">${uiT('ui.fileareas.tag', 'Tag')}</th>
<th width="18%">${uiT('ui.common.domain', 'Domain')}</th>
<th width="29%">${uiT('ui.common.description', 'Description')}</th>
<th width="10%">${uiT('ui.fileareas.files', 'Files')}</th>
<th width="10%">${uiT('ui.fileareas.size', 'Size')}</th>
<th width="10%">${uiT('ui.common.status', 'Status')}</th>
<th width="15%">${uiT('ui.common.actions', 'Actions')}</th>
</tr>
</thead>
<tbody>
`;
fileareas.forEach(function(area) {
const statusBadge = area.is_active
? `<span class="badge bg-success">${uiT('ui.common.active', 'Active')}</span>`
: `<span class="badge bg-secondary">${uiT('ui.common.inactive', 'Inactive')}</span>`;
const lovlyNetIssueIcon = buildLovlyNetFileareaIssueIcon(area);
const localBadge = area.is_local
? `<span class="badge bg-info ms-1">${uiT('ui.fileareas.local', 'Local')}</span>`
: '';
const replaceBadge = area.replace_existing
? `<span class="badge bg-warning ms-1">${uiT('ui.fileareas.replace', 'Replace')}</span>`
: '';
const geminiPublicBadge = area.gemini_public
? `<span class="badge bg-primary ms-1">${uiT('ui.fileareas.gemini_public', 'Gemini Public')}</span>`
: '';
const publicBadge = area.is_public
? `<span class="badge bg-dark ms-1">${uiT('ui.fileareas.is_public', 'Public File Area')}</span>`
: '';
const freqBadge = area.freq_enabled
? `<span class="badge bg-warning text-dark ms-1">FREQ</span>`
: '';
let isoBadge = '';
if (area.area_type === 'iso') {
const mountDot = area.iso_accessible
? '<span class="text-success ms-1" title="Accessible">?</span>'
: '<span class="text-secondary ms-1" title="Not accessible">?</span>';
isoBadge = `<span class="badge bg-secondary ms-1">ISO</span>${mountDot}`;
}
const fileSize = formatBytes(area.total_size || 0);
html += `
<tr>
<td><strong>${escapeHtml(area.tag)}</strong>${lovlyNetIssueIcon}</td>
<td>${escapeHtml(area.domain || '')}</td>
<td>${escapeHtml(area.description || '')}</td>
<td>${area.file_count || 0}</td>
<td>${fileSize}</td>
<td>${statusBadge}${localBadge}${replaceBadge}${geminiPublicBadge}${publicBadge}${freqBadge}${isoBadge}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="editFilearea(${area.id})">
<i class="fas fa-edit"></i>
</button>
<button class="btn btn-sm btn-outline-danger" onclick="showDeleteModal(${area.id}, '${escapeHtml(area.tag)}')">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`;
});
html += '</tbody></table></div>';
$('#fileareasContainer').html(html);
}
function toggleFileareaPasswordVisibility(button) {
const input = document.getElementById('fileareaPassword');
if (!input || !button) {
return;
}
const icon = button.querySelector('i');
const isHidden = input.type === 'password';
const nextLabel = isHidden
? uiT('ui.common.hide_password', 'Hide password')
: uiT('ui.common.show_password', 'Show password');
input.type = isHidden ? 'text' : 'password';
button.setAttribute('title', nextLabel);
button.setAttribute('aria-label', nextLabel);
if (icon) {
icon.classList.toggle('fa-eye', !isHidden);
icon.classList.toggle('fa-eye-slash', isHidden);
}
}
function resetFileareaPasswordVisibility() {
const input = document.getElementById('fileareaPassword');
if (!input) {
return;
}
input.type = 'password';
const button = input.parentElement ? input.parentElement.querySelector('button') : null;
const icon = button ? button.querySelector('i') : null;
const label = uiT('ui.common.show_password', 'Show password');
if (button) {
button.setAttribute('title', label);
button.setAttribute('aria-label', label);
}
if (icon) {
icon.classList.remove('fa-eye-slash');
icon.classList.add('fa-eye');
}
}
function updateIsoFieldsVisibility() {
const isIso = $('input[name="area_type"]:checked').val() === 'iso';
$('#isoFieldsGroup').toggle(isIso);
// Lock upload permission to read-only for ISO areas
if (isIso) {
$('#fileareaUploadPermission').val(2).prop('disabled', true);
} else {
$('#fileareaUploadPermission').prop('disabled', false);
}
}
function updateIsoMountStatusUI(area) {
if (!area || area.area_type !== 'iso') return;
$('#isoMountControls').show();
const accessible = !!area.iso_accessible;
const badgeClass = accessible ? 'bg-success' : 'bg-secondary';
const badgeText = accessible ? uiT('ui.fileareas.accessible', 'Accessible') : uiT('ui.fileareas.not_accessible', 'Not accessible');
$('#isoMountStatusBadge').attr('class', 'badge ' + badgeClass).text(badgeText);
if (area.iso_last_indexed) {
$('#isoLastIndexed').text(uiT('ui.fileareas.last_indexed', 'Last indexed') + ': ' + area.iso_last_indexed);
} else {
$('#isoLastIndexed').text('');
}
$('#isoReindexBtn').prop('disabled', !accessible);
}
function ensureFileareaDomainOption(domain) {
domain = String(domain || '').trim();
if (domain === '' || $('#fileareaDomain option').filter(function() { return this.value === domain; }).length > 0) {
return;
}
$('#fileareaDomain').append(new Option(`${domain} (${uiT('ui.admin.binkp_config.uplinks.modal.unconfigured_network', 'unconfigured')})`, domain));
}
function showAddFileareaModal() {
currentFileareaId = null;
$('#fileareaModalTitle').text(uiT('ui.fileareas.add_file_area', 'Add File Area'));
$('#fileareaForm')[0].reset();
$('#fileareaIsActive').prop('checked', true);
$('#fileareaDomain').val('');
$('#fileareaPassword').val('');
$('#fileareaMaxSize').val(10);
$('#areaTypeNormal').prop('checked', true);
$('#fileareaIsoMountPoint').val('');
$('#isoFieldsGroup').hide();
$('#isoMountControls').hide();
$('#fileareaUploadPermission').prop('disabled', false);
$('#fileareaTag').prop('readonly', false).removeClass('bg-body-secondary');
$('#fileareaTagHelp').show();
$('#fileareaTagReadonlyNote').hide();
resetFileareaPasswordVisibility();
$('#fileareaCommentEchoareaSelect').val('');
$('#fileareaCommentNewTagGroup').hide();
$('#fileareaCommentTagInput').val('');
loadEchoareasForCombobox();
new bootstrap.Modal('#fileareaModal').show();
}
function editFilearea(id) {
currentFileareaId = id;
$('#fileareaModalTitle').text(uiT('ui.fileareas.edit_file_area', 'Edit File Area') + ' #' + id);
$.get(`/api/fileareas/${id}`)
.done(function(data) {
const area = data.filearea;
$('#fileareaId').val(area.id);
$('#fileareaTag').val(area.tag).prop('readonly', true).addClass('bg-body-secondary');
$('#fileareaTagHelp').hide();
$('#fileareaTagReadonlyNote').show();
$('#fileareaDescription').val(area.description);
ensureFileareaDomainOption(area.domain || '');
$('#fileareaDomain').val(area.domain || '');
$('#fileareaPassword').val(area.password || '');
$('#fileareaMaxSize').val((area.max_file_size || 10485760) / 1048576);
$('#fileareaAllowedExt').val(area.allowed_extensions || '');
$('#fileareaBlockedExt').val(area.blocked_extensions || '');
$('#fileareaReplaceExisting').prop('checked', area.replace_existing);
$('#fileareaAllowDuplicateHash').prop('checked', area.allow_duplicate_hash);
$('#fileareaIsLocal').prop('checked', area.is_local);
$('#fileareaGeminiPublic').prop('checked', area.gemini_public);
$('#fileareaIsPublic').prop('checked', !!area.is_public);
$('#fileareaFreqEnabled').prop('checked', area.freq_enabled);
$('#fileareaFreqPassword').val(area.freq_password || '');
$('#freqPasswordGroup').toggle(!!area.freq_enabled);
$('#fileareaUploadPermission').val(area.upload_permission !== undefined ? area.upload_permission : 1);
$('#fileareaScanVirus').prop('checked', area.scan_virus !== false);
$('#fileareaIsActive').prop('checked', area.is_active);
// ISO fields
const isIso = area.area_type === 'iso';
$(`input[name="area_type"][value="${isIso ? 'iso' : 'normal'}"]`).prop('checked', true);
$('#fileareaIsoMountPoint').val(area.iso_mount_point || '');
updateIsoFieldsVisibility();
updateIsoMountStatusUI(area);
resetFileareaPasswordVisibility();
populateCommentAreaUI(area);
new bootstrap.Modal('#fileareaModal').show();
})
.fail(function() {
showError(uiT('ui.fileareas.load_details_failed', 'Failed to load file area details'));
});
}
function saveFilearea() {
const maxSizeMB = parseFloat($('#fileareaMaxSize').val()) || 10;
const maxSizeBytes = maxSizeMB * 1048576;
const areaType = $('input[name="area_type"]:checked').val() || 'normal';
const data = {
tag: $('#fileareaTag').val(),
description: $('#fileareaDescription').val(),
domain: $('#fileareaDomain').val().trim(),
password: $('#fileareaPassword').val(),
max_file_size: maxSizeBytes,
allowed_extensions: $('#fileareaAllowedExt').val(),
blocked_extensions: $('#fileareaBlockedExt').val(),
replace_existing: $('#fileareaReplaceExisting').is(':checked'),
allow_duplicate_hash: $('#fileareaAllowDuplicateHash').is(':checked'),
is_local: $('#fileareaIsLocal').is(':checked'),
gemini_public: $('#fileareaGeminiPublic').is(':checked'),
is_public: $('#fileareaIsPublic').is(':checked'),
freq_enabled: $('#fileareaFreqEnabled').is(':checked'),
freq_password: $('#fileareaFreqPassword').val(),
upload_permission: parseInt($('#fileareaUploadPermission').val()),
scan_virus: $('#fileareaScanVirus').is(':checked'),
is_active: $('#fileareaIsActive').is(':checked'),
area_type: areaType,
iso_mount_point: areaType === 'iso' ? ($('#fileareaIsoMountPoint').val().trim() || null) : null
};
const url = currentFileareaId ? `/api/fileareas/${currentFileareaId}` : '/api/fileareas';
const method = currentFileareaId ? 'PUT' : 'POST';
$.ajax({
url: url,
method: method,
contentType: 'application/json',
data: JSON.stringify(data),
success: function(response) {
const areaId = currentFileareaId || response.id;
const fallbackMessage = currentFileareaId
? uiT('ui.fileareas.updated_success', 'File area updated')
: uiT('ui.fileareas.created_success', 'File area created');
const successMessage = window.getApiMessage
? window.getApiMessage(response, fallbackMessage)
: fallbackMessage;
// Save comment area setting, then close modal and refresh
saveCommentAreaForFilearea(areaId, function() {
bootstrap.Modal.getInstance('#fileareaModal').hide();
showSuccess(successMessage);
loadFileareas();
loadStats();
});
},
error: function(xhr) {
const error = apiError(
xhr.responseJSON,
currentFileareaId
? uiT('errors.fileareas.update_failed', 'Failed to update file area')
: uiT('errors.fileareas.create_failed', 'Failed to create file area')
);
showError(error);
}
});
}
function showDeleteModal(id, tag) {
currentFileareaId = id;
$('#deleteFileareaTag').text(tag);
new bootstrap.Modal('#deleteModal').show();
$('#confirmDelete').off('click').on('click', function() {
deleteFilearea();
});
}
function deleteFilearea() {
$.ajax({
url: `/api/fileareas/${currentFileareaId}`,
method: 'DELETE',
success: function(response) {
bootstrap.Modal.getInstance('#deleteModal').hide();
const successMessage = window.getApiMessage
? window.getApiMessage(response, uiT('ui.fileareas.deleted_success', 'File area deleted'))
: uiT('ui.fileareas.deleted_success', 'File area deleted');
showSuccess(successMessage);
loadFileareas();
loadStats();
},
error: function(xhr) {
const error = apiError(xhr.responseJSON, uiT('errors.fileareas.delete_failed', 'Failed to delete file area'));
showError(error);
}
});
}
function loadStats() {
$.get('/api/fileareas/stats')
.done(function(data) {
$('#activeCount').text(data.active_count);
$('#totalFiles').text(data.total_files);
$('#totalSize').text(formatBytes(data.total_size));
});
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function escapeHtmlAttr(value) {
return String(value)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/'/g, ''')
.replace(/</g, '<')
.replace(/>/g, '>');
}
$(document).on('change', '#fileareaFreqEnabled', function() {
$('#freqPasswordGroup').toggle(this.checked);
});
$(document).on('change', 'input[name="area_type"]', function() {
updateIsoFieldsVisibility();
});
// Bootstrap 5 does not support nested modals. When isoPreviewModal closes it
// strips modal-open from <body> and removes the backdrop, leaving fileareaModal
// broken. Restore the outer modal's state when the inner one is fully hidden.
$('#isoPreviewModal').on('hidden.bs.modal', function () {
if ($('#fileareaModal').hasClass('show')) {
$('body').addClass('modal-open');
if (!$('.modal-backdrop').length) {
$('<div class="modal-backdrop fade show"></div>').appendTo('body');
}
}
});
function openIsoPreview() {
if (!currentFileareaId) return;
const flat = $('#isoPreviewFlat').is(':checked');
const catalogueOnly = $('#isoPreviewCatalogueOnly').is(':checked');
$('#isoPreviewLoading').show();
$('#isoPreviewContent').hide();
$('#isoPreviewError').hide();
$('#isoPreviewSubmitBtn').hide();
$('#isoPreviewBody').empty();
$('#isoPreviewSummary').text('');
// Use getOrCreateInstance to avoid creating duplicate Bootstrap modal instances
// (e.g. when the checkbox change handler calls openIsoPreview while already open).
bootstrap.Modal.getOrCreateInstance('#isoPreviewModal').show();
$.get(`/api/fileareas/${currentFileareaId}/preview-iso?flat=${flat ? 1 : 0}&catalogue_only=${catalogueOnly ? 1 : 0}`)
.done(function(data) {
renderIsoPreview(data);
})
.fail(function(xhr) {
$('#isoPreviewLoading').hide();
$('#isoPreviewError').text((xhr.responseJSON && xhr.responseJSON.error) || 'Failed to load preview').show();
});
}
// Re-load preview when import options change
$('#isoPreviewFlat, #isoPreviewCatalogueOnly').on('change', function() {
if (bootstrap.Modal.getInstance('#isoPreviewModal')) {
openIsoPreview();
}
});
function renderIsoPreview(data) {
const entries = data.entries || [];
const summary = data.summary || {};
const summaryText = `${entries.length} directories · ${summary.new_dirs || 0} new, ${summary.existing_dirs || 0} existing · ${summary.total_files || 0} files`;
$('#isoPreviewSummary').text(summaryText);
let rows = '';
entries.forEach(function(entry, idx) {
const desc = entry.db_description || entry.catalogue_description || entry.name;
const statusBadge = entry.status === 'new'
? '<span class="badge bg-success">New</span>'
: '<span class="badge bg-secondary">Existing</span>';
rows += `
<tr>
<td class="text-center">
<input type="checkbox" class="form-check-input iso-preview-check" data-idx="${idx}" checked>
</td>
<td>
<i class="fas fa-folder text-warning me-1"></i>
<span class="small font-monospace">${escapeHtml(entry.rel_path)}</span>
</td>
<td>
<input type="text" class="form-control form-control-sm iso-preview-desc" data-idx="${idx}"
value="${escapeHtml(desc)}" placeholder="${escapeHtml(entry.catalogue_description || entry.name)}">
</td>
<td class="small text-muted">${entry.file_count}</td>
<td>${statusBadge}</td>
</tr>
`;
});
$('#isoPreviewBody').html(rows);
// Store entries for submit
$('#isoPreviewModal').data('entries', entries);
$('#isoPreviewLoading').hide();
$('#isoPreviewContent').show();
$('#isoPreviewSubmitBtn').show();
}
function isoPreviewSelectAll(checked) {
$('.iso-preview-check').prop('checked', checked);
}
// -----------------------------------------------------------------------
// Comment echo area management
// -----------------------------------------------------------------------
let allEchoareas = [];
function loadEchoareasForCombobox() {
if (allEchoareas.length > 0) return Promise.resolve();
return $.get('/api/echoareas/simple-list')
.done(function(data) {
allEchoareas = data.echoareas || [];
});
}
function rebuildCommentEchoareaOptions(domain) {
const sel = $('#fileareaCommentEchoareaSelect');
const createOpt = sel.find('#fileareaCommentCreateOpt').detach();
sel.find('option:not(:first)').remove();
const lcDomain = (domain || '').toLowerCase().trim();
const sameNet = lcDomain ? allEchoareas.filter(function(ea) { return (ea.domain || '').toLowerCase() === lcDomain; }) : [];
const others = lcDomain ? allEchoareas.filter(function(ea) { return (ea.domain || '').toLowerCase() !== lcDomain; }) : allEchoareas;
function appendOpt(ea) {
sel.append(new Option(ea.tag + (ea.description ? ' ? ' + ea.description : ''), ea.id));
}
if (sameNet.length > 0) {
sameNet.forEach(appendOpt);
const sep = document.createElement('option');
sep.value = '';
sep.text = '????????????????????????????????';
sep.disabled = true;
sel[0].appendChild(sep);
}
others.forEach(appendOpt);
sel.append(createOpt);
}
function suggestCommentTag(areaTag, domain) {
if (!areaTag) return '';
const lcDomain = (domain || '').toLowerCase();
if (lcDomain === 'lovlynet') return 'LVLY_FILECHAT';
if (areaTag.length <= 31) return areaTag + '-COMMENTS';
return areaTag;
}
function populateCommentAreaUI(area) {
loadEchoareasForCombobox().then(function() {
rebuildCommentEchoareaOptions(area.domain);
if (area.comment_echoarea_id) {
$('#fileareaCommentEchoareaSelect').val(area.comment_echoarea_id);
$('#fileareaCommentNewTagGroup').hide();
} else {
// For lovlynet areas, default to the existing LVLY_FILECHAT area if present
const suggestedTag = suggestCommentTag(area.tag, area.domain);
const existing = suggestedTag
? allEchoareas.find(e => e.tag.toUpperCase() === suggestedTag.toUpperCase())
: null;
// Don't auto-select an existing echoarea ? that would make it appear
// configured when comment_echoarea_id is actually NULL in the database.
// Just leave the select blank and pre-fill the suggested tag so the
// sysop can review and explicitly save the link.
$('#fileareaCommentEchoareaSelect').val('');
$('#fileareaCommentTagInput').val(suggestedTag);
$('#fileareaCommentNewTagGroup').hide();
}
});
}
/**
* Save comment echo area link as part of the file area save flow.
* Called by saveFilearea() after the main save succeeds.
* @param {number} areaId - The file area ID
* @param {function} callback - Called when done (success or non-fatal error)
*/
function saveCommentAreaForFilearea(areaId, callback) {
const selVal = $('#fileareaCommentEchoareaSelect').val();
const tagVal = $('#fileareaCommentTagInput').val().trim().toUpperCase();
let payload;
if (selVal && selVal !== '__new__') {
payload = { action: 'link', echoarea_id: parseInt(selVal) };
} else if (selVal === '__new__' && tagVal) {
const description = tagVal === 'LVLY_FILECHAT'
? 'Comments and discussions about files posted on LovlyNet'
: 'File comments for ' + $('#fileareaTag').val();
payload = {
action: 'create',
tag: tagVal,
description: description,
};
} else {
payload = { action: 'unlink' };
}
$.ajax({
url: '/api/fileareas/' + areaId + '/comment-area',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload),
success: function() { callback(); },
error: function(xhr) {
// Non-fatal: proceed but show a warning
callback();
showError(apiError(xhr.responseJSON, uiT('errors.fileareas.comment_area_failed', 'Failed to save comment area setting')));
}
});
}
// Show/hide the "Create new" tag input based on select value
$(document).on('change', '#fileareaCommentEchoareaSelect', function() {
$('#fileareaCommentNewTagGroup').toggle($(this).val() === '__new__');
});
function submitIsoImport() {
if (!currentFileareaId) return;
const entries = $('#isoPreviewModal').data('entries') || [];
const flat = $('#isoPreviewFlat').is(':checked');
const catalogueOnly = $('#isoPreviewCatalogueOnly').is(':checked');
const overrides = entries.map(function(entry, idx) {
const skip = !$('.iso-preview-check[data-idx="' + idx + '"]').is(':checked');
const description = $('.iso-preview-desc[data-idx="' + idx + '"]').val().trim();
return { rel_path: entry.rel_path, description, skip };
});
$('#isoPreviewSubmitBtn').prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i>Importing?');
$.ajax({
url: `/api/fileareas/${currentFileareaId}/reindex-iso`,
method: 'POST',
contentType: 'application/json',
data: JSON.stringify({ flat, catalogue_only: catalogueOnly, overrides })
})
.done(function(data) {
const c = data.counters || {};
bootstrap.Modal.getInstance('#isoPreviewModal').hide();
const msg = uiT('ui.fileareas.reindex_done', 'Re-index complete') +
`: ${c.imported || 0} imported, ${c.updated || 0} updated, ${c.skipped || 0} skipped`;
showSuccess(msg);
$.get(`/api/fileareas/${currentFileareaId}`).done(d => updateIsoMountStatusUI(d.filearea));
})
.fail(function(xhr) {
$('#isoPreviewSubmitBtn').prop('disabled', false).html('<i class="fas fa-download me-1"></i>Apply Import');
showError(uiT('errors.fileareas.reindex_failed', 'Failed to re-index ISO'));
});
}
</script>
{% endblock %}
|