web/satellite: vuetify upload progress panel
vuetify snackbar with expansion panels displaying upload statuses. https://github.com/storj/storj/issues/6257 Change-Id: Ife01616f5a07a4987153ef85331ff71f53b8cf78
This commit is contained in:
parent
6555a68fa9
commit
8689f609d7
@ -9,6 +9,7 @@
|
||||
elevation="24"
|
||||
rounded="lg"
|
||||
class="upload-snackbar"
|
||||
:max-width="xs ? '400px' : ''"
|
||||
>
|
||||
<v-row>
|
||||
<v-col>
|
||||
@ -18,27 +19,29 @@
|
||||
rounded="lg"
|
||||
>
|
||||
<v-expansion-panel-title color="">
|
||||
<span>Uploading 3 items</span>
|
||||
<span>{{ statusLabel }}</span>
|
||||
</v-expansion-panel-title>
|
||||
<v-progress-linear
|
||||
v-if="!isClosable"
|
||||
rounded
|
||||
model-value="73"
|
||||
:indeterminate="!progress"
|
||||
:model-value="progress"
|
||||
height="6"
|
||||
color="success"
|
||||
class="mt-1"
|
||||
/>
|
||||
<v-expansion-panel-text>
|
||||
<v-row class="pt-2">
|
||||
<v-col cols="10">
|
||||
<p class="text-medium-emphasis">4 seconds left...</p>
|
||||
<v-expansion-panel-text v-if="!isClosable && objectsInProgress.length > 1">
|
||||
<v-row justify="space-between" class="pt-2">
|
||||
<v-col cols="auto">
|
||||
<p class="text-medium-emphasis">{{ remainingTimeString }}</p>
|
||||
</v-col>
|
||||
<v-col cols="2">
|
||||
<v-col cols="auto">
|
||||
<v-tooltip text="Cancel all uploads">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-icon
|
||||
v-bind="activatorProps"
|
||||
icon="mdi-close-circle"
|
||||
@click="onCancel"
|
||||
@click="cancelAll"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
@ -46,83 +49,14 @@
|
||||
</v-row>
|
||||
</v-expansion-panel-text>
|
||||
<v-divider />
|
||||
<v-expansion-panel-text>
|
||||
<v-row class="pt-2">
|
||||
<v-col cols="10">
|
||||
<p>Image.jpg</p>
|
||||
</v-col>
|
||||
<v-col cols="2">
|
||||
<v-tooltip text="Uploading...">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-progress-circular
|
||||
v-bind="activatorProps"
|
||||
:size="20"
|
||||
color="secondary"
|
||||
model-value="20"
|
||||
<v-expansion-panel-text class="uploading-content">
|
||||
<UploadItem
|
||||
v-for="item in uploading"
|
||||
:key="item.Key"
|
||||
:item="item"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
<v-expansion-panel-text />
|
||||
</v-expansion-panel-text>
|
||||
<v-divider />
|
||||
<v-expansion-panel-text>
|
||||
<v-row class="pt-2">
|
||||
<v-col cols="10">
|
||||
<p>Video.mp4</p>
|
||||
</v-col>
|
||||
<v-col cols="2">
|
||||
<v-tooltip text="Upload complete">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-icon
|
||||
v-bind="activatorProps"
|
||||
icon="mdi-checkbox-marked-circle"
|
||||
color="success"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-text>
|
||||
<v-divider />
|
||||
<v-expansion-panel-text>
|
||||
<v-row class="pt-2">
|
||||
<v-col cols="10">
|
||||
<p>Text.pdf</p>
|
||||
</v-col>
|
||||
<v-col cols="2">
|
||||
<v-tooltip text="Upload failed">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-icon
|
||||
v-bind="activatorProps"
|
||||
icon="mdi-information"
|
||||
color="warning"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-text>
|
||||
<v-divider />
|
||||
<v-expansion-panel-text>
|
||||
<v-row class="pt-2">
|
||||
<v-col cols="10">
|
||||
<p>Bigvideo.mov</p>
|
||||
</v-col>
|
||||
<v-col cols="2">
|
||||
<v-tooltip text="File is too big">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-icon
|
||||
v-bind="activatorProps"
|
||||
icon="mdi-cancel"
|
||||
color="error2"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-expansion-panel-text>
|
||||
<v-divider />
|
||||
</v-expansion-panel>
|
||||
</v-expansion-panels>
|
||||
</v-col>
|
||||
@ -131,6 +65,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import {
|
||||
VSnackbar,
|
||||
VRow,
|
||||
@ -143,10 +78,156 @@ import {
|
||||
VTooltip,
|
||||
VIcon,
|
||||
VDivider,
|
||||
VProgressCircular,
|
||||
} from 'vuetify/components';
|
||||
import { useDisplay } from 'vuetify';
|
||||
|
||||
const props = defineProps<{
|
||||
onCancel: () => void,
|
||||
}>();
|
||||
import { UploadingBrowserObject, UploadingStatus, useObjectBrowserStore } from '@/store/modules/objectBrowserStore';
|
||||
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
|
||||
import { Duration } from '@/utils/time';
|
||||
import { useNotify } from '@/utils/hooks';
|
||||
|
||||
import UploadItem from '@poc/components/UploadItem.vue';
|
||||
|
||||
const obStore = useObjectBrowserStore();
|
||||
const remainingTimeString = ref<string>('');
|
||||
const interval = ref<NodeJS.Timer>();
|
||||
const notify = useNotify();
|
||||
const startDate = ref<number>(Date.now());
|
||||
|
||||
const { xs } = useDisplay();
|
||||
|
||||
/**
|
||||
* Returns header's status label.
|
||||
*/
|
||||
const statusLabel = computed((): string => {
|
||||
let inProgress = 0, finished = 0, failed = 0, cancelled = 0;
|
||||
uploading.value.forEach(u => {
|
||||
switch (u.status) {
|
||||
case UploadingStatus.InProgress:
|
||||
inProgress++;
|
||||
break;
|
||||
case UploadingStatus.Failed:
|
||||
failed++;
|
||||
break;
|
||||
case UploadingStatus.Cancelled:
|
||||
cancelled++;
|
||||
break;
|
||||
default:
|
||||
finished++;
|
||||
}
|
||||
});
|
||||
|
||||
if (failed === uploading.value.length) return 'Uploading failed';
|
||||
if (cancelled === uploading.value.length) return 'Uploading cancelled';
|
||||
if (inProgress) return `Uploading ${inProgress} item${inProgress > 1 ? 's' : ''}`;
|
||||
|
||||
const statuses = [
|
||||
failed ? `${failed} failed` : '',
|
||||
cancelled ? `${cancelled} cancelled` : '',
|
||||
].filter(s => s).join(', ');
|
||||
|
||||
return `Uploading completed${statuses ? ` (${statuses})` : ''}`;
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns upload progress.
|
||||
*/
|
||||
const progress = computed((): number => {
|
||||
return uploading.value.reduce((total: number, item: UploadingBrowserObject) => {
|
||||
total += item.progress || 0;
|
||||
return total;
|
||||
}, 0) / uploading.value.length;
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns uploading objects from store.
|
||||
*/
|
||||
const uploading = computed((): UploadingBrowserObject[] => {
|
||||
return obStore.state.uploading;
|
||||
});
|
||||
|
||||
/**
|
||||
* Calculates remaining seconds.
|
||||
*/
|
||||
function calculateRemainingTime(): void {
|
||||
const progress = uploading.value.reduce((total: number, item: UploadingBrowserObject) => {
|
||||
if (item.progress && item.progress !== 100) {
|
||||
total += item.progress;
|
||||
}
|
||||
return total;
|
||||
}, 0);
|
||||
|
||||
const remainingProgress = 100 - progress;
|
||||
const averageProgressPerNanosecond = progress / ((Date.now() - startDate.value) * 1000000);
|
||||
const remainingNanoseconds = remainingProgress / averageProgressPerNanosecond;
|
||||
if (!isFinite(remainingNanoseconds) || remainingNanoseconds < 0) {
|
||||
remainingTimeString.value = 'Unknown ETA';
|
||||
return;
|
||||
}
|
||||
|
||||
remainingTimeString.value = new Duration(remainingNanoseconds).remainingFormatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all uploads in progress.
|
||||
*/
|
||||
function cancelAll(): void {
|
||||
objectsInProgress.value.forEach(item => {
|
||||
try {
|
||||
obStore.cancelUpload(item.Key);
|
||||
} catch (error) {
|
||||
notify.error(`Unable to cancel upload for '${item.Key}'. ${error.message}`, AnalyticsErrorEventSource.OBJECTS_UPLOAD_MODAL);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns uploading objects with InProgress status.
|
||||
*/
|
||||
const objectsInProgress = computed((): UploadingBrowserObject[] => {
|
||||
return uploading.value.filter(f => f.status === UploadingStatus.InProgress);
|
||||
});
|
||||
|
||||
/**
|
||||
* Indicates if modal is closable.
|
||||
*/
|
||||
const isClosable = computed((): boolean => {
|
||||
return !objectsInProgress.value.length;
|
||||
});
|
||||
|
||||
/**
|
||||
* Starts interval for recalculating remaining time.
|
||||
*/
|
||||
function startInterval(): void {
|
||||
const int = setInterval(() => {
|
||||
if (isClosable.value) {
|
||||
clearInterval(int);
|
||||
interval.value = undefined;
|
||||
remainingTimeString.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
calculateRemainingTime();
|
||||
}, 2000); // recalculate every 2 seconds.
|
||||
|
||||
interval.value = int;
|
||||
}
|
||||
|
||||
watch(() => objectsInProgress.value.length, () => {
|
||||
if (!interval.value) {
|
||||
startDate.value = Date.now();
|
||||
startInterval();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
startInterval();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.uploading-content {
|
||||
overflow-y: auto;
|
||||
max-height: 200px;
|
||||
}
|
||||
</style>
|
118
web/satellite/vuetify-poc/src/components/UploadItem.vue
Normal file
118
web/satellite/vuetify-poc/src/components/UploadItem.vue
Normal file
@ -0,0 +1,118 @@
|
||||
// Copyright (C) 2023 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
<template>
|
||||
<v-row class="pt-2" justify="space-between">
|
||||
<v-col cols="9">
|
||||
<p class="text-truncate">{{ item.Key }}</p>
|
||||
</v-col>
|
||||
<v-col cols="auto">
|
||||
<v-tooltip :text="uploadStatus">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-progress-circular
|
||||
v-if="props.item.status === UploadingStatus.InProgress"
|
||||
v-bind="activatorProps"
|
||||
:indeterminate="!item.progress"
|
||||
:size="20"
|
||||
color="secondary"
|
||||
:model-value="progressStyle"
|
||||
/>
|
||||
<v-icon
|
||||
v-else
|
||||
v-bind="activatorProps"
|
||||
:icon="icon"
|
||||
:color="iconColor"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip v-if="props.item.status === UploadingStatus.InProgress" text="Cancel upload">
|
||||
<template #activator="{ props: activatorProps }">
|
||||
<v-icon
|
||||
class="ml-2"
|
||||
v-bind="activatorProps"
|
||||
icon="mdi-close-circle"
|
||||
@click="cancelUpload"
|
||||
/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { VCol, VIcon, VProgressCircular, VRow, VTooltip } from 'vuetify/components';
|
||||
|
||||
import {
|
||||
UploadingBrowserObject,
|
||||
UploadingStatus,
|
||||
useObjectBrowserStore,
|
||||
} from '@/store/modules/objectBrowserStore';
|
||||
import { useNotify } from '@/utils/hooks';
|
||||
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
|
||||
|
||||
const obStore = useObjectBrowserStore();
|
||||
const notify = useNotify();
|
||||
|
||||
const props = defineProps<{
|
||||
item: UploadingBrowserObject
|
||||
}>();
|
||||
|
||||
const progressStyle = computed((): number => {
|
||||
if (props.item.progress) {
|
||||
return 360*(props.item.progress/100);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const uploadStatus = computed((): string => {
|
||||
if (props.item.status === UploadingStatus.InProgress) {
|
||||
return 'Uploading...';
|
||||
} else if (props.item.status === UploadingStatus.Finished) {
|
||||
return 'Upload complete';
|
||||
} else if (props.item.status === UploadingStatus.Failed) {
|
||||
return 'Upload failed';
|
||||
} else if (props.item.status === UploadingStatus.Cancelled) {
|
||||
return 'Upload cancelled';
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const icon = computed((): string => {
|
||||
if (props.item.status === UploadingStatus.Finished) {
|
||||
return 'mdi-checkbox-marked-circle';
|
||||
} else if (props.item.status === UploadingStatus.Failed) {
|
||||
return 'mdi-information';
|
||||
} else if (props.item.status === UploadingStatus.Cancelled) {
|
||||
return 'mdi-cancel';
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const iconColor = computed((): string => {
|
||||
if (props.item.status === UploadingStatus.Finished) {
|
||||
return 'success';
|
||||
} else if (props.item.status === UploadingStatus.Failed) {
|
||||
return 'warning';
|
||||
} else if (props.item.status === UploadingStatus.Cancelled) {
|
||||
return 'error2';
|
||||
} else {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Cancels active upload.
|
||||
*/
|
||||
function cancelUpload(): void {
|
||||
try {
|
||||
obStore.cancelUpload(props.item.Key);
|
||||
} catch (error) {
|
||||
notify.error(error.message, AnalyticsErrorEventSource.OBJECTS_UPLOAD_MODAL);
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
@ -10,7 +10,6 @@
|
||||
<page-title-component title="Browse Files" />
|
||||
|
||||
<browser-breadcrumbs-component />
|
||||
|
||||
<v-col>
|
||||
<v-row class="mt-2 mb-4">
|
||||
<v-menu v-model="menu" location="bottom" transition="scale-transition" offset="5">
|
||||
@ -21,7 +20,7 @@
|
||||
:disabled="!isInitialized"
|
||||
v-bind="props"
|
||||
>
|
||||
<browser-snackbar-component :on-cancel="() => { snackbar = false }" />
|
||||
<browser-snackbar-component v-model="isObjectsUploadModal" />
|
||||
<IconUpload />
|
||||
Upload
|
||||
</v-btn>
|
||||
@ -108,6 +107,7 @@ import { EdgeCredentials } from '@/types/accessGrants';
|
||||
import { useNotify } from '@/utils/hooks';
|
||||
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
|
||||
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
|
||||
import { useAppStore } from '@/store/modules/appStore';
|
||||
|
||||
import PageTitleComponent from '@poc/components/PageTitleComponent.vue';
|
||||
import BrowserBreadcrumbsComponent from '@poc/components/BrowserBreadcrumbsComponent.vue';
|
||||
@ -124,6 +124,7 @@ const bucketsStore = useBucketsStore();
|
||||
const obStore = useObjectBrowserStore();
|
||||
const projectsStore = useProjectsStore();
|
||||
const analyticsStore = useAnalyticsStore();
|
||||
const appStore = useAppStore();
|
||||
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
@ -158,6 +159,13 @@ const projectId = computed<string>(() => projectsStore.state.selectedProject.id)
|
||||
*/
|
||||
const isPromptForPassphrase = computed<boolean>(() => bucketsStore.state.promptForPassphrase);
|
||||
|
||||
/**
|
||||
* Indicates whether objects upload modal should be shown.
|
||||
*/
|
||||
const isObjectsUploadModal = computed((): boolean => {
|
||||
return appStore.state.isUploadingModal;
|
||||
});
|
||||
|
||||
/**
|
||||
* Open the operating system's file system for file upload.
|
||||
*/
|
||||
|
Loading…
Reference in New Issue
Block a user