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"
|
elevation="24"
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
class="upload-snackbar"
|
class="upload-snackbar"
|
||||||
|
:max-width="xs ? '400px' : ''"
|
||||||
>
|
>
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col>
|
<v-col>
|
||||||
@ -18,27 +19,29 @@
|
|||||||
rounded="lg"
|
rounded="lg"
|
||||||
>
|
>
|
||||||
<v-expansion-panel-title color="">
|
<v-expansion-panel-title color="">
|
||||||
<span>Uploading 3 items</span>
|
<span>{{ statusLabel }}</span>
|
||||||
</v-expansion-panel-title>
|
</v-expansion-panel-title>
|
||||||
<v-progress-linear
|
<v-progress-linear
|
||||||
|
v-if="!isClosable"
|
||||||
rounded
|
rounded
|
||||||
model-value="73"
|
:indeterminate="!progress"
|
||||||
|
:model-value="progress"
|
||||||
height="6"
|
height="6"
|
||||||
color="success"
|
color="success"
|
||||||
class="mt-1"
|
class="mt-1"
|
||||||
/>
|
/>
|
||||||
<v-expansion-panel-text>
|
<v-expansion-panel-text v-if="!isClosable && objectsInProgress.length > 1">
|
||||||
<v-row class="pt-2">
|
<v-row justify="space-between" class="pt-2">
|
||||||
<v-col cols="10">
|
<v-col cols="auto">
|
||||||
<p class="text-medium-emphasis">4 seconds left...</p>
|
<p class="text-medium-emphasis">{{ remainingTimeString }}</p>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="2">
|
<v-col cols="auto">
|
||||||
<v-tooltip text="Cancel all uploads">
|
<v-tooltip text="Cancel all uploads">
|
||||||
<template #activator="{ props: activatorProps }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-icon
|
<v-icon
|
||||||
v-bind="activatorProps"
|
v-bind="activatorProps"
|
||||||
icon="mdi-close-circle"
|
icon="mdi-close-circle"
|
||||||
@click="onCancel"
|
@click="cancelAll"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
@ -46,83 +49,14 @@
|
|||||||
</v-row>
|
</v-row>
|
||||||
</v-expansion-panel-text>
|
</v-expansion-panel-text>
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<v-expansion-panel-text>
|
<v-expansion-panel-text class="uploading-content">
|
||||||
<v-row class="pt-2">
|
<UploadItem
|
||||||
<v-col cols="10">
|
v-for="item in uploading"
|
||||||
<p>Image.jpg</p>
|
:key="item.Key"
|
||||||
</v-col>
|
:item="item"
|
||||||
<v-col cols="2">
|
/>
|
||||||
<v-tooltip text="Uploading...">
|
<v-expansion-panel-text />
|
||||||
<template #activator="{ props: activatorProps }">
|
|
||||||
<v-progress-circular
|
|
||||||
v-bind="activatorProps"
|
|
||||||
:size="20"
|
|
||||||
color="secondary"
|
|
||||||
model-value="20"
|
|
||||||
/>
|
|
||||||
</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-panel>
|
||||||
</v-expansion-panels>
|
</v-expansion-panels>
|
||||||
</v-col>
|
</v-col>
|
||||||
@ -131,6 +65,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import {
|
import {
|
||||||
VSnackbar,
|
VSnackbar,
|
||||||
VRow,
|
VRow,
|
||||||
@ -143,10 +78,156 @@ import {
|
|||||||
VTooltip,
|
VTooltip,
|
||||||
VIcon,
|
VIcon,
|
||||||
VDivider,
|
VDivider,
|
||||||
VProgressCircular,
|
|
||||||
} from 'vuetify/components';
|
} from 'vuetify/components';
|
||||||
|
import { useDisplay } from 'vuetify';
|
||||||
|
|
||||||
const props = defineProps<{
|
import { UploadingBrowserObject, UploadingStatus, useObjectBrowserStore } from '@/store/modules/objectBrowserStore';
|
||||||
onCancel: () => void,
|
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
|
||||||
}>();
|
import { Duration } from '@/utils/time';
|
||||||
</script>
|
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>
|
@ -318,4 +318,4 @@ table {
|
|||||||
// Positions
|
// Positions
|
||||||
.pos-relative {
|
.pos-relative {
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
}
|
}
|
@ -10,7 +10,6 @@
|
|||||||
<page-title-component title="Browse Files" />
|
<page-title-component title="Browse Files" />
|
||||||
|
|
||||||
<browser-breadcrumbs-component />
|
<browser-breadcrumbs-component />
|
||||||
|
|
||||||
<v-col>
|
<v-col>
|
||||||
<v-row class="mt-2 mb-4">
|
<v-row class="mt-2 mb-4">
|
||||||
<v-menu v-model="menu" location="bottom" transition="scale-transition" offset="5">
|
<v-menu v-model="menu" location="bottom" transition="scale-transition" offset="5">
|
||||||
@ -21,7 +20,7 @@
|
|||||||
:disabled="!isInitialized"
|
:disabled="!isInitialized"
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
>
|
>
|
||||||
<browser-snackbar-component :on-cancel="() => { snackbar = false }" />
|
<browser-snackbar-component v-model="isObjectsUploadModal" />
|
||||||
<IconUpload />
|
<IconUpload />
|
||||||
Upload
|
Upload
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@ -108,6 +107,7 @@ import { EdgeCredentials } from '@/types/accessGrants';
|
|||||||
import { useNotify } from '@/utils/hooks';
|
import { useNotify } from '@/utils/hooks';
|
||||||
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
|
import { AnalyticsErrorEventSource, AnalyticsEvent } from '@/utils/constants/analyticsEventNames';
|
||||||
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
|
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
|
||||||
|
import { useAppStore } from '@/store/modules/appStore';
|
||||||
|
|
||||||
import PageTitleComponent from '@poc/components/PageTitleComponent.vue';
|
import PageTitleComponent from '@poc/components/PageTitleComponent.vue';
|
||||||
import BrowserBreadcrumbsComponent from '@poc/components/BrowserBreadcrumbsComponent.vue';
|
import BrowserBreadcrumbsComponent from '@poc/components/BrowserBreadcrumbsComponent.vue';
|
||||||
@ -124,6 +124,7 @@ const bucketsStore = useBucketsStore();
|
|||||||
const obStore = useObjectBrowserStore();
|
const obStore = useObjectBrowserStore();
|
||||||
const projectsStore = useProjectsStore();
|
const projectsStore = useProjectsStore();
|
||||||
const analyticsStore = useAnalyticsStore();
|
const analyticsStore = useAnalyticsStore();
|
||||||
|
const appStore = useAppStore();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
@ -158,6 +159,13 @@ const projectId = computed<string>(() => projectsStore.state.selectedProject.id)
|
|||||||
*/
|
*/
|
||||||
const isPromptForPassphrase = computed<boolean>(() => bucketsStore.state.promptForPassphrase);
|
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.
|
* Open the operating system's file system for file upload.
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user