-
Daniel Göbel authored
#173
Daniel Göbel authored#173
BucketView.vue 29.56 KiB
<script setup lang="ts">
import { onMounted, reactive, watch, computed } from "vue";
import type {
FolderTree,
S3PseudoFolder,
S3ObjectWithFolder,
} from "@/types/PseudoFolder";
import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue";
import { filesize } from "filesize";
import dayjs from "dayjs";
import { Toast, Tooltip } from "bootstrap";
import PermissionListModal from "@/components/object-storage/modals/PermissionListModal.vue";
import UploadObjectModal from "@/components/object-storage/modals/UploadObjectModal.vue";
import CopyObjectModal from "@/components/object-storage/modals/CopyObjectModal.vue";
import PermissionModal from "@/components/object-storage/modals/PermissionModal.vue";
import ObjectDetailModal from "@/components/object-storage/modals/ObjectDetailModal.vue";
import CreateFolderModal from "@/components/object-storage/modals/CreateFolderModal.vue";
import DeleteModal from "@/components/modals/DeleteModal.vue";
import type { _Object as S3Object } from "@aws-sdk/client-s3";
import { useUserStore } from "@/stores/users";
import { useBucketStore } from "@/stores/buckets";
import { useS3ObjectStore } from "@/stores/s3objects";
import { useS3KeyStore } from "@/stores/s3keys";
import BootstrapToast from "@/components/BootstrapToast.vue";
import { useSettingsStore } from "@/stores/settings";
import { md5 } from "@/utils/md5";
const authStore = useUserStore();
const bucketRepository = useBucketStore();
const objectRepository = useS3ObjectStore();
const s3KeyRepository = useS3KeyStore();
const settingsStore = useSettingsStore();
// Constants
// -----------------------------------------------------------------------------
const props = defineProps<{
bucketName: string;
subFolders: string[] | string;
}>();
const randomIDSuffix = Math.random().toString(16).substring(2, 8);
let successToast: Toast | null = null;
let refreshTimeout: NodeJS.Timeout | undefined = undefined;
// Reactive State
// -----------------------------------------------------------------------------
const deleteObjectsState = reactive<{
deletedItem: string;
potentialObjectsToDelete: string[];
}>({
deletedItem: "",
potentialObjectsToDelete: [],
});
const objectState = reactive<{
loading: boolean;
filterString: string;
bucketNotFoundError: boolean;
bucketPermissionError: boolean;
editObjectKey: string;
copyObject: S3Object;
viewDetailKey: string | undefined;
selectedObjs: string[];
}>({
loading: true,
filterString: "",
bucketNotFoundError: false,
bucketPermissionError: false,
editObjectKey: "",
copyObject: {
Key: "",
Size: 0,
LastModified: new Date(),
},
viewDetailKey: undefined,
selectedObjs: [],
});
// Computed Properties
// -----------------------------------------------------------------------------
const filteredObjects = computed<(S3ObjectWithFolder | S3PseudoFolder)[]>(
() => {
return objectState.filterString.length > 0
? visibleObjects.value.filter((obj) =>
obj.Key?.includes(objectState.filterString),
)
: visibleObjects.value;
},
);
const s3Objects = computed<S3Object[]>(
() => objectRepository.objectMapping[props.bucketName] ?? [],
);
const folderStructure = computed<FolderTree>(() => {
/**
* Store the entire folder structure in a bucket in a tree-like data structure
*/
return objectsWithFolders.value.reduce(
// Add one object after another to the folder structure
(fTree, currentObject) => {
// If the object is not in a sub folder, but it in the top level 'folder'
if (currentObject.folder.length === 0) {
fTree.files.push(currentObject);
} else {
// If the object is in a sub folder
let currentFolder: FolderTree = fTree;
// For every sub folder the object is in , navigate into the sub folder
for (const folderName of currentObject.folder) {
// If the sub folder doesn't exist yet, create it
if (
Object.keys(currentFolder.subFolders).find(
(subFolderName) => subFolderName === folderName,
) == undefined
) {
currentFolder.subFolders[folderName] = {
subFolders: {},
files: [],
};
}
// navigate into the sub folder
currentFolder = currentFolder.subFolders[folderName] as FolderTree;
}
// Add object to the folder
currentFolder.files.push(currentObject);
}
return fTree;
},
// Empty folder structure as initial value
{
subFolders: {},
files: [],
} as FolderTree,
);
});
const objectsWithFolders = computed<S3ObjectWithFolder[]>(() => {
/**
* Add to the meta information from objects the pseudo filename and their pseudo folder
* This can be inferred from the key of the object where the '/' character is the delimiter, e.g.
* dir1/dir2/text.txt ->
* folder: dir1, dir2
* filename: text.txt
*/
return s3Objects.value.map((obj) => {
const splittedKey = obj.Key?.split("/") ?? [""];
return {
...obj,
pseudoFileName: splittedKey[splittedKey.length - 1],
folder: splittedKey.slice(0, splittedKey.length - 1),
};
});
});
const currentSubFolders = computed<string[]>(() => {
/**
* Transform a single sub folder from a string to an array containing the string and
* replace an empty string with an empty list
*/
return props.subFolders instanceof Array
? props.subFolders
: props.subFolders.length > 0
? [props.subFolders]
: [];
});
const visibleObjects = computed<(S3ObjectWithFolder | S3PseudoFolder)[]>(() => {
/**
* Compute the visible objects based on the current sub folder
*/
let currentFolder = folderStructure.value;
// Navigate into right sub folder
for (const subFolder of currentSubFolders.value) {
if (currentFolder.subFolders[subFolder] == null) {
// If sub folder doesn't exist, no object is visible
return [];
} else {
currentFolder = currentFolder.subFolders[subFolder];
}
}
// Add all objects and sub folders from the current sub folder as visible object
const arr = [];
arr.push(...currentFolder.files);
arr.push(
...Object.keys(currentFolder.subFolders).map((subFolderName) => {
const folderSize = calculateFolderSize(
currentFolder.subFolders[subFolderName],
);
const folderLastModified = dayjs(
calculateFolderLastModified(currentFolder.subFolders[subFolderName]),
).toDate();
const folderQuantity = calculateFolderObjectNumber(
currentFolder.subFolders[subFolderName],
);
return {
name: subFolderName,
Size: folderSize,
Key: subFolderName,
quantity: folderQuantity,
parentFolder: currentSubFolders.value,
LastModified: folderLastModified,
} as S3PseudoFolder;
}),
);
return arr.filter(
(obj) => !obj.Key?.endsWith("/") && (obj.Key?.length ?? 0) > 0,
);
});
const subFolderInUrl = computed<boolean>(
() => currentSubFolders.value.length > 0,
);
const errorLoadingObjects = computed<boolean>(
() => objectState.bucketPermissionError || objectState.bucketNotFoundError,
);
const writableBucket = computed<boolean>(() => {
// Allow only upload in bucket folder with respect to permission prefix
let prefixWritable = true;
if (
bucketRepository.ownPermissions[props.bucketName]?.file_prefix != undefined
) {
prefixWritable =
bucketRepository.ownPermissions[props.bucketName]?.file_prefix ===
currentSubFolders.value.join("/") + "/";
}
return bucketRepository.writableBucket(props.bucketName) && prefixWritable;
});
const readableBucket = computed<boolean>(() =>
bucketRepository.readableBucket(props.bucketName),
);
// Watchers
// -----------------------------------------------------------------------------
watch(
() => props.bucketName,
(newBucketName, oldBucketName) => {
if (oldBucketName !== newBucketName) {
document.title = newBucketName + " - CloWM";
objectState.viewDetailKey = undefined;
// If bucket is changed, update the objects
objectState.bucketPermissionError = false;
objectState.bucketNotFoundError = false;
fetchObjects();
objectState.filterString = "";
}
},
);
watch(
visibleObjects,
(visObjs) => {
if (visObjs.length > 0) {
// Initialise tooltips after DOM changes
setTimeout(() => {
document
.querySelectorAll("span.date-tooltip")
.forEach((tooltipTriggerEl) => new Tooltip(tooltipTriggerEl));
}, 500);
}
},
{ flush: "post" },
);
watch(
() => props.subFolders,
() => {
objectState.selectedObjs = [];
},
);
// Lifecycle Hooks
// -----------------------------------------------------------------------------
onMounted(() => {
let counter = 0;
const onFinally = () => {
counter++;
if (counter > 1) {
fetchObjects();
}
};
// wait till s3keys and ownPermissions are available before fetching objects
s3KeyRepository.fetchS3Keys(onFinally);
bucketRepository.fetchOwnPermissions(onFinally);
document
.querySelectorAll(".tooltip-container")
.forEach(
(tooltipTriggerEl) => new Tooltip(tooltipTriggerEl, { trigger: "hover" }),
);
successToast = new Toast("#successToast-" + randomIDSuffix);
});
// Functions
// -----------------------------------------------------------------------------
/**
* Calculate recursively the number of objects in a folder
* @param folder Folder to inspect
* @returns The number of objects in this folder
*/
function calculateFolderObjectNumber(folder: FolderTree): number {
return (
folder.files.filter((obj) => !obj.Key?.endsWith("/") ?? false).length +
Object.keys(folder.subFolders).reduce(
(size, subFolderName) =>
size + calculateFolderObjectNumber(folder.subFolders[subFolderName]),
0,
)
);
}
/**
* Calculate recursively the cumulative file size of all objects in a folder
* @param folder Folder to inspect
* @returns The size of this folder in bytes
*/
function calculateFolderSize(folder: FolderTree): number {
return (
folder.files.reduce((acc, file) => acc + (file.Size ?? 0), 0) +
Object.keys(folder.subFolders).reduce(
(size, subFolderName) =>
size + calculateFolderSize(folder.subFolders[subFolderName]),
0,
)
);
}
/**
* Calculate recursively when an object in a folder were modified the last time
* @param folder Folder to inspect
* @returns The last modified timestamp as ISO string
*/
function calculateFolderLastModified(folder: FolderTree): string {
let lastModified: dayjs.Dayjs;
lastModified = folder.files
.map((f) => dayjs(f.LastModified))
.reduce(
(acc, fileAccessed) => (fileAccessed.isAfter(acc) ? fileAccessed : acc),
dayjs("2000-01-01"),
);
for (const subFolderName of Object.keys(folder.subFolders)) {
const lastModifiedSubFolder = dayjs(
calculateFolderLastModified(folder.subFolders[subFolderName]),
);
if (lastModifiedSubFolder.isAfter(lastModified)) {
lastModified = lastModifiedSubFolder;
}
}
return lastModified.toISOString();
}
/**
* Fetch object from bucket with loading animation
*/
function fetchObjects() {
objectState.loading = true;
objectState.bucketPermissionError = false;
objectState.bucketNotFoundError = false;
const prefix: string | undefined =
bucketRepository.ownPermissions[props.bucketName]?.file_prefix ?? undefined;
objectRepository
.fetchS3Objects(props.bucketName, prefix, () => {
objectState.loading = false;
objectState.bucketPermissionError = false;
objectState.bucketNotFoundError = false;
})
.catch((error) => {
if (error.Code == "AccessDenied") {
objectState.bucketPermissionError = true;
} else {
objectState.bucketNotFoundError = true;
}
});
}
/**
* Fetch the meta information about objects from a bucket
*/
function refreshObjects() {
clearTimeout(refreshTimeout);
refreshTimeout = setTimeout(() => {
fetchObjects();
}, 500);
}
function isS3Object(
obj: S3PseudoFolder | S3ObjectWithFolder,
): obj is S3ObjectWithFolder {
return (obj as S3ObjectWithFolder).folder !== undefined;
}
function deleteObject(key?: string) {
if (key == undefined) {
return;
}
deleteObjectsState.potentialObjectsToDelete = [key];
}
/**
* Initiate the download of the provided object
* @param key Key of the object
* @param bucket Bucket of the object
*/
async function downloadObject(bucket: string, key?: string) {
if (key == undefined) {
return;
}
const url = await objectRepository.getPresignedUrl(bucket, key);
//creating an invisible element
const element = document.createElement("a");
element.setAttribute("href", url);
element.setAttribute("download", "");
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
function deleteFolder(folder: S3PseudoFolder) {
deleteObjectsState.potentialObjectsToDelete = [folderKeyPrefix(folder)];
}
function folderKeyPrefix(folder: S3PseudoFolder): string {
return folder.parentFolder.concat([""]).join("/") + folder.name + "/";
}
/**
* Delete a folder in the current Bucket
* @param keys Path to the folder with a trailing "/", e.g. some/path/to/a/folder/
*/
function confirmedDeleteKeys(keys: string[]) {
Promise.all(
keys
.filter((key) => key.endsWith("/"))
.map((key) =>
objectRepository.deleteObjectsWithPrefix(props.bucketName, key),
)
.concat(
objectRepository.deleteObjects(
props.bucketName,
keys.filter((key) => !key.endsWith("/")),
),
),
).then(() => {
objectState.selectedObjs = objectState.selectedObjs.filter(
(obj) => !keys.includes(obj),
);
if (keys.length > 0) {
deleteObjectsState.deletedItem =
keys.length > 1 ? "selected items" : keys[0];
successToast?.show();
}
});
}
function getObjectFileName(key: string): string {
const splittedKey = key.split("/");
return splittedKey[splittedKey.length - 1];
}
function deleteSelected() {
deleteObjectsState.potentialObjectsToDelete = objectState.selectedObjs;
}
function clickBox() {
if (objectState.selectedObjs.length < visibleObjects.value.length) {
objectState.selectedObjs = visibleObjects.value.map((obj) =>
isS3Object(obj) ? (obj.Key ?? "") : folderKeyPrefix(obj),
);
} else {
objectState.selectedObjs = [];
}
}
</script>
<template>
<bootstrap-toast :toast-id="'successToast-' + randomIDSuffix">
Successfully deleted {{ deleteObjectsState.deletedItem }}
</bootstrap-toast>
<DeleteModal
modal-id="delete-object-modal"
:object-name-delete="
deleteObjectsState.potentialObjectsToDelete.length === 1
? deleteObjectsState.potentialObjectsToDelete[0]
: 'selected items'
"
@confirm-delete="
confirmedDeleteKeys(deleteObjectsState.potentialObjectsToDelete)
"
/>
<!-- Navbar Breadcrumb -->
<nav aria-label="breadcrumb" class="fs-2">
<ol class="breadcrumb">
<li class="breadcrumb-item" :class="{ active: subFolderInUrl }">
<router-link
v-if="subFolderInUrl"
:to="{
name: 'bucket',
params: { bucketName: props.bucketName, subFolders: [] },
}"
>{{ props.bucketName }}
</router-link>
<span v-else class="text-secondary">{{ props.bucketName }}</span>
</li>
<li
v-for="(folder, index) in currentSubFolders"
:key="folder"
class="breadcrumb-item"
:class="{ active: index === currentSubFolders.length }"
>
<router-link
v-if="index !== currentSubFolders.length - 1"
:to="{
name: 'bucket',
params: {
bucketName: props.bucketName,
subFolders: currentSubFolders.slice(0, index + 1),
},
}"
>{{ folder }}
</router-link>
<span v-else class="text-secondary">{{ folder }}</span>
</li>
</ol>
</nav>
<!-- Inputs on top -->
<!-- Search bucket text input -->
<div class="d-flex justify-content-between align-items-center">
<div class="flex-grow-1 me-2">
<div class="input-group rounded shadow-sm">
<span id="objects-search-wrapping" class="input-group-text"
><font-awesome-icon icon="fa-solid fa-magnifying-glass"
/></span>
<input
id="filterObjectNameInput"
v-model.trim="objectState.filterString"
type="search"
class="form-control"
placeholder="Search Files"
aria-label="Search Files"
aria-describedby="objects-search-wrapping"
:disabled="errorLoadingObjects"
/>
</div>
</div>
<!-- Upload object button -->
<div id="BucketViewButtons" class="">
<button
type="button"
class="btn me-3 tooltip-container border shadow-sm"
:class="{
'btn-light': settingsStore.lightThemeActive,
'btn-secondary': settingsStore.darkThemeActive,
}"
:disabled="errorLoadingObjects"
data-bs-toggle="tooltip"
data-bs-title="Refresh Objects"
@click="refreshObjects"
>
<font-awesome-icon icon="fa-solid fa-arrow-rotate-right" />
<span class="visually-hidden">Refresh Objects</span>
</button>
<button
type="button"
class="btn me-2 tooltip-container border shadow-sm"
:disabled="errorLoadingObjects || !writableBucket"
:class="{
'btn-light': settingsStore.lightThemeActive,
'btn-secondary': settingsStore.darkThemeActive,
}"
data-bs-toggle="modal"
data-bs-title="Upload File"
data-bs-target="#upload-object-modal"
>
<font-awesome-icon icon="fa-solid fa-upload" />
<span class="visually-hidden">Upload File</span>
</button>
<upload-object-modal
:bucket-name="props.bucketName"
modal-id="upload-object-modal"
:key-prefix="currentSubFolders.join('/')"
:edit-object-file-name="undefined"
/>
<!-- Add folder button -->
<button
type="button"
class="btn me-3 tooltip-container border shadow-sm"
:disabled="errorLoadingObjects || !writableBucket"
:class="{
'btn-light': settingsStore.lightThemeActive,
'btn-secondary': settingsStore.darkThemeActive,
}"
data-bs-toggle="modal"
data-bs-title="Create Folder"
data-bs-target="#create-folder-modal"
>
<font-awesome-icon icon="fa-solid fa-plus" />
Folder
<span class="visually-hidden">Add Folder</span>
</button>
<create-folder-modal
:bucket-name="props.bucketName"
modal-id="create-folder-modal"
:key-prefix="currentSubFolders.join('/')"
/>
<!-- Add bucket permission button -->
<button
v-if="!authStore.foreignUser"
:hidden="!bucketRepository.permissionFeatureAllowed(props.bucketName)"
type="button"
class="btn me-2 tooltip-container border shadow-sm"
:disabled="errorLoadingObjects"
:class="{
'btn-light': settingsStore.lightThemeActive,
'btn-secondary': settingsStore.darkThemeActive,
}"
data-bs-toggle="modal"
data-bs-title="Create Bucket Permission"
data-bs-target="#create-permission-modal"
>
<font-awesome-icon icon="fa-solid fa-user-plus" />
<span class="visually-hidden">Add Bucket Permission</span>
</button>
<permission-modal
v-if="!authStore.foreignUser"
modal-id="create-permission-modal"
:bucket-name="props.bucketName"
:sub-folders="folderStructure"
:edit-user-permission="undefined"
:editable="false"
:readonly="false"
:deletable="false"
:back-modal-id="undefined"
/>
<button
v-if="!authStore.foreignUser"
:hidden="!bucketRepository.permissionFeatureAllowed(props.bucketName)"
type="button"
class="btn tooltip-container border shadow-sm"
:disabled="errorLoadingObjects"
:class="{
'btn-light': settingsStore.lightThemeActive,
'btn-secondary': settingsStore.darkThemeActive,
}"
data-bs-title="List Bucket Permission"
data-bs-toggle="modal"
data-bs-target="#permission-list-modal"
>
<font-awesome-icon icon="fa-solid fa-users-line" />
<span class="visually-hidden">View Bucket Permissions</span>
</button>
<button
class="btn tooltip-container btn-danger ms-2"
:disabled="!writableBucket || objectState.selectedObjs.length === 0"
data-bs-toggle="modal"
data-bs-target="#delete-object-modal"
data-bs-title="Delete selected files"
@click="deleteSelected()"
>
<font-awesome-icon icon="fa-solid fa-trash" />
</button>
<permission-list-modal
v-if="
objectState.loading == false &&
bucketRepository.ownPermissions[props.bucketName] == undefined &&
!authStore.foreignUser
"
:bucket-name="props.bucketName"
:sub-folders="folderStructure"
modal-id="permission-list-modal"
/>
</div>
</div>
<!-- Body -->
<div class="pt-3">
<!-- If bucket not found -->
<div v-if="objectState.bucketNotFoundError" class="text-center fs-2 mt-5">
<font-awesome-icon
icon="fa-solid fa-magnifying-glass"
class="mb-3 fs-0"
style="color: var(--bs-secondary)"
/>
<p>
Bucket <i>{{ props.bucketName }}</i> not found
</p>
</div>
<!-- If no permission for bucket -->
<div
v-else-if="objectState.bucketPermissionError"
class="text-center fs-2 mt-5"
>
<font-awesome-icon
icon="fa-solid fa-folder-xmark"
class="mb-3 fs-0"
style="color: var(--bs-secondary)"
/>
<p>You don't have permission for this bucket</p>
</div>
<!-- Show content of bucket -->
<div v-else>
<!-- Table header -->
<table class="table table-sm table-hover caption-top align-middle">
<caption v-if="objectState.selectedObjs.length === 0">
Displaying
{{
objectState.loading ? 0 : filteredObjects.length
}}
Items
</caption>
<caption v-else>
Selected
{{
objectState.selectedObjs.length
}}
Item(s)
</caption>
<thead>
<tr>
<th v-if="writableBucket && visibleObjects.length > 0">
<input
class="form-check-input"
type="checkbox"
:checked="
objectState.selectedObjs.length === visibleObjects.length
"
@click="clickBox()"
/>
</th>
<th scope="col">Name</th>
<th scope="col">Last Accessed</th>
<th scope="col">Size</th>
<th scope="col"></th>
<th scope="col"></th>
</tr>
</thead>
<!-- Table body when loading the objects -->
<tbody v-if="objectState.loading">
<tr v-for="n in 5" :key="n" class="placeholder-glow">
<td v-if="writableBucket"></td>
<th scope="row">
<span class="placeholder w-100 bg-secondary"></span>
</th>
<td><span class="placeholder w-50 bg-secondary"></span></td>
<td><span class="placeholder w-50 bg-secondary"></span></td>
<td><span class="placeholder w-50 bg-secondary"></span></td>
<td></td>
</tr>
</tbody>
<!-- Table body when no objects are in the bucket -->
<tbody v-else-if="filteredObjects.length === 0">
<tr>
<td colspan="5" class="text-center fst-italic fw-light">
No objects to display
</td>
</tr>
</tbody>
<!-- Table body when showing objects -->
<tbody v-else>
<tr v-for="obj in filteredObjects" :key="md5(obj.Key ?? '')">
<td v-if="writableBucket">
<input
v-model="objectState.selectedObjs"
class="form-check-input"
type="checkbox"
:value="
isS3Object(obj) ? (obj.Key ?? '') : folderKeyPrefix(obj)
"
/>
</td>
<th scope="row" class="text-truncate">
<!-- Show file name if row is an object -->
<div v-if="isS3Object(obj)">{{ obj.pseudoFileName }}</div>
<!-- Show link to subfolder if row is a folder -->
<div v-else>
<router-link
class="text-decoration-none"
:to="{
name: 'bucket',
params: {
bucketName: props.bucketName,
subFolders: obj.parentFolder.concat(obj.name),
},
}"
>{{ obj.name }}
</router-link>
</div>
</th>
<td>
<span
class="date-tooltip"
data-bs-toggle="tooltip"
:data-bs-title="
dayjs(obj.LastModified).format('DD.MM.YYYY HH:mm:ss')
"
>{{ dayjs(obj.LastModified).fromNow() }}</span
>
</td>
<td>
{{ filesize(obj.Size ?? 0) }}
</td>
<td>
<span v-if="!isS3Object(obj)"
>{{ obj.quantity }} object<template v-if="obj.quantity != 1"
>s</template
></span
>
</td>
<!-- Show buttons with dropdown menu if row is an object -->
<td class="text-end">
<div
v-if="isS3Object(obj)"
class="btn-group btn-group-sm dropdown-center dropdown-menu-start"
>
<!-- Download Button -->
<button
type="button"
class="btn btn-secondary"
:disabled="!readableBucket"
@click="downloadObject(props.bucketName, obj.Key)"
>
Download
</button>
<button
type="button"
class="btn btn-secondary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false"
>
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<!-- Dropdown menu -->
<ul class="dropdown-menu dropdown-menu">
<li>
<button
class="dropdown-item"
type="button"
data-bs-toggle="modal"
data-bs-target="#detail-object-modal"
@click="objectState.viewDetailKey = obj.Key"
>
Details
</button>
</li>
<li>
<button
class="dropdown-item"
type="button"
:disabled="!writableBucket"
data-bs-toggle="modal"
data-bs-target="#edit-object-modal"
@click="objectState.editObjectKey = obj.Key ?? ''"
>
Edit
</button>
</li>
<li>
<button
class="dropdown-item"
type="button"
:disabled="!readableBucket"
data-bs-toggle="modal"
data-bs-target="#copy-object-modal"
@click="objectState.copyObject = obj"
>
Copy
</button>
</li>
<li>
<button
class="dropdown-item text-danger align-middle"
type="button"
data-bs-toggle="modal"
data-bs-target="#delete-object-modal"
:disabled="!writableBucket"
@click="deleteObject(obj.Key)"
>
<font-awesome-icon icon="fa-solid fa-trash" />
<span class="ms-1">Delete</span>
</button>
</li>
</ul>
</div>
<!-- Show delete button when row is a folder -->
<div v-else>
<button
type="button"
class="btn btn-danger btn-sm align-baseline"
:disabled="!writableBucket"
data-bs-toggle="modal"
data-bs-target="#delete-object-modal"
@click="deleteFolder(obj)"
>
<font-awesome-icon icon="fa-solid fa-trash" class="me-2" />
<span>Delete</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
<upload-object-modal
:bucket-name="props.bucketName"
modal-id="edit-object-modal"
:key-prefix="currentSubFolders.join('/')"
:edit-object-file-name="getObjectFileName(objectState.editObjectKey)"
/>
<copy-object-modal
:src-object="objectState.copyObject"
:src-bucket="bucketName"
modal-id="copy-object-modal"
/>
<object-detail-modal
:bucket="bucketName"
:object-key="objectState.viewDetailKey"
modal-id="detail-object-modal"
/>
</div>
</div>
</template>
<style scoped></style>