feat(frontend): upload dialog (#16032)
* wip * wip * Update MkUploadDialog.vue * wip * wip * wip * wip * wip * Update MkUploadDialog.vue * wip * wip * Update MkDrive.vue
This commit is contained in:
parent
0dd224266c
commit
73a83c0064
|
@ -1210,6 +1210,10 @@ export interface Locale extends ILocale {
|
||||||
* アップロードが完了するまで時間がかかる場合があります。
|
* アップロードが完了するまで時間がかかる場合があります。
|
||||||
*/
|
*/
|
||||||
"uploadFromUrlMayTakeTime": string;
|
"uploadFromUrlMayTakeTime": string;
|
||||||
|
/**
|
||||||
|
* {n}個のファイルをアップロード
|
||||||
|
*/
|
||||||
|
"uploadNFiles": ParameterizedString<"n">;
|
||||||
/**
|
/**
|
||||||
* みつける
|
* みつける
|
||||||
*/
|
*/
|
||||||
|
@ -11898,6 +11902,20 @@ export interface Locale extends ILocale {
|
||||||
"text3": string;
|
"text3": string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
"_uploader": {
|
||||||
|
/**
|
||||||
|
* {x}に圧縮
|
||||||
|
*/
|
||||||
|
"compressedToX": ParameterizedString<"x">;
|
||||||
|
/**
|
||||||
|
* {x}%節約
|
||||||
|
*/
|
||||||
|
"savedXPercent": ParameterizedString<"x">;
|
||||||
|
/**
|
||||||
|
* アップロードされていないファイルがありますが、中止しますか?
|
||||||
|
*/
|
||||||
|
"abortConfirm": string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
declare const locales: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[lang: string]: Locale;
|
||||||
|
|
|
@ -298,6 +298,7 @@ uploadFromUrl: "URLアップロード"
|
||||||
uploadFromUrlDescription: "アップロードしたいファイルのURL"
|
uploadFromUrlDescription: "アップロードしたいファイルのURL"
|
||||||
uploadFromUrlRequested: "アップロードをリクエストしました"
|
uploadFromUrlRequested: "アップロードをリクエストしました"
|
||||||
uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。"
|
uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。"
|
||||||
|
uploadNFiles: "{n}個のファイルをアップロード"
|
||||||
explore: "みつける"
|
explore: "みつける"
|
||||||
messageRead: "既読"
|
messageRead: "既読"
|
||||||
noMoreHistory: "これより過去の履歴はありません"
|
noMoreHistory: "これより過去の履歴はありません"
|
||||||
|
@ -3181,3 +3182,8 @@ _serverSetupWizard:
|
||||||
text1: "Misskeyは有志によって開発されている無料のソフトウェアです。"
|
text1: "Misskeyは有志によって開発されている無料のソフトウェアです。"
|
||||||
text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。"
|
text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。"
|
||||||
text3: "支援者向け特典もあります!"
|
text3: "支援者向け特典もあります!"
|
||||||
|
|
||||||
|
_uploader:
|
||||||
|
compressedToX: "{x}に圧縮"
|
||||||
|
savedXPercent: "{x}%節約"
|
||||||
|
abortConfirm: "アップロードされていないファイルがありますが、中止しますか?"
|
||||||
|
|
|
@ -15,18 +15,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
@closed="emit('closed')"
|
@closed="emit('closed')"
|
||||||
>
|
>
|
||||||
<template #header>{{ i18n.ts.cropImage }}</template>
|
<template #header>{{ i18n.ts.cropImage }}</template>
|
||||||
<template #default="{ width, height }">
|
<div class="mk-cropper-dialog" :style="`--vw: 100%; --vh: 100%;`">
|
||||||
<div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`">
|
<Transition name="fade">
|
||||||
<Transition name="fade">
|
<div v-if="loading" class="loading">
|
||||||
<div v-if="loading" class="loading">
|
<MkLoading/>
|
||||||
<MkLoading/>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
<div class="container">
|
|
||||||
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
|
|
||||||
</div>
|
</div>
|
||||||
|
</Transition>
|
||||||
|
<div class="container">
|
||||||
|
<img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</div>
|
||||||
</MkModalWindow>
|
</MkModalWindow>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -53,7 +53,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref="main"
|
ref="main"
|
||||||
:class="[$style.main, { [$style.uploading]: uploadings.length > 0, [$style.fetching]: fetching }]"
|
:class="[$style.main, { [$style.fetching]: fetching }]"
|
||||||
@dragover.prevent.stop="onDragover"
|
@dragover.prevent.stop="onDragover"
|
||||||
@dragenter="onDragenter"
|
@dragenter="onDragenter"
|
||||||
@dragleave="onDragleave"
|
@dragleave="onDragleave"
|
||||||
|
@ -146,7 +146,6 @@ import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
import { useStream } from '@/stream.js';
|
import { useStream } from '@/stream.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { uploadFile, uploads } from '@/utility/upload.js';
|
|
||||||
import { claimAchievement } from '@/utility/achievements.js';
|
import { claimAchievement } from '@/utility/achievements.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
import { chooseFileFromPc } from '@/utility/select-file.js';
|
import { chooseFileFromPc } from '@/utility/select-file.js';
|
||||||
|
@ -176,7 +175,6 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const folder = ref<Misskey.entities.DriveFolder | null>(null);
|
const folder = ref<Misskey.entities.DriveFolder | null>(null);
|
||||||
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||||
const uploadings = uploads;
|
|
||||||
|
|
||||||
// ドロップされようとしているか
|
// ドロップされようとしているか
|
||||||
const draghover = ref(false);
|
const draghover = ref(false);
|
||||||
|
@ -561,12 +559,6 @@ function getMenu() {
|
||||||
menu.push({
|
menu.push({
|
||||||
text: i18n.ts.addFile,
|
text: i18n.ts.addFile,
|
||||||
type: 'label',
|
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,
|
text: i18n.ts.upload,
|
||||||
icon: 'ti ti-upload',
|
icon: 'ti ti-upload',
|
||||||
|
@ -766,10 +758,6 @@ onBeforeUnmount(() => {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.uploading {
|
|
||||||
height: calc(100% - 38px - 100px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.folders,
|
.folders,
|
||||||
|
|
|
@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<template>
|
<template>
|
||||||
<MkModal ref="modal" :preferType="'dialog'" @click="onBgClick" @closed="emit('closed')" @esc="emit('esc')">
|
<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="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>
|
<button v-if="withOkButton && withCloseButton" :class="$style.headerButton" class="_button" @click="emit('close')"><i class="ti ti-x"></i></button>
|
||||||
<span :class="$style.title">
|
<span :class="$style.title">
|
||||||
<slot name="header"></slot>
|
<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>
|
<button v-if="withOkButton" :class="$style.headerButton" class="_button" :disabled="okButtonDisabled" @click="emit('ok')"><i class="ti ti-check"></i></button>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.body">
|
<div :class="$style.body">
|
||||||
<slot :width="bodyWidth" :height="bodyHeight"></slot>
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.footer" :class="$style.footer">
|
||||||
|
<slot name="footer"></slot>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MkModal>
|
</MkModal>
|
||||||
|
@ -48,10 +51,6 @@ const emit = defineEmits<{
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const modal = useTemplateRef('modal');
|
const modal = useTemplateRef('modal');
|
||||||
const rootEl = useTemplateRef('rootEl');
|
|
||||||
const headerEl = useTemplateRef('headerEl');
|
|
||||||
const bodyWidth = ref(0);
|
|
||||||
const bodyHeight = ref(0);
|
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
modal.value?.close();
|
modal.value?.close();
|
||||||
|
@ -61,23 +60,6 @@ function onBgClick() {
|
||||||
emit('click');
|
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({
|
defineExpose({
|
||||||
close,
|
close,
|
||||||
});
|
});
|
||||||
|
@ -143,7 +125,14 @@ defineExpose({
|
||||||
.body {
|
.body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
background: var(--MI_THEME-panel);
|
background: var(--MI_THEME-bg);
|
||||||
container-type: size;
|
container-type: size;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 8px 16px;
|
||||||
|
overflow: auto;
|
||||||
|
background: var(--MI_THEME-bg);
|
||||||
|
border-top: 1px solid var(--MI_THEME-divider);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -127,7 +127,6 @@ import { i18n } from '@/i18n.js';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
import { ensureSignin, notesCount, incNotesCount } from '@/i.js';
|
import { ensureSignin, notesCount, incNotesCount } from '@/i.js';
|
||||||
import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js';
|
import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js';
|
||||||
import { uploadFile } from '@/utility/upload.js';
|
|
||||||
import { deepClone } from '@/utility/clone.js';
|
import { deepClone } from '@/utility/clone.js';
|
||||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
import { miLocalStorage } from '@/local-storage.js';
|
||||||
|
|
|
@ -0,0 +1,375 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MkModalWindow
|
||||||
|
ref="dialog"
|
||||||
|
:width="800"
|
||||||
|
:height="500"
|
||||||
|
@close="cancel()"
|
||||||
|
@closed="emit('closed')"
|
||||||
|
>
|
||||||
|
<template #header>
|
||||||
|
<i class="ti ti-upload"></i> {{ i18n.tsx.uploadNFiles({ n: files.length }) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div :class="$style.root" class="_gaps_s">
|
||||||
|
<div :class="$style.items" class="_gaps_s">
|
||||||
|
<div
|
||||||
|
v-for="ctx in items"
|
||||||
|
:key="ctx.id"
|
||||||
|
v-panel
|
||||||
|
:class="[$style.item, ctx.waiting ? $style.itemWaiting : null, ctx.uploaded ? $style.itemCompleted : null, ctx.uploadFailed ? $style.itemFailed : null]"
|
||||||
|
:style="{ '--p': ctx.progressValue !== null ? `${ctx.progressValue / ctx.progressMax * 100}%` : '0%' }"
|
||||||
|
>
|
||||||
|
<div :class="$style.itemInner">
|
||||||
|
<div>
|
||||||
|
<MkButton :iconOnly="true" rounded @click="showMenu($event, ctx)"><i class="ti ti-dots"></i></MkButton>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div>
|
||||||
|
<div :class="$style.itemBody">
|
||||||
|
<div>{{ ctx.name }}</div>
|
||||||
|
<div :class="$style.itemInfo">
|
||||||
|
<span>{{ bytes(ctx.file.size) }}</span>
|
||||||
|
<span v-if="ctx.compressedSize">({{ i18n.tsx._uploader.compressedToX({ x: bytes(ctx.compressedSize) }) }} = {{ i18n.tsx._uploader.savedXPercent({ x: Math.round((1 - ctx.compressedSize / ctx.file.size) * 100) }) }})</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<MkSystemIcon v-if="ctx.uploading" :class="$style.itemIcon" type="waiting"/>
|
||||||
|
<MkSystemIcon v-else-if="ctx.uploaded" :class="$style.itemIcon" type="success"/>
|
||||||
|
<MkSystemIcon v-else-if="ctx.uploadFailed" :class="$style.itemIcon" type="error"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MkSelect
|
||||||
|
v-if="items.length > 0"
|
||||||
|
v-model="compressionLevel"
|
||||||
|
:items="[
|
||||||
|
{ value: 0, label: i18n.ts.none },
|
||||||
|
{ value: 1, label: i18n.ts.low },
|
||||||
|
{ value: 2, label: i18n.ts.middle },
|
||||||
|
{ value: 3, label: i18n.ts.high },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<template #label>{{ i18n.ts.compress }}</template>
|
||||||
|
</MkSelect>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="_buttonsCenter">
|
||||||
|
<MkButton v-if="isUploading" rounded @click="cancel()"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
|
||||||
|
<MkButton v-else-if="!firstUploadAttempted" primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton>
|
||||||
|
<MkButton v-else-if="canRetry" primary rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</MkModalWindow>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { computed, markRaw, onMounted, ref, useTemplateRef, watch } 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 isAnimated from 'is-file-animated';
|
||||||
|
import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer';
|
||||||
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { prefer } from '@/preferences.js';
|
||||||
|
import { ensureSignin } from '@/i.js';
|
||||||
|
import { instance } from '@/instance.js';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import bytes from '@/filters/bytes.js';
|
||||||
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
|
import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
||||||
|
import { uploadFile } from '@/utility/upload.js';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
|
||||||
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
const compressionSupportedTypes = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/svg+xml',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
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;
|
||||||
|
waiting: boolean;
|
||||||
|
uploading: boolean;
|
||||||
|
uploaded: Misskey.entities.DriveFile | null;
|
||||||
|
uploadFailed: boolean;
|
||||||
|
compressedSize?: number | null;
|
||||||
|
compressedImage?: Blob | null;
|
||||||
|
file: File;
|
||||||
|
}[]);
|
||||||
|
|
||||||
|
const dialog = useTemplateRef('dialog');
|
||||||
|
|
||||||
|
const firstUploadAttempted = ref(false);
|
||||||
|
const isUploading = computed(() => items.value.some(item => item.uploading));
|
||||||
|
const canRetry = computed(() => firstUploadAttempted.value && !items.value.some(item => item.uploading || item.waiting) && items.value.some(item => item.uploaded == null));
|
||||||
|
|
||||||
|
const compressionLevel = ref<0 | 1 | 2 | 3>(2);
|
||||||
|
const compressionSettings = computed(() => {
|
||||||
|
if (compressionLevel.value === 1) {
|
||||||
|
return {
|
||||||
|
maxWidth: 2000,
|
||||||
|
maxHeight: 2000,
|
||||||
|
};
|
||||||
|
} else if (compressionLevel.value === 2) {
|
||||||
|
return {
|
||||||
|
maxWidth: 2000 * 0.75, // =1500
|
||||||
|
maxHeight: 2000 * 0.75, // =1500
|
||||||
|
};
|
||||||
|
} else if (compressionLevel.value === 3) {
|
||||||
|
return {
|
||||||
|
maxWidth: 2000 * 0.75 * 0.75, // =1125
|
||||||
|
maxHeight: 2000 * 0.75 * 0.75, // =1125
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(items, () => {
|
||||||
|
if (items.value.length === 0) {
|
||||||
|
dialog.value?.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.value.every(item => item.uploaded)) {
|
||||||
|
emit('done', items.value.map(item => item.uploaded!));
|
||||||
|
dialog.value?.close();
|
||||||
|
}
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
async function cancel() {
|
||||||
|
const { canceled } = await os.confirm({
|
||||||
|
type: 'question',
|
||||||
|
text: i18n.ts._uploader.abortConfirm,
|
||||||
|
okText: i18n.ts.yes,
|
||||||
|
cancelText: i18n.ts.no,
|
||||||
|
});
|
||||||
|
if (canceled) return;
|
||||||
|
|
||||||
|
emit('done', items.value.filter(item => item.uploaded != null).map(item => item.uploaded!));
|
||||||
|
dialog.value?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる
|
||||||
|
firstUploadAttempted.value = true;
|
||||||
|
|
||||||
|
for (const item of items.value.filter(item => item.uploaded == null)) {
|
||||||
|
item.waiting = true;
|
||||||
|
item.uploadFailed = false;
|
||||||
|
|
||||||
|
const shouldCompress = item.compressedImage == null && compressionLevel.value !== 0 && compressionSettings.value && compressionSupportedTypes.includes(item.file.type) && !(await isAnimated(item.file));
|
||||||
|
|
||||||
|
if (shouldCompress) {
|
||||||
|
const config = {
|
||||||
|
mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
|
||||||
|
maxWidth: compressionSettings.value.maxWidth,
|
||||||
|
maxHeight: compressionSettings.value.maxHeight,
|
||||||
|
quality: isWebpSupported() ? 0.85 : 0.8,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await readAndCompressImage(item.file, config);
|
||||||
|
if (result.size < item.file.size || item.file.type === 'image/webp') {
|
||||||
|
// The compression may not always reduce the file size
|
||||||
|
// (and WebP is not browser safe yet)
|
||||||
|
item.compressedImage = markRaw(result);
|
||||||
|
item.compressedSize = result.size;
|
||||||
|
item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to resize image', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
item.uploading = true;
|
||||||
|
|
||||||
|
const driveFile = await uploadFile(item.compressedImage ?? item.file, {
|
||||||
|
name: item.name,
|
||||||
|
folderId: props.folderId,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
item.waiting = false;
|
||||||
|
item.progressMax = progress.total;
|
||||||
|
item.progressValue = progress.loaded;
|
||||||
|
},
|
||||||
|
}).catch(err => {
|
||||||
|
item.uploadFailed = true;
|
||||||
|
item.progressMax = null;
|
||||||
|
item.progressValue = null;
|
||||||
|
throw err;
|
||||||
|
}).finally(() => {
|
||||||
|
item.uploading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
item.uploaded = driveFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
waiting: false,
|
||||||
|
uploading: false,
|
||||||
|
uploaded: null,
|
||||||
|
uploadFailed: false,
|
||||||
|
file: markRaw(file),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.root {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items {
|
||||||
|
}
|
||||||
|
|
||||||
|
.item {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: clip;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: var(--p);
|
||||||
|
height: 100%;
|
||||||
|
background: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
|
||||||
|
transition: width 0.2s ease, left 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.itemWaiting {
|
||||||
|
&::after {
|
||||||
|
--c: color(from var(--MI_THEME-accent) srgb r g b / 0.25);
|
||||||
|
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(-45deg, transparent 25%, var(--c) 25%,var(--c) 50%, transparent 50%, transparent 75%, var(--c) 75%, var(--c));
|
||||||
|
background-size: 25px 25px;
|
||||||
|
animation: stripe .8s infinite linear;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.itemCompleted {
|
||||||
|
&::before {
|
||||||
|
left: 100%;
|
||||||
|
width: var(--p);
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemBody {
|
||||||
|
color: var(--MI_THEME-accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.itemFailed {
|
||||||
|
.itemBody {
|
||||||
|
color: var(--MI_THEME-error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes stripe {
|
||||||
|
0% { background-position-x: 0; }
|
||||||
|
100% { background-position-x: -25px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemInner {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 8px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemThumbnail {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
background-color: var(--MI_THEME-bg);
|
||||||
|
background-size: contain;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemBody {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@container (max-width: 500px) {
|
||||||
|
.itemBody {
|
||||||
|
font-size: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemInfo {
|
||||||
|
opacity: 0.7;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 90%;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.itemIcon {
|
||||||
|
width: 35px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -28,13 +28,17 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.animLine]"/>
|
<path d="M96,63L63,96" style="--l:47;--duration:0.3s;--delay:0.2s;" :class="[$style.line, $style.animLine]"/>
|
||||||
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
|
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<svg v-else-if="type === 'waiting'" :class="[$style.icon, $style.waiting]" viewBox="0 0 160 160">
|
||||||
|
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircleWaiting]"/>
|
||||||
|
<circle cx="80" cy="80" r="56" style="opacity: 0.25;" :class="[$style.line]"/>
|
||||||
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {} from 'vue';
|
import {} from 'vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
type: 'info' | 'question' | 'success' | 'warn' | 'error';
|
type: 'info' | 'question' | 'success' | 'warn' | 'error' | 'waiting';
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -62,6 +66,10 @@ const props = defineProps<{
|
||||||
&.error {
|
&.error {
|
||||||
color: var(--MI_THEME-error);
|
color: var(--MI_THEME-error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.waiting {
|
||||||
|
color: var(--MI_THEME-accent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.line {
|
.line {
|
||||||
|
@ -87,6 +95,13 @@ const props = defineProps<{
|
||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.animCircleWaiting {
|
||||||
|
stroke-dasharray: var(--l);
|
||||||
|
stroke-dashoffset: calc(var(--l) / 1.5);
|
||||||
|
animation: waiting 0.75s linear infinite;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
.animFade {
|
.animFade {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
|
animation: fade-in var(--duration, 0.5s) cubic-bezier(0,0,.25,1) 1 forwards;
|
||||||
|
@ -104,6 +119,15 @@ const props = defineProps<{
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes waiting {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
|
@ -95,7 +95,6 @@ import MkButton from '@/components/MkButton.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { validators } from '@/components/grid/cell-validators.js';
|
import { validators } from '@/components/grid/cell-validators.js';
|
||||||
import { chooseFileFromDrive, chooseFileFromPc } from '@/utility/select-file.js';
|
import { chooseFileFromDrive, chooseFileFromPc } from '@/utility/select-file.js';
|
||||||
import { uploadFile } from '@/utility/upload.js';
|
|
||||||
import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js';
|
import { extractDroppedItems, flattenDroppedFiles } from '@/utility/file-drop.js';
|
||||||
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
|
import XRegisterLogs from '@/pages/admin/custom-emojis-manager.logs.vue';
|
||||||
import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
|
import { copyGridDataToClipboard } from '@/components/grid/grid-utils.js';
|
||||||
|
|
|
@ -41,7 +41,6 @@ import { formatTimeString } from '@/utility/format-time-string.js';
|
||||||
import { selectFile } from '@/utility/select-file.js';
|
import { selectFile } from '@/utility/select-file.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { uploadFile } from '@/utility/upload.js';
|
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
import { miLocalStorage } from '@/local-storage.js';
|
||||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
|
|
|
@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<MkSystemIcon v-if="iconType === 'success'" type="success" style="width: 150px;"/>
|
<MkSystemIcon v-if="iconType === 'success'" type="success" style="width: 150px;"/>
|
||||||
<MkSystemIcon v-if="iconType === 'warn'" type="warn" style="width: 150px;"/>
|
<MkSystemIcon v-if="iconType === 'warn'" type="warn" style="width: 150px;"/>
|
||||||
<MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 150px;"/>
|
<MkSystemIcon v-if="iconType === 'error'" type="error" style="width: 150px;"/>
|
||||||
|
<MkSystemIcon v-if="iconType === 'waiting'" type="waiting" style="width: 150px;"/>
|
||||||
<MkSelect
|
<MkSelect
|
||||||
v-model="iconType" :items="[
|
v-model="iconType" :items="[
|
||||||
{ label: 'info', value: 'info' },
|
{ label: 'info', value: 'info' },
|
||||||
|
@ -30,6 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
{ label: 'success', value: 'success' },
|
{ label: 'success', value: 'success' },
|
||||||
{ label: 'warn', value: 'warn' },
|
{ label: 'warn', value: 'warn' },
|
||||||
{ label: 'error', value: 'error' },
|
{ label: 'error', value: 'error' },
|
||||||
|
{ label: 'waiting', value: 'waiting' },
|
||||||
]"
|
]"
|
||||||
></MkSelect>
|
></MkSelect>
|
||||||
|
|
||||||
|
|
|
@ -65,8 +65,6 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
v-on="popup.events"
|
v-on="popup.events"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<XUpload v-if="uploads.length > 0"/>
|
|
||||||
|
|
||||||
<component
|
<component
|
||||||
:is="prefer.s.animation ? TransitionGroup : 'div'"
|
:is="prefer.s.animation ? TransitionGroup : 'div'"
|
||||||
tag="div"
|
tag="div"
|
||||||
|
@ -105,7 +103,6 @@ import { swInject } from './sw-inject.js';
|
||||||
import XNotification from './notification.vue';
|
import XNotification from './notification.vue';
|
||||||
import { popups } from '@/os.js';
|
import { popups } from '@/os.js';
|
||||||
import { pendingApiRequestsCount } from '@/utility/misskey-api.js';
|
import { pendingApiRequestsCount } from '@/utility/misskey-api.js';
|
||||||
import { uploads } from '@/utility/upload.js';
|
|
||||||
import * as sound from '@/utility/sound.js';
|
import * as sound from '@/utility/sound.js';
|
||||||
import { $i } from '@/i.js';
|
import { $i } from '@/i.js';
|
||||||
import { useStream } from '@/stream.js';
|
import { useStream } from '@/stream.js';
|
||||||
|
@ -116,7 +113,6 @@ import { store } from '@/store.js';
|
||||||
import XNavbar from '@/ui/_common_/navbar.vue';
|
import XNavbar from '@/ui/_common_/navbar.vue';
|
||||||
|
|
||||||
const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
|
const XStreamIndicator = defineAsyncComponent(() => import('./stream-indicator.vue'));
|
||||||
const XUpload = defineAsyncComponent(() => import('./upload.vue'));
|
|
||||||
const XWidgets = defineAsyncComponent(() => import('./widgets.vue'));
|
const XWidgets = defineAsyncComponent(() => import('./widgets.vue'));
|
||||||
|
|
||||||
const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing');
|
const drawerMenuShowing = defineModel<boolean>('drawerMenuShowing');
|
||||||
|
|
|
@ -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>
|
|
|
@ -3,25 +3,22 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* 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 Misskey from 'misskey-js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
import { useStream } from '@/stream.js';
|
import { useStream } from '@/stream.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { uploadFile } from '@/utility/upload.js';
|
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
|
|
||||||
export function chooseFileFromPc(
|
export function chooseFileFromPc(
|
||||||
multiple: boolean,
|
multiple: boolean,
|
||||||
options?: {
|
options?: {
|
||||||
uploadFolder?: string | null;
|
uploadFolder?: string | null;
|
||||||
keepOriginal?: boolean;
|
|
||||||
nameConverter?: (file: File) => string | undefined;
|
nameConverter?: (file: File) => string | undefined;
|
||||||
},
|
},
|
||||||
): Promise<Misskey.entities.DriveFile[]> {
|
): Promise<Misskey.entities.DriveFile[]> {
|
||||||
const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder;
|
const uploadFolder = options?.uploadFolder ?? prefer.s.uploadFolder;
|
||||||
const keepOriginal = options?.keepOriginal ?? false;
|
|
||||||
const nameConverter = options?.nameConverter ?? (() => undefined);
|
const nameConverter = options?.nameConverter ?? (() => undefined);
|
||||||
|
|
||||||
return new Promise((res, rej) => {
|
return new Promise((res, rej) => {
|
||||||
|
@ -30,15 +27,15 @@ export function chooseFileFromPc(
|
||||||
input.multiple = multiple;
|
input.multiple = multiple;
|
||||||
input.onchange = () => {
|
input.onchange = () => {
|
||||||
if (!input.files) return res([]);
|
if (!input.files) return res([]);
|
||||||
const promises = Array.from(
|
|
||||||
input.files,
|
|
||||||
file => uploadFile(file, uploadFolder, nameConverter(file), keepOriginal),
|
|
||||||
);
|
|
||||||
|
|
||||||
Promise.all(promises).then(driveFiles => {
|
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUploadDialog.vue')), {
|
||||||
res(driveFiles);
|
files: markRaw(Array.from(input.files)),
|
||||||
}).catch(err => {
|
folderId: uploadFolder,
|
||||||
// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
|
}, {
|
||||||
|
done: driveFiles => {
|
||||||
|
res(driveFiles);
|
||||||
|
},
|
||||||
|
closed: () => dispose(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 一応廃棄
|
// 一応廃棄
|
||||||
|
@ -100,10 +97,6 @@ function select(src: HTMLElement | EventTarget | null, label: string | null, mul
|
||||||
text: label,
|
text: label,
|
||||||
type: 'label',
|
type: 'label',
|
||||||
} : undefined, {
|
} : 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,
|
text: i18n.ts.upload,
|
||||||
icon: 'ti ti-upload',
|
icon: 'ti ti-upload',
|
||||||
action: () => chooseFileFromPc(multiple, { keepOriginal: true }).then(files => res(files)),
|
action: () => chooseFileFromPc(multiple, { keepOriginal: true }).then(files => res(files)),
|
||||||
|
|
|
@ -3,160 +3,95 @@
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { reactive, ref } from 'vue';
|
|
||||||
import * as Misskey from 'misskey-js';
|
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 { apiUrl } from '@@/js/config.js';
|
||||||
import { getCompressionConfig } from './upload/compress-config.js';
|
|
||||||
import { $i } from '@/i.js';
|
import { $i } from '@/i.js';
|
||||||
import { alert } from '@/os.js';
|
|
||||||
import { i18n } from '@/i18n.js';
|
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
import * as os from '@/os.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();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
export function uploadFile(file: File | Blob, options: {
|
||||||
|
name?: string;
|
||||||
|
folderId?: string | null;
|
||||||
|
onProgress?: (ctx: { total: number; loaded: number; }) => void;
|
||||||
|
} = {}): Promise<Misskey.entities.DriveFile> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const id = uuid();
|
if ($i == null) return reject();
|
||||||
|
|
||||||
const reader = new FileReader();
|
if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
|
||||||
reader.onload = async (): Promise<void> => {
|
os.alert({
|
||||||
const filename = name ?? file.name ?? 'untitled';
|
type: 'error',
|
||||||
const extension = filename.split('.').length > 1 ? '.' + filename.split('.').pop() : '';
|
title: i18n.ts.failedToUpload,
|
||||||
|
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
|
||||||
const ctx = reactive<Uploading>({
|
|
||||||
id,
|
|
||||||
name: prefer.s.keepOriginalFilename ? filename : id + extension,
|
|
||||||
progressMax: undefined,
|
|
||||||
progressValue: undefined,
|
|
||||||
img: window.URL.createObjectURL(file),
|
|
||||||
});
|
});
|
||||||
|
return reject();
|
||||||
|
}
|
||||||
|
|
||||||
uploads.value.push(ctx);
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('POST', apiUrl + '/drive/files/create', true);
|
||||||
const config = !keepOriginal ? await getCompressionConfig(file) : undefined;
|
xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
|
||||||
let resizedImage: Blob | undefined;
|
if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
|
||||||
if (config) {
|
if (xhr.status === 413) {
|
||||||
try {
|
os.alert({
|
||||||
const resized = await readAndCompressImage(file, config);
|
type: 'error',
|
||||||
if (resized.size < file.size || file.type === 'image/webp') {
|
title: i18n.ts.failedToUpload,
|
||||||
// The compression may not always reduce the file size
|
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
|
||||||
// (and WebP is not browser safe yet)
|
});
|
||||||
resizedImage = resized;
|
} else if (ev.target?.response) {
|
||||||
}
|
const res = JSON.parse(ev.target.response);
|
||||||
if (_DEV_) {
|
if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') {
|
||||||
const saved = ((1 - resized.size / file.size) * 100).toFixed(2);
|
os.alert({
|
||||||
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',
|
type: 'error',
|
||||||
title: i18n.ts.failedToUpload,
|
title: i18n.ts.failedToUpload,
|
||||||
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
|
text: i18n.ts.cannotUploadBecauseInappropriate,
|
||||||
});
|
});
|
||||||
} else if (ev.target?.response) {
|
} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
|
||||||
const res = JSON.parse(ev.target.response);
|
os.alert({
|
||||||
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',
|
type: 'error',
|
||||||
title: 'Failed to upload',
|
title: i18n.ts.failedToUpload,
|
||||||
text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
|
text: i18n.ts.cannotUploadBecauseNoFreeSpace,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.failedToUpload,
|
||||||
|
text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
reject();
|
os.alert({
|
||||||
return;
|
type: 'error',
|
||||||
|
title: 'Failed to upload',
|
||||||
|
text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const driveFile = JSON.parse(ev.target.response);
|
reject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
resolve(driveFile);
|
const driveFile = JSON.parse(ev.target.response);
|
||||||
|
resolve(driveFile);
|
||||||
uploads.value = uploads.value.filter(x => x.id !== id);
|
}) as (ev: ProgressEvent<EventTarget>) => any;
|
||||||
}) as (ev: ProgressEvent<EventTarget>) => any;
|
|
||||||
|
|
||||||
|
if (options.onProgress) {
|
||||||
xhr.upload.onprogress = ev => {
|
xhr.upload.onprogress = ev => {
|
||||||
if (ev.lengthComputable) {
|
if (ev.lengthComputable) {
|
||||||
ctx.progressMax = ev.total;
|
options.onProgress({
|
||||||
ctx.progressValue = ev.loaded;
|
total: ev.total,
|
||||||
|
loaded: ev.loaded,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
xhr.send(formData);
|
const formData = new FormData();
|
||||||
};
|
formData.append('i', $i.token);
|
||||||
reader.readAsArrayBuffer(file);
|
formData.append('force', 'true');
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('name', options.name ?? file.name ?? 'untitled');
|
||||||
|
if (options.folderId) formData.append('folderId', options.folderId);
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
/*
|
|
||||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only
|
|
||||||
*/
|
|
||||||
|
|
||||||
import isAnimated from 'is-file-animated';
|
|
||||||
import { isWebpSupported } from './isWebpSupported.js';
|
|
||||||
import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer';
|
|
||||||
|
|
||||||
const compressTypeMap = {
|
|
||||||
'image/jpeg': { quality: 0.90, mimeType: 'image/webp' },
|
|
||||||
'image/png': { quality: 1, mimeType: 'image/webp' },
|
|
||||||
'image/webp': { quality: 0.90, mimeType: 'image/webp' },
|
|
||||||
'image/svg+xml': { quality: 1, mimeType: 'image/webp' },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const compressTypeMapFallback = {
|
|
||||||
'image/jpeg': { quality: 0.85, mimeType: 'image/jpeg' },
|
|
||||||
'image/png': { quality: 1, mimeType: 'image/png' },
|
|
||||||
'image/webp': { quality: 0.85, mimeType: 'image/jpeg' },
|
|
||||||
'image/svg+xml': { quality: 1, mimeType: 'image/png' },
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export async function getCompressionConfig(file: File): Promise<BrowserImageResizerConfigWithConvertedOutput | undefined> {
|
|
||||||
const imgConfig = (isWebpSupported() ? compressTypeMap : compressTypeMapFallback)[file.type];
|
|
||||||
if (!imgConfig || await isAnimated(file)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
maxWidth: 2048,
|
|
||||||
maxHeight: 2048,
|
|
||||||
debug: true,
|
|
||||||
...imgConfig,
|
|
||||||
};
|
|
||||||
}
|
|
Loading…
Reference in New Issue