nhance/app/Views/storage_files.php

574 lines
16 KiB
PHP

<style>
.s3-page {
min-height: calc(100vh - 130px);
display: flex;
flex-direction: column;
justify-content: center;
padding: 2.75rem 0 3rem;
box-sizing: border-box;
}
.s3-browser {
--s3-primary: #02a8b5;
--s3-primary-deep: #028f9a;
--s3-ink: #333333;
--s3-muted: #6c757d;
--s3-line: #e3eef0;
--s3-soft: #f5ffff;
--s3-white: #ffffff;
font-family: "Poppins", system-ui, sans-serif;
color: var(--s3-ink);
width: 100%;
}
.s3-browser .s3-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
padding-top: 0.35rem;
}
.s3-browser .s3-header h3 {
margin: 0;
font-size: 1.45rem;
font-weight: 600;
color: var(--s3-ink);
}
.s3-browser .s3-header p {
margin: 0.3rem 0 0;
color: var(--s3-muted);
font-size: 0.9rem;
}
.s3-browser .bucket-pill {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--s3-soft);
border: 1px solid #b2ebf2;
border-radius: 999px;
padding: 0.4rem 0.85rem;
color: var(--s3-ink);
font-size: 0.82rem;
white-space: nowrap;
}
.s3-browser .bucket-pill i {
color: var(--s3-primary);
}
.s3-browser .panel,
.s3-browser .table-wrap {
background: var(--s3-white);
border: 1px solid var(--s3-line);
border-radius: 12px;
box-shadow: 0 1px 2px rgba(2, 168, 181, 0.05);
}
.s3-browser .panel {
padding: 1rem 1.1rem;
margin-bottom: 1rem;
}
.s3-browser .toolbar-grid {
display: grid;
grid-template-columns: minmax(200px, 1.1fr) minmax(240px, 2.4fr) 120px;
gap: 1rem;
align-items: end;
}
.s3-browser .toolbar-field {
display: flex;
flex-direction: column;
min-width: 0;
margin: 0;
}
.s3-browser .toolbar label {
display: block;
font-size: 0.8rem;
color: var(--s3-muted);
margin-bottom: 0.4rem;
font-weight: 500;
line-height: 1.2;
min-height: 1.2em;
}
.s3-browser .toolbar .control-row {
display: flex;
align-items: stretch;
min-height: 42px;
}
.s3-browser .toolbar .control-row > .form-control,
.s3-browser .toolbar .control-row > .select2-container,
.s3-browser .toolbar .control-row > .btn-search {
width: 100%;
}
.s3-browser .form-control,
.s3-browser .custom-select {
border-radius: 8px !important;
border: 1px solid #d7e6e8 !important;
height: 42px;
color: var(--s3-ink) !important;
background: #fff !important;
margin: 0;
}
.s3-browser .form-control:focus,
.s3-browser .custom-select:focus {
border-color: var(--s3-primary) !important;
box-shadow: 0 0 0 0.15rem rgba(2, 168, 181, 0.15) !important;
}
.s3-browser .btn-search {
height: 42px;
width: 100%;
border-radius: 8px;
background: var(--s3-primary);
color: #fff;
border: none;
font-weight: 600;
padding: 0 1rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.s3-browser .btn-search:hover {
background: var(--s3-primary-deep);
color: #fff;
}
.s3-browser .btn-ghost {
border-radius: 8px;
border: 1px solid #d7e6e8;
background: #fff;
color: var(--s3-ink);
}
.s3-browser .btn-ghost:hover {
background: var(--s3-soft);
color: var(--s3-ink);
border-color: #b2ebf2;
}
.s3-browser .btn-next {
border-radius: 8px;
border: none;
background: var(--s3-primary);
color: #fff;
font-weight: 600;
}
.s3-browser .btn-next:hover {
background: var(--s3-primary-deep);
color: #fff;
}
.s3-browser .btn-next:disabled,
.s3-browser .btn-ghost:disabled,
.s3-browser .btn-search:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.s3-browser .table-wrap {
overflow: hidden;
}
.s3-browser table {
margin: 0;
}
.s3-browser thead th {
background: var(--s3-soft);
border-bottom: 1px solid var(--s3-line);
border-top: 0;
color: var(--s3-muted);
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
padding: 0.85rem 1rem;
}
.s3-browser tbody td {
vertical-align: middle;
border-top: 1px solid var(--s3-line);
padding: 0.85rem 1rem;
color: var(--s3-ink);
font-size: 0.9rem;
background: #fff;
}
.s3-browser tbody tr:hover td {
background: #f7fcfc;
}
.s3-browser .file-cell {
display: flex;
align-items: flex-start;
gap: 0.7rem;
}
.s3-browser .file-icon {
width: 34px;
height: 34px;
border-radius: 8px;
background: #e0f7fa;
color: var(--s3-primary);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 1.05rem;
}
.s3-browser .file-name {
font-weight: 500;
word-break: break-all;
color: var(--s3-ink);
line-height: 1.3;
}
.s3-browser .file-key {
display: block;
color: var(--s3-muted);
font-size: 0.78rem;
margin-top: 0.15rem;
}
.s3-browser .badge-soft {
background: #e0f7fa;
color: #027a84;
border-radius: 999px;
font-weight: 500;
padding: 0.28rem 0.65rem;
font-size: 0.78rem;
}
.s3-browser .pager {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem 1rem;
border-top: 1px solid var(--s3-line);
background: #fafefe;
}
.s3-browser .pager .meta {
color: var(--s3-muted);
font-size: 0.85rem;
}
.s3-browser .empty-state,
.s3-browser .loading-state {
text-align: center;
padding: 3rem 1rem;
color: var(--s3-muted);
}
.s3-browser .empty-state i,
.s3-browser .loading-state i {
font-size: 2rem;
color: var(--s3-primary);
display: block;
margin-bottom: 0.5rem;
}
.s3-browser .alert-local {
border-radius: 10px;
border: 1px solid #ffe082;
background: #fff8e1;
color: #8d6e00;
padding: 0.75rem 1rem;
margin-bottom: 1rem;
}
.s3-browser .select2-container {
width: 100% !important;
}
.s3-browser .select2-container .select2-selection--single {
height: 42px !important;
border: 1px solid #d7e6e8 !important;
border-radius: 8px !important;
padding: 0.35rem 0.5rem;
background: #fff !important;
}
.s3-browser .select2-container--default.select2-container--focus .select2-selection--single {
border-color: var(--s3-primary) !important;
}
.s3-browser .select2-container--default .select2-selection--single .select2-selection__rendered {
line-height: 1.85 !important;
padding-left: 0.1rem;
color: var(--s3-ink) !important;
}
.s3-browser .select2-container--default .select2-selection--single .select2-selection__placeholder {
color: #adb5bd !important;
}
.s3-browser .select2-container--default .select2-selection--single .select2-selection__arrow {
height: 40px !important;
top: 1px !important;
}
.s3-browser .select2-dropdown {
border-radius: 8px;
border-color: #d7e6e8;
overflow: hidden;
}
.s3-browser .select2-search--dropdown .select2-search__field {
border-radius: 6px;
border-color: #d7e6e8;
}
.s3-browser .select2-container--default .select2-results__option--highlighted.select2-results__option--selectable {
background-color: var(--s3-primary) !important;
}
@media (max-width: 767.98px) {
.s3-browser .s3-header {
flex-direction: column;
}
.s3-browser .toolbar-grid {
grid-template-columns: 1fr;
gap: 0.85rem;
}
}
</style>
<div class="s3-page">
<div class="s3-browser">
<div class="s3-header">
<div>
<h3>File browser</h3>
<p>Browse upload objects by folder. Results load 10 at a time.</p>
</div>
<div class="bucket-pill">
<i class="mdi mdi-database"></i>
<span id="s3-bucket-label"><?= esc($bucket ?? 'Bucket not configured') ?></span>
</div>
</div>
<?php if (empty($uses_s3)): ?>
<div class="alert-local">
FILE_STORAGE_DRIVER is not set to <strong>s3</strong>, or the upload bucket is missing. Listing is disabled.
</div>
<?php endif; ?>
<div class="panel toolbar">
<div class="toolbar-grid">
<div class="toolbar-field">
<label for="s3-folder">Folder</label>
<div class="control-row">
<select id="s3-folder" class="form-control custom-select" data-placeholder="Search or select folder" <?= empty($uses_s3) ? 'disabled' : '' ?>>
<option value="">Select folder</option>
</select>
</div>
</div>
<div class="toolbar-field">
<label for="s3-search">File name</label>
<div class="control-row">
<input type="text" id="s3-search" class="form-control" placeholder="Search file name…" <?= empty($uses_s3) ? 'disabled' : '' ?>>
</div>
</div>
<div class="toolbar-field">
<label for="s3-search-btn">&nbsp;</label>
<div class="control-row">
<button type="button" id="s3-search-btn" class="btn btn-search" <?= empty($uses_s3) ? 'disabled' : '' ?>>
Search
</button>
</div>
</div>
</div>
</div>
<div class="table-wrap">
<div class="table-responsive">
<table class="table mb-0">
<thead>
<tr>
<th style="width:48%">File name</th>
<th>Size</th>
<th>Last modified</th>
<th class="text-right">Action</th>
</tr>
</thead>
<tbody id="s3-file-rows">
<tr>
<td colspan="4">
<div class="empty-state">
<i class="mdi mdi-folder-open-outline"></i>
Select a folder to load files
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="pager">
<div class="meta" id="s3-page-meta">Page —</div>
<div class="d-flex" style="gap:0.5rem;">
<button type="button" class="btn btn-sm btn-ghost" id="s3-prev-btn" disabled>Previous</button>
<button type="button" class="btn btn-sm btn-next" id="s3-next-btn" disabled>Next</button>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
const USES_S3 = <?= ! empty($uses_s3) ? 'true' : 'false' ?>;
const LIST_URL = <?= json_encode($list_url ?? '') ?>;
const FOLDERS_URL = <?= json_encode($folders_url ?? '') ?>;
const PER_PAGE = 10;
let page = 1;
let searchTimer = null;
const elFolder = document.getElementById('s3-folder');
const elSearch = document.getElementById('s3-search');
const elRows = document.getElementById('s3-file-rows');
const elMeta = document.getElementById('s3-page-meta');
const elPrev = document.getElementById('s3-prev-btn');
const elNext = document.getElementById('s3-next-btn');
const elBucket = document.getElementById('s3-bucket-label');
function escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function fileIconClass(name) {
const ext = String(name).split('.').pop().toLowerCase();
if (['xls', 'xlsx', 'csv', 'ods'].indexOf(ext) !== -1) return 'mdi mdi-file-excel';
if (['pdf'].indexOf(ext) !== -1) return 'mdi mdi-file-pdf-box';
if (['png', 'jpg', 'jpeg', 'gif', 'webp'].indexOf(ext) !== -1) return 'mdi mdi-file-image';
if (['zip', 'rar', '7z'].indexOf(ext) !== -1) return 'mdi mdi-zip-box';
return 'mdi mdi-file-document-outline';
}
function setLoading() {
elRows.innerHTML = '<tr><td colspan="4"><div class="loading-state"><i class="mdi mdi-loading mdi-spin"></i>Loading files…</div></td></tr>';
elPrev.disabled = true;
elNext.disabled = true;
}
function setEmpty(message) {
elRows.innerHTML = '<tr><td colspan="4"><div class="empty-state"><i class="mdi mdi-file-search-outline"></i>' + escapeHtml(message) + '</div></td></tr>';
}
function renderRows(files) {
if (!files || !files.length) {
setEmpty('No files found for this folder / search');
return;
}
elRows.innerHTML = files.map(function (f) {
const download = f.download_url
? '<a class="btn btn-sm btn-ghost" href="' + escapeHtml(f.download_url) + '" target="_blank" rel="noopener">Download</a>'
: '<span class="text-muted">—</span>';
return '<tr>' +
'<td><div class="file-cell">' +
'<span class="file-icon"><i class="' + fileIconClass(f.file_name) + '"></i></span>' +
'<div><div class="file-name">' + escapeHtml(f.file_name) + '</div>' +
'<span class="file-key">' + escapeHtml(f.key) + '</span></div>' +
'</div></td>' +
'<td><span class="badge-soft">' + escapeHtml(f.size_label || '') + '</span></td>' +
'<td>' + escapeHtml(f.last_modified || '') + '</td>' +
'<td class="text-right">' + download + '</td>' +
'</tr>';
}).join('');
}
function initFolderSelect() {
if (typeof jQuery === 'undefined' || !jQuery.fn.select2) {
return;
}
const $folder = jQuery('#s3-folder');
if ($folder.hasClass('select2-hidden-accessible')) {
$folder.select2('destroy');
}
$folder.select2({
placeholder: 'Search or select folder',
allowClear: true,
width: '100%',
dropdownParent: jQuery('.s3-browser')
});
$folder.off('change.s3browser').on('change.s3browser', function () {
page = 1;
loadFiles();
});
}
function loadFolders() {
if (!USES_S3) return;
fetch(FOLDERS_URL, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (data.bucket) elBucket.textContent = data.bucket;
const folders = data.folders || [];
elFolder.innerHTML = '<option value="">Select folder</option>' +
folders.map(function (f) {
return '<option value="' + escapeHtml(f) + '">' + escapeHtml(f) + '</option>';
}).join('');
initFolderSelect();
})
.catch(function () {
setEmpty('Failed to load folders');
});
}
function loadFiles() {
if (!USES_S3) return;
const folder = elFolder.value;
if (!folder) {
setEmpty('Select a folder to load files');
elMeta.textContent = 'Page —';
elPrev.disabled = true;
elNext.disabled = true;
return;
}
setLoading();
const params = new URLSearchParams({
folder: folder,
search: elSearch.value.trim(),
page: String(page),
per_page: String(PER_PAGE)
});
fetch(LIST_URL + '?' + params.toString(), { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
if (!data.success) {
setEmpty(data.message || 'Failed to load files');
elMeta.textContent = 'Page —';
return;
}
renderRows(data.files || []);
const noun = (data.count === 1) ? 'file' : 'files';
elMeta.textContent = 'Page ' + data.page + ' · showing ' + (data.count || 0) + ' ' + noun;
elPrev.disabled = !data.has_previous;
elNext.disabled = !data.has_more;
})
.catch(function () {
setEmpty('Failed to load files');
});
}
document.getElementById('s3-search-btn').addEventListener('click', function () {
page = 1;
loadFiles();
});
elSearch.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
page = 1;
loadFiles();
}
});
elSearch.addEventListener('input', function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(function () {
page = 1;
loadFiles();
}, 400);
});
elPrev.addEventListener('click', function () {
if (page > 1) {
page -= 1;
loadFiles();
}
});
elNext.addEventListener('click', function () {
page += 1;
loadFiles();
});
loadFolders();
})();
</script>