diff --git a/src/components/BucketListItem.vue b/src/components/BucketListItem.vue index 176cddc2cc9d48350316395fd5b77992019c9d0b..dc5c26335737b3545f92b15e7eefc3007da2fb80 100644 --- a/src/components/BucketListItem.vue +++ b/src/components/BucketListItem.vue @@ -34,7 +34,7 @@ const emit = defineEmits<{ :aria-current="props.active" :to="{ name: 'bucket', - params: { bucket_name: bucket.name, sub_folders: [] }, + params: { bucketName: bucket.name, subFolders: [] }, }" > {{ bucket.name }} diff --git a/src/components/BucketView.vue b/src/components/BucketView.vue index eeb94f5c115582f64dca9a4f58b01fe67781797a..d45d20f17c1f405f75e3110fb726fdbcc8ef70f9 100644 --- a/src/components/BucketView.vue +++ b/src/components/BucketView.vue @@ -1,104 +1,303 @@ <script setup lang="ts"> import { useRoute } from "vue-router"; import { onMounted, reactive, watch, computed } from "vue"; +import type { ComputedRef } from "vue"; import type { S3ObjectMetaInformation } from "@/client"; import { ObjectService } from "@/client"; import BootstrapIcon from "@/components/BootstrapIcon.vue"; import fileSize from "filesize"; import dayjs from "dayjs"; +// Constants +// ----------------------------------------------------------------------------- const route = useRoute(); +// Typescript types +// ----------------------------------------------------------------------------- +interface S3ObjectWithFolder extends S3ObjectMetaInformation { + folder: string[]; + pseudoFileName: string; +} + +type S3PseudoFolder = { + size: number; + parentFolder: string[]; + last_modified: string; + name: string; + key: string; +}; + +type FolderTree = { + subFolders: Record<string, FolderTree>; + files: S3ObjectWithFolder[]; +}; + +// Reactive State +// ----------------------------------------------------------------------------- const objectState = reactive({ objects: [], + visibleObjects: [], loading: true, - bucket_not_found_error: false, - bucket_permission_error: false, + bucketNotFoundError: false, + bucketPermissionError: false, } as { objects: S3ObjectMetaInformation[]; + visibleObjects: (S3ObjectWithFolder | S3PseudoFolder)[]; loading: boolean; - bucket_not_found_error: boolean; - bucket_permission_error: boolean; + bucketNotFoundError: boolean; + bucketPermissionError: boolean; }); +// Watcher +// ----------------------------------------------------------------------------- watch( () => route.params, (newRouteParams, oldRouteParams) => { if ( - newRouteParams.bucket_name && - oldRouteParams.bucket_name !== newRouteParams.bucket_name + newRouteParams.bucketName && + oldRouteParams.bucketName !== newRouteParams.bucketName + ) { + // If bucket is changed, update the objects + updateObjects(newRouteParams.bucketName as string); + } else if ( + newRouteParams.subFolders && + oldRouteParams.subFolders !== newRouteParams.subFolders ) { - update_objects(newRouteParams.bucket_name as string); + // If sub folder is changed, update the visible objects + updateVisibleObjects(newRouteParams.subFolders as string[]); } } ); -const sub_folder_in_url = computed(() => route.params.sub_folders.length > 0); -const error_loading_objects = computed( - () => - objectState.bucket_permission_error || objectState.bucket_not_found_error +watch( + () => objectState.objects, + () => { + updateVisibleObjects( + subFolderInUrl.value ? (route.params.subFolders as string[]) : [] + ); + } ); +// Computed Properties +// ----------------------------------------------------------------------------- +const folderStructure: ComputedRef<FolderTree> = computed(() => { + /** + * 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: ComputedRef<S3ObjectWithFolder[]> = computed(() => { + /** + * 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 objectState.objects.map((obj) => { + const splittedKey = obj.key.split("/"); + return { + ...obj, + pseudoFileName: splittedKey[splittedKey.length - 1], + folder: splittedKey.slice(0, splittedKey.length - 1), + }; + }); +}); + +const subFolderInUrl: ComputedRef<boolean> = computed( + () => route.params.subFolders != null && route.params.subFolders.length > 0 +); +const errorLoadingObjects: ComputedRef<boolean> = computed( + () => objectState.bucketPermissionError || objectState.bucketNotFoundError +); + +// Lifecycle Hooks +// ----------------------------------------------------------------------------- onMounted(() => { - update_objects(route.params.bucket_name as string); + updateObjects(route.params.bucketName as string); }); -function update_objects(bucket_name: string) { - objectState.bucket_not_found_error = false; - objectState.bucket_permission_error = false; +// Functions +// ----------------------------------------------------------------------------- +/** + * Calculate recursively the cumulative file size of all o objects in a folder + * @param folder Folder to inspect + * @returns The size of this folder in bytes + */ +function calculateFolderSize(folder: FolderTree): number { + let folderSize = 0; + folderSize += folder.files.reduce((acc, file) => acc + file.size, 0); + for (const subFolderName of Object.keys(folder.subFolders)) { + folderSize += calculateFolderSize(folder.subFolders[subFolderName]); + } + return folderSize; +} + +/** + * 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.last_modified)) + .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(); +} + +/** + * Load the meta information about objects from a bucket + * @param bucketName Name of a bucket + */ +function updateObjects(bucketName: string) { + objectState.bucketNotFoundError = false; + objectState.bucketPermissionError = false; objectState.loading = true; - ObjectService.objectGetBucketObjects(bucket_name) + ObjectService.objectGetBucketObjects(bucketName) .then((objs) => { objectState.objects = objs; }) .catch((error) => { if (error.status === 404) { - objectState.bucket_not_found_error = true; + objectState.bucketNotFoundError = true; } else if (error.status == 403) { - objectState.bucket_permission_error = true; + objectState.bucketPermissionError = true; } }) .finally(() => { objectState.loading = false; }); } + +/** + * Update the visible objects based on the current sub folder + * @param subFolders sub folders as ordered array + */ +function updateVisibleObjects(subFolders: string[]) { + objectState.visibleObjects = []; + let currentFolder = folderStructure.value; + for (const subFolder of subFolders) { + if (currentFolder.subFolders[subFolder] == null) { + return; + } else { + currentFolder = currentFolder.subFolders[subFolder]; + } + } + 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]) + ).toISOString(); + return { + name: subFolderName, + size: folderSize, + key: subFolderName, + parentFolder: subFolders, + last_modified: folderLastModified, + } as S3PseudoFolder; + }) + ); + objectState.visibleObjects = arr; +} + +function isS3Object( + obj: S3PseudoFolder | S3ObjectWithFolder +): obj is S3ObjectWithFolder { + return (obj as S3ObjectWithFolder).folder !== undefined; +} </script> <template> + <!-- Navbar Breadcrumb --> <nav aria-label="breadcrumb" class="fs-2"> <ol class="breadcrumb"> - <li class="breadcrumb-item" :class="{ active: sub_folder_in_url }"> + <li class="breadcrumb-item" :class="{ active: subFolderInUrl }"> <router-link - v-if="sub_folder_in_url" + v-if="subFolderInUrl" :to="{ name: 'bucket', - params: { bucket_name: route.params.bucket_name, sub_folders: [] }, + params: { bucketName: route.params.bucketName, subFolders: [] }, }" - >{{ route.params.bucket_name }}</router-link - > - <span v-else>{{ route.params.bucket_name }}</span> + >{{ route.params.bucketName }} + </router-link> + <span v-else>{{ route.params.bucketName }}</span> </li> <li class="breadcrumb-item" - v-for="(folder, index) in route.params.sub_folders" + v-for="(folder, index) in route.params.subFolders" :key="folder" - :class="{ active: index === route.params.sub_folders.length }" + :class="{ active: index === route.params.subFolders.length }" > <router-link - v-if="index !== route.params.sub_folders.length - 1" + v-if="index !== route.params.subFolders.length - 1" :to="{ name: 'bucket', params: { - bucket_name: route.params.bucket_name, - sub_folders: route.params.sub_folders.slice(0, index + 1), + bucketName: route.params.bucketName, + subFolders: route.params.subFolders.slice(0, index + 1), }, }" - >{{ folder }}</router-link - > + >{{ folder }} + </router-link> <span v-else>{{ folder }}</span> </li> </ol> </nav> + <!-- Inputs on top --> + <!-- Search bucket text input --> <div class="input-group mt-2"> <span class="input-group-text" id="objects-search-wrapping" ><bootstrap-icon icon="search" :width="16" :height="16" @@ -112,18 +311,20 @@ function update_objects(bucket_name: string) { disabled /> </div> + <!-- Upload object button --> <button type="button" class="btn btn-secondary m-2" - :disabled="error_loading_objects" + :disabled="errorLoadingObjects" > <bootstrap-icon icon="upload" :width="16" :height="16" fill="white" /> <span class="visually-hidden">Upload Object</span> </button> + <!-- Add bucket permission button --> <button type="button" class="btn btn-secondary m-2" - :disabled="error_loading_objects" + :disabled="errorLoadingObjects" > <bootstrap-icon icon="person-plus-fill" @@ -133,30 +334,36 @@ function update_objects(bucket_name: string) { /> <span class="visually-hidden">Add Bucket Permission</span> </button> + <!-- Add folder button --> <button type="button" class="btn btn-secondary m-2" - :disabled="error_loading_objects" + :disabled="errorLoadingObjects" > <bootstrap-icon icon="plus-lg" :width="16" :height="16" fill="white" /> Folder <span class="visually-hidden">Add Folder</span> </button> + <!-- Body --> <div class="pt-3"> - <div v-if="objectState.bucket_not_found_error"> + <!-- If bucket not found --> + <div v-if="objectState.bucketNotFoundError"> <p>Bucket not found</p> </div> - <div v-else-if="objectState.bucket_permission_error"> + <!-- If no permission for bucket --> + <div v-else-if="objectState.bucketPermissionError"> <p>No permission for this bucket</p> </div> + <!-- Show content of bucket --> <div v-else> + <!-- Table header --> <table class="table table-dark table-striped table-hover caption-top align-middle" > <caption> Displaying {{ - objectState.loading ? 0 : objectState.objects.length + objectState.loading ? 0 : objectState.visibleObjects.length }} Objects </caption> @@ -168,6 +375,7 @@ function update_objects(bucket_name: string) { <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"> <th scope="row"> @@ -178,22 +386,44 @@ function update_objects(bucket_name: string) { <td></td> </tr> </tbody> - <tbody v-else-if="objectState.objects.length === 0"> + <!-- Table body when no objects are in the bcuket --> + <tbody v-else-if="objectState.visibleObjects.length === 0"> <tr> - <td colspan="4" class="text-center text-secondary"> - <i>No objects to display</i> + <td colspan="4" 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 objectState.objects" :key="obj.key"> - <th scope="row" class="text-truncate">{{ obj.key }}</th> + <tr v-for="obj in objectState.visibleObjects" :key="obj.key"> + <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: route.params.bucketName, + subFolders: obj.parentFolder.concat(obj.name), + }, + }" + >{{ obj.name }} + </router-link> + </div> + </th> <td>{{ dayjs(obj.last_modified).fromNow() }}</td> <td>{{ fileSize(obj.size) }}</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"> Download </button> @@ -205,7 +435,7 @@ function update_objects(bucket_name: string) { > <span class="visually-hidden">Toggle Dropdown</span> </button> - + <!-- Dropdown menu --> <ul class="dropdown-menu dropdown-menu-dark"> <li> <button class="dropdown-item" type="button">Details</button> @@ -217,7 +447,10 @@ function update_objects(bucket_name: string) { <button class="dropdown-item" type="button">Copy</button> </li> <li> - <button class="dropdown-item text-danger" type="button"> + <button + class="dropdown-item text-danger align-middle" + type="button" + > <bootstrap-icon icon="trash-fill" class="text-danger" @@ -230,6 +463,22 @@ function update_objects(bucket_name: string) { </li> </ul> </div> + <!-- Show delete button when row is a folder --> + <div v-else> + <button + type="button" + class="btn btn-danger btn-sm align-middle" + > + <bootstrap-icon + icon="trash-fill" + class="text-danger me-2" + :width="12" + :height="12" + fill="white" + /> + <span>Delete</span> + </button> + </div> </td> </tr> </tbody> diff --git a/src/components/CreateBucketComponent.vue b/src/components/CreateBucketComponent.vue index c71438c9c08cb1065fe965dfe96e3b5a07f4221d..7980d6757d9cc68626b869215e3ab2b22781ad3c 100644 --- a/src/components/CreateBucketComponent.vue +++ b/src/components/CreateBucketComponent.vue @@ -3,11 +3,13 @@ import { BucketService } from "@/client"; import type { BucketIn } from "@/client"; import { reactive } from "vue"; import BootstrapModal from "@/components/BootstrapModal.vue"; +import { useRouter } from "vue-router"; /* import { onMounted } from "vue"; import { Modal } from "bootstrap"; */ +const router = useRouter(); const emit = defineEmits(["bucketCreated"]); const bucket = reactive({ name: "", description: "" } as BucketIn); const formState = reactive({ @@ -51,6 +53,10 @@ function createBucket() { bucket.description = ""; formState.bucketNameTaken = false; formState.validated = false; + router.push({ + name: "bucket", + params: { bucketName: createdBucket.name, subFolders: [] }, + }); }) .catch((error) => { if ( @@ -149,7 +155,12 @@ function modalClosed() { :disabled="formState.loading" @click.prevent="createBucket" > - <span v-if="formState.loading" class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> + <span + v-if="formState.loading" + class="spinner-border spinner-border-sm" + role="status" + aria-hidden="true" + ></span> Save </button> </template> diff --git a/src/router/index.ts b/src/router/index.ts index 094becc767a5ca50676e6286e8b3451e286598c0..1c4428c933dc41bb3a1a604ed6396e3c1949b02c 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -16,7 +16,7 @@ const router = createRouter({ component: () => import("../views/object-storage/BucketsView.vue"), children: [ { - path: ":bucket_name/:sub_folders*", + path: ":bucketName/:subFolders*", name: "bucket", component: () => import("../components/BucketView.vue"), }, diff --git a/src/views/object-storage/BucketsView.vue b/src/views/object-storage/BucketsView.vue index ad8942406104e436552ab3e2f07f5c0173bee8f2..a35fab70862ae2bf0008765e585947c598785469 100644 --- a/src/views/object-storage/BucketsView.vue +++ b/src/views/object-storage/BucketsView.vue @@ -32,11 +32,11 @@ function addBucket(bucket: BucketOut) { bucketsState.buckets.push(bucket); } -function deleteBucket(bucket_name: string) { - BucketService.bucketDeleteBucket(bucket_name).then(() => { +function deleteBucket(bucketName: string) { + BucketService.bucketDeleteBucket(bucketName).then(() => { router.push({ name: "buckets" }); bucketsState.buckets = bucketsState.buckets.filter( - (bucket) => bucket.name !== bucket_name + (bucket) => bucket.name !== bucketName ); }); } @@ -97,8 +97,8 @@ onMounted(() => { v-for="bucket in bucketsState.buckets" :key="bucket.name" :active=" - route.params.bucket_name != null && - route.params.bucket_name === bucket.name + route.params.bucketName != null && + route.params.bucketName === bucket.name " :bucket="bucket" :loading="false"