Compare commits
4 Commits
785258da20
...
a2e1b8b41b
Author | SHA1 | Date |
---|---|---|
|
a2e1b8b41b | |
|
988ebb8aaa | |
|
5a8cd627ef | |
|
5c44e5ba59 |
|
@ -11902,6 +11902,16 @@ export interface Locale extends ILocale {
|
||||||
"text3": string;
|
"text3": string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
"_uploader": {
|
||||||
|
/**
|
||||||
|
* {x}に圧縮
|
||||||
|
*/
|
||||||
|
"compressedToX": ParameterizedString<"x">;
|
||||||
|
/**
|
||||||
|
* {x}%節約
|
||||||
|
*/
|
||||||
|
"savedXPercent": ParameterizedString<"x">;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
declare const locales: {
|
declare const locales: {
|
||||||
[lang: string]: Locale;
|
[lang: string]: Locale;
|
||||||
|
|
|
@ -3182,3 +3182,7 @@ _serverSetupWizard:
|
||||||
text1: "Misskeyは有志によって開発されている無料のソフトウェアです。"
|
text1: "Misskeyは有志によって開発されている無料のソフトウェアです。"
|
||||||
text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。"
|
text2: "今後も開発を続けられるように、よろしければぜひカンパをお願いいたします。"
|
||||||
text3: "支援者向け特典もあります!"
|
text3: "支援者向け特典もあります!"
|
||||||
|
|
||||||
|
_uploader:
|
||||||
|
compressedToX: "{x}に圧縮"
|
||||||
|
savedXPercent: "{x}%節約"
|
||||||
|
|
|
@ -17,23 +17,31 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<div :class="$style.root" class="_gaps_s">
|
<div :class="$style.root" class="_gaps_s">
|
||||||
<div :class="$style.items" 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]" :style="{ '--p': ctx.progressValue !== null ? `${ctx.progressValue / ctx.progressMax * 100}%` : '0%' }">
|
<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 :class="$style.itemInner">
|
||||||
<div>
|
<div>
|
||||||
<MkButton :iconOnly="true" rounded><i class="ti ti-dots"></i></MkButton>
|
<MkButton :iconOnly="true" rounded @click="showMenu($event, ctx)"><i class="ti ti-dots"></i></MkButton>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div>
|
<div :class="$style.itemThumbnail" :style="{ backgroundImage: `url(${ ctx.thumbnail })` }"></div>
|
||||||
<div :class="$style.itemBody">
|
<div :class="$style.itemBody">
|
||||||
<div>{{ ctx.name }}</div>
|
<div>{{ ctx.name }}</div>
|
||||||
<div style="opacity: 0.7; margin-top: 4px; font-size: 90%;">
|
<div :class="$style.itemInfo">
|
||||||
<span>{{ bytes(ctx.file.size) }}</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<MkLoading v-if="ctx.uploading" :em="true"/>
|
<MkSystemIcon v-if="ctx.uploading" :class="$style.itemIcon" type="waiting"/>
|
||||||
<MkSystemIcon v-else-if="ctx.uploaded" type="success" style="width: 40px;"/>
|
<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>
|
</div>
|
||||||
|
@ -55,8 +63,9 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="_buttonsCenter">
|
<div class="_buttonsCenter">
|
||||||
<MkButton v-if="uploadStarted" rounded @click=""><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
|
<MkButton v-if="isUploading" rounded @click=""><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
|
||||||
<MkButton v-else primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</MkModalWindow>
|
</MkModalWindow>
|
||||||
|
@ -68,7 +77,8 @@ import * as Misskey from 'misskey-js';
|
||||||
import { v4 as uuid } from 'uuid';
|
import { v4 as uuid } from 'uuid';
|
||||||
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
|
import { readAndCompressImage } from '@misskey-dev/browser-image-resizer';
|
||||||
import { apiUrl } from '@@/js/config.js';
|
import { apiUrl } from '@@/js/config.js';
|
||||||
import { getCompressionConfig } from '@/utility/upload/compress-config.js';
|
import isAnimated from 'is-file-animated';
|
||||||
|
import type { BrowserImageResizerConfigWithConvertedOutput } from '@misskey-dev/browser-image-resizer';
|
||||||
import MkModalWindow from '@/components/MkModalWindow.vue';
|
import MkModalWindow from '@/components/MkModalWindow.vue';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { prefer } from '@/preferences.js';
|
import { prefer } from '@/preferences.js';
|
||||||
|
@ -78,9 +88,18 @@ import MkButton from '@/components/MkButton.vue';
|
||||||
import bytes from '@/filters/bytes.js';
|
import bytes from '@/filters/bytes.js';
|
||||||
import MkSwitch from '@/components/MkSwitch.vue';
|
import MkSwitch from '@/components/MkSwitch.vue';
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
|
import { isWebpSupported } from '@/utility/isWebpSupported.js';
|
||||||
|
import { uploadFile } from '@/utility/upload.js';
|
||||||
|
|
||||||
const $i = ensureSignin();
|
const $i = ensureSignin();
|
||||||
|
|
||||||
|
const compressionSupportedTypes = [
|
||||||
|
'image/jpeg',
|
||||||
|
'image/png',
|
||||||
|
'image/webp',
|
||||||
|
'image/svg+xml',
|
||||||
|
] as const;
|
||||||
|
|
||||||
const mimeTypeMap = {
|
const mimeTypeMap = {
|
||||||
'image/webp': 'webp',
|
'image/webp': 'webp',
|
||||||
'image/jpeg': 'jpg',
|
'image/jpeg': 'jpg',
|
||||||
|
@ -108,29 +127,34 @@ const items = ref([] as {
|
||||||
waiting: boolean;
|
waiting: boolean;
|
||||||
uploading: boolean;
|
uploading: boolean;
|
||||||
uploaded: Misskey.entities.DriveFile | null;
|
uploaded: Misskey.entities.DriveFile | null;
|
||||||
|
uploadFailed: boolean;
|
||||||
|
compressedSize?: number | null;
|
||||||
|
compressedImage?: Blob | null;
|
||||||
file: File;
|
file: File;
|
||||||
}[]);
|
}[]);
|
||||||
|
|
||||||
const dialog = useTemplateRef('dialog');
|
const dialog = useTemplateRef('dialog');
|
||||||
|
|
||||||
const uploadStarted = ref(false);
|
const firstUploadAttempted = ref(false);
|
||||||
const compressionLevel = ref<0 | 1 | 2 | 3>(2);
|
const isUploading = computed(() => items.value.some(item => item.uploading));
|
||||||
|
const canRetry = computed(() => firstUploadAttempted.value && !isUploading.value && items.value.some(item => item.uploaded == null));
|
||||||
|
|
||||||
|
const compressionLevel = ref<0 | 1 | 2 | 3>(2);
|
||||||
const compressionSettings = computed(() => {
|
const compressionSettings = computed(() => {
|
||||||
if (compressionLevel.value === 1) {
|
if (compressionLevel.value === 1) {
|
||||||
return {
|
return {
|
||||||
maxWidth: 1024 + 512,
|
maxWidth: 2000,
|
||||||
maxHeight: 1024 + 512,
|
maxHeight: 2000,
|
||||||
};
|
};
|
||||||
} else if (compressionLevel.value === 2) {
|
} else if (compressionLevel.value === 2) {
|
||||||
return {
|
return {
|
||||||
maxWidth: 1024 + 256,
|
maxWidth: 2000 * 0.75, // =1500
|
||||||
maxHeight: 1024 + 256,
|
maxHeight: 2000 * 0.75, // =1500
|
||||||
};
|
};
|
||||||
} else if (compressionLevel.value === 3) {
|
} else if (compressionLevel.value === 3) {
|
||||||
return {
|
return {
|
||||||
maxWidth: 1024,
|
maxWidth: 2000 * 0.75 * 0.75, // =1125
|
||||||
maxHeight: 1024,
|
maxHeight: 2000 * 0.75 * 0.75, // =1125
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return null;
|
return null;
|
||||||
|
@ -138,7 +162,12 @@ const compressionSettings = computed(() => {
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(items, () => {
|
watch(items, () => {
|
||||||
if (uploadStarted.value && items.value.every(item => item.uploaded)) {
|
if (items.value.length === 0) {
|
||||||
|
dialog.value?.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (items.value.every(item => item.uploaded)) {
|
||||||
emit('done', items.value.map(item => item.uploaded!));
|
emit('done', items.value.map(item => item.uploaded!));
|
||||||
dialog.value?.close();
|
dialog.value?.close();
|
||||||
}
|
}
|
||||||
|
@ -149,112 +178,61 @@ function cancel() {
|
||||||
dialog.value?.close();
|
dialog.value?.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function upload() {
|
function showMenu(ev: MouseEvent, item: typeof items.value[0]) {
|
||||||
uploadStarted.value = true;
|
|
||||||
|
|
||||||
for (const item of items.value) {
|
}
|
||||||
if ((item.file.size > instance.maxFileSize) || (item.file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
|
|
||||||
alert({
|
|
||||||
type: 'error',
|
|
||||||
title: i18n.ts.failedToUpload,
|
|
||||||
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
async function upload() { // エラーハンドリングなどを考慮してシーケンシャルにやる
|
||||||
|
firstUploadAttempted.value = true;
|
||||||
|
|
||||||
|
for (const item of items.value.filter(item => item.uploaded == null)) {
|
||||||
item.waiting = true;
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onload = async (): Promise<void> => {
|
|
||||||
const config = compressionLevel.value !== 0 ? await getCompressionConfig(item.file, compressionSettings.value) : undefined;
|
|
||||||
let resizedImage: Blob | undefined;
|
|
||||||
if (config) {
|
|
||||||
try {
|
try {
|
||||||
const resized = await readAndCompressImage(item.file, config);
|
const result = await readAndCompressImage(item.file, config);
|
||||||
if (resized.size < item.file.size || item.file.type === 'image/webp') {
|
if (result.size < item.file.size || item.file.type === 'image/webp') {
|
||||||
// The compression may not always reduce the file size
|
// The compression may not always reduce the file size
|
||||||
// (and WebP is not browser safe yet)
|
// (and WebP is not browser safe yet)
|
||||||
resizedImage = resized;
|
item.compressedImage = markRaw(result);
|
||||||
}
|
item.compressedSize = result.size;
|
||||||
if (_DEV_) {
|
|
||||||
const saved = ((1 - resized.size / item.file.size) * 100).toFixed(2);
|
|
||||||
console.log(`Image compression: before ${item.file.size} bytes, after ${resized.size} bytes, saved ${saved}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
|
item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to resize image', 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 ?? item.file);
|
|
||||||
formData.append('name', item.name);
|
|
||||||
if (props.folderId) formData.append('folderId', props.folderId);
|
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
|
||||||
xhr.open('POST', apiUrl + '/drive/files/create', true);
|
|
||||||
xhr.onload = ((ev: ProgressEvent<XMLHttpRequest>) => {
|
|
||||||
item.uploading = false;
|
|
||||||
|
|
||||||
if (xhr.status !== 200 || ev.target == null || ev.target.response == null) {
|
|
||||||
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);
|
|
||||||
item.uploaded = driveFile;
|
|
||||||
}) as (ev: ProgressEvent<EventTarget>) => any;
|
|
||||||
|
|
||||||
xhr.upload.onprogress = ev => {
|
|
||||||
if (ev.lengthComputable) {
|
|
||||||
item.waiting = false;
|
|
||||||
item.progressMax = ev.total;
|
|
||||||
item.progressValue = ev.loaded;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
xhr.send(formData);
|
|
||||||
item.uploading = true;
|
item.uploading = true;
|
||||||
};
|
|
||||||
reader.readAsArrayBuffer(item.file);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -272,6 +250,7 @@ onMounted(() => {
|
||||||
waiting: false,
|
waiting: false,
|
||||||
uploading: false,
|
uploading: false,
|
||||||
uploaded: null,
|
uploaded: null,
|
||||||
|
uploadFailed: false,
|
||||||
file: markRaw(file),
|
file: markRaw(file),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -300,7 +279,7 @@ onMounted(() => {
|
||||||
width: var(--p);
|
width: var(--p);
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
|
background: color(from var(--MI_THEME-accent) srgb r g b / 0.5);
|
||||||
transition: width 0.2s ease;
|
transition: width 0.2s ease, left 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.itemWaiting {
|
&.itemWaiting {
|
||||||
|
@ -319,6 +298,23 @@ onMounted(() => {
|
||||||
animation: stripe .8s infinite linear;
|
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 {
|
@keyframes stripe {
|
||||||
|
@ -338,6 +334,7 @@ onMounted(() => {
|
||||||
.itemThumbnail {
|
.itemThumbnail {
|
||||||
width: 70px;
|
width: 70px;
|
||||||
height: 70px;
|
height: 70px;
|
||||||
|
background-color: var(--MI_THEME-bg);
|
||||||
background-size: contain;
|
background-size: contain;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
|
@ -348,4 +345,22 @@ onMounted(() => {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
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>
|
</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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,97 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
|
import { apiUrl } from '@@/js/config.js';
|
||||||
|
import { $i } from '@/i.js';
|
||||||
|
import { instance } from '@/instance.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
|
||||||
|
export function uploadFile(file: File, options: {
|
||||||
|
name?: string;
|
||||||
|
folderId?: string | null;
|
||||||
|
onProgress?: (ctx: { total: number; loaded: number; }) => void;
|
||||||
|
} = {}): Promise<Misskey.entities.DriveFile> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if ($i == null) return reject();
|
||||||
|
|
||||||
|
if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) {
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.failedToUpload,
|
||||||
|
text: i18n.ts.cannotUploadBecauseExceedsFileSizeLimit,
|
||||||
|
});
|
||||||
|
return reject();
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (xhr.status === 413) {
|
||||||
|
os.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') {
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.failedToUpload,
|
||||||
|
text: i18n.ts.cannotUploadBecauseInappropriate,
|
||||||
|
});
|
||||||
|
} else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') {
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
title: i18n.ts.failedToUpload,
|
||||||
|
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 {
|
||||||
|
os.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);
|
||||||
|
}) as (ev: ProgressEvent<EventTarget>) => any;
|
||||||
|
|
||||||
|
if (options.onProgress) {
|
||||||
|
xhr.upload.onprogress = ev => {
|
||||||
|
if (ev.lengthComputable) {
|
||||||
|
options.onProgress({
|
||||||
|
total: ev.total,
|
||||||
|
loaded: ev.loaded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('i', $i.token);
|
||||||
|
formData.append('force', 'true');
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('name', options.name ?? file.name);
|
||||||
|
if (options.folderId) formData.append('folderId', options.folderId);
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
}
|
|
@ -1,29 +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 supportedTypes = [
|
|
||||||
'image/jpeg',
|
|
||||||
'image/png',
|
|
||||||
'image/webp',
|
|
||||||
'image/svg+xml',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export async function getCompressionConfig(file: File, options: Partial<{ maxWidth: number; maxHeight: number; }> = {}): Promise<BrowserImageResizerConfigWithConvertedOutput | undefined> {
|
|
||||||
if (!supportedTypes.includes(file.type) || await isAnimated(file)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
mimeType: isWebpSupported() ? 'image/webp' : 'image/jpeg',
|
|
||||||
maxWidth: options.maxWidth ?? 2048,
|
|
||||||
maxHeight: options.maxHeight ?? 2048,
|
|
||||||
quality: isWebpSupported() ? 0.9 : 0.85,
|
|
||||||
debug: true,
|
|
||||||
};
|
|
||||||
}
|
|
Loading…
Reference in New Issue