summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components/MkUploaderDialog.vue
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/components/MkUploaderDialog.vue')
-rw-r--r--packages/frontend/src/components/MkUploaderDialog.vue505
1 files changed, 505 insertions, 0 deletions
diff --git a/packages/frontend/src/components/MkUploaderDialog.vue b/packages/frontend/src/components/MkUploaderDialog.vue
new file mode 100644
index 0000000000..3a83247d4b
--- /dev/null
+++ b/packages/frontend/src/components/MkUploaderDialog.vue
@@ -0,0 +1,505 @@
+<!--
+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">
+ <div :class="[$style.overallProgress, canRetry ? $style.overallProgressError : null]" :style="{ '--op': `${overallProgress}%` }"></div>
+
+ <div :class="$style.main" 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.progress != null ? `${ctx.progress.value / ctx.progress.max * 100}%` : '0%' }"
+ >
+ <div :class="$style.itemInner">
+ <div :class="$style.itemActionWrapper">
+ <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><MkCondensedLine :minScale="2 / 3">{{ ctx.name }}</MkCondensedLine></div>
+ <div :class="$style.itemInfo">
+ <span>{{ ctx.file.type }}</span>
+ <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 :class="$style.itemIconWrapper">
+ <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>
+
+ <div v-if="props.multiple">
+ <MkButton style="margin: auto;" :iconOnly="true" rounded @click="chooseFile($event)"><i class="ti ti-plus"></i></MkButton>
+ </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>{{ i18n.tsx._uploader.maxFileSizeIsX({ x: $i.policies.maxFileSizeMb + 'MB' }) }}</div>
+ </div>
+ </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-if="canRetry" rounded @click="upload()"><i class="ti ti-reload"></i> {{ i18n.ts.retry }}</MkButton>
+ <MkButton v-if="canDone" rounded @click="done()"><i class="ti ti-arrow-right"></i> {{ i18n.ts.done }}</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 isAnimated from 'is-file-animated';
+import type { MenuItem } from '@/types/menu.js';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import { i18n } from '@/i18n.js';
+import { prefer } from '@/preferences.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/drive.js';
+import * as os from '@/os.js';
+import { ensureSignin } from '@/i.js';
+
+const $i = ensureSignin();
+
+const COMPRESSION_SUPPORTED_TYPES = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/webp',
+ 'image/svg+xml',
+];
+
+const CROPPING_SUPPORTED_TYPES = [
+ 'image/jpeg',
+ 'image/png',
+ 'image/webp',
+];
+
+const mimeTypeMap = {
+ 'image/webp': 'webp',
+ 'image/jpeg': 'jpg',
+ 'image/png': 'png',
+} as const;
+
+const props = withDefaults(defineProps<{
+ files: File[];
+ folderId?: string | null;
+ multiple?: boolean;
+}>(), {
+ multiple: true,
+});
+
+const emit = defineEmits<{
+ (ev: 'done', driveFiles: Misskey.entities.DriveFile[]): void;
+ (ev: 'canceled'): void;
+ (ev: 'closed'): void;
+}>();
+
+const items = ref([] as {
+ id: string;
+ name: string;
+ progress: { max: number; value: 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 canDone = computed(() => items.value.some(item => item.uploaded != null));
+const overallProgress = computed(() => {
+ const max = items.value.length;
+ if (max === 0) return 0;
+ const v = items.value.reduce((acc, item) => {
+ if (item.uploaded) return acc + 1;
+ if (item.progress) return acc + (item.progress.value / item.progress.max);
+ return acc;
+ }, 0);
+ return Math.round((v / max) * 100);
+});
+
+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) {
+ emit('canceled');
+ 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('canceled');
+ dialog.value?.close();
+}
+
+async function done() {
+ if (items.value.some(item => item.uploaded == null)) {
+ const { canceled } = await os.confirm({
+ type: 'question',
+ text: i18n.ts._uploader.doneConfirm,
+ 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]) {
+ const menu: MenuItem[] = [];
+
+ if (CROPPING_SUPPORTED_TYPES.includes(item.file.type) && !item.waiting && !item.uploading && !item.uploaded) {
+ menu.push({
+ icon: 'ti ti-crop',
+ text: i18n.ts.cropImage,
+ action: async () => {
+ const cropped = await os.cropImageFile(item.file, { aspectRatio: null });
+ items.value.splice(items.value.indexOf(item), 1, {
+ ...item,
+ file: markRaw(cropped),
+ thumbnail: window.URL.createObjectURL(cropped),
+ });
+ },
+ });
+ }
+
+ if (!item.waiting && !item.uploading && !item.uploaded) {
+ menu.push({
+ icon: 'ti ti-x',
+ text: i18n.ts.remove,
+ action: () => {
+ items.value.splice(items.value.indexOf(item), 1);
+ },
+ });
+ }
+
+ os.popupMenu(menu, ev.currentTarget ?? ev.target);
+}
+
+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 && COMPRESSION_SUPPORTED_TYPES.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;
+ if (item.progress == null) {
+ item.progress = { max: progress.total, value: progress.loaded };
+ } else {
+ item.progress.value = progress.loaded;
+ item.progress.max = progress.total;
+ }
+ },
+ }).catch(err => {
+ item.uploadFailed = true;
+ item.progress = null;
+ throw err;
+ }).finally(() => {
+ item.uploading = false;
+ item.waiting = false;
+ });
+
+ item.uploaded = driveFile;
+ }
+}
+
+async function chooseFile(ev: MouseEvent) {
+ const newFiles = await os.chooseFileFromPc({ multiple: true });
+
+ for (const file of newFiles) {
+ initializeFile(file);
+ }
+}
+
+function initializeFile(file: File) {
+ 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,
+ progress: null,
+ thumbnail: window.URL.createObjectURL(file),
+ waiting: false,
+ uploading: false,
+ uploaded: null,
+ uploadFailed: false,
+ file: markRaw(file),
+ });
+}
+
+onMounted(() => {
+ for (const file of props.files) {
+ initializeFile(file);
+ }
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ position: relative;
+}
+
+.overallProgress {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: var(--op);
+ height: 4px;
+ background: var(--MI_THEME-accent);
+ border-radius: 0 999px 999px 0;
+ transition: width 0.2s ease;
+
+ &.overallProgressError {
+ background: var(--MI_THEME-warn);
+ }
+}
+
+.main {
+ 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;
+}
+
+.itemInfo {
+ opacity: 0.7;
+ margin-top: 4px;
+ font-size: 90%;
+ display: flex;
+ gap: 8px;
+}
+
+.itemIcon {
+ width: 35px;
+}
+
+@container (max-width: 500px) {
+ .itemInner {
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .itemBody {
+ font-size: 90%;
+ text-align: center;
+ width: 100%;
+ min-width: 0;
+ }
+
+ .itemActionWrapper {
+ position: absolute;
+ top: 8px;
+ left: 8px;
+ }
+
+ .itemInfo {
+ justify-content: center;
+ }
+
+ .itemIconWrapper {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ }
+}
+</style>