wip
This commit is contained in:
parent
51b5d740f6
commit
9d09d016bb
|
@ -1210,6 +1210,10 @@ export interface Locale extends ILocale {
|
||||||
* アップロードが完了するまで時間がかかる場合があります。
|
* アップロードが完了するまで時間がかかる場合があります。
|
||||||
*/
|
*/
|
||||||
"uploadFromUrlMayTakeTime": string;
|
"uploadFromUrlMayTakeTime": string;
|
||||||
|
/**
|
||||||
|
* {n}個のファイルをアップロード
|
||||||
|
*/
|
||||||
|
"uploadNFiles": ParameterizedString<"n">;
|
||||||
/**
|
/**
|
||||||
* みつける
|
* みつける
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -298,6 +298,7 @@ uploadFromUrl: "URLアップロード"
|
||||||
uploadFromUrlDescription: "アップロードしたいファイルのURL"
|
uploadFromUrlDescription: "アップロードしたいファイルのURL"
|
||||||
uploadFromUrlRequested: "アップロードをリクエストしました"
|
uploadFromUrlRequested: "アップロードをリクエストしました"
|
||||||
uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。"
|
uploadFromUrlMayTakeTime: "アップロードが完了するまで時間がかかる場合があります。"
|
||||||
|
uploadNFiles: "{n}個のファイルをアップロード"
|
||||||
explore: "みつける"
|
explore: "みつける"
|
||||||
messageRead: "既読"
|
messageRead: "既読"
|
||||||
noMoreHistory: "これより過去の履歴はありません"
|
noMoreHistory: "これより過去の履歴はありません"
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -111,7 +111,7 @@ 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 { uploadFile, uploa______ds } 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';
|
||||||
|
@ -145,7 +145,7 @@ const moreFolders = ref(false);
|
||||||
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||||
const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
|
const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
|
||||||
const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
|
||||||
const uploadings = uploads;
|
const uploadings = uploa______ds;
|
||||||
const connection = useStream().useChannel('drive');
|
const connection = useStream().useChannel('drive');
|
||||||
|
|
||||||
// ドロップされようとしているか
|
// ドロップされようとしているか
|
||||||
|
@ -625,12 +625,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',
|
||||||
|
|
|
@ -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 :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,
|
||||||
});
|
});
|
||||||
|
@ -146,4 +128,12 @@ defineExpose({
|
||||||
background: var(--MI_THEME-panel);
|
background: var(--MI_THEME-panel);
|
||||||
container-type: size;
|
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>
|
</style>
|
||||||
|
|
|
@ -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>
|
|
@ -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,7 +3,7 @@
|
||||||
* 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';
|
||||||
|
@ -16,12 +16,10 @@ 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 +28,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 => {
|
uploadFolder,
|
||||||
// アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない
|
}, {
|
||||||
|
done: driveFiles => {
|
||||||
|
res(driveFiles);
|
||||||
|
},
|
||||||
|
closed: () => dispose(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// 一応廃棄
|
// 一応廃棄
|
||||||
|
@ -100,10 +98,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)),
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
Loading…
Reference in New Issue