Skip to content
Snippets Groups Projects
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>