PHP Classes

File: templates/fileareas.twig

Recommend this page to a friend!
  Packages of Matthew Asham   Binkterm PHP   templates/fileareas.twig   Download  
File: templates/fileareas.twig
Role: Auxiliary data
Content type: text/plain
Description: Auxiliary data
Class: Binkterm PHP
Bulletin board system based on the Web
Author: By
Last change:
Date: 5 days ago
Size: 57,707 bytes
 

Contents

Class file image Download
{% 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, '&amp;') .replace(/"/g, '&quot;') .replace(/'/g, '&#39;') .replace(/</g, '&lt;') .replace(/>/g, '&gt;'); } $(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 %}