{% 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>` : '')
: '—';
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('&', '&')
.replaceAll('"', '"')
.replaceAll('<', '<')
.replaceAll('>', '>');
}
/**
* 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 %}
|