This commit is contained in:
syuilo 2025-05-12 16:48:56 +09:00
parent 51b5d740f6
commit 9d09d016bb
10 changed files with 274 additions and 356 deletions

4
locales/index.d.ts vendored
View File

@ -1210,6 +1210,10 @@ export interface Locale extends ILocale {
*
*/
"uploadFromUrlMayTakeTime": string;
/**
* {n}
*/
"uploadNFiles": ParameterizedString<"n">;
/**
*
*/

View File

@ -298,6 +298,7 @@ uploadFromUrl: "URLアップロード"
uploadFromUrlDescription: "アップロードしたいファイルのURL"
uploadFromUrlRequested: "アップロードをリクエストしました"
uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。"
uploadNFiles: "{n}個のファイルをアップロード"
explore: "みつける"
messageRead: "既読"
noMoreHistory: "これより過去の履歴はありません"

View File

@ -15,18 +15,16 @@ SPDX-License-Identifier: AGPL-3.0-only
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.cropImage }}</template>
<template #default="{ width, height }">
<div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`">
<Transition name="fade">
<div v-if="loading" class="loading">
<MkLoading/>
</div>
</Transition>
<div class="container">
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
<div class="mk-cropper-dialog" :style="`--vw: 100%; --vh: 100%;`">
<Transition name="fade">
<div v-if="loading" class="loading">
<MkLoading/>
</div>
</Transition>
<div class="container">
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
</div>
</template>
</div>
</MkModalWindow>
</template>

View File

@ -111,7 +111,7 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
import { uploadFile, uploads } from '@/utility/upload.js';
import { uploadFile, uploa______ds } from '@/utility/upload.js';
import { claimAchievement } from '@/utility/achievements.js';
import { prefer } from '@/preferences.js';
import { chooseFileFromPc } from '@/utility/select-file.js';
@ -145,7 +145,7 @@ const moreFolders = ref(false);
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
const uploadings = uploads;
const uploadings = uploa______ds;
const connection = useStream().useChannel('drive');
//
@ -625,12 +625,6 @@ function getMenu() {
menu.push({
text: i18n.ts.addFile,
type: 'label',
}, {
text: i18n.ts.upload + ' (' + i18n.ts.compress + ')',
icon: 'ti ti-upload',
action: () => {
chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: false });
},
}, {
text: i18n.ts.upload,
icon: 'ti ti-upload',

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')">
<div ref="rootEl" :class="$style.root" :style="{ width: `${width}px`, height: `min(${height}px, 100%)` }">
<div ref="headerEl" :class="$style.header">
<div :class="$style.header">
<button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button>
<span :class="$style.title">
<slot name="header"></slot>
@ -15,7 +15,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button>
</div>
<div :class="$style.body">
<slot :width="bodyWidth" :height="bodyHeight"></slot>
<slot></slot>
</div>
<div :class="$style.footer">
<slot name="footer"></slot>
</div>
</div>
</MkModal>
@ -48,10 +51,6 @@ const emit = defineEmits<{
}>();
const modal = useTemplateRef('modal');
const rootEl = useTemplateRef('rootEl');
const headerEl = useTemplateRef('headerEl');
const bodyWidth = ref(0);
const bodyHeight = ref(0);
function close() {
modal.value?.close();
@ -61,23 +60,6 @@ function onBgClick() {
emit('click');
}
const ro = new ResizeObserver((entries, observer) => {
if (rootEl.value == null || headerEl.value == null) return;
bodyWidth.value = rootEl.value.offsetWidth;
bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight;
});
onMounted(() => {
if (rootEl.value == null || headerEl.value == null) return;
bodyWidth.value = rootEl.value.offsetWidth;
bodyHeight.value = rootEl.value.offsetHeight - headerEl.value.offsetHeight;
ro.observe(rootEl.value);
});
onUnmounted(() => {
ro.disconnect();
});
defineExpose({
close,
});
@ -146,4 +128,12 @@ defineExpose({
background: var(--MI_THEME-panel);
container-type: size;
}
.footer {
display: flex;
flex-shrink: 0;
background: var(--MI_THEME-windowHeader);
-webkit-backdrop-filter: var(--MI-blur, blur(15px));
backdrop-filter: var(--MI-blur, blur(15px));
}
</style>

View File

@ -0,0 +1,237 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="800"
:height="500"
@click="cancel()"
@close="cancel()"
@closed="emit('closed')"
>
<template #header>
{{ i18n.tsx.uploadNFiles({ n: files.length }) }}
</template>
<div>
<div :class="$style.items">
<div v-for="ctx in items" :key="ctx.id" :class="$style.item">
<div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div>
<div class="top">
<p class="name"><MkLoading :em="true"/>{{ ctx.name }}</p>
<p class="status">
<span v-if="ctx.progressValue === null" class="initing">{{ i18n.ts.waiting }}<MkEllipsis/></span>
<span v-if="ctx.progressValue !== null" class="kb">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span>
<span v-if="ctx.progressValue !== null" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span>
</p>
</div>
<progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === null, waiting: ctx.progressValue !== null && ctx.progressValue === ctx.progressMax }"></progress>
</div>
</div>
</div>
<template #footer>
<div>
<MkButton primary rounded @click="upload()">{{ i18n.ts.upload }}</MkButton>
</div>
</template>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { markRaw, onMounted, ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid';
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
import { getCompressionConfig } from '@/utility/upload/compress-config.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { $i } from '@/i.js';
import { instance } from '@/instance.js';
import MkButton from '@/components/MkButton.vue';
const mimeTypeMap = {
'image/webp': 'webp',
'image/jpeg': 'jpg',
'image/png': 'png',
} as const;
const props = withDefaults(defineProps<{
files: File[];
folderId?: string | null;
}>(), {
});
const emit = defineEmits<{
(ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void;
(ev: 'closed'): void;
}>();
const items = ref([] as {
id: string;
name: string;
progressMax: number | null;
progressValue: number | null;
thumbnail: string;
uploaded: Misskey.entities.DriveFile | null;
file: File;
}[]);
const dialog = useTemplateRef('dialog');
function cancel() {
// TODO:
dialog.value?.close();
}
function upload() {
for (const item of items.value) {
if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
});
return Promise.reject();
}
const reader = new FileReader();
reader.onload = async (): Promise<void> => {
const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
let resizedImage: Blob | undefined;
if (config) {
try {
const resized = await readAndCompressImage(file, config);
if (resized.size < file.size || file.type === 'image/webp') {
// The compression may not always reduce the file size
// (and WebP is not browser safe yet)
resizedImage = resized;
}
if (_DEV_) {
const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
}
ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name;
} catch (err) {
console.error('Failed to resize image', err);
}
}
const formData = new FormData();
formData.append('i', $i!.token);
formData.append('force', 'true');
formData.append('file', resizedImage ?? file);
formData.append('name', ctx.name);
if (_folder) formData.append('folderId', _folder);
const xhr = new XMLHttpRequest();
xhr.open('POST', apiUrl + '/drive/files/create', true);
xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
// TODO: ()
uploa______ds.value = uploa______ds.value.filter(x => x.id !== id);
if (xhr.status === 413) {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
});
} else if (ev.target?.response) {
const res = JSON.parse(ev.target.response);
if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseInappropriate,
});
} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseNoFreeSpace,
});
} else {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
});
}
} else {
alert({
type: 'error',
title: 'Failed to upload',
text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
});
}
reject();
return;
}
const driveFile = JSON.parse(ev.target.response);
resolve(driveFile);
uploa______ds.value = uploa______ds.value.filter(x => x.id !== id);
}) as (ev: ProgressEvent<EventTarget>) => any;
xhr.upload.onprogress = ev => {
if (ev.lengthComputable) {
ctx.progressMax = ev.total;
ctx.progressValue = ev.loaded;
}
};
xhr.send(formData);
};
reader.readAsArrayBuffer(file);
}
}
onMounted(() => {
for (const file of props.files) {
const id = uuid();
const filename = file.name ?? 'untitled';
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
items.value.push({
id,
name: prefer.s.keepOriginalFilename ? filename : id + extension,
progressMax: null,
progressValue: null,
thumbnail: window.URL.createObjectURL(file),
uploaded: null,
file: markRaw(file),
});
}
});
</script>
<style lang="scss" module>
.mk-uploader > ol > li > progress {
display: block;
background: transparent;
border: none;
border-radius: 4px;
overflow: hidden;
grid-column: 2/3;
grid-row: 2/3;
z-index: 2;
width: 100%;
height: 8px;
}
.mk-uploader > ol > li > progress::-webkit-progress-value {
background: var(--MI_THEME-accent);
}
.mk-uploader > ol > li > progress::-webkit-progress-bar {
//background: var(--MI_THEME-accentAlpha01);
background: transparent;
}
</style>

View File

@ -65,8 +65,6 @@ SPDX-License-Identifier: AGPL-3.0-only
v-on="popup.events"
/>
<XUpload v-if="uploads.length > 0"/>
<component
:is="prefer.s.animation ? TransitionGroup : 'div'"
tag="div"
@ -105,7 +103,6 @@ import { swInject } from './sw-inject.js';
import XNotification from './notification.vue';
import { popups } from '@/os.js';
import { pendingApiRequestsCount } from '@/utility/misskey-api.js';
import { uploads } from '@/utility/upload.js';
import * as sound from '@/utility/sound.js';
import { $i } from '@/i.js';
import { useStream } from '@/stream.js';
@ -116,7 +113,6 @@ import { store } from '@/store.js';
import XNavbar from '@/ui/_common_/navbar.vue';
const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
const XUpload = defineAsyncComponent(() => import('./upload.vue'));
const XWidgets = defineAsyncComponent(() => import('./widgets.vue'));
const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing');

View File

@ -1,134 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="mk-uploader _acrylic" :style="{ zIndex }">
<ol v-if="uploads.length > 0">
<li v-for="ctx in uploads" :key="ctx.id">
<div class="img" :style="{ backgroundImage: `url(${ ctx.img })` }"></div>
<div class="top">
<p class="name"><MkLoading :em="true"/>{{ ctx.name }}</p>
<p class="status">
<span v-if="ctx.progressValue === undefined" class="initing">{{ i18n.ts.waiting }}<MkEllipsis/></span>
<span v-if="ctx.progressValue !== undefined" class="kb">{{ String(Math.floor(ctx.progressValue / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i> / {{ String(Math.floor(ctx.progressMax / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') }}<i>KB</i></span>
<span v-if="ctx.progressValue !== undefined" class="percentage">{{ Math.floor((ctx.progressValue / ctx.progressMax) * 100) }}</span>
</p>
</div>
<progress :value="ctx.progressValue || 0" :max="ctx.progressMax || 0" :class="{ initing: ctx.progressValue === undefined, waiting: ctx.progressValue !== undefined && ctx.progressValue === ctx.progressMax }"></progress>
</li>
</ol>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as os from '@/os.js';
import { uploads } from '@/utility/upload.js';
import { i18n } from '@/i18n.js';
const zIndex = os.claimZIndex('high');
</script>
<style lang="scss" scoped>
.mk-uploader {
position: fixed;
right: 16px;
width: 260px;
top: 32px;
padding: 16px 20px;
pointer-events: none;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
border-radius: 8px;
}
.mk-uploader:empty {
display: none;
}
.mk-uploader > ol {
display: block;
margin: 0;
padding: 0;
list-style: none;
}
.mk-uploader > ol > li {
display: grid;
margin: 8px 0 0 0;
padding: 0;
height: 36px;
width: 100%;
border-top: solid 8px transparent;
grid-template-columns: 36px calc(100% - 44px);
grid-template-rows: 1fr 8px;
column-gap: 8px;
box-sizing: content-box;
}
.mk-uploader > ol > li:first-child {
margin: 0;
box-shadow: none;
border-top: none;
}
.mk-uploader > ol > li > .img {
display: block;
background-size: cover;
background-position: center center;
grid-column: 1/2;
grid-row: 1/3;
}
.mk-uploader > ol > li > .top {
display: flex;
grid-column: 2/3;
grid-row: 1/2;
}
.mk-uploader > ol > li > .top > .name {
display: block;
padding: 0 8px 0 0;
margin: 0;
font-size: 0.8em;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
flex-shrink: 1;
}
.mk-uploader > ol > li > .top > .name > i {
margin-right: 4px;
}
.mk-uploader > ol > li > .top > .status {
display: block;
margin: 0 0 0 auto;
padding: 0;
font-size: 0.8em;
flex-shrink: 0;
}
.mk-uploader > ol > li > .top > .status > .initing {
}
.mk-uploader > ol > li > .top > .status > .kb {
}
.mk-uploader > ol > li > .top > .status > .percentage {
display: inline-block;
width: 48px;
text-align: right;
}
.mk-uploader > ol > li > .top > .status > .percentage:after {
content: '%';
}
.mk-uploader > ol > li > progress {
display: block;
background: transparent;
border: none;
border-radius: 4px;
overflow: hidden;
grid-column: 2/3;
grid-row: 2/3;
z-index: 2;
width: 100%;
height: 8px;
}
.mk-uploader > ol > li > progress::-webkit-progress-value {
background: var(--MI_THEME-accent);
}
.mk-uploader > ol > li > progress::-webkit-progress-bar {
//background: var(--MI_THEME-accentAlpha01);
background: transparent;
}
</style>

View File

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { ref } from 'vue';
import { defineAsyncComponent, markRaw, ref } from 'vue';
import * as Misskey from 'misskey-js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
@ -16,12 +16,10 @@ export function chooseFileFromPc(
multiple: boolean,
options?: {
uploadFolder?: string | null;
keepOriginal?: boolean;
nameConverter?: (file: File) => string | undefined;
},
): Promise<Misskey.entities.DriveFile[]> {
const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder;
const keepOriginal = options?.keepOriginal ?? false;
const nameConverter = options?.nameConverter ?? (() => undefined);
return new Promise((res, rej) => {
@ -30,15 +28,15 @@ export function chooseFileFromPc(
input.multiple = multiple;
input.onchange = () => {
if (!input.files) return res([]);
const promises = Array.from(
input.files,
file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal),
);
Promise.all(promises).then(driveFiles => {
res(driveFiles);
}).catch(err => {
// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUploadDialog.vue')), {
files: markRaw(Array.from(input.files)),
uploadFolder,
}, {
done: driveFiles => {
res(driveFiles);
},
closed: () => dispose(),
});
// 一応廃棄
@ -100,10 +98,6 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul
text: label,
type: 'label',
} : undefined, {
text: i18n.ts.upload + ' (' + i18n.ts.compress + ')',
icon: 'ti ti-upload',
action: () => chooseFileFromPc(multiple, { keepOriginal: false }).then(files => res(files)),
}, {
text: i18n.ts.upload,
icon: 'ti ti-upload',
action: () => chooseFileFromPc(multiple, { keepOriginal: true }).then(files => res(files)),

View File

@ -1,162 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { reactive, ref } from 'vue';
import * as Misskey from 'misskey-js';
import { v4 as uuid } from 'uuid';
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
import { apiUrl } from '@@/js/config.js';
import { getCompressionConfig } from './upload/compress-config.js';
import { $i } from '@/i.js';
import { alert } from '@/os.js';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { prefer } from '@/preferences.js';
type Uploading = {
id: string;
name: string;
progressMax: number | undefined;
progressValue: number | undefined;
img: string;
};
export const uploads = ref<Uploading[]>([]);
const mimeTypeMap = {
'image/webp': 'webp',
'image/jpeg': 'jpg',
'image/png': 'png',
} as const;
export function uploadFile(
file: File,
folder?: string | Misskey.entities.DriveFolder | null,
name?: string,
keepOriginal = false,
): Promise<Misskey.entities.DriveFile> {
if ($i == null) throw new Error('Not logged in');
const _folder = typeof folder === 'string' ? folder : folder?.id;
if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
});
return Promise.reject();
}
return new Promise((resolve, reject) => {
const id = uuid();
const reader = new FileReader();
reader.onload = async (): Promise<void> => {
const filename = name ?? file.name ?? 'untitled';
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
const ctx = reactive<Uploading>({
id,
name: prefer.s.keepOriginalFilename ? filename : id + extension,
progressMax: undefined,
progressValue: undefined,
img: window.URL.createObjectURL(file),
});
uploads.value.push(ctx);
const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
let resizedImage: Blob | undefined;
if (config) {
try {
const resized = await readAndCompressImage(file, config);
if (resized.size < file.size || file.type === 'image/webp') {
// The compression may not always reduce the file size
// (and WebP is not browser safe yet)
resizedImage = resized;
}
if (_DEV_) {
const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
console.log(`Image compression: before ${file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
}
ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name;
} catch (err) {
console.error('Failed to resize image', err);
}
}
const formData = new FormData();
formData.append('i', $i!.token);
formData.append('force', 'true');
formData.append('file', resizedImage ?? file);
formData.append('name', ctx.name);
if (_folder) formData.append('folderId', _folder);
const xhr = new XMLHttpRequest();
xhr.open('POST', apiUrl + '/drive/files/create', true);
xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい
uploads.value = uploads.value.filter(x => x.id !== id);
if (xhr.status === 413) {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
});
} else if (ev.target?.response) {
const res = JSON.parse(ev.target.response);
if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseInappropriate,
});
} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: i18n.ts.cannotUploadBecauseNoFreeSpace,
});
} else {
alert({
type: 'error',
title: i18n.ts.failedToUpload,
text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
});
}
} else {
alert({
type: 'error',
title: 'Failed to upload',
text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
});
}
reject();
return;
}
const driveFile = JSON.parse(ev.target.response);
resolve(driveFile);
uploads.value = uploads.value.filter(x => x.id !== id);
}) as (ev: ProgressEvent<EventTarget>) => any;
xhr.upload.onprogress = ev => {
if (ev.lengthComputable) {
ctx.progressMax = ev.total;
ctx.progressValue = ev.loaded;
}
};
xhr.send(formData);
};
reader.readAsArrayBuffer(file);
});
}