MERGE_UAT_BUG_FIXES
This commit is contained in:
commit
de3f12316f
@ -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]],
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
|
||||
196
app/Controllers/StorageFilesController.php
Normal file
196
app/Controllers/StorageFilesController.php
Normal 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';
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
573
app/Views/storage_files.php
Normal 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"> </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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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>
|
||||
Loading…
Reference in New Issue
Block a user