web/satellite/vuetify-poc: show file previews

This change enables files to be previewed.

Issue: https://github.com/storj/storj/issues/6108

Change-Id: I3045950281822620de96b86e0180eb0d24e2d629
This commit is contained in:
Wilfred Asomani 2023-08-31 16:57:06 +00:00 committed by Wilfred Asomani
parent e2006d821c
commit 2d18f43b6a
3 changed files with 486 additions and 121 deletions

View File

@ -63,112 +63,7 @@
</template>
</v-data-table-server>
<v-dialog v-model="previewDialog" transition="fade-transition" class="preview-dialog" fullscreen theme="dark">
<v-card class="preview-card">
<v-carousel hide-delimiters show-arrows="hover" height="100vh">
<template #prev="{ props: slotProps }">
<v-btn
color="default"
class="rounded-circle"
icon
@click="slotProps.onClick"
>
<svg width="10" height="17" viewBox="0 0 10 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_24843_332342)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.30725 8.23141C0.276805 7.67914 0.528501 7.04398 1.03164 6.54085L6.84563 0.726856C7.64837 -0.0758889 8.78719 -0.238577 9.38925 0.363481C9.99131 0.96554 9.82862 2.10436 9.02587 2.9071L3.71149 8.22148L9.02681 13.5368C9.82955 14.3395 9.99224 15.4784 9.39018 16.0804C8.78812 16.6825 7.6493 16.5198 6.84656 15.717L1.03257 9.90305C0.535173 9.40565 0.283513 8.77923 0.30725 8.23141Z" fill="white" />
</g>
<defs>
<clipPath id="clip0_24843_332342">
<rect width="17.0002" height="10" fill="white" transform="translate(10) rotate(90)" />
</clipPath>
</defs>
</svg>
</v-btn>
</template>
<template #next="{ props: slotProps }">
<v-btn
color="default"
class="rounded-circle"
icon
@click="slotProps.onClick"
>
<svg width="10" height="17" viewBox="0 0 10 17" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_24843_332338)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.69263 8.23141C9.72307 7.67914 9.47138 7.04398 8.96824 6.54085L3.15425 0.726856C2.35151 -0.0758889 1.21269 -0.238577 0.61063 0.363481C0.00857207 0.96554 0.17126 2.10436 0.974005 2.9071L6.28838 8.22148L0.973072 13.5368C0.170328 14.3395 0.00763934 15.4784 0.609698 16.0804C1.21176 16.6825 2.35057 16.5198 3.15332 15.717L8.96731 9.90305C9.46471 9.40565 9.71637 8.77923 9.69263 8.23141Z" fill="white" />
</g>
<defs>
<clipPath id="clip0_24843_332338">
<rect width="17.0002" height="10" fill="white" transform="matrix(4.37114e-08 1 1 -4.37114e-08 0 0)" />
</clipPath>
</defs>
</svg>
</v-btn>
</template>
<v-toolbar
color="rgba(0, 0, 0, 0.3)"
theme="dark"
>
<!-- <v-img src="../assets/logo-white.svg" height="30" width="160" class="ml-3" alt="Storj Logo"/> -->
<v-toolbar-title>
Image.jpg
</v-toolbar-title>
<template #append>
<v-btn icon size="small" color="white">
<img src="@poc/assets/icon-download.svg" width="22" alt="Download">
<v-tooltip
activator="parent"
location="bottom"
>
Download
</v-tooltip>
</v-btn>
<v-btn icon size="small" color="white">
<icon-share size="22" />
<v-tooltip
activator="parent"
location="bottom"
>
Share
</v-tooltip>
</v-btn>
<v-btn icon size="small" color="white">
<img src="@poc/assets/icon-geo-distribution.svg" width="22" alt="Geographic Distribution">
<v-tooltip
activator="parent"
location="bottom"
>
Geographic Distribution
</v-tooltip>
</v-btn>
<v-btn icon size="small" color="white">
<img src="@poc/assets/icon-more.svg" width="22" alt="More">
<v-tooltip
activator="parent"
location="bottom"
>
More
</v-tooltip>
</v-btn>
<v-btn icon size="small" color="white" @click="previewDialog = false">
<img src="@poc/assets/icon-close.svg" width="18" alt="Close">
<v-tooltip
activator="parent"
location="bottom"
>
Close
</v-tooltip>
</v-btn>
<!-- <v-btn icon="$close" color="white" size="small" @click="previewDialog = false"></v-btn> -->
</template>
</v-toolbar>
<v-carousel-item
v-for="(item,i) in items"
:key="i"
:src="item.src"
/>
</v-carousel>
</v-card>
</v-dialog>
<file-preview-dialog v-model="previewDialog" />
</v-card>
</template>
@ -178,13 +73,7 @@ import { useRouter } from 'vue-router';
import {
VCard,
VTextField,
VDialog,
VCarousel,
VBtn,
VToolbar,
VToolbarTitle,
VTooltip,
VCarouselItem,
} from 'vuetify/components';
import { VDataTableServer } from 'vuetify/labs/components';
@ -196,8 +85,8 @@ import { Size } from '@/utils/bytesSize';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import { useBucketsStore } from '@/store/modules/bucketsStore';
import IconShare from '@poc/components/icons/IconShare.vue';
import BrowserRowActions from '@poc/components/BrowserRowActions.vue';
import FilePreviewDialog from '@poc/components/dialogs/FilePreviewDialog.vue';
import folderIcon from '@poc/assets/icon-folder-tonal.svg';
import pdfIcon from '@poc/assets/icon-pdf-tonal.svg';
@ -255,12 +144,6 @@ const selected = ref([]);
const previewDialog = ref<boolean>(false);
const options = ref<TableOptions>();
const items = [
{ src: 'https://cdn.vuetifyjs.com/images/carousel/squirrel.jpg' },
{ src: 'https://cdn.vuetifyjs.com/images/carousel/sky.jpg' },
{ src: 'https://cdn.vuetifyjs.com/images/carousel/bird.jpg' },
{ src: 'https://cdn.vuetifyjs.com/images/carousel/planet.jpg' },
];
const sortBy = [{ key: 'name', order: 'asc' }];
const headers = [
{
@ -413,8 +296,7 @@ function onFileClick(file: BrowserObject): void {
return;
}
// Implement logic to fetch the file content for preview or generate a URL for preview
// Then, open the preview dialog
obStore.setObjectPathForModal(obStore.state.path + file.Key);
previewDialog.value = true;
}

View File

@ -0,0 +1,256 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-dialog v-model="model" transition="fade-transition" class="preview-dialog" fullscreen theme="dark">
<v-card class="preview-card">
<v-carousel v-model="constCarouselIndex" hide-delimiters show-arrows="hover" height="100vh">
<template #prev>
<v-btn
color="default"
class="rounded-circle"
icon
@click="onPrevious"
>
<v-icon icon="mdi-chevron-left" size="x-large" />
</v-btn>
</template>
<template #next>
<v-btn
color="default"
class="rounded-circle"
icon
@click="onNext"
>
<v-icon icon="mdi-chevron-right" size="x-large" />
</v-btn>
</template>
<v-toolbar
color="rgba(0, 0, 0, 0.3)"
theme="dark"
>
<v-toolbar-title>
{{ fileName }}
</v-toolbar-title>
<template #append>
<v-btn :loading="isDownloading" icon size="small" color="white" @click="download">
<img src="@poc/assets/icon-download.svg" width="22" alt="Download">
<v-tooltip
activator="parent"
location="bottom"
>
Download
</v-tooltip>
</v-btn>
<v-btn icon size="small" color="white">
<icon-share size="22" />
<v-tooltip
activator="parent"
location="bottom"
>
Share
</v-tooltip>
</v-btn>
<v-btn icon size="small" color="white">
<img src="@poc/assets/icon-geo-distribution.svg" width="22" alt="Geographic Distribution">
<v-tooltip
activator="parent"
location="bottom"
>
Geographic Distribution
</v-tooltip>
</v-btn>
<v-btn icon size="small" color="white">
<img src="@poc/assets/icon-more.svg" width="22" alt="More">
<v-tooltip
activator="parent"
location="bottom"
>
More
</v-tooltip>
</v-btn>
<v-btn icon size="small" color="white" @click="model = false">
<img src="@poc/assets/icon-close.svg" width="18" alt="Close">
<v-tooltip
activator="parent"
location="bottom"
>
Close
</v-tooltip>
</v-btn>
</template>
</v-toolbar>
<v-carousel-item v-for="(file, i) in files" :key="file.Key">
<!-- v-carousel will mount all items at the same time -->
<!-- so :active will tell file-preview-item if it is the current. -->
<!-- If it is, it'll load the preview. -->
<file-preview-item :active="i === fileIndex" :file="file" @download="download" />
</v-carousel-item>
</v-carousel>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import {
VBtn,
VCard,
VCarousel,
VCarouselItem,
VDialog,
VIcon,
VToolbar,
VToolbarTitle,
VTooltip,
} from 'vuetify/components';
import { useRoute } from 'vue-router';
import { useAppStore } from '@/store/modules/appStore';
import { BrowserObject, useObjectBrowserStore } from '@/store/modules/objectBrowserStore';
import { useBucketsStore } from '@/store/modules/bucketsStore';
import { useNotify } from '@/utils/hooks';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import IconShare from '@poc/components/icons/IconShare.vue';
import FilePreviewItem from '@poc/components/dialogs/filePreviewComponents/FilePreviewItem.vue';
const appStore = useAppStore();
const obStore = useObjectBrowserStore();
const bucketsStore = useBucketsStore();
const notify = useNotify();
const route = useRoute();
const isDownloading = ref<boolean>(false);
const folderType = 'folder';
const props = defineProps<{
modelValue: boolean,
}>();
const emit = defineEmits<{
(event: 'update:modelValue', value: boolean): void,
}>();
const model = computed<boolean>({
get: () => props.modelValue,
set: value => emit('update:modelValue', value),
});
const constCarouselIndex = computed(() => carouselIndex.value);
const carouselIndex = ref(0);
/**
* Retrieve the file object that the modal is set to from the store.
*/
const currentFile = computed((): BrowserObject => {
return obStore.sortedFiles[fileIndex.value];
});
const files = computed((): BrowserObject[] => {
return obStore.sortedFiles;
});
/**
* Retrieve the file index that the modal is set to from the store.
*/
const fileIndex = computed((): number => {
return files.value.findIndex(f => f.Key === filePath.value.split('/').pop());
});
/**
* Retrieve the name of the current file.
*/
const fileName = computed((): string | undefined => {
return filePath.value.split('/').pop();
});
/**
* Retrieve the current filepath.
*/
const filePath = computed((): string => {
return obStore.state.objectPathForModal;
});
/**
* Returns current path without object key.
*/
const currentPath = computed((): string => {
return obStore.state.path;
});
/**
* Download the current opened file.
*/
async function download(): Promise<void> {
if (isDownloading.value) {
return;
}
isDownloading.value = true;
try {
await obStore.download(currentFile.value);
notify.success('Keep this download link private. If you want to share, use the Share option.');
} catch (error) {
notify.error('Can not download your file', AnalyticsErrorEventSource.OBJECT_DETAILS_MODAL);
}
isDownloading.value = false;
}
/**
* Handles on previous click logic.
*/
function onPrevious(): void {
const currentIndex = fileIndex.value;
const sortedFilesLength = obStore.sortedFiles.length;
let newFile: BrowserObject;
if (currentIndex <= 0) {
newFile = obStore.sortedFiles[sortedFilesLength - 1];
} else {
newFile = obStore.sortedFiles[currentIndex - 1];
if (newFile.type === folderType) {
newFile = obStore.sortedFiles[sortedFilesLength - 1];
}
}
setNewObjectPath(newFile.Key);
}
/**
* Handles on next click logic.
*/
function onNext(): void {
let newFile: BrowserObject | undefined = obStore.sortedFiles[fileIndex.value + 1];
if (!newFile || newFile.type === folderType) {
newFile = obStore.sortedFiles.find(f => f.type !== folderType);
if (!newFile) return;
}
setNewObjectPath(newFile.Key);
}
/**
* Sets new object path.
*/
function setNewObjectPath(objectKey: string): void {
obStore.setObjectPathForModal(`${currentPath.value}${objectKey}`);
}
/**
* Watch for changes on the filepath and changes the current carousel item accordingly.
*/
watch(filePath, () => {
if (!filePath.value) return;
carouselIndex.value = fileIndex.value;
});
watch(() => props.modelValue, shown => {
if (!shown) {
return;
}
carouselIndex.value = fileIndex.value;
}, { immediate: true });
</script>

View File

@ -0,0 +1,227 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
<template>
<v-container v-if="isLoading" class="fill-height flex-column justify-center align-center">
<v-progress-circular indeterminate />
</v-container>
<v-container v-else-if="previewIsVideo" class="fill-height flex-column justify-center align-center">
<video
controls
:src="objectPreviewUrl"
style="max-width: 100%; max-height: 90%;"
aria-roledescription="video-preview"
/>
</v-container>
<v-container v-else-if="previewIsAudio" class="fill-height flex-column justify-center align-center">
<audio
controls
:src="objectPreviewUrl"
aria-roledescription="audio-preview"
/>
</v-container>
<v-container v-else-if="previewIsImage" class="fill-height flex-column justify-center align-center">
<img
v-if="objectPreviewUrl"
:src="objectPreviewUrl"
class="v-img__img v-img__img--contain"
aria-roledescription="image-preview"
alt="preview"
>
</v-container>
<v-container v-else-if="placeHolderDisplayable || previewAndMapFailed" class="fill-height flex-column justify-center align-center">
<p class="mb-5">{{ file?.Key || '' }}</p>
<p class="text-h5 mb-5 font-weight-bold">No preview available</p>
<v-btn
@click="() => emits('download')"
>
<template #prepend>
<img src="@poc/assets/icon-download.svg" width="22" alt="Download">
</template>
{{ `Download (${prettyBytes(file?.Size || 0)})` }}
</v-btn>
</v-container>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { VBtn, VContainer, VProgressCircular } from 'vuetify/components';
import { useRoute } from 'vue-router';
import prettyBytes from 'pretty-bytes';
import { useAppStore } from '@/store/modules/appStore';
import { BrowserObject, PreviewCache, useObjectBrowserStore } from '@/store/modules/objectBrowserStore';
import { useBucketsStore } from '@/store/modules/bucketsStore';
import { useNotify } from '@/utils/hooks';
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
import { useLinksharing } from '@/composables/useLinksharing';
const appStore = useAppStore();
const obStore = useObjectBrowserStore();
const bucketsStore = useBucketsStore();
const notify = useNotify();
const { generateObjectPreviewAndMapURL } = useLinksharing();
const route = useRoute();
const isLoading = ref<boolean>(false);
const previewAndMapFailed = ref<boolean>(false);
const imgExts = ['bmp', 'svg', 'jpg', 'jpeg', 'png', 'ico', 'gif'];
const videoExts = ['m4v', 'mp4', 'webm', 'mov', 'mkv'];
const audioExts = ['m4a', 'mp3', 'wav', 'ogg'];
const props = defineProps<{
file: BrowserObject,
active: boolean, // whether this item is visible
}>();
const emits = defineEmits(['download']);
/**
* Returns object preview URLs cache from store.
*/
const cachedObjectPreviewURLs = computed((): Map<string, PreviewCache> => {
return obStore.state.cachedObjectPreviewURLs;
});
/**
* Returns object preview URL from cache.
*/
const objectPreviewUrl = computed((): string => {
const cache = cachedObjectPreviewURLs.value.get(encodedFilePath.value);
const url = cache?.url || '';
return `${url}?view=1`;
});
/**
* Returns bucket name from store.
*/
const bucket = computed((): string => {
return bucketsStore.state.fileComponentBucketName;
});
/**
* Retrieve the current filepath.
*/
const filePath = computed((): string => {
return obStore.state.objectPathForModal;
});
/**
* Retrieve the encoded filepath.
*/
const encodedFilePath = computed((): string => {
return encodeURIComponent(`${bucket.value}/${filePath.value.trim()}`);
});
/**
* Get the extension of the current file.
*/
const extension = computed((): string | undefined => {
return filePath.value.split('.').pop();
});
/**
* Check to see if the current file is an image file.
*/
const previewIsImage = computed((): boolean => {
if (!extension.value) {
return false;
}
return imgExts.includes(extension.value.toLowerCase());
});
/**
* Check to see if the current file is a video file.
*/
const previewIsVideo = computed((): boolean => {
if (!extension.value) {
return false;
}
return videoExts.includes(extension.value.toLowerCase());
});
/**
* Check to see if the current file is an audio file.
*/
const previewIsAudio = computed((): boolean => {
if (!extension.value) {
return false;
}
return audioExts.includes(extension.value.toLowerCase());
});
/**
* Check to see if the current file is neither an image file, video file, or audio file.
*/
const placeHolderDisplayable = computed((): boolean => {
return [
previewIsImage.value,
previewIsVideo.value,
previewIsAudio.value,
].every((value) => !value);
});
/**
* Get the object map url for the file being displayed.
*/
async function fetchPreviewAndMapUrl(): Promise<void> {
isLoading.value = true;
previewAndMapFailed.value = false;
let url = '';
try {
url = await generateObjectPreviewAndMapURL(
bucketsStore.state.fileComponentBucketName, filePath.value);
} catch (error) {
error.message = `Unable to get file preview and map URL. ${error.message}`;
notify.notifyError(error, AnalyticsErrorEventSource.GALLERY_VIEW);
}
if (!url) {
previewAndMapFailed.value = true;
isLoading.value = false;
return;
}
obStore.cacheObjectPreviewURL(encodedFilePath.value, { url, lastModified: props.file.LastModified.getTime() });
isLoading.value = false;
}
/**
* Loads object URL from cache or generates new URL.
*/
function processFilePath(): void {
const url = findCachedURL();
if (!url) {
fetchPreviewAndMapUrl();
return;
}
}
/**
* Try to find current object path in cache.
*/
function findCachedURL(): string | undefined {
const cache = cachedObjectPreviewURLs.value.get(encodedFilePath.value);
if (!cache) return undefined;
if (cache.lastModified !== props.file.LastModified.getTime()) {
obStore.removeFromObjectPreviewCache(encodedFilePath.value);
return undefined;
}
return cache.url;
}
watch(() => props.active, active => {
if (active) {
processFilePath();
}
}, { immediate: true });
</script>