MERGE_UAT_BUG_FIXES

This commit is contained in:
Ubuntu 2026-07-16 09:55:24 +05:30
commit de3f12316f
10 changed files with 897 additions and 6 deletions

View File

@ -31,6 +31,7 @@ class Acl
'#^/test/testingquerys#' => ['roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID]],
'#^/test/viewrfq#' => ['roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID]],
'#^/fedeploy#' => ['roles' => [ADMIN_ROLE_ID]],
'#^/storage/files#' => ['roles' => [ADMIN_ROLE_ID, HEAD_ROLE_ID]],
'#^/visitOffBoardCheck#' => ['roles' => [ADMIN_ROLE_ID]],
'#^/logs#' => ['roles' => [ADMIN_ROLE_ID]],
'#^/util/log_list#' => ['roles' => [ADMIN_ROLE_ID]],

View File

@ -33,6 +33,11 @@ $routes->get('/swagger/tpa', 'SwaggerController::tpa', ['filter' => 'authMVC']);
$routes->get('/swagger/tpa-spec', 'SwaggerController::tpaSpec', ['filter' => 'authMVC']);
$routes->get('/fedeploy', 'DeployController::fedeploy_view', ['filter' => 'authMVC']);
$routes->post('/fedeploy', 'DeployController::fedeploy', ['filter' => 'authMVC']);
$routes->group('/storage/files', ['filter' => 'authMVC'], function ($routes) {
$routes->get('/', 'StorageFilesController::index');
$routes->get('list', 'StorageFilesController::listFiles');
$routes->get('folders', 'StorageFilesController::folders');
});
$routes->get('/visitOffBoardCheck', 'EmployeeController::visitOffBoardCheck');
$routes->get('/metaDashboardDemo', 'TestingController::metaDashboardDemo');
$routes->get('/apacheSuperSetDemo', 'TestingController::apacheSuperSetDemo');

View File

@ -1929,6 +1929,7 @@ class PolicyTransactionController extends BaseController
$ptFileQuery = $this->PTFileModel
->select('pt_files.*')
->join('policy_transaction', 'pt_files.pt_id = policy_transaction.id')
->where('pt_files.pt_id', $id)
->where('pt_files.is_active', 1);
@ -2201,6 +2202,7 @@ class PolicyTransactionController extends BaseController
$ptFileQuery = $this->PTFileModel
->select('pt_files.*')
->join('policy_transaction', 'pt_files.pt_id = policy_transaction.id')
->where('pt_files.pt_id', $id)
->where('pt_files.is_active', 1);
@ -3414,6 +3416,7 @@ class PolicyTransactionController extends BaseController
$data['base_cd_amount'] = $pt_bp_amt['amount'] ?? null;
$ptFileQuery = $this->PTFileModel
->select('pt_files.*')
->join('policy_transaction', 'pt_files.pt_id = policy_transaction.id')
->where('pt_files.pt_id', $id)
->where('pt_files.is_active', 1);
@ -3731,6 +3734,7 @@ class PolicyTransactionController extends BaseController
if (!empty($uploadData)) {
$ptFileQuery = $this->PTFileModel
->select('pt_files.*')
->join('policy_transaction', 'pt_files.pt_id = policy_transaction.id')
->where('pt_files.pt_id', $pt_id)
->where('pt_files.is_active', 1);

View File

@ -0,0 +1,196 @@
<?php
namespace App\Controllers;
/**
* Browse S3 upload-bucket objects folder-wise with paginated search UI.
*/
class StorageFilesController extends AdminController
{
private const PER_PAGE = 10;
public function index()
{
$storage = \Config\Services::getFileStorageService(false);
$data = [
'tab_name' => 'S3 Files',
'page_title' => 'S3 File Browser',
'bucket' => $storage->getBucket(),
'uses_s3' => $storage->usesS3(),
'list_url' => base_url('storage/files/list'),
'folders_url' => base_url('storage/files/folders'),
];
$this->loadLayout('storage_files', $data);
}
/**
* GET /storage/files/folders
*/
public function folders()
{
$storage = \Config\Services::getFileStorageService(false);
if (! $storage->usesS3()) {
return $this->response->setJSON([
'success' => false,
'folders' => [],
'message' => 'S3 storage is not enabled',
])->setStatusCode(400);
}
$s3 = \Config\Services::getS3Service(false);
$listed = $s3->listRootFolders($storage->getBucket());
$fromS3 = $listed['folders'] ?? [];
// Merge known local upload module folders so empty S3 prefixes still appear.
$local = $this->localUploadFolders();
$folders = array_values(array_unique(array_merge($fromS3, $local)));
sort($folders);
return $this->response->setJSON([
'success' => true,
'bucket' => $storage->getBucket(),
'folders' => $folders,
'message' => $listed['message'] ?? 'OK',
]);
}
/**
* GET /storage/files/list?folder=&search=&page=1&per_page=10
*
* Fetches from S3 in small batches; stops once the requested page
* (+1 row to detect has_more) is satisfied. Does not load the whole bucket.
*/
public function listFiles()
{
$storage = \Config\Services::getFileStorageService(false);
if (! $storage->usesS3()) {
return $this->response->setJSON([
'success' => false,
'message' => 'S3 storage is not enabled',
])->setStatusCode(400);
}
$folder = basename(str_replace('\\', '/', trim((string) $this->request->getGet('folder'))));
if ($folder === '' || $folder === '.' || $folder === '..' || preg_match('/[^a-zA-Z0-9_\-]/', $folder)) {
return $this->response->setJSON([
'success' => false,
'message' => 'Invalid folder name',
])->setStatusCode(400);
}
$search = trim((string) $this->request->getGet('search'));
$page = max(1, (int) ($this->request->getGet('page') ?? 1));
$perPage = (int) ($this->request->getGet('per_page') ?? self::PER_PAGE);
$perPage = max(1, min(50, $perPage));
$needRows = ($page * $perPage) + 1; // one extra → has_more
$s3 = \Config\Services::getS3Service(false);
$bucket = $storage->getBucket();
$prefix = $folder . '/';
$matched = [];
$token = null;
$s3More = true;
$listErr = null;
while (count($matched) < $needRows && $s3More) {
$batch = $s3->listFilesPage($prefix, 100, $token, $bucket);
if (! ($batch['success'] ?? false)) {
$listErr = $batch['message'] ?? 'List failed';
break;
}
foreach ($batch['files'] ?? [] as $file) {
$key = (string) ($file['key'] ?? '');
$fileName = basename($key);
if ($search !== '' && stripos($fileName, $search) === false) {
continue;
}
$matched[] = [
'key' => $key,
'file_name' => $fileName,
'size' => (int) ($file['size'] ?? 0),
'size_label' => $this->formatBytes((int) ($file['size'] ?? 0)),
'last_modified' => (string) ($file['last_modified'] ?? ''),
];
}
$token = $batch['next_token'] ?? null;
$s3More = ! empty($batch['is_truncated']) && $token !== null;
}
if ($listErr !== null) {
return $this->response->setJSON([
'success' => false,
'message' => $listErr,
])->setStatusCode(500);
}
$offset = ($page - 1) * $perPage;
$pageRows = array_slice($matched, $offset, $perPage);
$hasMore = count($matched) > ($offset + count($pageRows))
|| ($s3More && count($pageRows) === $perPage);
// Optional short-lived download links for the 10 visible rows only.
foreach ($pageRows as &$row) {
$presigned = $storage->getPresignedUrl(
WRITEPATH . 'uploads' . DIRECTORY_SEPARATOR . $folder,
$row['file_name'],
300
);
$row['download_url'] = ($presigned['success'] ?? false) ? ($presigned['url'] ?? null) : null;
}
unset($row);
return $this->response->setJSON([
'success' => true,
'bucket' => $bucket,
'folder' => $folder,
'search' => $search,
'page' => $page,
'per_page' => $perPage,
'has_more' => $hasMore,
'has_previous' => $page > 1,
'count' => count($pageRows),
'files' => $pageRows,
'message' => 'OK',
]);
}
/** @return list<string> */
private function localUploadFolders(): array
{
$root = rtrim(WRITEPATH, '/\\') . DIRECTORY_SEPARATOR . 'uploads';
$out = [];
$entries = @scandir($root);
if (! is_array($entries)) {
return $out;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
if (is_dir($root . DIRECTORY_SEPARATOR . $entry)) {
$out[] = $entry;
}
}
return $out;
}
private function formatBytes(int $bytes): string
{
if ($bytes < 1024) {
return $bytes . ' B';
}
$units = ['KB', 'MB', 'GB', 'TB'];
$value = (float) $bytes;
foreach ($units as $unit) {
$value /= 1024;
if ($value < 1024) {
return round($value, 2) . ' ' . $unit;
}
}
return round($value, 2) . ' PB';
}
}

View File

@ -294,6 +294,118 @@ class S3Service
* @param string|null $bucket Override source bucket (optional)
* @return array ['success' => bool, 'files' => array, 'message' => string]
*/
/**
* List files in S3 bucket with optional MaxKeys / ContinuationToken pagination.
*
* @return array{success:bool,files:array,next_token:?string,is_truncated:bool,message:string}
*/
public function listFilesPage(string $prefix = '', int $maxKeys = 100, ?string $continuationToken = null, ?string $bucket = null): array
{
try {
$targetBucket = $this->resolveBucket($bucket);
$params = [
'Bucket' => $targetBucket,
'Prefix' => $prefix,
'MaxKeys' => max(1, min(1000, $maxKeys)),
];
if ($continuationToken !== null && $continuationToken !== '') {
$params['ContinuationToken'] = $continuationToken;
}
$result = $this->s3Client->listObjectsV2($params);
$files = [];
if (isset($result['Contents'])) {
foreach ($result['Contents'] as $object) {
$key = (string) ($object['Key'] ?? '');
if ($key === '' || substr($key, -1) === '/') {
continue;
}
$files[] = [
'key' => $key,
'size' => (int) ($object['Size'] ?? 0),
'last_modified' => $object['LastModified']->format('Y-m-d H:i:s'),
'url' => $this->buildBaseUrl($targetBucket) . $key,
];
}
}
$isTruncated = ! empty($result['IsTruncated']);
$nextToken = $isTruncated ? ($result['NextContinuationToken'] ?? null) : null;
return [
'success' => true,
'files' => $files,
'next_token' => $nextToken,
'is_truncated' => $isTruncated,
'message' => 'Files retrieved successfully',
];
} catch (AwsException $e) {
log_message('error', 'S3 List Page Error: ' . $e->getMessage());
return [
'success' => false,
'files' => [],
'next_token' => null,
'is_truncated' => false,
'message' => 'Failed to list files: ' . $e->getMessage(),
];
}
}
/**
* List top-level "folders" (common prefixes) in the bucket.
*
* @return array{success:bool,folders:list<string>,message:string}
*/
public function listRootFolders(?string $bucket = null): array
{
try {
$targetBucket = $this->resolveBucket($bucket);
$folders = [];
$token = null;
do {
$params = [
'Bucket' => $targetBucket,
'Delimiter' => '/',
'MaxKeys' => 1000,
];
if ($token !== null) {
$params['ContinuationToken'] = $token;
}
$result = $this->s3Client->listObjectsV2($params);
if (! empty($result['CommonPrefixes'])) {
foreach ($result['CommonPrefixes'] as $prefixRow) {
$p = rtrim((string) ($prefixRow['Prefix'] ?? ''), '/');
if ($p !== '') {
$folders[] = $p;
}
}
}
$token = ! empty($result['IsTruncated'])
? ($result['NextContinuationToken'] ?? null)
: null;
} while ($token !== null);
$folders = array_values(array_unique($folders));
sort($folders);
return [
'success' => true,
'folders' => $folders,
'message' => 'Folders retrieved successfully',
];
} catch (AwsException $e) {
log_message('error', 'S3 List Folders Error: ' . $e->getMessage());
return [
'success' => false,
'folders' => [],
'message' => 'Failed to list folders: ' . $e->getMessage(),
];
}
}
public function listFiles(string $prefix = '', ?string $bucket = null): array
{
try {

View File

@ -28,7 +28,7 @@ class PTFileModel extends Model
public function getPolicyDriveFilesIndex($policy_id,$policy_doc_name)
{
$clients = $this->select('pt_files.doc_name,pt_files.file_name,pt_files.url,pt.client_id,pt.client_policy_id,"policy" as file_type')
$clients = $this->select('pt_files.id,pt_files.doc_name,pt_files.file_name,pt_files.url,pt.client_id,pt.client_policy_id,"policy" as file_type')
->join('policy_transaction pt', 'pt_files.pt_id = pt.id')
->where('pt.client_policy_id', $policy_id)
->where('pt.is_active',1)

View File

@ -1116,7 +1116,7 @@
var link = $('<a>')
.attr('href', url)
.attr('download', true)
.attr('download', '')
.attr('style', 'font-size:18px;')
.attr('data-id', item.id)
.addClass('mdi mdi-download')

View File

@ -1369,7 +1369,7 @@ function appendFileTableBody(data)
var link = $('<a>')
.attr('href', url)
.attr('download', true)
.attr('download', '')
.attr('style', 'font-size:18px;')
.attr('data-id', item.id)
.addClass('mdi mdi-download')
@ -1472,7 +1472,7 @@ function appendVehicleFileTableBody(data)
// Create the download link
var downloadLink = $('<a>')
.attr('href', '<?= base_url('download-kyc-docs/') ?>' + item.id)
.attr('download', true)
.attr('download', '')
.attr('style', 'font-size:18px;')
.attr('data-id', item.id)
.addClass('mdi mdi-download')

View File

@ -1133,7 +1133,7 @@ function appendFileTableBody(data)
var link = $('<a>')
.attr('href', url)
.attr('download', true)
.attr('download', '')
.attr('style', 'font-size:18px;')
.attr('data-id', item.id)
.addClass('mdi mdi-download')
@ -1229,7 +1229,7 @@ function appendVehicleFileTableBody(data)
// Create the download link
var downloadLink = $('<a>')
.attr('href', '<?= base_url('download-kyc-docs/') ?>' + item.id)
.attr('download', true)
.attr('download', '')
.attr('style', 'font-size:18px;')
.attr('data-id', item.id)
.addClass('mdi mdi-download')

573
app/Views/storage_files.php Normal file
View File

@ -0,0 +1,573 @@
<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>