PHP Classes

File: templates/files.twig

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

Contents

Class file image Download
{% extends "base.twig" %} {% block title %}{% if is_public_area is defined and is_public_area and initial_area %}{{ initial_area.tag }} - {% endif %}{{ t('ui.files.title', {}, 'common') }} - {{ parent() }}{% endblock %} {% block meta_tags %} {% if is_public_area is defined and is_public_area and initial_area %} <meta property="og:title" content="{{ initial_area.tag }} ? {{ system_name }}"> <meta property="og:description" content="{{ initial_area.description }}{% if initial_area.file_count %} · {{ initial_area.file_count }} {{ t('ui.files.files_count_label', {}, 'common') }}{% endif %}"> <meta property="og:url" content="{{ site_url }}/files/{{ initial_area.tag }}"> <meta property="og:type" content="website"> {% endif %} {% endblock %} {% block content %} <div class="d-flex justify-content-between align-items-center mb-4"> <h2> <i class="fas fa-file"></i> {{ t('ui.files.title', {}, 'common') }} </h2> </div> <div class="alert alert-warning mb-4" role="alert"> <i class="fas fa-exclamation-triangle me-2"></i> <strong>{{ t('ui.files.security_notice_label', {}, 'common') }}</strong> {{ t('ui.files.security_notice_text', {}, 'common') }} </div> <div class="row"> <div class="col-lg-9 order-2 order-lg-1"> <div class="card"> <div class="card-header"> <div class="d-flex justify-content-between align-items-start"> <div> <h6 class="mb-0" id="currentAreaTitle"><i class="fas fa-clock"></i> {{ t('ui.files.recent_uploads', {}, 'common') }}</h6> <div id="currentAreaMeta" class="mt-1" style="display:none;"></div> </div> <div class="d-flex gap-2 flex-shrink-0 ms-2"> <button class="btn btn-sm btn-primary" id="uploadFileBtn" onclick="showUploadModal()" style="display: none;"> <i class="fas fa-upload"></i> {{ t('ui.files.upload_file', {}, 'common') }} </button> <input type="text" class="form-control form-control-sm" id="searchFiles" placeholder="{{ t('ui.files.search_placeholder', {}, 'common') }}" style="width: 250px;"> </div> </div> </div> <div class="card-body p-0"> <div id="filesContainer"> <div class="loading-spinner p-4 text-center text-muted"> <i class="fas fa-spinner fa-spin me-2"></i> {{ t('ui.files.loading_recent_uploads', {}, 'common') }} </div> </div> </div> </div> </div> <!-- File Areas sidebar --> <div class="col-lg-3 order-1 order-lg-2"> <div class="card mb-3"> <div class="card-header"> <h6 class="mb-0">{{ t('ui.files.title', {}, 'common') }}</h6> </div> <div class="px-2 pt-2"> <input type="text" class="form-control form-control-sm" id="fileAreaSearch" placeholder="{{ t('ui.files.search_areas_placeholder', {}, 'common') }}" onkeyup="searchFileAreas(this.value)"> </div> <div class="list-group list-group-flush" id="fileAreasList" style="max-height: 300px; overflow-y: auto;"> <div class="loading-spinner p-3"> <i class="fas fa-spinner fa-spin me-2"></i> {{ t('ui.common.loading', {}, 'common') }} </div> </div> </div> <!-- File Search --> <div class="card mb-3" id="fileSearchCard"> <div class="card-header"> <h6 class="mb-0"><i class="fas fa-search me-1"></i>{{ t('ui.files.search_heading', {}, 'common') }}</h6> </div> <div class="px-2 py-2"> <div class="input-group input-group-sm"> <input type="text" class="form-control" id="globalFileSearch" placeholder="{{ t('ui.files.search_global_placeholder', {}, 'common') }}" oninput="scheduleFileSearch(this.value)"> <button class="btn btn-outline-secondary" type="button" id="clearFileSearch" onclick="clearFileSearch()" style="display:none;" title="Clear search"> <i class="fas fa-times"></i> </button> </div> <div id="fileSearchStatus" class="small text-muted mt-1" style="display:none;"></div> </div> </div> <!-- Statistics (desktop only - directly under File Areas) --> <div class="card d-none d-lg-block"> <div class="card-header"> <h6 class="mb-0">{{ t('ui.files.status_heading', {}, 'common') }}</h6> </div> <div class="card-body"> <dl class="row mb-0 small"> <dt class="col-7">{{ t('ui.files.total_areas', {}, 'common') }}:</dt> <dd class="col-5" id="totalAreas">-</dd> <dt class="col-7">{{ t('ui.files.total_files', {}, 'common') }}:</dt> <dd class="col-5" id="totalFiles">-</dd> <dt class="col-7">{{ t('ui.files.total_size', {}, 'common') }}:</dt> <dd class="col-5" id="totalSize">-</dd> </dl> </div> </div> {% if file_areas_appearance is defined and file_areas_appearance.sidebar_info_html %} <!-- Custom sidebar info panel (desktop only) --> <div class="card mt-3 d-none d-lg-block"> {% if file_areas_appearance.sidebar_info_title %} <div class="card-header"> <h6 class="mb-0">{{ file_areas_appearance.sidebar_info_title }}</h6> </div> {% endif %} <div class="card-body small"> {{ file_areas_appearance.sidebar_info_html|raw }} </div> </div> {% endif %} </div> <!-- Statistics (mobile only - at bottom) --> <div class="col-12 d-lg-none order-3"> <div class="card"> <div class="card-header"> <h6 class="mb-0">{{ t('ui.files.status_heading', {}, 'common') }}</h6> </div> <div class="card-body"> <dl class="row mb-0 small"> <dt class="col-7">{{ t('ui.files.total_areas', {}, 'common') }}:</dt> <dd class="col-5" id="totalAreas-mobile">-</dd> <dt class="col-7">{{ t('ui.files.total_files', {}, 'common') }}:</dt> <dd class="col-5" id="totalFiles-mobile">-</dd> <dt class="col-7">{{ t('ui.files.total_size', {}, 'common') }}:</dt> <dd class="col-5" id="totalSize-mobile">-</dd> </dl> </div> </div> {% if file_areas_appearance is defined and file_areas_appearance.sidebar_info_html %} <!-- Custom sidebar info panel (mobile) --> <div class="card mt-3"> {% if file_areas_appearance.sidebar_info_title %} <div class="card-header"> <h6 class="mb-0">{{ file_areas_appearance.sidebar_info_title }}</h6> </div> {% endif %} <div class="card-body small"> {{ file_areas_appearance.sidebar_info_html|raw }} </div> </div> {% endif %} </div> </div> {% if file_areas_appearance is defined and file_areas_appearance.footer_html %} <!-- Custom file area footer --> <div class="mt-4"> <div class="card"> <div class="card-body small"> {{ file_areas_appearance.footer_html|raw }} </div> </div> </div> {% endif %} <!-- File Preview Modal --> <div class="modal fade" id="filePreviewModal" tabindex="-1"> <div class="modal-dialog modal-xl modal-dialog-centered modal-dialog-scrollable"> <div class="modal-content" style="max-height:90vh;"> <div class="modal-header py-2"> <div class="d-flex align-items-center gap-2 flex-shrink-0"> <button type="button" class="btn btn-outline-secondary btn-sm" id="prevPreviewBtn" onclick="navigatePreview(-1)" title="{{ t('ui.files.previous_file', {}, 'common') }}"> <i class="fas fa-chevron-left"></i> </button> <button type="button" class="btn btn-outline-secondary btn-sm" id="nextPreviewBtn" onclick="navigatePreview(1)" title="{{ t('ui.files.next_file', {}, 'common') }}"> <i class="fas fa-chevron-right"></i> </button> </div> <h5 class="modal-title ms-2 text-truncate" id="previewModalTitle">{{ t('ui.files.preview_title', {}, 'common') }}</h5> <div class="ms-auto d-flex align-items-center gap-2 flex-shrink-0"> <button type="button" class="btn btn-outline-secondary btn-sm" onclick="openDetailsFromPreview()" title="{{ t('ui.files.file_info', {}, 'common') }}"> <i class="fas fa-info-circle"></i> </button> <button type="button" class="btn btn-outline-secondary btn-sm d-none" id="previewMagnetBtn" title="{{ t('ui.files.copy_magnet_link', {}, 'common') }}"> <i class="fas fa-magnet"></i> </button> <a class="btn btn-outline-success btn-sm" id="previewDownloadBtn" href="#" download title="{{ t('ui.files.download', {}, 'common') }}"> <i class="fas fa-download"></i> </a> <button type="button" class="btn btn-outline-secondary btn-sm" id="previewFullscreenToggle" onclick="togglePreviewFullscreen()" title="{{ t('ui.common.toggle_fullscreen', {}, 'common') }}"> <i class="fas fa-expand"></i> </button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> </div> <div class="modal-body p-0 text-center" id="previewModalBody" style="min-height:200px;max-height:60vh;flex:0 1 auto;background:#1a1a1a;overflow:auto;"> <div class="py-5 text-muted"> <i class="fas fa-spinner fa-spin fa-2x"></i> </div> </div> <!-- File comments section (preview modal) --> <div id="previewFileCommentsSection" class="px-3 pt-2 pb-3" style="display:none;max-height:28vh;overflow:auto;"> <hr class="mt-0"> <h6><i class="fas fa-comments me-1"></i>{{ t('ui.files.comments', {}, 'common') }}</h6> <div id="previewFileCommentsBody"> <div class="text-muted small">{{ t('ui.files.loading_comments', {}, 'common') }}</div> </div> {% if current_user %} <div id="previewFileCommentReplyForm" class="mt-3" style="display:none;"> <input type="hidden" id="previewCommentReplyToId" value=""> <div id="previewCommentReplyToLabel" class="small text-muted mb-1" style="display:none;"></div> <div class="input-group"> <textarea class="form-control form-control-sm" id="previewCommentBodyInput" rows="3" placeholder="{{ t('ui.files.leave_comment', {}, 'common') }}?" maxlength="4096"></textarea> </div> <div class="d-flex gap-2 mt-2"> <button class="btn btn-sm btn-primary" onclick="submitFileComment()"> <i class="fas fa-paper-plane me-1"></i>{{ t('ui.files.post_comment', {}, 'common') }} </button> <button class="btn btn-sm btn-outline-secondary" id="previewCancelReplyBtn" onclick="cancelCommentReply()" style="display:none;"> {{ t('ui.common.cancel', {}, 'common') }} </button> </div> </div> <div class="mt-2"> <button class="btn btn-sm btn-outline-secondary" id="previewLeaveCommentBtn" onclick="showCommentForm()"> <i class="fas fa-comment me-1"></i>{{ t('ui.files.leave_comment', {}, 'common') }} </button> </div> {% else %} <div class="mt-2"> <div class="alert alert-secondary py-2 small"> <i class="fas fa-lock me-1"></i>{{ t('ui.files.login_to_comment', {}, 'common') }} </div> </div> {% endif %} </div> </div> </div> </div> <!-- File Details Modal --> <div class="modal fade" id="fileDetailsModal" tabindex="-1"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <div class="modal-header"> <div class="d-flex align-items-center gap-2"> <button type="button" class="btn btn-outline-secondary btn-sm" id="prevFileBtn" onclick="navigateFile(-1)" title="{{ t('ui.files.previous_file', {}, 'common') }}"> <i class="fas fa-chevron-left"></i> </button> <button type="button" class="btn btn-outline-secondary btn-sm" id="nextFileBtn" onclick="navigateFile(1)" title="{{ t('ui.files.next_file', {}, 'common') }}"> <i class="fas fa-chevron-right"></i> </button> </div> <h5 class="modal-title ms-2" id="fileDetailsTitle">{{ t('ui.files.file_details', {}, 'common') }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <dl class="row"> <dt class="col-sm-3">{{ t('ui.files.filename', {}, 'common') }}:</dt> <dd class="col-sm-9" id="detailFilename"></dd> <dt class="col-sm-3">{{ t('ui.files.size', {}, 'common') }}:</dt> <dd class="col-sm-9" id="detailSize"></dd> <dt class="col-sm-3">{{ t('ui.files.uploaded', {}, 'common') }}:</dt> <dd class="col-sm-9" id="detailUploaded"></dd> <dt class="col-sm-3">{{ t('ui.files.from', {}, 'common') }}:</dt> <dd class="col-sm-9" id="detailFrom"></dd> <dt class="col-sm-3">{{ t('ui.files.area', {}, 'common') }}:</dt> <dd class="col-sm-9" id="detailArea"></dd> <dt class="col-sm-3">{{ t('ui.files.virus_scan', {}, 'common') }}:</dt> <dd class="col-sm-9"> <span id="detailVirusScan"></span> {% if current_user.is_admin %} <div id="scanStatusEditor" class="mt-1 d-none"> <div class="input-group input-group-sm" style="max-width:340px;"> <select class="form-select form-select-sm" id="scanStatusSelect"> <option value="not_scanned">{{ t('ui.files.not_scanned', {}, 'common') }}</option> <option value="clean">{{ t('ui.files.clean', {}, 'common') }}</option> <option value="infected">{{ t('ui.files.infected', {}, 'common') }}</option> </select> <input type="text" class="form-control form-control-sm" id="scanStatusSignature" placeholder="{{ t('ui.files.virus_name_placeholder', {}, 'common') }}" style="display:none;"> <button class="btn btn-outline-primary btn-sm" onclick="saveScanStatus()">{{ t('ui.common.save', {}, 'common') }}</button> </div> </div> {% endif %} </dd> <dt class="col-sm-3" id="detailUrlLabel" style="display:none;">{{ t('ui.files.link_url', {}, 'common') }}:</dt> <dd class="col-sm-9" id="detailUrl" style="display:none;"></dd> <dt class="col-sm-3">{{ t('ui.common.description', {}, 'common') }}:</dt> <dd class="col-sm-9" id="detailDescription"></dd> </dl> <div id="detailLongDescription" class="mt-3"></div> <!-- File comments section --> <div id="fileCommentsSection" class="mt-4" style="display:none;"> <hr> <h6><i class="fas fa-comments me-1"></i>{{ t('ui.files.comments', {}, 'common') }}</h6> <div id="fileCommentsBody"> <div class="text-muted small">{{ t('ui.files.loading_comments', {}, 'common') }}</div> </div> {% if current_user %} <div id="fileCommentReplyForm" class="mt-3" style="display:none;"> <input type="hidden" id="commentReplyToId" value=""> <div id="commentReplyToLabel" class="small text-muted mb-1" style="display:none;"></div> <div class="input-group"> <textarea class="form-control form-control-sm" id="commentBodyInput" rows="3" placeholder="{{ t('ui.files.leave_comment', {}, 'common') }}?" maxlength="4096"></textarea> </div> <div class="d-flex gap-2 mt-2"> <button class="btn btn-sm btn-primary" onclick="submitFileComment()"> <i class="fas fa-paper-plane me-1"></i>{{ t('ui.files.post_comment', {}, 'common') }} </button> <button class="btn btn-sm btn-outline-secondary" id="cancelReplyBtn" onclick="cancelCommentReply()" style="display:none;"> {{ t('ui.common.cancel', {}, 'common') }} </button> </div> </div> <div class="mt-2"> <button class="btn btn-sm btn-outline-secondary" id="leaveCommentBtn" onclick="showCommentForm()"> <i class="fas fa-comment me-1"></i>{{ t('ui.files.leave_comment', {}, 'common') }} </button> </div> {% else %} <div class="mt-2"> <div class="alert alert-secondary py-2 small"> <i class="fas fa-lock me-1"></i>{{ t('ui.files.login_to_comment', {}, 'common') }} </div> </div> {% endif %} </div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.close', {}, 'common') }}</button> {% if current_user.is_admin and not virus_scan_disabled %} <button type="button" class="btn btn-outline-secondary" id="virusScanBtn" onclick="requestVirusScan()" style="display:none;"> <i class="fas fa-shield-alt"></i> {{ t('ui.files.virus_scan_now', {}, 'common') }} </button> {% endif %} {# Rehatch button ? wired up but hidden until ready for use {% if current_user.is_admin %} <button type="button" class="btn btn-outline-secondary" id="rehatchFileBtn" onclick="rehatchFile()" style="display:none;"> <i class="fas fa-retweet"></i> {{ t('ui.files.rehatch', {}, 'common') }} </button> {% endif %} #} <button type="button" class="btn btn-outline-warning" id="renameFileBtn" onclick="showEditDialog()" style="display:none;"> <i class="fas fa-pencil-alt"></i> {{ t('ui.files.edit', {}, 'common') }} </button> {% if current_user %} <button type="button" class="btn btn-outline-info" id="shareFileBtn" onclick="showFileShareDialog()"> <i class="fas fa-share-alt"></i> {{ t('ui.common.share', {}, 'common') }} </button> {% endif %} <a href="#" class="btn btn-primary" id="downloadFileBtn" download> <i class="fas fa-download"></i> {{ t('ui.files.download', {}, 'common') }} </a> </div> </div> </div> </div> <!-- Edit File Modal --> <div class="modal fade" id="fileRenameModal" tabindex="-1"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title"><i class="fas fa-pencil-alt me-2"></i>{{ t('ui.files.edit_file', {}, 'common') }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <div class="mb-3"> <label for="renameFilenameInput" class="form-label">{{ t('ui.files.new_filename', {}, 'common') }}</label> <input type="text" class="form-control" id="renameFilenameInput" maxlength="255"> </div> <div class="mb-3"> <label for="editShortDescInput" class="form-label">{{ t('ui.files.short_description', {}, 'common') }}</label> <input type="text" class="form-control" id="editShortDescInput" maxlength="255"> </div> <div class="mb-3"> <label for="editLongDescInput" class="form-label">{{ t('ui.files.long_description', {}, 'common') }}</label> <textarea class="form-control" id="editLongDescInput" rows="4" maxlength="2000"></textarea> </div> <div class="mb-3" id="editMoveAreaGroup" style="display:none;"> <label for="editMoveAreaSelect" class="form-label">{{ t('ui.files.move_to_area', {}, 'common') }}</label> <select class="form-select" id="editMoveAreaSelect"></select> </div> {% if current_user.is_admin %} <div class="mb-3" id="editUrlGroup" style="display:none;"> <label for="editUrlInput" class="form-label">{{ t('ui.files.link_url_edit', {}, 'common') }}</label> <input type="url" class="form-control" id="editUrlInput" maxlength="2000"> </div> {% endif %} <div id="renameErrorMsg" class="alert alert-danger" style="display:none;"></div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel', {}, 'common') }}</button> <button type="button" class="btn btn-warning" onclick="submitEdit()"> <i class="fas fa-pencil-alt"></i> {{ t('ui.files.edit', {}, 'common') }} </button> </div> </div> </div> </div> <!-- File Share Modal --> <div class="modal fade" id="fileShareModal" tabindex="-1"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title"><i class="fas fa-share-alt me-2"></i>{{ t('ui.files.share_file', {}, 'common') }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <div id="shareModalLoading" class="text-center py-3"> <i class="fas fa-spinner fa-spin me-2"></i>{{ t('ui.common.loading', {}, 'common') }} </div> <div id="shareModalContent" style="display:none;"> <div id="existingShareInfo" class="alert alert-info mb-3" style="display:none;"> <i class="fas fa-info-circle me-1"></i> {{ t('ui.files.active_share_exists', {}, 'common') }} </div> <div id="shareAccessInfo" class="alert alert-secondary mb-3" style="display:none;"> <i class="fas fa-chart-bar me-2"></i> <span id="shareAccessText"></span> </div> <div class="form-check mb-3" id="shareFreqGroup"> <input class="form-check-input" type="checkbox" id="shareFreqAccessible" checked> <label class="form-check-label" for="shareFreqAccessible"> {{ t('ui.files.freq_accessible', {}, 'common') }} </label> <div class="form-text">{{ t('ui.files.freq_accessible_help', {}, 'Also allow this file to be requested via FidoNet FREQ') }}</div> </div> <div class="mb-3" id="shareExpiryGroup"> <label for="shareExpiry" class="form-label">{{ t('ui.files.link_expiry', {}, 'common') }}</label> <select class="form-select" id="shareExpiry"> <option value="">{{ t('ui.common.never', {}, 'common') }}</option> <option value="1">{{ t('ui.common.time.1_hour', {}, 'common') }}</option> <option value="24">{{ t('ui.common.time.24_hours', {}, 'common') }}</option> <option value="168">{{ t('ui.common.time.1_week', {}, 'common') }}</option> <option value="720">{{ t('ui.common.time.30_days', {}, 'common') }}</option> </select> </div> <div class="mb-3" id="shareLinkGroup" style="display:none;"> <label class="form-label">{{ t('ui.files.share_link', {}, 'common') }}</label> <div class="input-group"> <input type="text" class="form-control" id="shareUrlInput" readonly> <button class="btn btn-outline-secondary" type="button" onclick="copyFileShareUrl()"> <i class="fas fa-copy"></i> {{ t('ui.common.copy', {}, 'common') }} </button> </div> </div> <div id="shareRevokeGroup" style="display:none;"> <button type="button" class="btn btn-outline-danger btn-sm" onclick="revokeFileShare()"> <i class="fas fa-times me-1"></i>{{ t('ui.files.revoke_link', {}, 'common') }} </button> </div> </div> <div id="shareModalError" class="alert alert-danger" style="display:none;"></div> </div> <div class="modal-footer"> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.close', {}, 'common') }}</button> <button type="button" class="btn btn-primary" id="createShareBtn" onclick="createFileShare()"> <i class="fas fa-share-alt me-1"></i>{{ t('ui.files.create_share_link', {}, 'common') }} </button> </div> </div> </div> </div> <!-- Upload File Modal --> <div class="modal fade" id="uploadFileModal" tabindex="-1"> <div class="modal-dialog modal-lg"> <div class="modal-content"> <div class="modal-header"> <h5 class="modal-title">{{ t('ui.files.upload_file', {}, 'common') }}</h5> <button type="button" class="btn-close" data-bs-dismiss="modal"></button> </div> <div class="modal-body"> <!-- Tab nav --> <ul class="nav nav-tabs mb-3" id="uploadModalTabs"> <li class="nav-item"> <button class="nav-link active" id="uploadFileTab" data-bs-toggle="tab" data-bs-target="#uploadFilePane" type="button"> <i class="fas fa-upload me-1"></i>{{ t('ui.files.upload_file_tab', {}, 'common') }} </button> </li> <li class="nav-item"> <button class="nav-link" id="addLinkTab" data-bs-toggle="tab" data-bs-target="#addLinkPane" type="button"> <i class="fas fa-link me-1"></i>{{ t('ui.files.add_link_tab', {}, 'common') }} </button> </li> </ul> <div class="tab-content"> <!-- Upload File pane --> <div class="tab-pane fade show active" id="uploadFilePane"> <form id="uploadFileForm" enctype="multipart/form-data"> <div class="mb-3"> <label for="uploadFile" class="form-label">{{ t('ui.files.select_file_required', {}, 'common') }}</label> <input type="file" class="form-control" id="uploadFile" name="file" required multiple> <div class="form-text">{{ t('ui.files.maximum_file_size', {}, 'common') }}: <span id="maxFileSize">10 MB</span></div> </div> <div id="uploadTorrentPreviewContainer"></div> <div class="mb-3" id="uploadDescriptionGroup"> <label for="uploadDescription" class="form-label">{{ t('ui.files.short_description_required', {}, 'common') }}</label> <input type="text" class="form-control" id="uploadDescription" name="description" required maxlength="255"> <div class="form-text">{{ t('ui.files.short_description_help', {}, 'common') }}</div> </div> <div class="mb-3 d-none" id="uploadBatchDescriptionsGroup"> <label class="form-label">{{ t('ui.files.upload_descriptions', {}, 'common') }}</label> <div class="table-responsive border rounded"> <table class="table table-sm mb-0 align-middle"> <thead> <tr> <th>{{ t('ui.common.filename') }}</th> <th>{{ t('ui.files.short_description_required', {}, 'common') }}</th> </tr> </thead> <tbody id="uploadBatchDescriptionsBody"></tbody> </table> </div> <div class="form-text">{{ t('ui.files.upload_descriptions_help', {}, 'common') }}</div> </div> <div class="mb-3"> <label for="uploadLongDescription" class="form-label">{{ t('ui.files.long_description', {}, 'common') }}</label> <textarea class="form-control" id="uploadLongDescription" name="long_description" rows="4" maxlength="2000"></textarea> <div class="form-text">{{ t('ui.files.long_description_help', {}, 'common') }}</div> </div> <div id="uploadProgress" class="progress" style="display: none;"> <div class="progress-bar progress-bar-striped progress-bar-animated" role="progressbar" style="width: 0%"></div> </div> </form> </div> <!-- Add Link pane --> <div class="tab-pane fade" id="addLinkPane"> <div class="mb-3"> <label for="linkUrl" class="form-label">{{ t('ui.files.link_url_required', {}, 'common') }}</label> <div class="input-group"> <input type="url" class="form-control" id="linkUrl" placeholder="https://" maxlength="2000"> <button class="btn btn-outline-secondary" type="button" id="fetchUrlMetaBtn" onclick="fetchUrlMeta()"> <i class="fas fa-search me-1"></i>{{ t('ui.files.fetch_info', {}, 'common') }} </button> </div> <div class="form-text">{{ t('ui.files.link_url_help', {}, 'common') }}</div> </div> <div class="mb-3"> <label for="linkFileName" class="form-label">{{ t('ui.files.link_file_name', {}, 'common') }}</label> <input type="text" class="form-control" id="linkFileName" maxlength="255"> <div class="form-text">{{ t('ui.files.link_file_name_help', {}, 'common') }}</div> </div> <div class="mb-3"> <label for="linkShortDescription" class="form-label">{{ t('ui.files.short_description_required', {}, 'common') }}</label> <input type="text" class="form-control" id="linkShortDescription" maxlength="255"> <div class="form-text">{{ t('ui.files.short_description_help', {}, 'common') }}</div> </div> <div class="mb-3"> <label for="linkLongDescription" class="form-label">{{ t('ui.files.long_description', {}, 'common') }}</label> <textarea class="form-control" id="linkLongDescription" rows="4" maxlength="2000"></textarea> <div class="form-text">{{ t('ui.files.long_description_help', {}, 'common') }}</div> </div> <div id="linkAddError" class="alert alert-danger d-none"></div> </div> </div> </div> <div class="modal-footer"> <div id="uploadErrorBox" class="alert alert-danger w-100 mb-2 d-none"></div> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ t('ui.common.cancel', {}, 'common') }}</button> <button type="button" class="btn btn-primary" id="uploadModalSubmitBtn" onclick="uploadModalSubmit()"> <i class="fas fa-upload"></i> {{ t('ui.files.upload', {}, 'common') }} </button> </div> </div> </div> </div> {% endblock %} {% block scripts %} <script src="/js/ansisys.js"></script> <script src="/js/sixel.js"></script> <script src="/js/pcboard.js"></script> <script src="/js/file-preview.js"></script> <script src="/js/media-player.js"></script> <script> let currentAreaId = null; let currentSubfolder = null; let pendingFileSearch = null; // applied once after the next displayFiles() call let allFiles = []; let displayedFiles = []; let currentFileIndex = -1; let previewNavigationFiles = []; let loadedAreas = []; let allFileAreas = []; let fileAreaSearchQuery = ''; let currentFileId = null; let currentFile = null; let currentShareId = null; let currentViewMode = 'recent'; let myUploadsSummary = null; let linkFileNameAutoPopulated = false; const MY_UPLOADS_VIEW = '__my_uploads__'; const currentUserId = window.currentUserId ?? null; const isAdmin = !!(window.currentUserIsAdmin); function uiT(key, fallback, params = {}) { if (window.t) { return window.t(key, params, fallback); } return fallback; } function apiError(payload, fallback) { if (window.getApiErrorMessage) { return window.getApiErrorMessage(payload, fallback); } return payload && payload.error ? payload.error : fallback; } function apiMessage(payload, fallback) { if (window.getApiMessage) { return window.getApiMessage(payload, fallback); } return payload && payload.message ? payload.message : fallback; } function canDeleteFileRecord(file) { if (isAdmin) { return true; } if (currentUserId === null || file.owner_id === null || Number(file.owner_id) !== Number(currentUserId)) { return false; } return file.area_tag === `PRIVATE_USER_${currentUserId}`; } const isPublicAreaGuest = {{ (is_public_area is defined and is_public_area and not current_user) ? 'true' : 'false' }}; const guestIndexPath = {{ current_user ? '"/files"' : '"/public-files"' }}; $(document).ready(function() { loadI18nNamespaces(['common', 'errors']).then(function() { loadFileAreas(); loadStats(); }); $('#searchFiles').on('input', function() { filterFiles($(this).val()); }); // Auto-populate filename from URL when the URL field loses focus $('#linkUrl').on('blur', function() { const url = $(this).val().trim(); if (!url) return; if (!$('#linkFileName').val().trim() || linkFileNameAutoPopulated) { $('#linkFileName').val(deriveFilenameFromUrl(url)); linkFileNameAutoPopulated = true; } }); // Clear auto-populated flag when user manually edits the filename $('#linkFileName').on('input', function() { linkFileNameAutoPopulated = false; }); // Keyboard navigation for file details modal $(document).on('keydown', function(e) { // Don't intercept keys when focus is inside a text input or textarea const tag = document.activeElement && document.activeElement.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; if ($('#fileDetailsModal').hasClass('show')) { switch (e.key) { case 'ArrowLeft': e.preventDefault(); navigateFile(-1); break; case 'ArrowRight': e.preventDefault(); navigateFile(1); break; } } if ($('#filePreviewModal').hasClass('show')) { switch (e.key) { case 'ArrowLeft': e.preventDefault(); navigatePreview(-1); break; case 'ArrowRight': e.preventDefault(); navigatePreview(1); break; } } }); // Stop media playback when preview modal closes $('#filePreviewModal').on('hidden.bs.modal', function() { $(this).find('video,audio').each(function() { this.pause(); this.src = ''; }); if (window._modPlayer) { try { window._modPlayer.unload(); } catch (e) {} window._modPlayer = null; } if (window._sidPlayerReady) { try { ScriptNodePlayer.getInstance().pause(); } catch (e) {} } if (window.BinkRetroAudio) { try { window.BinkRetroAudio.stopWithin(this); } catch (e) {} } $('#previewModalBody').css('background', '#1a1a1a').html( '<div class="py-5 text-muted"><i class="fas fa-spinner fa-spin fa-2x"></i></div>' ); }); }); function togglePreviewFullscreen() { const modal = document.getElementById('filePreviewModal'); const dialog = modal.querySelector('.modal-dialog'); const icon = document.querySelector('#previewFullscreenToggle i'); if (dialog.classList.contains('modal-fullscreen')) { dialog.classList.remove('modal-fullscreen'); dialog.classList.add('modal-xl'); icon.classList.remove('fa-compress'); icon.classList.add('fa-expand'); UserStorage.setItem('previewModalFullscreen', 'false'); } else { dialog.classList.remove('modal-xl'); dialog.classList.add('modal-fullscreen'); icon.classList.remove('fa-expand'); icon.classList.add('fa-compress'); UserStorage.setItem('previewModalFullscreen', 'true'); } } function applyPreviewFullscreenPreference() { const isFullscreen = UserStorage.getItem('previewModalFullscreen') === 'true'; const modal = document.getElementById('filePreviewModal'); const dialog = modal.querySelector('.modal-dialog'); const icon = document.querySelector('#previewFullscreenToggle i'); if (isFullscreen) { dialog.classList.remove('modal-xl'); dialog.classList.add('modal-fullscreen'); icon.classList.remove('fa-expand'); icon.classList.add('fa-compress'); } } function loadRecentFiles() { currentViewMode = 'recent'; currentAreaId = null; $.get('/api/files/recent?limit=25') .done(function(data) { if (currentAreaId === null) { displayRecentFiles(data.files); } }) .fail(function() { if (currentAreaId === null) { $('#filesContainer').html(`<div class="alert alert-danger m-3">${uiT('ui.files.recent_uploads_load_failed', 'Failed to load recent uploads')}</div>`); } }); } function displayRecentFiles(files) { displayedFiles = files; setPreviewNavigationFiles(files); $('#currentAreaTitle').html(`<i class="fas fa-clock"></i> ${uiT('ui.files.recent_uploads', 'Recent Uploads')}`); $('#currentAreaMeta').hide(); $('#uploadFileBtn').hide(); // Return URL to the correct file index when showing recent uploads if (window.location.pathname !== guestIndexPath) { history.replaceState(null, '', guestIndexPath); } if (files.length === 0) { $('#filesContainer').html(`<div class="p-4 text-center text-muted">${uiT('ui.files.no_recent_uploads', 'No files have been uploaded yet')}</div>`); return; } let html = ` <div class="table-responsive"> <table class="table table-hover mb-0"> <thead> <tr> <th width="25%">${uiT('ui.files.filename', 'Filename')}</th> <th width="35%">${uiT('ui.files.description', 'Description')}</th> <th width="10%">${uiT('ui.files.size', 'Size')}</th> <th width="15%">${uiT('ui.files.uploaded', 'Uploaded')}</th> <th width="15%">${uiT('ui.files.actions', 'Actions')}</th> </tr> </thead> <tbody> `; // Track which ISO subfolders have already been rendered so each subfolder // appears only once in the recent-uploads list (avoids duplicate folder rows // caused by multiple files sharing the same parent directory). const seenIsoSubfolders = new Set(); files.forEach(function(file) { const shortDesc = escapeHtml(file.short_description || ''); const fileSize = formatBytes(file.filesize); const uploadDate = formatDate(file.created_at); const canDelete = canDeleteFileRecord(file); const isIsoDir = file.source_type === 'iso_import' && !!file.subfolder; if (isIsoDir) { // Use "areaId:subfolder" as the dedup key so the same subfolder path // in different areas is treated as distinct entries. const subfolderKey = `${file.file_area_id}:${file.subfolder}`; if (seenIsoSubfolders.has(subfolderKey)) { return; // already rendered a row for this subfolder } seenIsoSubfolders.add(subfolderKey); // Use the subfolder's short_description if set, otherwise the last path component. const dirName = file.subfolder.split('/').pop() || file.subfolder; const sfLabel = file.subfolder_label && file.subfolder_label !== dirName ? file.subfolder_label : dirName; const subfolderName = escapeHtml(sfLabel); const onclick = `selectFileArea(${file.file_area_id}, '${escapeHtml(file.subfolder)}'); return false;`; html += ` <tr> <td> <i class="fas fa-folder text-warning me-2"></i> <a href="#" class="file-preview-link fw-bold" onclick="${onclick}">${subfolderName}</a> </td> <td></td> <td>?</td> <td><small>${uploadDate}</small></td> <td></td> </tr> `; return; } const isUrlLink = file.source_type === 'url'; // Icon for the filename column let scanIcon; if (isUrlLink) { scanIcon = '<i class="fas fa-link text-info me-2"></i>'; } else { scanIcon = '<i class="fas fa-file me-2"></i>'; if (file.virus_scanned) { if (file.virus_scan_result === 'clean') { scanIcon = `<i class="fas fa-shield-alt text-success me-2" title="${uiT('ui.files.virus_scan_clean', 'Virus scan: Clean')}"></i>`; } else if (file.virus_scan_result === 'infected') { scanIcon = `<i class="fas fa-shield-alt text-danger me-2" title="${uiT('ui.files.virus_detected_prefix', 'Virus detected: ')}${escapeHtml(file.virus_signature || uiT('ui.common.unknown', 'Unknown'))}"></i>`; } else if (file.virus_scan_result === 'error') { scanIcon = `<i class="fas fa-shield-alt text-warning me-2" title="${uiT('ui.files.virus_scan_error', 'Virus scan: Error')}"></i>`; } else if (file.virus_scan_result === 'skipped') { scanIcon = `<i class="fas fa-shield-alt text-muted me-2" title="${uiT('ui.files.virus_scan_skipped', 'Virus scan: Skipped')}"></i>`; } } } const shareIcon = file.is_shared ? `<i class="fas fa-share-alt text-success ms-1" title="${uiT('ui.files.shared', 'Shared')}"></i>` : ''; const commentCount0 = parseInt(file.comment_count || 0, 10); const commentBadge0 = commentCount0 > 0 ? `<a href="#" class="badge bg-secondary ms-1 text-decoration-none" onclick="showFileDetails(${file.id}); return false;" title="${uiT('ui.files.comments', 'Comments')}"><i class="fas fa-comment me-1"></i>${commentCount0}</a>` : ''; // Action buttons: URL links get a "Visit" link, files get download button const primaryAction0 = isUrlLink ? `<a href="${escapeHtml(file.url || '#')}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-success" title="${uiT('ui.files.visit_link', 'Visit')}"> <i class="fas fa-external-link-alt"></i> </a>` : `<a href="/api/files/${file.id}/download" class="btn btn-sm btn-outline-success" download> <i class="fas fa-download"></i> </a>`; html += ` <tr> <td style="word-break: break-all;"> ${scanIcon}${shareIcon} <a href="#" class="file-preview-link fw-bold" onclick="previewFile(${file.id}); return false;">${escapeHtml(file.filename)}</a>${commentBadge0} </td> <td style="word-break: break-all;"><small class="text-muted">${shortDesc}</small></td> <td>${isUrlLink ? '?' : fileSize}</td> <td><small>${uploadDate}</small></td> <td> <button class="btn btn-sm btn-outline-primary" onclick="showFileDetails(${file.id})"> <i class="fas fa-info-circle"></i> </button> ${primaryAction0} ${canDelete ? ` <button class="btn btn-sm btn-outline-danger" onclick="confirmDeleteFile(${file.id}, '${escapeHtml(file.filename)}')"> <i class="fas fa-trash"></i> </button> ` : ''} </td> </tr> `; }); html += '</tbody></table></div>'; $('#filesContainer').html(html); } function uploadStatusBadge(status, reason) { if (status === 'approved') { return `<span class="badge bg-success">${uiT('ui.files.status_approved', 'Approved')}</span>`; } if (status === 'pending') { return `<span class="badge bg-warning text-dark">${uiT('ui.files.status_pending', 'Pending Approval')}</span>`; } if (status === 'rejected') { const reasonHtml = reason ? `<div class="small text-muted mt-1">${escapeHtml(reason)}</div>` : ''; return `<span class="badge bg-danger">${uiT('ui.files.status_rejected', 'Rejected')}</span>${reasonHtml}`; } return `<span class="badge bg-secondary">${escapeHtml(status || uiT('ui.common.unknown', 'Unknown'))}</span>`; } function loadMyUploads() { currentViewMode = 'my_uploads'; currentAreaId = null; currentSubfolder = null; $('#filesContainer').html(`<div class="loading-spinner p-4"><i class="fas fa-spinner fa-spin me-2"></i>${uiT('ui.common.loading', 'Loading...')}</div>`); $.get('/api/files/my-uploads') .done(function(data) { myUploadsSummary = data.summary || myUploadsSummary; displayMyUploads(data.files || []); }) .fail(function() { $('#filesContainer').html(`<div class="alert alert-danger m-3">${uiT('ui.files.my_uploads_load_failed', 'Failed to load your uploads')}</div>`); }); } function displayMyUploads(files) { displayedFiles = files; setPreviewNavigationFiles(files); $('#currentAreaTitle').html(`<i class="fas fa-upload"></i> ${uiT('ui.files.my_uploads', 'My Uploads')}`); const summaryText = uiT('ui.files.my_uploads_summary', 'Pending: {pending} Approved: {approved} Rejected: {rejected}', { pending: myUploadsSummary ? (myUploadsSummary.pending_count || 0) : 0, approved: myUploadsSummary ? (myUploadsSummary.approved_count || 0) : 0, rejected: myUploadsSummary ? (myUploadsSummary.rejected_count || 0) : 0 }); $('#currentAreaMeta').html(`<small class="text-muted">${summaryText}</small>`).show(); $('#uploadFileBtn').hide(); history.replaceState(null, '', '/files?view=my-uploads'); if (files.length === 0) { $('#filesContainer').html(`<div class="p-4 text-center text-muted">${uiT('ui.files.no_my_uploads', 'You have not uploaded any files yet')}</div>`); return; } let html = ` <div class="table-responsive"> <table class="table table-hover mb-0"> <thead> <tr> <th width="24%">${uiT('ui.files.filename', 'Filename')}</th> <th width="24%">${uiT('ui.files.area', 'Area')}</th> <th width="18%">${uiT('ui.files.status', 'Status')}</th> <th width="19%">${uiT('ui.files.uploaded', 'Uploaded')}</th> <th width="15%">${uiT('ui.files.actions', 'Actions')}</th> </tr> </thead> <tbody> `; files.forEach(function(file) { const uploadDate = formatDate(file.created_at); const canDelete = canDeleteFileRecord(file); const isUrlLink = file.source_type === 'url'; const areaTag = file.area_tag ? escapeHtml(file.area_tag) + (file.domain ? `<span class="text-muted">@${escapeHtml(file.domain)}</span>` : '') : '&mdash;'; const fileIcon = isUrlLink ? '<i class="fas fa-link text-info me-1"></i>' : ''; const primaryAction = isUrlLink ? `<a href="${escapeHtml(file.url || '#')}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-success" title="${uiT('ui.files.visit_link', 'Visit')}"> <i class="fas fa-external-link-alt"></i> </a>` : `<a href="/api/files/${file.id}/download" class="btn btn-sm btn-outline-success" download> <i class="fas fa-download"></i> </a>`; html += ` <tr> <td style="word-break: break-all;"> ${fileIcon}<a href="#" class="file-preview-link fw-bold" onclick="showFileDetails(${file.id}); return false;">${escapeHtml(file.filename)}</a> <div class="small text-muted">${escapeHtml(file.short_description || '')}</div> </td> <td>${areaTag}</td> <td>${uploadStatusBadge(file.status, file.rejection_reason || '')}</td> <td><small>${uploadDate}</small></td> <td> <button class="btn btn-sm btn-outline-primary" onclick="showFileDetails(${file.id})"> <i class="fas fa-info-circle"></i> </button> ${primaryAction} ${canDelete ? ` <button class="btn btn-sm btn-outline-danger" onclick="confirmDeleteFile(${file.id}, '${escapeHtml(file.filename)}')"> <i class="fas fa-trash"></i> </button> ` : ''} </td> </tr> `; }); html += '</tbody></table></div>'; $('#filesContainer').html(html); } function loadFileAreas() { $.get('/api/fileareas?filter=active') .done(function(data) { displayFileAreas(data.fileareas, data.private_area || null, data.my_uploads_summary || null); }) .fail(function() { $('#fileAreasList').html(`<div class="alert alert-danger m-3">${uiT('ui.files.file_areas_load_failed', 'Failed to load file areas')}</div>`); }); } function searchFileAreas(query) { fileAreaSearchQuery = query.toLowerCase(); applyFileAreaFilter(); } function applyFileAreaFilter() { let filtered = allFileAreas; if (fileAreaSearchQuery.length > 0) { filtered = allFileAreas.filter(area => (area.tag || '').toLowerCase().includes(fileAreaSearchQuery) || (area.description || '').toLowerCase().includes(fileAreaSearchQuery) ); } renderFileAreaList(filtered); } let privateFileArea = null; function displayFileAreas(areas, privateArea, uploadsSummary) { allFileAreas = areas; privateFileArea = privateArea || null; myUploadsSummary = uploadsSummary || null; // Inject private area into allFileAreas so selectFileArea/canAccessFileArea can find it if (privateFileArea && !allFileAreas.find(a => a.id === privateFileArea.id)) { allFileAreas = [privateFileArea, ...allFileAreas]; } applyFileAreaFilter(); } function renderFileAreaList(areas) { // Exclude private area from the main sorted list ? it gets its own pinned entry const publicAreas = areas.filter(a => !privateFileArea || a.id !== privateFileArea.id); if (publicAreas.length === 0 && !privateFileArea && currentUserId === null) { $('#fileAreasList').html(`<div class="p-3 text-muted">${uiT('ui.files.no_file_areas', 'No file areas available')}</div>`); return; } const sortedAreas = [...publicAreas].sort((a, b) => { const aDomainRaw = a.domain || ''; const bDomainRaw = b.domain || ''; const aDomain = aDomainRaw.toLowerCase(); const bDomain = bDomainRaw.toLowerCase(); const aHasDomain = aDomainRaw !== ''; const bHasDomain = bDomainRaw !== ''; if (aHasDomain !== bHasDomain) { return aHasDomain ? 1 : -1; } if (aDomain !== bDomain) { return aDomain.localeCompare(bDomain); } const aTag = (a.tag || '').toLowerCase(); const bTag = (b.tag || '').toLowerCase(); return aTag.localeCompare(bTag); }); loadedAreas = allFileAreas; let html = ''; if (currentUserId !== null) { const uploadCount = myUploadsSummary ? (myUploadsSummary.total_count || 0) : 0; const uploadSize = formatBytes(myUploadsSummary ? (myUploadsSummary.total_size || 0) : 0); const pendingCount = myUploadsSummary ? (myUploadsSummary.pending_count || 0) : 0; const pendingBadge = pendingCount > 0 ? `<span class="badge bg-warning text-dark ms-1">${pendingCount}</span>` : ''; html += ` <a href="#" class="list-group-item list-group-item-action" data-area-id="${MY_UPLOADS_VIEW}" onclick="selectMyUploads(); return false;"> <div class="d-flex justify-content-between align-items-start"> <div class="flex-grow-1"> <i class="fas fa-upload me-1 text-info"></i> <strong>${uiT('ui.files.my_uploads', 'My Uploads')}</strong>${pendingBadge} </div> <div class="text-end ms-2"> <small class="d-block">${uploadCount} ${uiT('ui.files.files_count_label', 'files')}</small> <small class="text-muted">${uploadSize}</small> </div> </div> </a> `; if (privateFileArea || sortedAreas.length > 0) { html += `<div class="list-group-item p-0"><hr class="m-0"></div>`; } } // "My Files" pinned entry ? only shown when user has a private area if (privateFileArea) { const myFileCount = privateFileArea.file_count || 0; const myTotalSize = formatBytes(privateFileArea.total_size || 0); html += ` <a href="#" class="list-group-item list-group-item-action" data-area-id="${privateFileArea.id}" onclick="selectFileArea(${privateFileArea.id}); return false;"> <div class="d-flex justify-content-between align-items-start"> <div class="flex-grow-1"> <i class="fas fa-user-circle me-1 text-primary"></i> <strong>${uiT('ui.files.my_files', 'My Files')}</strong> </div> <div class="text-end ms-2"> <small class="d-block">${myFileCount} ${uiT('ui.files.files_count_label', 'files')}</small> <small class="text-muted">${myTotalSize}</small> </div> </div> </a> `; if (sortedAreas.length > 0) { html += `<div class="list-group-item p-0"><hr class="m-0"></div>`; } } sortedAreas.forEach(function(area) { const fileCount = area.file_count || 0; const totalSize = formatBytes(area.total_size || 0); // Display domain for non-local areas (e.g., "GENERAL_FILES@fidonet") let displayTag = escapeHtml(area.tag); if (!area.is_local && area.domain) { displayTag += '<span class="text-muted">@' + escapeHtml(area.domain) + '</span>'; } const uploadPermission = area.upload_permission !== undefined ? area.upload_permission : 1; html += ` <a href="#" class="list-group-item list-group-item-action" data-area-id="${area.id}" onclick="selectFileArea(${area.id}); return false;"> <div class="d-flex justify-content-between align-items-start"> <div class="flex-grow-1"> <strong>${displayTag}</strong> <small class="d-block text-muted">${escapeHtml(area.description || '')}</small> </div> <div class="text-end ms-2"> <small class="d-block">${fileCount} ${uiT('ui.files.files_count_label', 'files')}</small> <small class="text-muted">${totalSize}</small> </div> </div> </a> `; }); $('#fileAreasList').html(html); // Preserve the active selection when the area list is refreshed after an // in-page action like delete/rename/upload. if (currentViewMode === 'my_uploads' && currentUserId !== null) { selectMyUploads(); return; } if (currentViewMode === 'area' && currentAreaId !== null) { const activeArea = allFileAreas.find(a => a.id === currentAreaId); if (activeArea) { selectFileArea(currentAreaId, currentSubfolder); return; } } // Auto-select area from the current URL path (e.g. /files/AREATAG), the // server-supplied initial tag, or legacy ?area= query param. const initialTag = {{ initial_area_tag is defined ? initial_area_tag|json_encode|raw : 'null' }}; const params = new URLSearchParams(window.location.search); if (params.get('view') === 'my-uploads' && currentUserId !== null) { selectMyUploads(); return; } let pathAreaTag = null; const pathMatch = window.location.pathname.match(/^\/files\/([^\/?#]+)$/); if (pathMatch && pathMatch[1]) { pathAreaTag = decodeURIComponent(pathMatch[1]); } const areaParam = pathAreaTag || initialTag || params.get('area'); const searchParam = params.get('search'); if (areaParam) { const targetId = parseInt(areaParam, 10); const allAreas = [...sortedAreas, ...(privateFileArea ? [privateFileArea] : [])]; const target = isNaN(targetId) ? allAreas.find(a => (a.tag || '').toUpperCase() === areaParam.toUpperCase()) : allAreas.find(a => a.id === targetId); if (target) { if (searchParam) { pendingFileSearch = searchParam; } selectFileArea(target.id); return; } } // No area ? show recent uploads loadRecentFiles(); } function selectMyUploads() { $('#searchFiles').val(''); $('#fileAreasList .list-group-item').removeClass('active'); $(`#fileAreasList .list-group-item[data-area-id="${MY_UPLOADS_VIEW}"]`).addClass('active'); loadMyUploads(); } function selectFileArea(areaId, subfolder) { currentViewMode = 'area'; currentAreaId = areaId; currentSubfolder = subfolder !== undefined ? subfolder : null; const area = allFileAreas.find(a => a.id === areaId); if (area) { const isLocal = !!area.is_local; const domain = area.domain || ''; const uploadPermission = area.upload_permission !== undefined ? area.upload_permission : 1; // Display domain for non-local areas; use friendly label for private area let displayTag; if (privateFileArea && area.id === privateFileArea.id) { displayTag = privateFileArea._username ? `${escapeHtml(privateFileArea._username)}'s private file area` : uiT('ui.files.my_files', 'My Files'); } else { displayTag = escapeHtml(area.tag); if (!isLocal && domain) { displayTag += '<span class="text-muted">@' + escapeHtml(domain) + '</span>'; } } $('#currentAreaTitle').html(`<i class="fas fa-folder"></i> ${displayTag}`); $('#searchFiles').val(''); // Build description + capability icons under the area name let icons = ''; if (area.gemini_public) { icons += `<span class="badge text-bg-info me-1" style="font-size:0.65em;" title="${uiT('ui.fileareas.gemini_public', 'Gemini Public')}"><i class="fas fa-gem"></i> Gemini</span>`; } if (area.freq_enabled) { icons += `<span class="badge text-bg-warning me-1" style="font-size:0.65em;" title="${uiT('ui.fileareas.freq_enabled', 'FREQ Enabled')}"><i class="fas fa-file-download"></i> FREQ</span>`; } const desc = area.description ? escapeHtml(area.description) : ''; if (desc || icons) { $('#currentAreaMeta').html(`${icons}<small class="text-muted">${desc}</small>`).show(); } else { $('#currentAreaMeta').hide(); } // Control upload button visibility based on upload permission // 0 = UPLOAD_ADMIN_ONLY, 1 = UPLOAD_USERS_ALLOWED, 2 = UPLOAD_READ_ONLY const UPLOAD_ADMIN_ONLY = 0; const UPLOAD_READ_ONLY = 2; if (uploadPermission === UPLOAD_READ_ONLY) { $('#uploadFileBtn').hide(); } else if (currentUserId === null) { $('#uploadFileBtn').hide(); } else if (uploadPermission === UPLOAD_ADMIN_ONLY) { if (isAdmin) { $('#uploadFileBtn').show(); } else { $('#uploadFileBtn').hide(); } } else { $('#uploadFileBtn').show(); } // Highlight selected area $('#fileAreasList .list-group-item').removeClass('active'); $(`#fileAreasList .list-group-item[data-area-id="${areaId}"]`).addClass('active'); // Update URL so this area can be linked to directly if (area.tag) { history.replaceState(null, '', '/files/' + encodeURIComponent(area.tag)); } } loadFiles(areaId, currentSubfolder); } function loadFiles(areaId, subfolder) { subfolder = subfolder !== undefined ? subfolder : currentSubfolder; currentSubfolder = subfolder; $('#filesContainer').html(`<div class="loading-spinner p-4"><i class="fas fa-spinner fa-spin me-2"></i>${uiT('ui.files.loading_files', 'Loading files...')}</div>`); let url = `/api/files?area_id=${areaId}`; if (subfolder !== null) { url += `&subfolder=${encodeURIComponent(subfolder)}`; } $.get(url) .done(function(data) { allFiles = data.files; const subfolders = data.subfolders || []; displayFiles(allFiles, subfolders, subfolder, data.subfolder_label || null); }) .fail(function() { $('#filesContainer').html(`<div class="alert alert-danger m-3">${uiT('ui.files.load_files_failed', 'Failed to load files')}</div>`); }); } function enterSubfolder(name) { currentSubfolder = name; loadFiles(currentAreaId, name); } function exitSubfolder() { currentSubfolder = null; loadFiles(currentAreaId, null); } const subfolderLabels = { 'attachments': 'Netmail Attachments', 'incoming': 'FREQ Responses', }; function subfolderLabel(name) { return subfolderLabels[name] || name; } function displayFiles(files, subfolders, subfolder, subfolderDisplayLabel) { subfolders = subfolders || []; subfolder = subfolder !== undefined ? subfolder : currentSubfolder; subfolderDisplayLabel = subfolderDisplayLabel !== undefined ? subfolderDisplayLabel : null; displayedFiles = files; setPreviewNavigationFiles(files); if (files.length === 0 && subfolders.length === 0) { let emptyHtml = ''; if (subfolder !== null) { emptyHtml = buildBreadcrumb(subfolder, subfolderDisplayLabel); } emptyHtml += `<div class="p-4 text-center text-muted">${uiT('ui.files.no_files_in_area', 'No files in this area')}</div>`; $('#filesContainer').html(emptyHtml); return; } let html = ''; // Breadcrumb when inside a subfolder if (subfolder !== null) { html += buildBreadcrumb(subfolder, subfolderDisplayLabel); } html += ` <div class="table-responsive"> <table class="table table-hover mb-0"> <thead> <tr> <th width="25%">${uiT('ui.files.filename', 'Filename')}</th> <th width="35%">${uiT('ui.files.description', 'Description')}</th> <th width="10%">${uiT('ui.files.size', 'Size')}</th> <th width="15%">${uiT('ui.files.uploaded', 'Uploaded')}</th> <th width="15%">${uiT('ui.files.actions', 'Actions')}</th> </tr> </thead> <tbody> `; // Subfolder rows (only shown at root level) // Each folder is {subfolder, description, subdir_id} subfolders.forEach(function(folder) { const sf = folder.subfolder; const dirName = subfolderLabel(sf); // Show short_description as the folder label if it's set and differs from the directory name const shortDesc = folder.description || ''; const label = escapeHtml(shortDesc && shortDesc !== sf ? shortDesc : dirName); const desc = escapeHtml(folder.long_description || ''); const editBtn = isAdmin && folder.subdir_id ? `<button class="btn btn-sm btn-outline-secondary me-1" title="${uiT('ui.files.edit_description', 'Edit description')}" onclick="event.stopPropagation(); openSubdirEdit(${folder.subdir_id})"> <i class="fas fa-pencil-alt"></i> </button>` : ''; const deleteBtn = isAdmin ? `<button class="btn btn-sm btn-outline-danger me-1" title="${uiT('ui.files.delete_subfolder', 'Delete subfolder')}" onclick="event.stopPropagation(); confirmDeleteSubfolder(${currentAreaId}, '${escapeHtml(sf)}')"> <i class="fas fa-trash"></i> </button>` : ''; html += ` <tr class="table-secondary" style="cursor:pointer;" onclick="enterSubfolder('${escapeHtml(sf)}')"> <td> <i class="fas fa-folder text-warning me-2"></i> <strong>${label}</strong> </td> <td><small class="text-muted">${desc}</small></td> <td>?</td> <td>?</td> <td class="text-end"> ${editBtn}${deleteBtn} <i class="fas fa-chevron-right text-muted"></i> </td> </tr> `; }); files.forEach(function(file) { const shortDesc = escapeHtml(file.short_description || ''); const fileSize = formatBytes(file.filesize); const uploadDate = formatDate(file.created_at); const canDelete = canDeleteFileRecord(file); const isUrlLink = file.source_type === 'url'; // Icon for the filename column let scanIcon; if (isUrlLink) { scanIcon = '<i class="fas fa-link text-info me-2"></i>'; } else { scanIcon = '<i class="fas fa-file me-2"></i>'; if (file.virus_scanned) { if (file.virus_scan_result === 'clean') { scanIcon = `<i class="fas fa-shield-alt text-success me-2" title="${uiT('ui.files.virus_scan_clean', 'Virus scan: Clean')}"></i>`; } else if (file.virus_scan_result === 'infected') { scanIcon = `<i class="fas fa-shield-alt text-danger me-2" title="${uiT('ui.files.virus_detected_prefix', 'Virus detected: ')}${escapeHtml(file.virus_signature || uiT('ui.common.unknown', 'Unknown'))}"></i>`; } else if (file.virus_scan_result === 'error') { scanIcon = `<i class="fas fa-shield-alt text-warning me-2" title="${uiT('ui.files.virus_scan_error', 'Virus scan: Error')}"></i>`; } else if (file.virus_scan_result === 'skipped') { scanIcon = `<i class="fas fa-shield-alt text-muted me-2" title="${uiT('ui.files.virus_scan_skipped', 'Virus scan: Skipped')}"></i>`; } } } const shareIcon = file.is_shared ? `<i class="fas fa-share-alt text-success ms-1" title="${uiT('ui.files.shared', 'Shared')}"></i>` : ''; const commentCount1 = parseInt(file.comment_count || 0, 10); const commentBadge1 = commentCount1 > 0 ? `<a href="#" class="badge bg-secondary ms-1 text-decoration-none" onclick="showFileDetails(${file.id}); return false;" title="${uiT('ui.files.comments', 'Comments')}"><i class="fas fa-comment me-1"></i>${commentCount1}</a>` : ''; // Filename cell: both URL links and files open the preview modal const filenameCell = `<a href="#" class="file-preview-link fw-bold" onclick="previewFile(${file.id}); return false;">${escapeHtml(file.filename)}</a>${commentBadge1}`; // Action buttons: URL links get a "Visit" link, files get download button const primaryAction = isUrlLink ? `<a href="${escapeHtml(file.url || '#')}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-success" title="${uiT('ui.files.visit_link', 'Visit')}"> <i class="fas fa-external-link-alt"></i> </a>` : `<a href="/api/files/${file.id}/download" class="btn btn-sm btn-outline-success" download> <i class="fas fa-download"></i> </a>`; html += ` <tr> <td style="word-break: break-all;"> ${scanIcon}${shareIcon} ${filenameCell} </td> <td style="word-break: break-all;"><small class="text-muted">${shortDesc}</small></td> <td>${isUrlLink ? '?' : fileSize}</td> <td><small>${uploadDate}</small></td> <td> <button class="btn btn-sm btn-outline-primary" onclick="showFileDetails(${file.id})"> <i class="fas fa-info-circle"></i> </button> ${primaryAction} ${canDelete ? ` <button class="btn btn-sm btn-outline-danger" onclick="confirmDeleteFile(${file.id}, '${escapeHtml(file.filename)}')"> <i class="fas fa-trash"></i> </button> ` : ''} </td> </tr> `; }); html += '</tbody></table></div>'; $('#filesContainer').html(html); // Apply any pending search from URL params (set before files were loaded) if (pendingFileSearch !== null) { const q = pendingFileSearch; pendingFileSearch = null; $('#searchFiles').val(q); filterFiles(q); } } function buildBreadcrumb(subfolder, label) { const area = allFileAreas.find(a => a.id === currentAreaId); const areaLabel = area ? area.tag : uiT('ui.files.title', 'Files'); const parts = subfolder ? subfolder.split('/') : []; let items = ` <li class="breadcrumb-item"> <a href="#" onclick="exitSubfolder(); return false;"> <i class="fas fa-folder-open me-1"></i>${escapeHtml(areaLabel)} </a> </li> `; parts.forEach(function(part, i) { const path = parts.slice(0, i + 1).join('/'); const isLast = i === parts.length - 1; // Use server-resolved label only for the deepest segment const segLabel = isLast ? (label || subfolderLabel(path)) : subfolderLabel(part); if (isLast) { items += ` <li class="breadcrumb-item fw-semibold" aria-current="page"> <i class="fas fa-folder me-1 text-warning"></i>${escapeHtml(segLabel)} </li> `; } else { items += ` <li class="breadcrumb-item"> <a href="#" onclick="selectFileArea(${currentAreaId}, '${escapeHtml(path)}'); return false;"> <i class="fas fa-folder me-1 text-warning"></i>${escapeHtml(segLabel)} </a> </li> `; } }); return ` <nav class="px-3 pt-2 pb-1 small border-bottom" aria-label="breadcrumb"> <ol class="breadcrumb mb-0">${items}</ol> </nav> `; } function filterFiles(query) { if (!query) { displayFiles(allFiles); return; } query = query.toLowerCase(); const filtered = allFiles.filter(function(file) { return file.filename.toLowerCase().includes(query) || (file.short_description && file.short_description.toLowerCase().includes(query)); }); displayFiles(filtered); } function showFileDetails(fileId) { currentFileId = fileId; currentFile = null; currentShareId = null; currentFileIndex = displayedFiles.findIndex(f => f.id == fileId); $.get(`/api/files/${fileId}`) .done(function(data) { const file = data.file; currentFile = file; $('#detailFilename').text(file.filename); $('#detailSize').text(file.source_type === 'url' ? '?' : formatBytes(file.filesize)); $('#detailUploaded').text(formatDate(file.created_at)); $('#detailFrom').text(file.display_from || uiT('ui.common.unknown', 'Unknown')); const detailAreaTag = file.area_tag || file.file_area_tag || ''; const detailAreaDomain = file.domain ? `<span class="text-muted">@${escapeHtml(file.domain)}</span>` : ''; $('#detailArea').html(detailAreaTag ? `<span class="badge bg-secondary">${escapeHtml(detailAreaTag)}</span>${detailAreaDomain}` : uiT('ui.common.unknown', 'Unknown') ); // Virus scan status let scanStatus = uiT('ui.files.not_scanned', 'Not scanned'); if (file.virus_scanned) { if (file.virus_scan_result === 'clean') { scanStatus = `<span class="badge bg-success"><i class="fas fa-shield-alt"></i> ${uiT('ui.files.clean', 'Clean')}</span>`; } else if (file.virus_scan_result === 'infected') { const sigName = file.virus_signature ? escapeHtml(file.virus_signature) : uiT('ui.common.unknown', 'Unknown'); scanStatus = `<span class="badge bg-danger"><i class="fas fa-shield-alt"></i> ${uiT('ui.files.infected', 'Infected')}</span> <small class="text-danger">${sigName}</small>`; } else if (file.virus_scan_result === 'error') { scanStatus = `<span class="badge bg-warning"><i class="fas fa-shield-alt"></i> ${uiT('ui.files.scan_error', 'Scan Error')}</span>`; } else if (file.virus_scan_result === 'skipped') { scanStatus = `<span class="badge bg-secondary"><i class="fas fa-shield-alt"></i> ${uiT('ui.files.skipped', 'Skipped')}</span>`; } if (file.virus_scanned_at) { scanStatus += ' <small class="text-muted">(' + formatDate(file.virus_scanned_at) + ')</small>'; } } $('#detailVirusScan').html(scanStatus); if (isAdmin) { const editorVal = !file.virus_scanned ? 'not_scanned' : (file.virus_scan_result || 'not_scanned'); $('#scanStatusSelect').val(editorVal); $('#scanStatusSignature').val(file.virus_signature || ''); $('#scanStatusSignature').toggle(editorVal === 'infected'); $('#scanStatusEditor').removeClass('d-none'); } // URL row (shown for url-type links only) if (file.source_type === 'url' && file.url) { $('#detailUrlLabel').show(); $('#detailUrl').show().html( `<a href="${escapeHtml(file.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(file.url)}</a>` ); if (window.BinkMediaPlayer) { BinkMediaPlayer.cleanup(document.getElementById('detailUrl')); BinkMediaPlayer.scan(document.getElementById('detailUrl')); } } else { $('#detailUrlLabel').hide(); $('#detailUrl').hide().html(''); } $('#detailDescription').text(file.short_description || uiT('ui.files.no_description', 'No description')); if (file.long_description) { $('#detailLongDescription').html(` <strong>${uiT('ui.files.long_description', 'Long Description')}:</strong> <pre class="bg-light p-2 rounded">${escapeHtml(file.long_description)}</pre> `); } else { $('#detailLongDescription').html(''); } // Download/Visit button if (file.source_type === 'url' && file.url) { $('#downloadFileBtn') .attr('href', file.url) .attr('target', '_blank') .attr('rel', 'noopener noreferrer') .attr('download', null) .html(`<i class="fas fa-external-link-alt"></i> ${uiT('ui.files.visit_link', 'Visit')}`); } else { $('#downloadFileBtn') .attr('href', `/api/files/${fileId}/download`) .attr('target', null) .attr('rel', null) .attr('download', '') .html(`<i class="fas fa-download"></i> ${uiT('ui.files.download', 'Download')}`); } // Update title with position indicator const position = displayedFiles.length > 1 && currentFileIndex >= 0 ? ` <small class="text-muted">(${currentFileIndex + 1} / ${displayedFiles.length})</small>` : ''; $('#fileDetailsTitle').html(escapeHtml(file.filename) + position); updateFileNavButtons(); const isUrlLink = file.source_type === 'url'; const canEdit = isAdmin || (currentUserId !== null && file.owner_id !== null && file.owner_id === currentUserId); $('#renameFileBtn').toggle(canEdit); // Virus scan button not applicable for URL links $('#virusScanBtn').toggle(isAdmin && !isUrlLink).data('file-id', fileId); $('#shareFileBtn').toggle((file.status || 'approved') === 'approved' && !isUrlLink); const canRehatch = isAdmin && !file.is_local && !file.is_private && !isUrlLink; $('#rehatchFileBtn').toggle(canRehatch); // Reset and load comments commentCtxPrefix = ''; currentCommentFileId = fileId; $('#fileCommentsSection').hide(); $('#fileCommentReplyForm').hide(); $('#leaveCommentBtn').show(); cancelCommentReply(); $('#commentBodyInput').val(''); if (!isPublicAreaGuest && (file.status || 'approved') === 'approved') { loadFileComments(fileId); } if (!$('#fileDetailsModal').hasClass('show')) { new bootstrap.Modal('#fileDetailsModal').show(); } }) .fail(function() { showError(uiT('ui.files.load_details_failed', 'Failed to load file details')); }); } function updateFileNavButtons() { $('#prevFileBtn').prop('disabled', currentFileIndex <= 0); $('#nextFileBtn').prop('disabled', currentFileIndex < 0 || currentFileIndex >= displayedFiles.length - 1); } function navigateFile(direction) { if (displayedFiles.length === 0) return; const newIndex = currentFileIndex + direction; if (newIndex < 0 || newIndex >= displayedFiles.length) return; const nextFile = displayedFiles[newIndex]; if (!nextFile) return; currentFileIndex = newIndex; showFileDetails(nextFile.id); } // --------------------------------------------------------------------------- // File preview (lightbox-style modal for images, video, audio, text) // --------------------------------------------------------------------------- let currentPreviewFileId = null; function setPreviewNavigationFiles(files) { previewNavigationFiles = Array.isArray(files) ? files : []; } function getPreviewPositionHtml(fileIndex) { return previewNavigationFiles.length > 1 && fileIndex >= 0 ? ` <small class="text-muted">(${fileIndex + 1}/${previewNavigationFiles.length})</small>` : ''; } /** * Open the file preview modal. * * When called from the normal file list, only fileId is needed ? the filename * and list position are resolved from displayedFiles. When called from search * results (where the file is not in displayedFiles), pass filename explicitly; * prev/next navigation is disabled automatically when the file is not found in * the list. * * @param {number} fileId * @param {string} [filename] - Optional; resolved from displayedFiles when omitted */ /** * Render a URL link record's preview card into the preview modal body. * Shows the short description, long description, and a prominent link button. * * @param {object} file File record (must have url, short_description, long_description) * @param {jQuery} $target The modal body element to render into */ function renderUrlLinkPreview(file, $target) { const url = escapeHtml(file.url || ''); const shortDesc = escapeHtml(file.short_description || ''); const longDesc = file.long_description ? `<pre class="mt-3 mb-0 text-start text-wrap" style="white-space:pre-wrap;word-break:break-word;">${escapeHtml(file.long_description)}</pre>` : ''; const visitBtn = `<a href="${url}" target="_blank" rel="noopener noreferrer" class="btn btn-primary"> <i class="fas fa-external-link-alt me-2"></i>${uiT('ui.files.visit_link', 'Visit')} </a>`; $target.css('background', '').html(` <div class="p-4 text-start"> <div class="d-flex align-items-center gap-2 mb-3"> <i class="fas fa-link fa-lg text-info"></i> <span class="fw-semibold fs-5">${shortDesc || url}</span> </div> <div id="urlLinkOgImage"></div> ${longDesc} <div class="mt-4"> ${visitBtn} <div class="mt-2 small text-muted text-break">${url}</div> </div> </div> `); // Fetch og:image from the server-side meta endpoint (avoids CORS) if (file.url) { $.ajax({ url: '/api/files/fetch-url-meta', type: 'POST', contentType: 'application/json', data: JSON.stringify({ url: file.url }), timeout: 10000, }).done(function(data) { if (data.og_image_url) { const img = $('<img>') .attr('src', data.og_image_url) .attr('alt', '') .css({ maxWidth: '100%', maxHeight: '300px', borderRadius: '6px', display: 'block', marginBottom: '12px' }) .on('error', function() { $(this).parent().remove(); }); $('#urlLinkOgImage').html('').append(img); } }); } } function previewFile(fileId, filename) { if (window._modPlayer) { try { window._modPlayer.unload(); } catch (e) {} window._modPlayer = null; } if (!previewNavigationFiles.length) { setPreviewNavigationFiles(displayedFiles); } let fileIndex = previewNavigationFiles.findIndex(f => f.id == fileId); if (!filename) { if (fileIndex === -1) return; filename = previewNavigationFiles[fileIndex].filename; } currentPreviewFileId = fileId; currentFileIndex = fileIndex; const fileRecord = fileIndex >= 0 ? previewNavigationFiles[fileIndex] : null; const isUrlLink = fileRecord && fileRecord.source_type === 'url'; const position = getPreviewPositionHtml(fileIndex); $('#previewModalTitle').html(escapeHtml(filename) + position); $('#previewMagnetBtn').addClass('d-none').removeData('magnet'); if (isUrlLink && fileRecord.url) { $('#previewDownloadBtn') .attr('href', fileRecord.url) .attr('target', '_blank') .attr('rel', 'noopener noreferrer') .attr('download', null) .attr('title', uiT('ui.files.visit_link', 'Visit')) .html('<i class="fas fa-external-link-alt"></i>'); renderUrlLinkPreview(fileRecord, $('#previewModalBody')); } else { $('#previewDownloadBtn') .attr('href', `/api/files/${fileId}/download`) .attr('target', null) .attr('rel', null) .attr('download', '') .attr('title', uiT('ui.files.download', 'Download')) .html('<i class="fas fa-download"></i>'); renderPreviewContent(fileId, filename, $('#previewModalBody')); } updatePreviewNavButtons(); commentCtxPrefix = 'preview'; currentCommentFileId = fileId; $('#previewFileCommentsSection').hide(); $('#previewFileCommentReplyForm').hide(); $('#previewLeaveCommentBtn').show(); cancelCommentReply(); $('#previewCommentBodyInput').val(''); if (!isPublicAreaGuest) { loadFileComments(fileId); } applyPreviewFullscreenPreference(); if (!$('#filePreviewModal').hasClass('show')) { new bootstrap.Modal('#filePreviewModal').show(); } } function updatePreviewNavButtons() { $('#prevPreviewBtn').prop('disabled', currentFileIndex <= 0); $('#nextPreviewBtn').prop('disabled', currentFileIndex < 0 || currentFileIndex >= previewNavigationFiles.length - 1); } function navigatePreview(direction) { if (window._modPlayer) { try { window._modPlayer.unload(); } catch (e) {} window._modPlayer = null; } if (previewNavigationFiles.length === 0) return; const newIndex = currentFileIndex + direction; if (newIndex < 0 || newIndex >= previewNavigationFiles.length) return; const file = previewNavigationFiles[newIndex]; if (!file) return; currentFileIndex = newIndex; currentPreviewFileId = file.id; const position = getPreviewPositionHtml(currentFileIndex); $('#previewModalTitle').html(escapeHtml(file.filename) + position); $('#previewMagnetBtn').addClass('d-none').removeData('magnet'); const isUrlLinkNav = file.source_type === 'url'; if (isUrlLinkNav && file.url) { $('#previewDownloadBtn') .attr('href', file.url) .attr('target', '_blank') .attr('rel', 'noopener noreferrer') .attr('download', null) .attr('title', uiT('ui.files.visit_link', 'Visit')) .html('<i class="fas fa-external-link-alt"></i>'); renderUrlLinkPreview(file, $('#previewModalBody')); } else { $('#previewDownloadBtn') .attr('href', `/api/files/${file.id}/download`) .attr('target', null) .attr('rel', null) .attr('download', '') .attr('title', uiT('ui.files.download', 'Download')) .html('<i class="fas fa-download"></i>'); renderPreviewContent(file.id, file.filename, $('#previewModalBody')); } updatePreviewNavButtons(); // Reload comments for the new file commentCtxPrefix = 'preview'; currentCommentFileId = file.id; $('#previewFileCommentsSection').hide(); $('#previewFileCommentReplyForm').hide(); $('#previewLeaveCommentBtn').show(); cancelCommentReply(); $('#previewCommentBodyInput').val(''); if (!isPublicAreaGuest) { loadFileComments(file.id); } } function openDetailsFromPreview() { const modal = bootstrap.Modal.getInstance(document.getElementById('filePreviewModal')); if (modal) modal.hide(); if (currentPreviewFileId) { showFileDetails(currentPreviewFileId); } } function loadStats() { $.get('/api/fileareas/stats') .done(function(data) { // Update desktop stats $('#totalAreas').text(data.active_count); $('#totalFiles').text(data.total_files); $('#totalSize').text(formatBytes(data.total_size)); // Update mobile stats $('#totalAreas-mobile').text(data.active_count); $('#totalFiles-mobile').text(data.total_files); $('#totalSize-mobile').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 formatDate(dateStr) { if (!dateStr) return '-'; if (typeof window.formatFullDate === 'function') { return window.formatFullDate(dateStr); } const date = new Date(dateStr + 'Z'); const userTimezone = window.userSettings?.timezone || 'America/Los_Angeles'; const userDateFormat = window.userSettings?.date_format || 'en-US'; return date.toLocaleString(userDateFormat, { timeZone: userTimezone, year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }); } function showUploadModal() { if (!currentAreaId) { showError(uiT('ui.files.select_area_first', 'Please select a file area first')); return; } $('#uploadFileForm')[0].reset(); resetUploadDescriptions(); $('#uploadTorrentPreviewContainer').empty(); $('#uploadProgress').hide(); $('#uploadProgress .progress-bar').css('width', '0%'); $('#uploadErrorBox').addClass('d-none').text(''); // Reset link tab $('#linkUrl').val(''); $('#linkFileName').val(''); $('#linkShortDescription').val(''); $('#linkLongDescription').val(''); $('#linkAddError').addClass('d-none').text(''); linkFileNameAutoPopulated = false; // Always start on the Upload File tab const uploadTab = document.getElementById('uploadFileTab'); if (uploadTab) { bootstrap.Tab.getOrCreateInstance(uploadTab).show(); } // Update submit button label for the active tab updateUploadModalSubmitLabel(); new bootstrap.Modal('#uploadFileModal').show(); } /** Update the modal submit button label based on active tab. */ function updateUploadModalSubmitLabel() { const isLinkTab = document.getElementById('addLinkPane')?.classList.contains('active'); const btn = document.getElementById('uploadModalSubmitBtn'); if (!btn) return; if (isLinkTab) { btn.innerHTML = `<i class="fas fa-link"></i> ${uiT('ui.files.add_link', 'Add Link')}`; } else { btn.innerHTML = `<i class="fas fa-upload"></i> ${uiT('ui.files.upload', 'Upload')}`; } } /** Route the modal submit to the correct handler based on the active tab. */ function uploadModalSubmit() { const isLinkTab = document.getElementById('addLinkPane')?.classList.contains('active'); if (isLinkTab) { submitAddLink(); } else { uploadFile(); } } /** Derive a display filename from a URL (uses path basename if it has an extension, else hostname). */ function deriveFilenameFromUrl(url) { try { const parsed = new URL(url); const segments = parsed.pathname.replace(/\/$/, '').split('/').filter(Boolean); const last = segments.length ? decodeURIComponent(segments[segments.length - 1]) : ''; if (last && last.includes('.')) { return last; } return parsed.hostname || ''; } catch (e) { return ''; } } /** Sanitize a page title into a usable display filename (removes path-unsafe chars, truncates). */ function sanitizeFilename(str) { return str .replace(/[<>:"/\\|?*\x00-\x1f]/g, '') .trim() .substring(0, 100) .trim(); } /** Fetch URL metadata (title + og:description) and populate the link form fields. */ function fetchUrlMeta() { const url = $('#linkUrl').val().trim(); if (!url) return; const btn = $('#fetchUrlMetaBtn'); btn.prop('disabled', true).html(`<i class="fas fa-spinner fa-spin me-1"></i>${uiT('ui.files.fetching_info', 'Fetching?')}`); $.ajax({ url: '/api/files/fetch-url-meta', method: 'POST', contentType: 'application/json', data: JSON.stringify({ url }), }).done(function(data) { if (data.short_description) { $('#linkShortDescription').val(data.short_description); } if (data.long_description) { $('#linkLongDescription').val(data.long_description); } // Update filename if still auto-populated or empty ? use page title as a better suggestion if (linkFileNameAutoPopulated || !$('#linkFileName').val().trim()) { const suggested = data.short_description ? sanitizeFilename(data.short_description) : deriveFilenameFromUrl(url); if (suggested) { $('#linkFileName').val(suggested); linkFileNameAutoPopulated = true; } } }).fail(function() { $('#linkAddError').removeClass('d-none').text(uiT('ui.files.fetch_info_failed', 'Could not fetch URL metadata')); }).always(function() { btn.prop('disabled', false).html( `<i class="fas fa-search me-1"></i>${uiT('ui.files.fetch_info', 'Fetch Info')}` ); }); } /** Submit an external URL link to the current file area. */ function submitAddLink() { const url = $('#linkUrl').val().trim(); const fileName = $('#linkFileName').val().trim(); const shortDesc = $('#linkShortDescription').val().trim(); const longDesc = $('#linkLongDescription').val().trim(); $('#linkAddError').addClass('d-none').text(''); if (!url) { $('#linkAddError').removeClass('d-none').text(uiT('errors.files.link.invalid_url', 'A valid URL is required')); return; } if (!fileName) { $('#linkAddError').removeClass('d-none').text(uiT('errors.files.link.file_name_required', 'File name is required')); return; } if (!shortDesc) { $('#linkAddError').removeClass('d-none').text(uiT('errors.files.upload.short_description_required', 'Short description is required')); return; } const btn = document.getElementById('uploadModalSubmitBtn'); if (btn) btn.disabled = true; $.ajax({ url: '/api/files/add-link', method: 'POST', contentType: 'application/json', data: JSON.stringify({ file_area_id: currentAreaId, url, file_name: fileName, short_description: shortDesc, long_description: longDesc || null, }), }).done(function(data) { bootstrap.Modal.getInstance('#uploadFileModal')?.hide(); const isPending = data.approval_required; showSuccess(isPending ? uiT('ui.files.upload_pending_approval', 'File uploaded and queued for approval') : uiT('ui.files.link_added', 'Link added successfully') ); loadFiles(currentAreaId); loadStats(); loadFileAreas(); }).fail(function(xhr) { const msg = apiError(xhr.responseJSON, uiT('errors.files.link.add_failed', 'Failed to add link')); $('#linkAddError').removeClass('d-none').text(msg); }).always(function() { if (btn) btn.disabled = false; }); } function escapeAttribute(value) { return String(value ?? '') .replaceAll('&', '&amp;') .replaceAll('"', '&quot;') .replaceAll('<', '&lt;') .replaceAll('>', '&gt;'); } /** * Render the torrent metadata preview card into the upload form's preview * container. Delegates HTML generation to buildTorrentPreviewHtml() in * file-preview.js (loaded before this inline script). * * @param {object} meta Return value of parseTorrentMeta() * @param {ArrayBuffer} buffer Raw .torrent bytes for magnet link computation */ function renderTorrentPreview(meta, buffer) { const html = `<div class="card mb-3"> <div class="card-header py-2"> <strong><i class="fas fa-magnet me-1"></i> Torrent Info</strong> </div> <div class="card-body p-0">${buildTorrentPreviewHtml(meta)}</div> </div>`; $('#uploadTorrentPreviewContainer').html(html); computeMagnetLink(buffer, meta).then(magnet => { injectMagnetLink($('#uploadTorrentPreviewContainer')[0], magnet); }); } function resetUploadDescriptions() { $('#uploadDescriptionGroup').removeClass('d-none'); $('#uploadDescription').prop('required', true).val(''); $('#uploadBatchDescriptionsGroup').addClass('d-none'); $('#uploadBatchDescriptionsBody').empty(); } function rebuildUploadDescriptions() { const files = Array.from($('#uploadFile')[0]?.files || []); if (files.length <= 1) { $('#uploadBatchDescriptionsGroup').addClass('d-none'); $('#uploadBatchDescriptionsBody').empty(); $('#uploadDescriptionGroup').removeClass('d-none'); $('#uploadDescription').prop('required', true); if (files.length === 1) { const file = files[0]; if (file.name.toLowerCase().endsWith('.torrent')) { const reader = new FileReader(); reader.onload = function (e) { const meta = parseTorrentMeta(e.target.result); if (meta) { if (meta.shortDesc) $('#uploadDescription').val(meta.shortDesc); if (meta.longDesc) $('#uploadLongDescription').val(meta.longDesc); renderTorrentPreview(meta, e.target.result); } else { $('#uploadTorrentPreviewContainer').empty(); if (!$('#uploadDescription').val().trim()) $('#uploadDescription').val(file.name); } }; reader.readAsArrayBuffer(file); } else { $('#uploadTorrentPreviewContainer').empty(); if (!$('#uploadDescription').val().trim()) $('#uploadDescription').val(file.name); } } else { $('#uploadTorrentPreviewContainer').empty(); } return; } $('#uploadDescriptionGroup').addClass('d-none'); $('#uploadDescription').prop('required', false); $('#uploadBatchDescriptionsGroup').removeClass('d-none'); const rows = files.map((file, index) => ` <tr> <td class="font-monospace small">${escapeHtml(file.name)}</td> <td> <input type="text" class="form-control form-control-sm upload-batch-description" data-index="${index}" maxlength="255" value="${escapeAttribute(file.name)}" required > </td> </tr> `).join(''); $('#uploadBatchDescriptionsBody').html(rows); } function collectUploadEntries() { const files = Array.from($('#uploadFile')[0]?.files || []); if (!files.length) { return []; } if (files.length === 1) { return [{ file: files[0], shortDescription: $('#uploadDescription').val().trim() }]; } return files.map((file, index) => ({ file, shortDescription: ($(`.upload-batch-description[data-index="${index}"]`).val() || '').trim() })); } function uploadSingleFile(formData, totalFiles, fileIndex) { return $.ajax({ url: '/api/files/upload', method: 'POST', data: formData, processData: false, contentType: false, xhr: function() { const xhr = new window.XMLHttpRequest(); xhr.upload.addEventListener('progress', function(e) { if (e.lengthComputable) { const overallPercent = ((fileIndex + (e.loaded / e.total)) / totalFiles) * 100; $('#uploadProgress .progress-bar').css('width', overallPercent + '%'); } }, false); return xhr; } }); } async function uploadFile() { if (!currentAreaId) { showError(uiT('ui.files.select_area_first', 'Please select a file area first')); return; } $('#uploadErrorBox').addClass('d-none').text(''); const entries = collectUploadEntries(); if (!entries.length) { showError(uiT('ui.files.select_file_prompt', 'Please select a file')); return; } const missingDescription = entries.find(entry => !entry.shortDescription); if (missingDescription) { $('#uploadErrorBox').removeClass('d-none').text(uiT('errors.files.upload.short_description_required', 'Short description is required')); return; } $('#uploadProgress').show(); $('#uploadProgress .progress-bar').css('width', '0%'); const sharedLongDescription = $('#uploadLongDescription').val(); let uploadedCount = 0; let pendingApprovalCount = 0; try { for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const formData = new FormData(); formData.append('file', entry.file); formData.append('file_area_id', currentAreaId); formData.append('short_description', entry.shortDescription); formData.append('long_description', sharedLongDescription); const response = await uploadSingleFile(formData, entries.length, i); uploadedCount++; if (response && response.approval_required) { pendingApprovalCount++; } $('#uploadProgress .progress-bar').css('width', ((uploadedCount / entries.length) * 100) + '%'); } bootstrap.Modal.getInstance('#uploadFileModal').hide(); $('#uploadErrorBox').addClass('d-none').text(''); if (uploadedCount === 1 && pendingApprovalCount === 1) { showSuccess(uiT('ui.files.upload_pending_approval', 'File uploaded and queued for approval')); } else if (uploadedCount === pendingApprovalCount && uploadedCount > 1) { showSuccess(uiT('ui.files.upload_pending_approval_multiple', '{count} files uploaded and queued for approval').replace('{count}', uploadedCount)); } else if (pendingApprovalCount > 0) { showSuccess( uiT('ui.files.upload_mixed_results', '{approved} file(s) uploaded and {pending} queued for approval') .replace('{approved}', uploadedCount - pendingApprovalCount) .replace('{pending}', pendingApprovalCount) ); } else if (uploadedCount === 1) { showSuccess(uiT('ui.files.upload_success', 'File uploaded successfully')); } else { showSuccess(uiT('ui.files.upload_success_multiple', '{count} files uploaded successfully').replace('{count}', uploadedCount)); } loadFiles(currentAreaId); loadStats(); loadFileAreas(); } catch (xhr) { const error = apiError(xhr.responseJSON, uiT('errors.files.upload.failed', 'Failed to upload file')); const message = uploadedCount > 0 ? uiT('ui.files.upload_partial_failure', 'Upload stopped after {count} file(s): {error}') .replace('{count}', uploadedCount) .replace('{error}', error) : error; $('#uploadErrorBox').removeClass('d-none').text(message); $('#uploadProgress').hide(); if (uploadedCount > 0) { loadFiles(currentAreaId); loadStats(); loadFileAreas(); } } } $('#uploadFile').on('change', rebuildUploadDescriptions); // Keep the submit button label in sync with the active tab document.addEventListener('shown.bs.tab', function(e) { if (e.target && (e.target.id === 'uploadFileTab' || e.target.id === 'addLinkTab')) { updateUploadModalSubmitLabel(); } }); $('#previewMagnetBtn').on('click', function () { const magnet = $(this).data('magnet'); if (magnet) navigator.clipboard.writeText(magnet); }); function rehatchFile() { if (!currentFileId) return; const $btn = $('#rehatchFileBtn'); $btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i>'); $.ajax({ url: `/api/files/${currentFileId}/rehatch`, method: 'POST', success: function() { showSuccess(uiT('ui.files.rehatch_ok', 'File re-hatched successfully')); }, error: function(xhr) { const msg = window.getApiErrorMessage && xhr.responseJSON ? window.getApiErrorMessage(xhr.responseJSON, uiT('ui.files.rehatch_failed', 'Rehatch failed')) : uiT('ui.files.rehatch_failed', 'Rehatch failed'); showError(msg); }, complete: function() { $btn.prop('disabled', false).html('<i class="fas fa-retweet"></i> ' + uiT('ui.files.rehatch', 'Rehatch')); } }); } function requestVirusScan() { const fileId = $('#virusScanBtn').data('file-id'); if (!fileId) return; const $btn = $('#virusScanBtn'); $btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> ' + uiT('ui.files.scanning', 'Scanning?')); $.ajax({ url: `/api/files/${fileId}/scan`, method: 'POST', success: function(data) { const result = data.result; if (result === 'clean') { showSuccess(uiT('ui.files.virus_scan_clean', 'Virus scan: Clean')); } else if (result === 'infected') { const sig = data.signature || uiT('ui.common.unknown', 'Unknown'); showError(uiT('ui.files.virus_detected_prefix', 'Virus detected: ') + sig); } else { showError(uiT('ui.files.virus_scan_error', 'Virus scan: Error')); } // Refresh the file details panel to show the updated scan status row showFileDetails(fileId); }, error: function(xhr) { const msg = window.getApiErrorMessage && xhr.responseJSON ? window.getApiErrorMessage(xhr.responseJSON, uiT('ui.files.scan_failed', 'Virus scan failed')) : uiT('ui.files.scan_failed', 'Virus scan failed'); showError(msg); }, complete: function() { $btn.prop('disabled', false).html('<i class="fas fa-shield-alt"></i> ' + uiT('ui.files.virus_scan_now', 'Virus Scan')); } }); } // Show/hide signature field when infected is selected $(document).on('change', '#scanStatusSelect', function() { $('#scanStatusSignature').toggle($(this).val() === 'infected'); }); function saveScanStatus() { const fileId = currentFile ? currentFile.id : null; if (!fileId) return; const status = $('#scanStatusSelect').val(); const signature = status === 'infected' ? $('#scanStatusSignature').val().trim() : null; $.ajax({ url: `/api/files/${fileId}/scan-status`, method: 'PUT', contentType: 'application/json', data: JSON.stringify({ status, signature }), success: function() { showSuccess(uiT('ui.files.scan_status_saved', 'Scan status updated')); showFileDetails(fileId); }, error: function(xhr) { const msg = window.getApiErrorMessage && xhr.responseJSON ? window.getApiErrorMessage(xhr.responseJSON, uiT('ui.files.scan_status_save_failed', 'Failed to update scan status')) : uiT('ui.files.scan_status_save_failed', 'Failed to update scan status'); showError(msg); } }); } // --------------------------------------------------------------------------- // File comments // --------------------------------------------------------------------------- const COMMENTS_PREVIEW_COUNT = 5; let allComments = []; let showingAllComments = false; // 'details' or 'preview' ? determines which modal's comment DOM nodes are targeted let commentCtxPrefix = ''; let currentCommentFileId = null; /** * jQuery selector helper that targets the correct modal's comment elements. * With prefix='preview', prepends 'preview' + capitalises the first letter of id. * e.g. $cc('fileCommentsSection') ? $('#previewFileCommentsSection') */ function $cc(id) { return $('#' + (commentCtxPrefix ? commentCtxPrefix + id.charAt(0).toUpperCase() + id.slice(1) : id)); } /** * Load comments for the currently open file and render them. */ function loadFileComments(fileId) { $cc('fileCommentsSection').hide(); allComments = []; showingAllComments = false; $.get(`/api/files/${fileId}/comments`) .done(function(data) { if (!data.enabled) return; // No linked echo area ? hide section $cc('fileCommentsSection').show(); allComments = flattenCommentTree(data.comments || []); renderComments(); }) .fail(function() { // Silently skip ? comments are supplementary }); } /** * Flatten the nested comment tree to a flat array preserving level metadata. */ function flattenCommentTree(nodes) { const result = []; function walk(list) { list.forEach(function(node) { result.push(node); if (node.children && node.children.length) walk(node.children); }); } walk(nodes); return result; } function renderComments() { const container = $cc('fileCommentsBody'); if (allComments.length === 0) { container.html(`<div class="text-muted small">${uiT('ui.files.no_comments_yet', 'No comments yet. Be the first to leave one.')}</div>`); return; } const visible = showingAllComments ? allComments : allComments.slice(-COMMENTS_PREVIEW_COUNT); let html = ''; visible.forEach(function(msg) { const indent = Math.min(msg.level || 0, 2) * 20; const dateStr = msg.date_written ? new Date(msg.date_written).toLocaleString() : ''; const body = escapeHtml(msg.body || ''); const id = msg.id; html += ` <div class="border-start border-2 ps-2 mb-2" style="margin-left:${indent}px;" data-comment-id="${id}"> <div class="d-flex gap-2 align-items-baseline"> <strong class="small">${escapeHtml(msg.from_name || '')}</strong> <span class="text-muted" style="font-size:.75em;">${escapeHtml(dateStr)}</span> <a href="#" class="ms-auto text-muted" style="font-size:.75em;" onclick="startCommentReply(${id}, '${escapeHtml(msg.from_name || '')}'); return false;">?</a> </div> <div class="small mt-1" style="white-space:pre-wrap;">${body}</div> </div> `; }); if (!showingAllComments && allComments.length > COMMENTS_PREVIEW_COUNT) { const hidden = allComments.length - COMMENTS_PREVIEW_COUNT; const label = uiT('ui.files.show_all_comments', 'Show all {count} comments', { count: allComments.length }); html += `<a href="#" class="small" onclick="expandAllComments(); return false;">${label}</a>`; } else if (showingAllComments && allComments.length > COMMENTS_PREVIEW_COUNT) { html += `<a href="#" class="small" onclick="collapseComments(); return false;">${uiT('ui.files.show_fewer_comments', 'Show fewer')}</a>`; } container.html(html); } function expandAllComments() { showingAllComments = true; renderComments(); } function collapseComments() { showingAllComments = false; renderComments(); } function showCommentForm() { cancelCommentReply(); $cc('leaveCommentBtn').hide(); $cc('fileCommentReplyForm').show(); $cc('commentBodyInput').focus(); } function startCommentReply(replyToId, fromName) { showCommentForm(); $cc('commentReplyToId').val(replyToId); $cc('commentReplyToLabel').text('? ' + fromName).show(); $cc('cancelReplyBtn').show(); $cc('commentBodyInput').focus(); } function cancelCommentReply() { $cc('commentReplyToId').val(''); $cc('commentReplyToLabel').hide(); $cc('cancelReplyBtn').hide(); } function submitFileComment() { const body = $cc('commentBodyInput').val().trim(); const replyToId = parseInt($cc('commentReplyToId').val(), 10) || null; if (!body) return; if (!currentCommentFileId) return; const btn = $('[onclick="submitFileComment()"]').prop('disabled', true); $.ajax({ url: `/api/files/${currentCommentFileId}/comments`, method: 'POST', contentType: 'application/json', data: JSON.stringify({ body, reply_to_id: replyToId }), success: function() { btn.prop('disabled', false); $cc('commentBodyInput').val(''); cancelCommentReply(); $cc('fileCommentReplyForm').hide(); $cc('leaveCommentBtn').show(); showSuccess(uiT('ui.files.comment_posted', 'Comment posted')); // Reload comments and bump the badge in file listing loadFileComments(currentCommentFileId); updateFileBadge(currentCommentFileId); }, error: function(xhr) { btn.prop('disabled', false); showError(apiError(xhr.responseJSON, uiT('ui.files.comment_post_failed', 'Failed to post comment'))); } }); } /** * After posting a comment, increment the badge counter in the file listing. */ function updateFileBadge(fileId) { const file = displayedFiles.find(f => f.id == fileId); if (file) { file.comment_count = (parseInt(file.comment_count || 0, 10)) + 1; // Re-render happens naturally on next loadFiles() ? for now update the badge in-place $(`.badge[onclick*="showFileDetails(${fileId})"]`).each(function() { const count = parseInt($(this).text(), 10) + 1; $(this).html(`<i class="fas fa-comment me-1"></i>${count}`); }); } } /** * Load an iso_subdir record by ID then open the standard edit dialog, * which already disables the filename field and hides move-area for ISO types. */ function openSubdirEdit(subdirId) { $.get(`/api/files/${subdirId}`) .done(function(data) { currentFile = data.file; currentFileId = subdirId; showEditDialog(); }) .fail(function() { showError(uiT('ui.files.load_files_failed', 'Failed to load file details')); }); } function showEditDialog() { const currentFilename = currentFile ? currentFile.filename : $('#detailFilename').text(); const isIso = currentFile && (currentFile.source_type === 'iso_import' || currentFile.source_type === 'iso_subdir'); const isUrlLink = currentFile && currentFile.source_type === 'url'; $('#renameFilenameInput').val(currentFilename).prop('disabled', isIso || isUrlLink); $('#editShortDescInput').val(currentFile ? (currentFile.short_description || '') : ''); $('#editLongDescInput').val(currentFile ? (currentFile.long_description || '') : ''); if ($('#editUrlGroup').length) { if (isAdmin && isUrlLink) { $('#editUrlInput').val(currentFile ? (currentFile.url || '') : ''); $('#editUrlGroup').show(); } else { $('#editUrlGroup').hide(); } } $('#renameErrorMsg').hide(); // Admin-only: populate and show the Move to Area dropdown if (isAdmin && loadedAreas.length > 0) { const currentAreaId = currentFile ? currentFile.file_area_id : null; let options = ''; loadedAreas.forEach(function(area) { const label = area.domain ? `${area.tag}@${area.domain}` : area.tag; const selected = area.id === currentAreaId ? ' selected' : ''; options += `<option value="${area.id}"${selected}>${escapeHtml(label)}</option>`; }); $('#editMoveAreaSelect').html(options); $('#editMoveAreaGroup').toggle(!isIso); } else { $('#editMoveAreaGroup').hide(); } bootstrap.Modal.getInstance('#fileDetailsModal')?.hide(); new bootstrap.Modal('#fileRenameModal').show(); $('#fileRenameModal').on('shown.bs.modal', function() { const input = $('#renameFilenameInput')[0]; input.focus(); const dotIndex = currentFilename.lastIndexOf('.'); const end = dotIndex > 0 ? dotIndex : currentFilename.length; input.setSelectionRange(0, end); }); } function submitEdit() { const newFilename = $('#renameFilenameInput').val().trim(); const shortDesc = $('#editShortDescInput').val().trim(); const longDesc = $('#editLongDescInput').val().trim(); const newUrl = $('#editUrlGroup').is(':visible') ? ($('#editUrlInput').val().trim() || null) : undefined; if (!newFilename && !$('#renameFilenameInput').prop('disabled')) { $('#renameErrorMsg').text(uiT('errors.files.rename_filename_required', 'New filename is required')); $('#renameErrorMsg').show(); return; } if (!shortDesc) { $('#renameErrorMsg').text(uiT('errors.files.short_description_required', 'Short description is required')); $('#renameErrorMsg').show(); return; } $('#renameErrorMsg').hide(); const originalFilename = currentFile ? currentFile.filename : null; const originalAreaId = currentFile ? currentFile.file_area_id : null; const selectedAreaId = isAdmin ? parseInt($('#editMoveAreaSelect').val(), 10) : null; const payload = { short_description: shortDesc, long_description: longDesc || null, }; // Only send filename if it changed if (newFilename !== originalFilename) { payload.filename = newFilename; } // Only send file_area_id if admin moved it to a different area if (isAdmin && selectedAreaId && selectedAreaId !== originalAreaId) { payload.file_area_id = selectedAreaId; } // Only send url if the admin URL field is visible and has changed if (newUrl !== undefined) { payload.url = newUrl; } $.ajax({ url: `/api/files/${currentFileId}/rename`, method: 'PUT', contentType: 'application/json', data: JSON.stringify(payload) }).done(function(data) { bootstrap.Modal.getInstance('#fileRenameModal').hide(); showSuccess(uiT('ui.files.edit_success', 'File updated successfully')); if (currentAreaId) { loadFiles(currentAreaId); } else { loadRecentFiles(); } }).fail(function(xhr) { const err = xhr.responseJSON; const errMsg = window.getApiErrorMessage ? window.getApiErrorMessage(err, uiT('errors.files.edit_failed', 'Failed to update file')) : (err?.error || uiT('errors.files.edit_failed', 'Failed to update file')); $('#renameErrorMsg').text(errMsg); $('#renameErrorMsg').show(); }); } // Allow Enter key in filename input to submit $(document).on('keydown', '#renameFilenameInput', function(e) { if (e.key === 'Enter') submitEdit(); }); function confirmDeleteFile(fileId, filename) { if (confirm(uiT('ui.files.delete_confirm', `Are you sure you want to delete "${filename}"? This action cannot be undone.`, { filename }))) { deleteFile(fileId); } } function deleteFile(fileId) { $.ajax({ url: `/api/files/${fileId}/delete`, method: 'DELETE', success: function(response) { showSuccess(apiMessage(response, uiT('ui.api.files.deleted', 'File deleted successfully'))); if (currentViewMode === 'my_uploads') { loadMyUploads(); } else { loadFiles(currentAreaId); } loadStats(); loadFileAreas(); }, error: function(xhr) { const error = apiError(xhr.responseJSON, uiT('errors.files.delete_failed', 'Failed to delete file')); showError(error); } }); } function confirmDeleteSubfolder(areaId, subfolder) { const msg = uiT('ui.files.delete_subfolder_confirm', `Are you sure you want to delete the subfolder "${subfolder}" and all its files? This cannot be undone.`, { subfolder }); if (confirm(msg)) { deleteSubfolder(areaId, subfolder); } } function deleteSubfolder(areaId, subfolder) { $.ajax({ url: `/api/fileareas/${areaId}/subfolder`, method: 'DELETE', contentType: 'application/json', data: JSON.stringify({ subfolder }), success: function() { showSuccess(uiT('ui.files.subfolder_deleted', 'Subfolder deleted')); loadFiles(areaId); loadStats(); }, error: function(xhr) { const error = window.getApiErrorMessage ? window.getApiErrorMessage(xhr.responseJSON, uiT('errors.files.delete_failed', 'Failed to delete subfolder')) : uiT('errors.files.delete_failed', 'Failed to delete subfolder'); showError(error); } }); } // ------------------------------------------------------------------------- // File share functions // ------------------------------------------------------------------------- function showFileShareDialog() { if (!currentFileId) return; // Reset modal state $('#shareModalLoading').show(); $('#shareModalContent').hide(); $('#shareModalError').hide(); $('#existingShareInfo').hide(); $('#shareAccessInfo').hide(); $('#shareAccessText').text(''); $('#shareLinkGroup').hide(); $('#shareRevokeGroup').hide(); $('#shareExpiryGroup').show(); $('#createShareBtn').show(); currentShareId = null; // Check for an existing share $.get(`/api/files/shared/check/${currentFileId}`) .done(function(data) { $('#shareModalLoading').hide(); $('#shareModalContent').show(); if (data.success && data.share_url) { // Existing global share found currentShareId = data.share_id; $('#existingShareInfo').show(); updateFileShareAccessInfo(data.access_count || 0, data.last_accessed_at || null); $('#shareLinkGroup').show(); $('#shareUrlInput').val(data.share_url); $('#shareExpiryGroup').hide(); $('#createShareBtn').hide(); if (data.can_revoke) { $('#shareRevokeGroup').show(); } } }) .fail(function() { // No existing share or error ? show creation form $('#shareModalLoading').hide(); $('#shareModalContent').show(); }); bootstrap.Modal.getOrCreateInstance(document.getElementById('fileShareModal')).show(); } function createFileShare() { if (!currentFileId) return; const expiresHours = $('#shareExpiry').val(); $('#shareModalError').hide(); $.ajax({ url: `/api/files/${currentFileId}/share`, method: 'POST', contentType: 'application/json', data: JSON.stringify({ expires_hours: expiresHours, freq_accessible: $('#shareFreqAccessible').is(':checked') }), success: function(data) { if (data.success) { currentShareId = data.share_id; updateFileShareAccessInfo(data.access_count || 0, data.last_accessed_at || null); $('#shareUrlInput').val(data.share_url); $('#shareLinkGroup').show(); $('#shareRevokeGroup').show(); $('#shareExpiryGroup').hide(); $('#createShareBtn').hide(); if (data.existing) { $('#existingShareInfo').show(); } } else { $('#shareModalError').text(apiError(data, uiT('errors.messages.share_create_failed', 'Failed to create share link'))).show(); } }, error: function(xhr) { const error = apiError(xhr.responseJSON, uiT('errors.messages.share_create_failed', 'Failed to create share link')); $('#shareModalError').text(error).show(); } }); } function revokeFileShare() { if (!currentShareId) return; if (!confirm(uiT('ui.files.revoke_confirm', 'Are you sure you want to revoke this share link? Anyone with the link will no longer be able to access it.'))) return; $.ajax({ url: `/api/files/shares/${currentShareId}`, method: 'DELETE', success: function(response) { currentShareId = null; $('#shareAccessInfo').hide(); $('#shareAccessText').text(''); $('#shareLinkGroup').hide(); $('#shareRevokeGroup').hide(); $('#shareExpiryGroup').show(); $('#createShareBtn').show(); $('#existingShareInfo').hide(); showSuccess(apiMessage(response, uiT('ui.files.share_revoked', 'Share link revoked'))); }, error: function(xhr) { const error = apiError(xhr.responseJSON, uiT('errors.messages.share_revoke_failed', 'Failed to revoke share link')); $('#shareModalError').text(error).show(); } }); } function copyFileShareUrl() { const url = $('#shareUrlInput').val(); if (!url) return; navigator.clipboard.writeText(url).then(function() { showSuccess(uiT('ui.files.share_link_copied_clipboard', 'Share link copied to clipboard')); }).catch(function() { $('#shareUrlInput').select(); document.execCommand('copy'); showSuccess(uiT('ui.files.share_link_copied', 'Share link copied')); }); } function updateFileShareAccessInfo(count, lastAccessedAt) { const lastAccessed = lastAccessedAt ? formatDate(lastAccessedAt) : uiT('ui.files.never_accessed', 'Never'); $('#shareAccessText') .text(uiT('ui.files.share_access_stats', 'Accessed {count} times. Last accessed: {last_accessed}', { count: count, last_accessed: lastAccessed })); $('#shareAccessInfo').show(); } // --------------------------------------------------------------------------- // Global file search // --------------------------------------------------------------------------- let fileSearchTimer = null; /** * Schedule a global file search after a short debounce delay. * Called on each keystroke in the global search input. * * @param {string} query - Current value of the search input */ function scheduleFileSearch(query) { clearTimeout(fileSearchTimer); $('#clearFileSearch').toggle(query.length > 0); if (query.length < 2) { if (query.length === 0) { // Restore normal view if (currentAreaId) { loadFiles(currentAreaId, currentSubfolder); } else { loadRecentFiles(); } $('#fileSearchStatus').hide(); } return; } fileSearchTimer = setTimeout(function() { runFileSearch(query); }, 350); } /** * Clear the global search input and restore the previous view. */ function clearFileSearch() { $('#globalFileSearch').val(''); $('#clearFileSearch').hide(); $('#fileSearchStatus').hide(); setPreviewNavigationFiles(displayedFiles); if (currentAreaId) { loadFiles(currentAreaId, currentSubfolder); } else { loadRecentFiles(); } } /** * Execute the global file search against the API. * * @param {string} query - Search query string (minimum 2 chars) */ function runFileSearch(query) { $('#fileSearchStatus').text(uiT('ui.common.loading', 'Loading?')).show(); $('#filesContainer').html('<div class="p-4 text-center text-muted"><i class="fas fa-spinner fa-spin me-2"></i>' + uiT('ui.common.loading', 'Searching?') + '</div>'); $.get('/api/files/search', { q: query }) .done(function(data) { const results = data.results || []; $('#fileSearchStatus').text( window.t('ui.files.search_result_count', { count: results.length }, results.length + ' result(s)') ).show(); displaySearchResults(results, query); }) .fail(function() { $('#fileSearchStatus').text(uiT('ui.files.search_failed', 'Search failed')).show(); $('#filesContainer').html('<div class="p-4 text-center text-muted">' + uiT('ui.files.search_failed', 'Search failed') + '</div>'); }); } /** * Render global search results into the main files container. * * @param {Array} results - Array of file result objects from the API * @param {string} query - Original search query (reserved for future highlighting) */ function displaySearchResults(results, query) { setPreviewNavigationFiles(results); if (results.length === 0) { $('#filesContainer').html('<div class="p-4 text-center text-muted">' + uiT('ui.files.search_no_results', 'No files found') + '</div>'); return; } let html = ` <div class="table-responsive"> <table class="table table-hover mb-0"> <thead> <tr> <th width="28%">${uiT('ui.files.filename', 'Filename')}</th> <th width="10%">${uiT('ui.files.area', 'Area')}</th> <th width="22%">${uiT('ui.common.description', 'Description')}</th> <th width="10%">${uiT('ui.files.size', 'Size')}</th> <th width="15%">${uiT('ui.files.uploaded', 'Uploaded')}</th> <th width="15%">${uiT('ui.files.actions', 'Actions')}</th> </tr> </thead> <tbody> `; results.forEach(function(file) { const isUrlLink = file.source_type === 'url'; const filename = escapeHtml(file.filename); const desc = escapeHtml(file.short_description || ''); const areaTag = escapeHtml(file.area_tag || ''); const size = isUrlLink ? '?' : formatBytes(file.filesize || 0); const uploaded = formatDate(file.created_at); const subfolder = file.subfolder ? escapeHtml(file.subfolder) : null; const areaOnclick = subfolder ? `selectFileArea(${file.area_id}, '${subfolder}'); clearFileSearch();` : `selectFileArea(${file.area_id}, null); clearFileSearch();`; const fileIcon = isUrlLink ? '<i class="fas fa-link text-info me-1"></i>' : ''; const primaryAction = isUrlLink ? `<a href="${escapeHtml(file.url || '#')}" target="_blank" rel="noopener noreferrer" class="btn btn-sm btn-outline-success" title="${uiT('ui.files.visit_link', 'Visit')}"><i class="fas fa-external-link-alt"></i></a>` : `<a href="/api/files/${file.id}/download" class="btn btn-sm btn-outline-success" download title="${uiT('ui.files.download', 'Download')}"><i class="fas fa-download"></i></a>`; html += ` <tr> <td class="font-monospace small"> ${fileIcon}<a href="#" onclick="previewFile(${file.id}, ${escapeHtml(JSON.stringify(file.filename))}); return false;">${filename}</a> </td> <td class="small"> <a href="#" onclick="${areaOnclick} return false;" class="text-muted">${areaTag}</a> </td> <td class="small text-muted">${desc}</td> <td class="small">${size}</td> <td class="small">${uploaded}</td> <td> <button class="btn btn-sm btn-outline-primary me-1" onclick="showFileDetails(${file.id})" title="${uiT('ui.files.file_info', 'File info')}"> <i class="fas fa-info-circle"></i> </button> ${primaryAction} </td> </tr> `; }); html += '</tbody></table></div>'; $('#filesContainer').html(html); } </script> {% endblock %}