-
Daniel Göbel authored
#17
Daniel Göbel authored#17
BucketView.vue 15.18 KiB
<script setup lang="ts">
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 props = defineProps<{
bucketName: string;
subFolders: string[] | string;
}>();
const currentSubFolders: ComputedRef<string[]> = computed(() => {
/**
* 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]
: [];
});
// 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: [],
loading: true,
bucketNotFoundError: false,
bucketPermissionError: false,
} as {
objects: S3ObjectMetaInformation[];
loading: boolean;
bucketNotFoundError: boolean;
bucketPermissionError: boolean;
});
// Watcher
// -----------------------------------------------------------------------------
watch(
() => props.bucketName,
(newBucketName, oldBucketName) => {
if (oldBucketName !== newBucketName) {
// If bucket is changed, update the objects
updateObjects(newBucketName);
}
}
);
// 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(
() => currentSubFolders.value.length > 0
);
const errorLoadingObjects: ComputedRef<boolean> = computed(
() => objectState.bucketPermissionError || objectState.bucketNotFoundError
);
// Lifecycle Hooks
// -----------------------------------------------------------------------------
onMounted(() => {
updateObjects(props.bucketName);
});
// 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(bucketName)
.then((objs) => {
objectState.objects = objs;
})
.catch((error) => {
if (error.status === 404) {
objectState.bucketNotFoundError = true;
} else if (error.status == 403) {
objectState.bucketPermissionError = true;
}
})
.finally(() => {
objectState.loading = false;
});
}
const visibleObjects: ComputedRef<(S3ObjectWithFolder | S3PseudoFolder)[]> =
computed(() => {
/**
* 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])
).toISOString();
return {
name: subFolderName,
size: folderSize,
key: subFolderName,
parentFolder: currentSubFolders.value,
last_modified: folderLastModified,
} as S3PseudoFolder;
})
);
return 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: subFolderInUrl }">
<router-link
v-if="subFolderInUrl"
:to="{
name: 'bucket',
params: { bucketName: props.bucketName, subFolders: [] },
}"
>{{ props.bucketName }}
</router-link>
<span v-else>{{ props.bucketName }}</span>
</li>
<li
class="breadcrumb-item"
v-for="(folder, index) in currentSubFolders"
:key="folder"
: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>{{ 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"
/></span>
<input
type="text"
class="form-control"
placeholder="Search Objects"
aria-label="Search Objects"
aria-describedby="objects-search-wrapping"
disabled
/>
</div>
<!-- Upload object button -->
<button
type="button"
class="btn btn-secondary m-2"
: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="errorLoadingObjects"
>
<bootstrap-icon
icon="person-plus-fill"
:width="16"
:height="16"
fill="white"
/>
<span class="visually-hidden">Add Bucket Permission</span>
</button>
<!-- Add folder button -->
<button
type="button"
class="btn btn-secondary m-2"
: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">
<!-- If bucket not found -->
<div v-if="objectState.bucketNotFoundError">
<p>Bucket not found</p>
</div>
<!-- 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 : visibleObjects.length
}}
Objects
</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Last Accessed</th>
<th scope="col">Size</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">
<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></td>
</tr>
</tbody>
<!-- Table body when no objects are in the bcuket -->
<tbody v-else-if="visibleObjects.length === 0">
<tr>
<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 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: props.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>
<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-dark">
<li>
<button class="dropdown-item" type="button">Details</button>
</li>
<li>
<button class="dropdown-item" type="button">Edit</button>
</li>
<li>
<button class="dropdown-item" type="button">Copy</button>
</li>
<li>
<button
class="dropdown-item text-danger align-middle"
type="button"
>
<bootstrap-icon
icon="trash-fill"
class="text-danger"
:width="13"
:height="13"
fill="currentColor"
/>
<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-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>
</table>
</div>
</div>
</template>
<style scoped></style>