新增功能文件保险箱

*目前只是一个lite版文件管理器
This commit is contained in:
Chenx221 2024-03-10 16:37:22 +08:00
parent a3fa8c87e0
commit 9eb9d9d60f
Signed by: chenx221
GPG Key ID: D7A9EC07024C3021
6 changed files with 619 additions and 4 deletions

View File

@ -346,6 +346,10 @@ class UserController extends Controller
if (!is_dir($userFolder)) {
mkdir($userFolder);
}
$secretFolder = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . $model->id.'.secret';
if (!is_dir($secretFolder)) {
mkdir($secretFolder);
}
Yii::$app->session->setFlash('success', 'Registration successful. You can now log in.');
return $this->redirect(['login']);
} else {
@ -373,8 +377,8 @@ class UserController extends Controller
public function actionInfo(string $focus = null): Response|string
{
$model = Yii::$app->user->identity;
$usedSpace = FileSizeHelper::getDirectorySize(Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id);
$vaultUsedSpace = 0; // 保险箱已用空间暂时为0
$usedSpace = FileSizeHelper::getUserHomeDirSize();
$vaultUsedSpace = FileSizeHelper::getUserVaultDirSize();
$storageLimit = $model->storage_limit;
$totp_secret = null;
$totp_url = null;

View File

@ -0,0 +1,243 @@
<?php
/*
* 保险箱设计: 仅文件上传下载及删除,不支持文件夹
* 有些文件管理的代码迁移过来没删干净,这个忽略一下
*/
namespace app\controllers;
use app\models\UploadForm;
use app\utils\FileSizeHelper;
use app\utils\FileTypeDetector;
use Yii;
use yii\filters\AccessControl;
use yii\filters\VerbFilter;
use yii\web\Controller;
use yii\web\NotFoundHttpException;
use yii\web\Response;
use yii\web\UploadedFile;
class VaultController extends Controller
{
protected string $pattern = '/^[^\p{C}:*?"<>|\\\\]+$/u';
public function behaviors(): array
{
return array_merge(
parent::behaviors(),
[
'access' => [
'class' => AccessControl::class,
'rules' => [
[
'allow' => true,
'actions' => ['index', 'download', 'delete', 'upload'],
'roles' => ['user'],
],
],
],
'verbs' => [
'class' => VerbFilter::class,
'actions' => [
'index' => ['GET'],
'download' => ['GET'],
'delete' => ['POST'],
'upload' => ['POST'],
],
],
]
);
}
/**
* @param null $directory
* @return string|Response
* @throws NotFoundHttpException
*/
public function actionIndex($directory = null): Response|string
{
$model = Yii::$app->user->identity;
$rootDataDirectory = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id . '.secret';
if ($directory === '.' || $directory == null) {
$directory = null;
$parentDirectory = null;
} elseif (str_contains($directory, '..')) {
throw new NotFoundHttpException('Invalid directory.');
} else {
$parentDirectory = dirname($directory);
}
$directoryContents = $this->getDirectoryContents(join(DIRECTORY_SEPARATOR, [$rootDataDirectory, $directory ?: '.']));
foreach ($directoryContents as $key => $item) {
$relativePath = $directory ? $directory . '/' . $item : $item;
$absolutePath = Yii::getAlias('@app') . '/data/' . Yii::$app->user->id . '.secret/' . $relativePath;
$type = FileTypeDetector::detect($absolutePath);
$lastModified = filemtime($absolutePath);
$size = is_file($absolutePath) ? filesize($absolutePath) : null;
$rawType = is_file($absolutePath) ? mime_content_type($absolutePath) : null;
$directoryContents[$key] = ['name' => $item, 'type' => $type, 'lastModified' => $lastModified, 'size' => $size, 'rawType' => $rawType];
}
$usedSpace = FileSizeHelper::getUserHomeDirSize();
$vaultUsedSpace = FileSizeHelper::getUserVaultDirSize(); // 保险箱已用空间暂时为0
$storageLimit = $model->storage_limit;
return $this->render('index', [
'directoryContents' => $directoryContents,
'parentDirectory' => $parentDirectory,
'directory' => $directory, // 将$directory传递给视图
'usedSpace' => $usedSpace, // B
'vaultUsedSpace' => $vaultUsedSpace, // B
'storageLimit' => $storageLimit, // MB
]);
}
/**
* 获取指定路径下的文件和文件夹内容
* @param string $path 路径
* @return array 文件和文件夹内容数组
* @throws NotFoundHttpException 如果路径不存在
*/
protected function getDirectoryContents(string $path): array
{
// 确定路径是否存在
if (!is_dir($path)) {
throw new NotFoundHttpException('Directory not found.');
}
// 获取路径下的所有文件和文件夹
$directoryContents = scandir($path);
// 移除 '.' 和 '..'
$directoryContents = array_diff($directoryContents, ['.', '..']);
// 使用 usort 对目录内容进行排序,使文件夹始终在文件之前
usort($directoryContents, function ($a, $b) use ($path) {
$aIsDir = is_dir($path . '/' . $a);
$bIsDir = is_dir($path . '/' . $b);
if ($aIsDir === $bIsDir) {
return strnatcasecmp($a, $b); // 如果两者都是文件夹或都是文件,按名称排序
}
return $aIsDir ? -1 : 1; // 文件夹始终在文件之前
});
return $directoryContents;
}
/**
* 下载指定路径下的文件
*
* @param string $relativePath 文件的相对路径
* @throws NotFoundHttpException 如果文件不存在
*/
public function actionDownload(string $relativePath): void
{
// 对相对路径进行解码
$relativePath = rawurldecode($relativePath);
// 检查相对路径是否只包含允许的字符
if (!preg_match($this->pattern, $relativePath) || str_contains($relativePath, '..')) {
throw new NotFoundHttpException('Invalid file path.');
}
// 确定文件的绝对路径
$absolutePath = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id . '.secret/' . $relativePath;
// 使用realpath函数解析路径并检查解析后的路径是否在预期的目录中
$realPath = realpath($absolutePath);
$dataDirectory = str_replace('/', '\\', Yii::getAlias(Yii::$app->params['dataDirectory']));
if (!$realPath || !str_starts_with($realPath, $dataDirectory)) {
throw new NotFoundHttpException('File not found.');
}
// 检查文件是否存在
if (!file_exists($realPath)) {
throw new NotFoundHttpException('File not found.');
}
// 将文件发送给用户进行下载
Yii::$app->response->sendFile($realPath)->send();
}
/**
* 删除文件
* @throws NotFoundHttpException 如果文件不存在
*/
public function actionDelete(): Response
{
$relativePaths = Yii::$app->request->post('relativePath');
if (!is_array($relativePaths)) {
$relativePaths = [$relativePaths];
}
foreach ($relativePaths as $relativePath) {
$relativePath = rawurldecode($relativePath);
if (!preg_match($this->pattern, $relativePath) || str_contains($relativePath, '..')) {
throw new NotFoundHttpException('Invalid file path.');
}
$absolutePath = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id . '.secret/' . $relativePath;
if (!file_exists($absolutePath)) {
throw new NotFoundHttpException('File or directory not found.');
} else {
$realPath = realpath($absolutePath);
$expectedPathPrefix = realpath(Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id . '.secret');
if (!str_starts_with($realPath, $expectedPathPrefix)) {
throw new NotFoundHttpException('File or directory not found.');
}
}
if (!unlink($absolutePath)) {
Yii::$app->session->setFlash('error', 'Failed to delete file.');
} else {
Yii::$app->session->setFlash('success', 'File deleted successfully.');
}
}
return $this->redirect(['index', 'directory' => dirname($relativePaths[0])]);
}
/**
* 文件上传
* 注意,已存在的同名文件会被覆盖
*
* @return int|Response
*/
public function actionUpload(): Response|int
{
$model = new UploadForm();
$model->targetDir = Yii::$app->request->post('targetDir', '.');
$uploadedFiles = UploadedFile::getInstancesByName('files');
$successCount = 0;
$totalCount = count($uploadedFiles);
$sp = Yii::$app->request->post('sp', null);
foreach ($uploadedFiles as $uploadedFile) {
$model->uploadFile = $uploadedFile;
if (!preg_match($this->pattern, $model->uploadFile->fullPath) || str_contains($model->uploadFile->fullPath, '..')) {
continue;
}
if (!FileSizeHelper::hasEnoughSpace($model->uploadFile->size)) {
continue;
}
if ($model->upload(1)) {
$successCount++;
}
}
if ($sp === 'editSaving') {
if ($successCount === $totalCount) {
Yii::$app->response->statusCode = 200;
return $this->asJson(['status' => 200, 'message' => '文件上传成功']);
} else {
Yii::$app->response->statusCode = 500;
return $this->asJson(['status' => 500, 'message' => '文件上传失败']);
}
} else {
if ($successCount === $totalCount) {
Yii::$app->session->setFlash('success', '文件上传成功');
} elseif ($successCount > 0) {
Yii::$app->session->setFlash('warning', '部分文件上传失败,这可能是用户剩余空间不足导致');
} else {
Yii::$app->session->setFlash('error', '文件上传失败,这可能是用户剩余空间不足导致');
}
//返回状态码200
return Yii::$app->response->statusCode = 200;
}
}
}

View File

@ -22,7 +22,7 @@ class UploadForm extends Model
];
}
public function upload(): bool
public function upload(int $is_vault_file = 0): bool
{
if ($this->validate()) {
if ($this->targetDir === null) {
@ -32,6 +32,9 @@ class UploadForm extends Model
return false;
}
$userHomeDir = Yii::getAlias(Yii::$app->params['dataDirectory']) . '/' . Yii::$app->user->id;
if ($is_vault_file == 1) {
$userHomeDir .= '.secret';
}
$absolutePath = $userHomeDir . '/' . $this->targetDir;
if (!is_dir($absolutePath)) {
return false;

View File

@ -44,10 +44,10 @@ $darkMode = Yii::$app->user->isGuest ? 0 : Yii::$app->user->identity->dark_mode;
'items' => [
['label' => '首页', 'url' => ['/site/index']],
['label' => '我的文件', 'url' => ['/home/index']],
['label' => '文件保险箱', 'url' => ['/vault/index']],
['label' => '分享管理', 'url' => ['/share/index']],
['label' => '文件收集', 'url' => ['/collection/index']],
['label' => '个人设置', 'url' => ['/user/info']],
// ['label' => '系统设置', 'url' => ['/site/contact']],
// ['label' => '应用下载', 'url' => ['/site/contact']],
Yii::$app->user->isGuest
? ['label' => '登录', 'url' => ['/user/login']]

164
views/vault/index.php Normal file
View File

@ -0,0 +1,164 @@
<?php
// 文件管理精简版是吗?
/* @var $this yii\web\View */
/* @var $directoryContents array 文件和文件夹内容数组 */
/* @var $parentDirectory string 父目录 */
/* @var $usedSpace int */
/* @var $vaultUsedSpace int */
/* @var $storageLimit int */
/* @var $directory string 当前路径 */
use app\utils\FileSizeHelper;
use yii\bootstrap5\Html;
use app\assets\FontAwesomeAsset;
use yii\bootstrap5\Modal;
use yii\bootstrap5\Progress;
use yii\helpers\Url;
use yii\web\JqueryAsset;
use yii\web\View;
$this->title = '文件保险箱';
$this->params['breadcrumbs'][] = $this->title;
$totalUsed_F = FileSizeHelper::formatBytes($usedSpace + $vaultUsedSpace); //总已用空间 格式化文本
$storageLimit_F = FileSizeHelper::formatMegaBytes($storageLimit); //存储限制 格式化文本
$is_unlimited = ($storageLimit === -1); //检查是否为无限制容量
$usedPercent = $is_unlimited ? 0 : round($usedSpace / ($storageLimit * 1024 * 1024) * 100); //网盘已用百分比
$vaultUsedPercent = $is_unlimited ? 0 : round($vaultUsedSpace / ($storageLimit * 1024 * 1024) * 100); //保险箱已用百分比
$totalUsedPercent = min(($usedPercent + $vaultUsedPercent), 100); //总已用百分比
$freeSpace = $is_unlimited ? 'unlimited' : ($storageLimit * 1024 * 1024 - $usedSpace - $vaultUsedSpace); //剩余空间
FontAwesomeAsset::register($this);
JqueryAsset::register($this);
$this->registerCssFile('@web/css/home_style.css');
?>
<div class="home-directory">
<div class="d-flex justify-content-between align-items-center">
<h1><?= Html::encode($this->title) ?></h1>
<div>
<?= Html::button('下载', ['class' => 'btn btn-outline-primary single-download-btn']) ?>
<?= Html::button('删除', ['class' => 'btn btn-outline-danger batch-delete-btn']) ?>
<?= Html::button('刷新', ['class' => 'btn btn-outline-primary refresh-btn']) ?>
<div class="dropdown d-inline-block">
<button class="btn btn-primary dropdown-toggle" type="button" id="dropdownMenuButton"
data-bs-toggle="dropdown" aria-expanded="false">
<i class="fa-solid fa-arrow-up-from-bracket"></i> 上传文件
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton" style="z-index: 1080;">
<li hidden>
<input type="file" id="file-input" name="uploadFile" multiple>
<input type="file" id="folder-input" name="uploadFile" multiple webkitdirectory>
<input type="hidden" name="targetDir" value="<?= $directory ?>" id="target-dir">
</li>
<li><?= Html::button('上传文件', ['class' => 'dropdown-item file-upload-btn']) ?></li>
<!-- 上传文件功能将会覆盖已存在的同名文件,这点请注意-->
</ul>
</div>
<button type="button" class="btn btn-primary" data-bs-toggle="popover" data-bs-title="容量使用情况"
data-bs-placement="bottom"
data-bs-content="已用:<?= $totalUsed_F ?>/ <?= $storageLimit_F ?><?= $freeSpace == 'unlimited' ? '' : ($freeSpace <= 0 ? ' 容量超限,功能受限' : '') ?>">
<i
class="fa-solid fa-info"></i>
</button>
</div>
</div>
<!--上传进度条-->
<?php
echo Progress::widget([
'percent' => 0,
'barOptions' => ['class' => ['bg-success', 'progress-bar-animated', 'progress-bar-striped']],
'label' => '123', //NMD 不是说可选吗
'options' => ['style' => 'display: none;margin-top: 10px;', 'id' => 'progress-bar']
]);
?>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<?= Html::a('<i class="fa-solid fa-house"></i> HOME', ['vault/index'], ['class' => 'breadcrumb-item']) ?>
<?php if ($directory !== null): ?>
<?php
$parts = explode('/', $directory);
$path = '';
$lastIndex = count($parts) - 1;
foreach ($parts as $index => $part):
$path .= $part;
$class = $index === $lastIndex ? 'breadcrumb-item active' : 'breadcrumb-item';
echo Html::a($part, ['vault/index', 'directory' => $path], ['class' => $class]);
$path .= '/';
endforeach;
?>
<?php endif; ?>
</ol>
</nav>
<div class="dropdown" id="contextMenu" style="display: none;position: absolute">
<ul class="dropdown-menu shadow" aria-labelledby="dropdownMenuButton" id="contextMenu-content">
<li><a class="dropdown-item" id="option-download" href="#">下载</a></li>
<li><a class="dropdown-item" id="option-batch-delete" href="#">删除</a></li>
<li><a class="dropdown-item" id="option-refresh" href="#">刷新</a></li>
</ul>
</div>
<table class="table table-hover" id="drop-area">
<thead>
<tr>
<th scope="col" class="selector-col"><label for="select-all" hidden></label><input type="checkbox"
id="select-all"></th>
<th scope="col" class="name-col">名称</th>
<th scope="col" class="modified-col">最近修改时间</th>
<th scope="col" class="size-col">大小</th>
<th scope="col" class="action-col">操作</th>
</tr>
</thead>
<tbody>
<?php foreach ($directoryContents as $item): ?>
<?php $relativePath = $directory ? $directory . '/' . $item['name'] : $item['name']; ?>
<?php $absolutePath = Yii::getAlias('@app') . '/data/' . Yii::$app->user->id . '.secret/' . $relativePath; ?>
<tr>
<td><label for="selector1" hidden></label><input id="selector1" type="checkbox" class="select-item"
data-relative-path="<?= Html::encode($relativePath) ?>"
data-is-directory="<?= Html::encode(is_dir($absolutePath)) ?>">
</td>
<td>
<?= Html::tag('i', '', ['class' => $item['type'] . ' file_icon']) ?>
<?= Html::a($item['name'], ['vault/download', 'relativePath' => $relativePath], ['class' => 'file_name']) ?>
</td>
<td class="file_info">
<?= date('Y-m-d H:i:s', $item['lastModified']) ?>
</td>
<td class="file_info">
<?= $item['size'] !== null ? Yii::$app->formatter->asShortSize($item['size'], 2) : '' ?>
</td>
<td>
<?= Html::button(Html::tag('i', '', ['class' => 'fa-regular fa-circle-down']), [
'value' => Url::to(['vault/download', 'relativePath' => $relativePath]),
'class' => 'btn btn-outline-primary download-btn',
'data-bs-toggle' => 'tooltip',
'data-bs-placement' => 'top',
'data-bs-title' => '下载'
]) ?>
<?= Html::button(Html::tag('i', '', ['class' => 'fa-regular fa-trash-can']), ['value' => $relativePath, 'class' => 'btn btn-outline-danger delete-btn', 'data-bs-toggle' => 'tooltip', 'data-bs-placement' => 'top', 'data-bs-title' => '删除']) ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php
Modal::begin([
'title' => '<h4>确认删除</h4>',
'id' => 'deleteModal',
'size' => 'modal-sm',
]);
echo Html::tag('div', '你确定要删除这个文件吗?', ['class' => 'modal-body']);
echo Html::beginForm(['vault/delete'], 'post', ['id' => 'delete-form']);
echo Html::hiddenInput('relativePath', '', ['id' => 'deleteRelativePath']);
echo Html::submitButton('确认', ['class' => 'btn btn-danger']);
echo Html::endForm();
Modal::end();
$this->registerJsFile('@web/js/vault_script.js', ['depends' => [JqueryAsset::class], 'position' => View::POS_END]);
?>

201
web/js/vault_script.js Normal file
View File

@ -0,0 +1,201 @@
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl)
});
$(document).on('click', '.download-btn', function () {
window.location.href = $(this).attr('value');
});
$(document).on('click', '.delete-btn', function () {
var relativePath = $(this).attr('value');
$('#deleteRelativePath').val(relativePath);
$('#deleteModal').modal('show');
});
$(document).on('click', '.file-upload-btn', function () {
$('#file-input').click();
});
$('#file-input').on('change', function () {
uploadFiles(this.files);
});
$('#folder-input').on('change', function () {
uploadFiles(this.files);
});
$(document).on('click', '.refresh-btn', function () {
window.location.reload();
});
$(document).on('click', '.single-download-btn', function () {
var relativePath = $('.select-item:checked').first().data('relativePath');
window.location.href = 'index.php?r=vault%2Fdownload&relativePath=' + encodeURIComponent(relativePath);
});
function uploadFiles(files) {
$('#progress-bar').show();
var formData = new FormData();
for (var i = 0; i < files.length; i++) {
formData.append('files[]', files[i]);
}
formData.append('targetDir', $('#target-dir').val());
formData.append('_csrf', $('meta[name="csrf-token"]').attr('content'));
var xhr = new XMLHttpRequest();
xhr.upload.onprogress = function (event) {
if (event.lengthComputable) {
var percentComplete = event.loaded / event.total * 100;
$('#progress-bar .progress-bar').css('width', percentComplete + '%').text(Math.round(percentComplete) + '%');
}
};
xhr.onload = function () {
if (xhr.status !== 200) {
alert('An error occurred during the upload.');
}
window.location.reload();
};
xhr.open('POST', 'index.php?r=vault%2Fupload');
xhr.send(formData);
}
var dropArea = document.getElementById('drop-area');
dropArea.addEventListener('dragover', function (event) {
event.preventDefault();
});
dropArea.addEventListener('drop', function (event) {
event.preventDefault();
var items = event.dataTransfer.items;
var files = [];
for (var i = 0; i < items.length; i++) {
var item = items[i];
files.push(item.getAsFile());
}
uploadFiles(files);
dropArea.classList.remove('dragging');
});
dropArea.addEventListener('dragenter', function (event) {
event.preventDefault();
dropArea.classList.add('dragging');
});
dropArea.addEventListener('dragleave', function (event) {
event.preventDefault();
if (!dropArea.contains(event.relatedTarget)) {
dropArea.classList.remove('dragging');
}
});
document.getElementById('select-all').addEventListener('change', function () {
var checkboxes = document.querySelectorAll('.select-item');
for (var i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = this.checked;
checkboxes[i].closest('tr').classList.toggle('selected', this.checked);
}
});
var itemCheckboxes = document.querySelectorAll('.select-item');
for (var i = 0; i < itemCheckboxes.length; i++) {
itemCheckboxes[i].addEventListener('change', function () {
if (!this.checked) {
document.getElementById('select-all').checked = false;
} else {
var allChecked = true;
for (var j = 0; j < itemCheckboxes.length; j++) {
if (!itemCheckboxes[j].checked) {
allChecked = false;
break;
}
}
document.getElementById('select-all').checked = allChecked;
}
this.closest('tr').classList.toggle('selected', this.checked);
});
}
document.addEventListener('keydown', function (event) {
if (event.ctrlKey && event.key === 'a') {
event.preventDefault();
var checkboxes = document.querySelectorAll('.select-item');
var allChecked = Array.from(checkboxes).every(checkbox => checkbox.checked);
for (var i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = !allChecked;
checkboxes[i].closest('tr').classList.toggle('selected', !allChecked);
}
updateButtons();
}
if (event.ctrlKey && event.key === 'd') {
event.preventDefault();
$('tr.selected').removeClass('selected').find('input[type="checkbox"]').prop('checked', false);
$('#select-all').prop('checked', false);
updateButtons();
}
});
$(document).on('click', 'tr', function (event) {
if ($(event.target).is('input[type="checkbox"]') || $(event.target).is('a')) {
return;
}
var checkboxes = document.querySelectorAll('.select-item');
var checkedCount = Array.from(checkboxes).filter(checkbox => checkbox.checked).length;
var allChecked = checkedCount >= 2;
if (!event.ctrlKey) {
$('tr.selected').not(this).removeClass('selected').find('input[type="checkbox"]').prop('checked', false);
}
if ((!allChecked) || (allChecked && event.ctrlKey)) {
$(this).toggleClass('selected');
var checkbox = $(this).children(':first-child').find('input[type="checkbox"]');
checkbox.prop('checked', !checkbox.prop('checked'));
}
updateButtons();
});
function updateButtons() {
var checkboxes = $('.select-item:checked');
var count = checkboxes.length;
var isSingleFile = count === 1 && !checkboxes.first().data('isDirectory');
$('.single-download-btn').toggle(isSingleFile);
$('.batch-delete-btn').toggle(count >= 1);
}
$(document).on('change', '.select-item', updateButtons);
$(document).ready(function () {
updateButtons();
$('tr').contextmenu(function (e) {
e.preventDefault();
if ($(e.target).is('button') || $(e.target).is('i') || $(e.target).is('a')) {
e.preventDefault();
return;
}
var clickedElement = $(this);
if (!clickedElement.hasClass('selected')) {
$('tr.selected').removeClass('selected').find('input[type="checkbox"]').prop('checked', false);
clickedElement.addClass('selected');
var checkbox = clickedElement.children(':first-child').find('input[type="checkbox"]');
checkbox.prop('checked', true);
updateButtons();
}
$('#option-download').toggle($('.single-download-btn').css('display') !== 'none');
$('#option-batch-delete').toggle($('.batch-delete-btn').css('display') !== 'none');
$('#option-refresh').toggle($('.refresh-btn').css('display') !== 'none');
$('#contextMenu').css({
display: "block",
left: e.pageX,
top: e.pageY
}).addClass('show');
$('#contextMenu .dropdown-menu').addClass('show');
});
$('#contextMenu a').off('click').on('click', function (e) {
e.preventDefault();
var clickedMenuItem = $(this).attr('id');
switch (clickedMenuItem) {
case 'option-download':
$('.single-download-btn').click();
break;
case 'option-batch-delete':
$('.batch-delete-btn').click();
break;
case 'option-refresh':
$('.refresh-btn').click();
break;
}
$('#contextMenu').hide().removeClass('show');
});
$(document).click(function () {
$('#contextMenu').hide().removeClass('show');
$('#contextMenu .dropdown-menu').removeClass('show');
});
});
const popoverTriggerList = document.querySelectorAll('[data-bs-toggle="popover"]')
const popoverList = [...popoverTriggerList].map(popoverTriggerEl => new bootstrap.Popover(popoverTriggerEl))