|  |  |  | @ -8,34 +8,44 @@ SPDX-License-Identifier: AGPL-3.0-only | 
		
	
		
			
				|  |  |  |  | 	ref="dialog" | 
		
	
		
			
				|  |  |  |  | 	:width="800" | 
		
	
		
			
				|  |  |  |  | 	:height="500" | 
		
	
		
			
				|  |  |  |  | 	@click="cancel()" | 
		
	
		
			
				|  |  |  |  | 	@close="cancel()" | 
		
	
		
			
				|  |  |  |  | 	@closed="emit('closed')" | 
		
	
		
			
				|  |  |  |  | > | 
		
	
		
			
				|  |  |  |  | 	<template #header> | 
		
	
		
			
				|  |  |  |  | 		{{ i18n.tsx.uploadNFiles({ n: files.length }) }} | 
		
	
		
			
				|  |  |  |  | 		<i class="ti ti-upload"></i> {{ 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 :class="$style.root" class="_gaps_s"> | 
		
	
		
			
				|  |  |  |  | 		<MkSwitch v-model="compress"> | 
		
	
		
			
				|  |  |  |  | 			<template #label>{{ i18n.ts.compress }}</template> | 
		
	
		
			
				|  |  |  |  | 		</MkSwitch> | 
		
	
		
			
				|  |  |  |  | 		<div :class="$style.items" class="_gaps_s"> | 
		
	
		
			
				|  |  |  |  | 			<div v-for="ctx in items" :key="ctx.id" v-panel :class="$style.item" :style="{ '--p': ctx.progressValue !== null ? `${ctx.progressValue / ctx.progressMax * 100}%` : '0%' }"> | 
		
	
		
			
				|  |  |  |  | 				<div :class="$style.itemInner"> | 
		
	
		
			
				|  |  |  |  | 					<div> | 
		
	
		
			
				|  |  |  |  | 						<MkButton :iconOnly="true" rounded><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 style="opacity: 0.7; margin-top: 4px; font-size: 90%;"> | 
		
	
		
			
				|  |  |  |  | 							<span>{{ bytes(ctx.file.size) }}</span> | 
		
	
		
			
				|  |  |  |  | 						</div> | 
		
	
		
			
				|  |  |  |  | 						<div> | 
		
	
		
			
				|  |  |  |  | 						</div> | 
		
	
		
			
				|  |  |  |  | 					</div> | 
		
	
		
			
				|  |  |  |  | 					<div> | 
		
	
		
			
				|  |  |  |  | 						<MkLoading v-if="ctx.uploading" :em="true"/> | 
		
	
		
			
				|  |  |  |  | 						<MkSystemIcon v-else-if="ctx.uploaded" type="success" style="width: 40px;"/> | 
		
	
		
			
				|  |  |  |  | 					</div> | 
		
	
		
			
				|  |  |  |  | 				</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 class="_buttonsCenter"> | 
		
	
		
			
				|  |  |  |  | 			<MkButton primary rounded @click="upload()"><i class="ti ti-upload"></i> {{ i18n.ts.upload }}</MkButton> | 
		
	
		
			
				|  |  |  |  | 		</div> | 
		
	
		
			
				|  |  |  |  | 	</template> | 
		
	
		
			
				|  |  |  |  | </MkModalWindow> | 
		
	
	
		
			
				
					|  |  |  | @ -46,13 +56,18 @@ 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 { apiUrl } from '@@/js/config.js'; | 
		
	
		
			
				|  |  |  |  | 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 { 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'; | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | const $i = ensureSignin(); | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | const mimeTypeMap = { | 
		
	
		
			
				|  |  |  |  | 	'image/webp': 'webp', | 
		
	
	
		
			
				
					|  |  |  | @ -78,12 +93,15 @@ const items = ref([] as { | 
		
	
		
			
				|  |  |  |  | 	progressMax: number | null; | 
		
	
		
			
				|  |  |  |  | 	progressValue: number | null; | 
		
	
		
			
				|  |  |  |  | 	thumbnail: string; | 
		
	
		
			
				|  |  |  |  | 	uploading: boolean; | 
		
	
		
			
				|  |  |  |  | 	uploaded: Misskey.entities.DriveFile | null; | 
		
	
		
			
				|  |  |  |  | 	file: File; | 
		
	
		
			
				|  |  |  |  | }[]); | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | const dialog = useTemplateRef('dialog'); | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | const compress = ref(true); | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | function cancel() { | 
		
	
		
			
				|  |  |  |  | 	// TODO: アップロードを中止しますか? | 
		
	
		
			
				|  |  |  |  | 	dialog.value?.close(); | 
		
	
	
		
			
				
					|  |  |  | @ -91,52 +109,51 @@ function cancel() { | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | function upload() { | 
		
	
		
			
				|  |  |  |  | 	for (const item of items.value) { | 
		
	
		
			
				|  |  |  |  | 		if ((file.size > instance.maxFileSize) || (file.size > ($i.policies.maxFileSizeMb * 1024 * 1024))) { | 
		
	
		
			
				|  |  |  |  | 		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, | 
		
	
		
			
				|  |  |  |  | 			}); | 
		
	
		
			
				|  |  |  |  | 			return Promise.reject(); | 
		
	
		
			
				|  |  |  |  | 			continue; | 
		
	
		
			
				|  |  |  |  | 		} | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | 		const reader = new FileReader(); | 
		
	
		
			
				|  |  |  |  | 		reader.onload = async (): Promise<void> => { | 
		
	
		
			
				|  |  |  |  | 			const config = !keepOriginal ? await getCompressionConfig(file) : undefined; | 
		
	
		
			
				|  |  |  |  | 			const config = compress.value ? await getCompressionConfig(item.file) : undefined; | 
		
	
		
			
				|  |  |  |  | 			let resizedImage: Blob | undefined; | 
		
	
		
			
				|  |  |  |  | 			if (config) { | 
		
	
		
			
				|  |  |  |  | 				try { | 
		
	
		
			
				|  |  |  |  | 					const resized = await readAndCompressImage(file, config); | 
		
	
		
			
				|  |  |  |  | 					if (resized.size < file.size || file.type === 'image/webp') { | 
		
	
		
			
				|  |  |  |  | 					const resized = await readAndCompressImage(item.file, config); | 
		
	
		
			
				|  |  |  |  | 					if (resized.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) | 
		
	
		
			
				|  |  |  |  | 						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}%`); | 
		
	
		
			
				|  |  |  |  | 						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}%`); | 
		
	
		
			
				|  |  |  |  | 					} | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | 					ctx.name = file.type !== config.mimeType ? `${ctx.name}.${mimeTypeMap[config.mimeType]}` : ctx.name; | 
		
	
		
			
				|  |  |  |  | 					item.name = item.file.type !== config.mimeType ? `${item.name}.${mimeTypeMap[config.mimeType]}` : item.name; | 
		
	
		
			
				|  |  |  |  | 				} catch (err) { | 
		
	
		
			
				|  |  |  |  | 					console.error('Failed to resize image', err); | 
		
	
		
			
				|  |  |  |  | 				} | 
		
	
		
			
				|  |  |  |  | 			} | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | 			const formData = new FormData(); | 
		
	
		
			
				|  |  |  |  | 			formData.append('i', $i!.token); | 
		
	
		
			
				|  |  |  |  | 			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); | 
		
	
		
			
				|  |  |  |  | 			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>) => { | 
		
	
		
			
				|  |  |  |  | 				if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { | 
		
	
		
			
				|  |  |  |  | 					// TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい | 
		
	
		
			
				|  |  |  |  | 					uploa______ds.value = uploa______ds.value.filter(x => x.id !== id); | 
		
	
		
			
				|  |  |  |  | 				item.uploading = false; | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | 				if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { | 
		
	
		
			
				|  |  |  |  | 					if (xhr.status === 413) { | 
		
	
		
			
				|  |  |  |  | 						alert({ | 
		
	
		
			
				|  |  |  |  | 							type: 'error', | 
		
	
	
		
			
				
					|  |  |  | @ -177,22 +194,19 @@ function upload() { | 
		
	
		
			
				|  |  |  |  | 				} | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | 				const driveFile = JSON.parse(ev.target.response); | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | 				resolve(driveFile); | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | 				uploa______ds.value = uploa______ds.value.filter(x => x.id !== id); | 
		
	
		
			
				|  |  |  |  | 				item.uploaded = driveFile; | 
		
	
		
			
				|  |  |  |  | 			}) as (ev: ProgressEvent<EventTarget>) => any; | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | 			xhr.upload.onprogress = ev => { | 
		
	
		
			
				|  |  |  |  | 				if (ev.lengthComputable) { | 
		
	
		
			
				|  |  |  |  | 					ctx.progressMax = ev.total; | 
		
	
		
			
				|  |  |  |  | 					ctx.progressValue = ev.loaded; | 
		
	
		
			
				|  |  |  |  | 					item.progressMax = ev.total; | 
		
	
		
			
				|  |  |  |  | 					item.progressValue = ev.loaded; | 
		
	
		
			
				|  |  |  |  | 				} | 
		
	
		
			
				|  |  |  |  | 			}; | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | 			xhr.send(formData); | 
		
	
		
			
				|  |  |  |  | 		}; | 
		
	
		
			
				|  |  |  |  | 		reader.readAsArrayBuffer(file); | 
		
	
		
			
				|  |  |  |  | 		reader.readAsArrayBuffer(item.file); | 
		
	
		
			
				|  |  |  |  | 	} | 
		
	
		
			
				|  |  |  |  | } | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
	
		
			
				
					|  |  |  | @ -215,23 +229,50 @@ onMounted(() => { | 
		
	
		
			
				|  |  |  |  | </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; | 
		
	
		
			
				|  |  |  |  | .root { | 
		
	
		
			
				|  |  |  |  | 	padding: 12px; | 
		
	
		
			
				|  |  |  |  | } | 
		
	
		
			
				|  |  |  |  | .mk-uploader > ol > li > progress::-webkit-progress-value { | 
		
	
		
			
				|  |  |  |  |   background: var(--MI_THEME-accent); | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | .items { | 
		
	
		
			
				|  |  |  |  | } | 
		
	
		
			
				|  |  |  |  | .mk-uploader > ol > li > progress::-webkit-progress-bar { | 
		
	
		
			
				|  |  |  |  |   //background: var(--MI_THEME-accentAlpha01); | 
		
	
		
			
				|  |  |  |  | 	background: transparent; | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | .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); | 
		
	
		
			
				|  |  |  |  | 	} | 
		
	
		
			
				|  |  |  |  | } | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | .itemInner { | 
		
	
		
			
				|  |  |  |  | 	position: relative; | 
		
	
		
			
				|  |  |  |  | 	z-index: 1; | 
		
	
		
			
				|  |  |  |  | 	padding: 8px 16px; | 
		
	
		
			
				|  |  |  |  | 	display: flex; | 
		
	
		
			
				|  |  |  |  | 	align-items: center; | 
		
	
		
			
				|  |  |  |  | 	gap: 8px; | 
		
	
		
			
				|  |  |  |  | } | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | .itemThumbnail { | 
		
	
		
			
				|  |  |  |  | 	width: 70px; | 
		
	
		
			
				|  |  |  |  | 	height: 70px; | 
		
	
		
			
				|  |  |  |  | 	background-size: contain; | 
		
	
		
			
				|  |  |  |  | 	background-position: center; | 
		
	
		
			
				|  |  |  |  | 	background-repeat: no-repeat; | 
		
	
		
			
				|  |  |  |  | 	border-radius: 6px; | 
		
	
		
			
				|  |  |  |  | } | 
		
	
		
			
				|  |  |  |  | 
 | 
		
	
		
			
				|  |  |  |  | .itemBody { | 
		
	
		
			
				|  |  |  |  | 	flex: 1; | 
		
	
		
			
				|  |  |  |  | 	min-width: 0; | 
		
	
		
			
				|  |  |  |  | } | 
		
	
		
			
				|  |  |  |  | </style> | 
		
	
	
		
			
				
					| 
							
							
							
						 |  |  | 
 |