summaryrefslogtreecommitdiff
path: root/packages/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/frontend/src/components')
-rw-r--r--packages/frontend/src/components/MkCropperDialog.vue65
-rw-r--r--packages/frontend/src/components/MkDrive.file.vue45
-rw-r--r--packages/frontend/src/components/MkDrive.folder.vue155
-rw-r--r--packages/frontend/src/components/MkDrive.navFolder.vue79
-rw-r--r--packages/frontend/src/components/MkDrive.vue795
-rw-r--r--packages/frontend/src/components/MkDriveFileSelectDialog.stories.impl.ts (renamed from packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts)2
-rw-r--r--packages/frontend/src/components/MkDriveFileSelectDialog.vue (renamed from packages/frontend/src/components/MkDriveSelectDialog.vue)20
-rw-r--r--packages/frontend/src/components/MkDriveFolderSelectDialog.vue63
-rw-r--r--packages/frontend/src/components/MkDriveWindow.vue6
-rw-r--r--packages/frontend/src/components/MkFormDialog.file.vue2
-rw-r--r--packages/frontend/src/components/MkModalWindow.vue37
-rw-r--r--packages/frontend/src/components/MkPostForm.vue53
-rw-r--r--packages/frontend/src/components/MkPostFormAttaches.vue18
-rw-r--r--packages/frontend/src/components/MkPreview.vue11
-rw-r--r--packages/frontend/src/components/MkUploaderDialog.vue505
-rw-r--r--packages/frontend/src/components/MkUserSetupDialog.Profile.vue44
-rw-r--r--packages/frontend/src/components/global/MkSystemIcon.vue26
17 files changed, 1235 insertions, 691 deletions
diff --git a/packages/frontend/src/components/MkCropperDialog.vue b/packages/frontend/src/components/MkCropperDialog.vue
index ba21394cbc..7f592fba79 100644
--- a/packages/frontend/src/components/MkCropperDialog.vue
+++ b/packages/frontend/src/components/MkCropperDialog.vue
@@ -15,18 +15,16 @@ SPDX-License-Identifier: AGPL-3.0-only
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.cropImage }}</template>
- <template #default="{ width, height }">
- <div class="mk-cropper-dialog" :style="`--vw: ${width}px; --vh: ${height}px;`">
- <Transition name="fade">
- <div v-if="loading" class="loading">
- <MkLoading/>
- </div>
- </Transition>
- <div class="container">
- <img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
+ <div class="mk-cropper-dialog" :style="`--vw: 100%; --vh: 100%;`">
+ <Transition name="fade">
+ <div v-if="loading" class="loading">
+ <MkLoading/>
</div>
+ </Transition>
+ <div class="container">
+ <img ref="imgEl" :src="imgUrl" style="display: none;" @load="onImageLoad">
</div>
- </template>
+ </div>
</MkModalWindow>
</template>
@@ -35,27 +33,23 @@ import { onMounted, useTemplateRef, ref } from 'vue';
import * as Misskey from 'misskey-js';
import Cropper from 'cropperjs';
import tinycolor from 'tinycolor2';
-import { apiUrl } from '@@/js/config.js';
import MkModalWindow from '@/components/MkModalWindow.vue';
import * as os from '@/os.js';
-import { $i } from '@/i.js';
import { i18n } from '@/i18n.js';
-import { getProxiedImageUrl } from '@/utility/media-proxy.js';
-import { prefer } from '@/preferences.js';
+
+const props = defineProps<{
+ imageFile: File | Blob;
+ aspectRatio: number | null;
+ uploadFolder?: string | null;
+}>();
const emit = defineEmits<{
- (ev: 'ok', cropped: Misskey.entities.DriveFile): void;
+ (ev: 'ok', cropped: File | Blob): void;
(ev: 'cancel'): void;
(ev: 'closed'): void;
}>();
-const props = defineProps<{
- file: Misskey.entities.DriveFile;
- aspectRatio: number;
- uploadFolder?: string | null;
-}>();
-
-const imgUrl = getProxiedImageUrl(props.file.url, undefined, true);
+const imgUrl = URL.createObjectURL(props.imageFile);
const dialogEl = useTemplateRef('dialogEl');
const imgEl = useTemplateRef('imgEl');
let cropper: Cropper | null = null;
@@ -73,31 +67,10 @@ const ok = async () => {
const croppedCanvas = await croppedSection?.$toCanvas({ width: widthToRender });
croppedCanvas?.toBlob(blob => {
if (!blob) return;
- const formData = new FormData();
- formData.append('file', blob);
- formData.append('name', `cropped_${props.file.name}`);
- formData.append('isSensitive', props.file.isSensitive ? 'true' : 'false');
- if (props.file.comment) { formData.append('comment', props.file.comment);}
- formData.append('i', $i!.token);
- if (props.uploadFolder) {
- formData.append('folderId', props.uploadFolder);
- } else if (props.uploadFolder !== null && prefer.s.uploadFolder) {
- formData.append('folderId', prefer.s.uploadFolder);
- }
-
- window.fetch(apiUrl + '/drive/files/create', {
- method: 'POST',
- body: formData,
- })
- .then(response => response.json())
- .then(f => {
- res(f);
- });
+ res(blob);
});
});
- os.promiseDialog(promise);
-
const f = await promise;
emit('ok', f);
@@ -126,8 +99,8 @@ onMounted(() => {
const selection = cropper.getCropperSelection()!;
selection.themeColor = tinycolor(computedStyle.getPropertyValue('--MI_THEME-accent')).toHexString();
- selection.aspectRatio = props.aspectRatio;
- selection.initialAspectRatio = props.aspectRatio;
+ if (props.aspectRatio != null) selection.aspectRatio = props.aspectRatio;
+ selection.initialAspectRatio = props.aspectRatio ?? 1;
selection.outlined = true;
window.setTimeout(() => {
diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue
index 70ab60cfae..bbb6a7ea2a 100644
--- a/packages/frontend/src/components/MkDrive.file.vue
+++ b/packages/frontend/src/components/MkDrive.file.vue
@@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root, { [$style.isSelected]: isSelected }]"
draggable="true"
:title="title"
- @click="onClick"
@contextmenu.stop="onContextmenu"
@dragstart="onDragstart"
@dragend="onDragend"
@@ -46,24 +45,18 @@ import * as os from '@/os.js';
import { i18n } from '@/i18n.js';
import { $i } from '@/i.js';
import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
-import { deviceKind } from '@/utility/device-kind.js';
-import { useRouter } from '@/router.js';
-
-const router = useRouter();
+import { setDragData } from '@/drag-and-drop.js';
const props = withDefaults(defineProps<{
file: Misskey.entities.DriveFile;
folder: Misskey.entities.DriveFolder | null;
isSelected?: boolean;
- selectMode?: boolean;
}>(), {
isSelected: false,
- selectMode: false,
});
const emit = defineEmits<{
- (ev: 'chosen', r: Misskey.entities.DriveFile): void;
- (ev: 'dragstart'): void;
+ (ev: 'dragstart', dragEvent: DragEvent): void;
(ev: 'dragend'): void;
}>();
@@ -71,18 +64,6 @@ const isDragging = ref(false);
const title = computed(() => `${props.file.name}\n${props.file.type} ${bytes(props.file.size)}`);
-function onClick(ev: MouseEvent) {
- if (props.selectMode) {
- emit('chosen', props.file);
- } else {
- if (deviceKind === 'desktop') {
- router.push(`/my/drive/file/${props.file.id}`);
- } else {
- os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
- }
- }
-}
-
function onContextmenu(ev: MouseEvent) {
os.contextMenu(getDriveFileMenu(props.file, props.folder), ev);
}
@@ -90,11 +71,11 @@ function onContextmenu(ev: MouseEvent) {
function onDragstart(ev: DragEvent) {
if (ev.dataTransfer) {
ev.dataTransfer.effectAllowed = 'move';
- ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FILE_, JSON.stringify(props.file));
+ setDragData(ev, 'driveFiles', [props.file]);
}
isDragging.value = true;
- emit('dragstart');
+ emit('dragstart', ev);
}
function onDragend() {
@@ -114,7 +95,7 @@ function onDragend() {
&:hover {
background: rgba(#000, 0.05);
- > .label {
+ .label {
&::before,
&::after {
background: #0b65a5;
@@ -132,7 +113,7 @@ function onDragend() {
&:active {
background: rgba(#000, 0.1);
- > .label {
+ .label {
&::before,
&::after {
background: #0b588c;
@@ -158,19 +139,19 @@ function onDragend() {
background: hsl(from var(--MI_THEME-accent) h s calc(l - 10));
}
- > .label {
+ .label {
&::before,
&::after {
display: none;
}
}
- > .name {
- color: #fff;
+ .name {
+ color: var(--MI_THEME-fgOnAccent);
}
- > .thumbnail {
- color: #fff;
+ .thumbnail {
+ color: var(--MI_THEME-fgOnAccent);
}
}
}
@@ -240,8 +221,8 @@ function onDragend() {
.name {
display: block;
- margin: 4px 0 0 0;
- font-size: 0.8em;
+ margin: 8px 0 0 0;
+ font-size: 82%;
text-align: center;
word-break: break-all;
color: var(--MI_THEME-fg);
diff --git a/packages/frontend/src/components/MkDrive.folder.vue b/packages/frontend/src/components/MkDrive.folder.vue
index 9c72691d21..83472eec3d 100644
--- a/packages/frontend/src/components/MkDrive.folder.vue
+++ b/packages/frontend/src/components/MkDrive.folder.vue
@@ -8,7 +8,6 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="[$style.root, { [$style.draghover]: draghover }]"
draggable="true"
:title="title"
- @click="onClick"
@contextmenu.stop="onContextmenu"
@mouseover="onMouseover"
@mouseout="onMouseout"
@@ -19,14 +18,13 @@ SPDX-License-Identifier: AGPL-3.0-only
@dragstart="onDragstart"
@dragend="onDragend"
>
- <p :class="$style.name">
- <template v-if="hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
- <template v-if="!hover"><i :class="$style.icon" class="ti ti-folder ti-fw"></i></template>
- {{ folder.name }}
- </p>
- <p v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
+ <svg :class="[$style.shape]" viewBox="0 0 200 150" preserveAspectRatio="none">
+ <path d="M190,25C195.523,25 200,29.477 200,35C200,58.415 200,116.585 200,140C200,145.523 195.523,150 190,150C155.86,150 44.14,150 10,150C4.477,150 0,145.523 0,140C0,112.727 0,37.273 0,10C0,4.477 4.477,0 10,-0C26.642,0 59.332,0 70.858,0C73.51,-0 76.054,1.054 77.929,2.929C82.74,7.74 92.26,17.26 97.071,22.071C98.946,23.946 101.49,25 104.142,25C118.808,25 168.535,25 190,25Z" style="fill:var(--MI_THEME-accentedBg);"/>
+ </svg>
+ <div :class="$style.name">{{ folder.name }}</div>
+ <div v-if="prefer.s.uploadFolder == folder.id" :class="$style.upload">
{{ i18n.ts.uploadFolder }}
- </p>
+ </div>
<button v-if="selectMode" class="_button" :class="$style.checkboxWrapper" @click.prevent.stop="checkboxClicked">
<div :class="[$style.checkbox, { [$style.checked]: isSelected }]"></div>
</button>
@@ -43,6 +41,9 @@ import { i18n } from '@/i18n.js';
import { claimAchievement } from '@/utility/achievements.js';
import { copyToClipboard } from '@/utility/copy-to-clipboard.js';
import { prefer } from '@/preferences.js';
+import { globalEvents } from '@/events.js';
+import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js';
+import { selectDriveFolder } from '@/utility/drive.js';
const props = withDefaults(defineProps<{
folder: Misskey.entities.DriveFolder;
@@ -56,10 +57,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{
(ev: 'chosen', v: Misskey.entities.DriveFolder): void;
(ev: 'unchose', v: Misskey.entities.DriveFolder): void;
- (ev: 'move', v: Misskey.entities.DriveFolder): void;
- (ev: 'upload', file: File, folder: Misskey.entities.DriveFolder);
- (ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
- (ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
+ (ev: 'upload', files: File[], folder: Misskey.entities.DriveFolder);
(ev: 'dragstart'): void;
(ev: 'dragend'): void;
}>();
@@ -78,10 +76,6 @@ function checkboxClicked() {
}
}
-function onClick() {
- emit('move', props.folder);
-}
-
function onMouseover() {
hover.value = true;
}
@@ -101,10 +95,7 @@ function onDragover(ev: DragEvent) {
}
const isFile = ev.dataTransfer.items[0].kind === 'file';
- const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
- const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
-
- if (isFile || isDriveFile || isDriveFolder) {
+ if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) {
switch (ev.dataTransfer.effectAllowed) {
case 'all':
case 'uninitialized':
@@ -141,55 +132,64 @@ function onDrop(ev: DragEvent) {
// ファイルだったら
if (ev.dataTransfer.files.length > 0) {
- for (const file of Array.from(ev.dataTransfer.files)) {
- emit('upload', file, props.folder);
- }
+ emit('upload', Array.from(ev.dataTransfer.files), props.folder);
return;
}
//#region ドライブのファイル
- const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
- if (driveFile != null && driveFile !== '') {
- const file = JSON.parse(driveFile);
- emit('removeFile', file.id);
- misskeyApi('drive/files/update', {
- fileId: file.id,
- folderId: props.folder.id,
- });
+ {
+ const droppedData = getDragData(ev, 'driveFiles');
+ if (droppedData != null) {
+ misskeyApi('drive/files/move-bulk', {
+ fileIds: droppedData.map(f => f.id),
+ folderId: props.folder.id,
+ }).then(() => {
+ globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({
+ ...x,
+ folderId: props.folder.id,
+ folder: props.folder,
+ })));
+ });
+ }
}
//#endregion
//#region ドライブのフォルダ
- const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
- if (driveFolder != null && driveFolder !== '') {
- const folder = JSON.parse(driveFolder);
+ {
+ const droppedData = getDragData(ev, 'driveFolders');
+ if (droppedData != null) {
+ const droppedFolder = droppedData[0];
- // 移動先が自分自身ならreject
- if (folder.id === props.folder.id) return;
+ // 移動先が自分自身ならreject
+ if (droppedFolder.id === props.folder.id) return;
- emit('removeFolder', folder.id);
- misskeyApi('drive/folders/update', {
- folderId: folder.id,
- parentId: props.folder.id,
- }).then(() => {
- // noop
- }).catch(err => {
- switch (err.code) {
- case 'RECURSIVE_NESTING':
- claimAchievement('driveFolderCircularReference');
- os.alert({
- type: 'error',
- title: i18n.ts.unableToProcess,
- text: i18n.ts.circularReferenceFolder,
- });
- break;
- default:
- os.alert({
- type: 'error',
- text: i18n.ts.somethingHappened,
- });
- }
- });
+ misskeyApi('drive/folders/update', {
+ folderId: droppedFolder.id,
+ parentId: props.folder.id,
+ }).then(() => {
+ globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({
+ ...x,
+ parentId: props.folder.id,
+ parent: props.folder,
+ })));
+ }).catch(err => {
+ switch (err.code) {
+ case 'RECURSIVE_NESTING':
+ claimAchievement('driveFolderCircularReference');
+ os.alert({
+ type: 'error',
+ title: i18n.ts.unableToProcess,
+ text: i18n.ts.circularReferenceFolder,
+ });
+ break;
+ default:
+ os.alert({
+ type: 'error',
+ text: i18n.ts.somethingHappened,
+ });
+ }
+ });
+ }
}
//#endregion
}
@@ -198,7 +198,7 @@ function onDragstart(ev: DragEvent) {
if (!ev.dataTransfer) return;
ev.dataTransfer.effectAllowed = 'move';
- ev.dataTransfer.setData(_DATA_TRANSFER_DRIVE_FOLDER_, JSON.stringify(props.folder));
+ setDragData(ev, 'driveFolders', [props.folder]);
isDragging.value = true;
// 親ブラウザに対して、ドラッグが開始されたフラグを立てる
@@ -211,10 +211,6 @@ function onDragend() {
emit('dragend');
}
-function go() {
- emit('move', props.folder);
-}
-
function rename() {
os.inputText({
title: i18n.ts.renameFolder,
@@ -225,17 +221,28 @@ function rename() {
misskeyApi('drive/folders/update', {
folderId: props.folder.id,
name: name,
+ }).then(() => {
+ globalEvents.emit('driveFoldersUpdated', [{
+ ...props.folder,
+ name: name,
+ }]);
});
});
}
function move() {
- os.selectDriveFolder(false).then(folder => {
+ selectDriveFolder(null).then(folder => {
if (folder[0] && folder[0].id === props.folder.id) return;
misskeyApi('drive/folders/update', {
folderId: props.folder.id,
parentId: folder[0] ? folder[0].id : null,
+ }).then(() => {
+ globalEvents.emit('driveFoldersUpdated', [{
+ ...props.folder,
+ parentId: folder[0] ? folder[0].id : null,
+ parent: folder[0] ?? null,
+ }]);
});
});
}
@@ -247,6 +254,7 @@ function deleteFolder() {
if (prefer.s.uploadFolder === props.folder.id) {
prefer.commit('uploadFolder', null);
}
+ globalEvents.emit('driveFoldersDeleted', [props.folder]);
}).catch(err => {
switch (err.id) {
case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
@@ -311,10 +319,9 @@ function onContextmenu(ev: MouseEvent) {
<style lang="scss" module>
.root {
position: relative;
- padding: 8px;
- height: 64px;
- background: var(--MI_THEME-driveFolderBg);
- border-radius: 4px;
+ height: 90px;
+ padding: 24px 16px;
+ box-sizing: border-box;
cursor: pointer;
&.draghover {
@@ -332,6 +339,14 @@ function onContextmenu(ev: MouseEvent) {
}
}
+.shape {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+}
+
.checkboxWrapper {
position: absolute;
border-radius: 50%;
@@ -373,7 +388,6 @@ function onContextmenu(ev: MouseEvent) {
}
.name {
- margin: 0;
font-size: 0.9em;
}
@@ -384,7 +398,6 @@ function onContextmenu(ev: MouseEvent) {
}
.upload {
- margin: 4px 4px;
font-size: 0.8em;
text-align: right;
}
diff --git a/packages/frontend/src/components/MkDrive.navFolder.vue b/packages/frontend/src/components/MkDrive.navFolder.vue
index 7433aea061..224aa2dca7 100644
--- a/packages/frontend/src/components/MkDrive.navFolder.vue
+++ b/packages/frontend/src/components/MkDrive.navFolder.vue
@@ -6,7 +6,6 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<div
:class="[$style.root, { [$style.draghover]: draghover }]"
- @click="onClick"
@dragover.prevent.stop="onDragover"
@dragenter="onDragenter"
@dragleave="onDragleave"
@@ -22,6 +21,8 @@ import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
+import { globalEvents } from '@/events.js';
+import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
const props = defineProps<{
folder?: Misskey.entities.DriveFolder;
@@ -29,27 +30,11 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
- (ev: 'move', v?: Misskey.entities.DriveFolder): void;
- (ev: 'upload', file: File, folder?: Misskey.entities.DriveFolder | null): void;
- (ev: 'removeFile', v: Misskey.entities.DriveFile['id']): void;
- (ev: 'removeFolder', v: Misskey.entities.DriveFolder['id']): void;
+ (ev: 'upload', files: File[], folder?: Misskey.entities.DriveFolder | null): void;
}>();
-const hover = ref(false);
const draghover = ref(false);
-function onClick() {
- emit('move', props.folder);
-}
-
-function onMouseover() {
- hover.value = true;
-}
-
-function onMouseout() {
- hover.value = false;
-}
-
function onDragover(ev: DragEvent) {
if (!ev.dataTransfer) return;
@@ -59,10 +44,7 @@ function onDragover(ev: DragEvent) {
}
const isFile = ev.dataTransfer.items[0].kind === 'file';
- const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
- const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
-
- if (isFile || isDriveFile || isDriveFolder) {
+ if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) {
switch (ev.dataTransfer.effectAllowed) {
case 'all':
case 'uninitialized':
@@ -101,35 +83,46 @@ function onDrop(ev: DragEvent) {
// ファイルだったら
if (ev.dataTransfer.files.length > 0) {
- for (const file of Array.from(ev.dataTransfer.files)) {
- emit('upload', file, props.folder);
- }
+ emit('upload', Array.from(ev.dataTransfer.files), props.folder);
return;
}
//#region ドライブのファイル
- const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
- if (driveFile != null && driveFile !== '') {
- const file = JSON.parse(driveFile);
- emit('removeFile', file.id);
- misskeyApi('drive/files/update', {
- fileId: file.id,
- folderId: props.folder ? props.folder.id : null,
- });
+ {
+ const droppedData = getDragData(ev, 'driveFiles');
+ if (droppedData != null) {
+ misskeyApi('drive/files/move-bulk', {
+ fileIds: droppedData.map(f => f.id),
+ folderId: props.folder ? props.folder.id : null,
+ }).then(() => {
+ globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({
+ ...x,
+ folderId: props.folder ? props.folder.id : null,
+ folder: props.folder ?? null,
+ })));
+ });
+ }
}
//#endregion
//#region ドライブのフォルダ
- const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
- if (driveFolder != null && driveFolder !== '') {
- const folder = JSON.parse(driveFolder);
- // 移動先が自分自身ならreject
- if (props.folder && folder.id === props.folder.id) return;
- emit('removeFolder', folder.id);
- misskeyApi('drive/folders/update', {
- folderId: folder.id,
- parentId: props.folder ? props.folder.id : null,
- });
+ {
+ const droppedData = getDragData(ev, 'driveFolders');
+ if (droppedData != null) {
+ const droppedFolder = droppedData[0];
+ // 移動先が自分自身ならreject
+ if (props.folder && droppedFolder.id === props.folder.id) return;
+ misskeyApi('drive/folders/update', {
+ folderId: droppedFolder.id,
+ parentId: props.folder ? props.folder.id : null,
+ }).then(() => {
+ globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({
+ ...x,
+ parentId: props.folder ? props.folder.id : null,
+ parent: props.folder ?? null,
+ })));
+ });
+ }
}
//#endregion
}
diff --git a/packages/frontend/src/components/MkDrive.vue b/packages/frontend/src/components/MkDrive.vue
index a1f76ac563..5604f0226f 100644
--- a/packages/frontend/src/components/MkDrive.vue
+++ b/packages/frontend/src/components/MkDrive.vue
@@ -4,17 +4,15 @@ SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
-<MkStickyContainer>
+<MkStickyContainer style="background: var(--MI_THEME-bg);">
<template #header>
<nav :class="$style.nav">
<div :class="$style.navPath" @contextmenu.prevent.stop="() => {}">
<XNavFolder
:class="[$style.navPathItem, { [$style.navCurrent]: folder == null }]"
:parentFolder="folder"
- @move="move"
- @upload="upload"
- @removeFile="removeFile"
- @removeFolder="removeFolder"
+ @click="cd(null)"
+ @upload="onUploadRequested"
/>
<template v-for="f in hierarchyFolders">
<span :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span>
@@ -22,10 +20,8 @@ SPDX-License-Identifier: AGPL-3.0-only
:folder="f"
:parentFolder="folder"
:class="[$style.navPathItem]"
- @move="move"
- @upload="upload"
- @removeFile="removeFile"
- @removeFolder="removeFolder"
+ @click="cd(f)"
+ @upload="onUploadRequested"
/>
</template>
<span v-if="folder != null" :class="[$style.navPathItem, $style.navSeparator]"><i class="ti ti-chevron-right"></i></span>
@@ -35,20 +31,42 @@ SPDX-License-Identifier: AGPL-3.0-only
</nav>
</template>
- <div
- ref="main"
- :class="[$style.main, { [$style.uploading]: uploadings.length > 0, [$style.fetching]: fetching }]"
- @dragover.prevent.stop="onDragover"
- @dragenter="onDragenter"
- @dragleave="onDragleave"
- @drop.prevent.stop="onDrop"
- @contextmenu.stop="onContextmenu"
- >
- <div ref="contents">
- <MkInfo v-if="!store.r.readDriveTip.value" closable @close="closeTip()"><div v-html="i18n.ts.driveAboutTip"></div></MkInfo>
- <div v-show="folders.length > 0" ref="foldersContainer" :class="$style.folders">
+ <div>
+ <div v-if="select === 'folder'">
+ <template v-if="folder == null">
+ <MkButton v-if="!isRootSelected" @click="isRootSelected = true">
+ <i class="ti ti-square"></i> {{ i18n.ts.selectThisFolder }}
+ </MkButton>
+ <MkButton v-else @click="isRootSelected = false">
+ <i class="ti ti-checkbox"></i> {{ i18n.ts.unselectThisFolder }}
+ </MkButton>
+ </template>
+ <template v-else>
+ <MkButton v-if="!selectedFolders.some(f => f.id === folder!.id)" @click="selectedFolders.push(folder)">
+ <i class="ti ti-square"></i> {{ i18n.ts.selectThisFolder }}
+ </MkButton>
+ <MkButton v-else @click="selectedFolders = selectedFolders.filter(f => f.id !== folder!.id)">
+ <i class="ti ti-checkbox"></i> {{ i18n.ts.unselectThisFolder }}
+ </MkButton>
+ </template>
+ </div>
+
+ <div
+ ref="main"
+ :class="[$style.main, { [$style.fetching]: fetching }]"
+ @dragover.prevent.stop="onDragover"
+ @dragenter="onDragenter"
+ @dragleave="onDragleave"
+ @drop.prevent.stop="onDrop"
+ @contextmenu.stop="onContextmenu"
+ >
+ <div v-if="!store.r.readDriveTip.value" style="padding: 8px;">
+ <MkInfo closable @close="closeTip()"><div v-html="i18n.ts.driveAboutTip"></div></MkInfo>
+ </div>
+
+ <div :class="$style.folders">
<XFolder
- v-for="(f, i) in folders"
+ v-for="(f, i) in foldersPaginator.items.value"
:key="f.id"
v-anim="i"
:class="$style.folder"
@@ -57,49 +75,64 @@ SPDX-License-Identifier: AGPL-3.0-only
:isSelected="selectedFolders.some(x => x.id === f.id)"
@chosen="chooseFolder"
@unchose="unchoseFolder"
- @move="move"
- @upload="upload"
- @removeFile="removeFile"
- @removeFolder="removeFolder"
- @dragstart="isDragSource = true"
- @dragend="isDragSource = false"
- />
- <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
- <div v-for="(n, i) in 16" :key="i" :class="$style.padding"></div>
- <MkButton v-if="moreFolders" ref="moreFolders" @click="fetchMoreFolders">{{ i18n.ts.loadMore }}</MkButton>
- </div>
- <div v-show="files.length > 0" ref="filesContainer" :class="$style.files">
- <XFile
- v-for="(file, i) in files"
- :key="file.id"
- v-anim="i"
- :class="$style.file"
- :file="file"
- :folder="folder"
- :selectMode="select === 'file'"
- :isSelected="selectedFiles.some(x => x.id === file.id)"
- @chosen="chooseFile"
+ @click="cd(f)"
+ @upload="onUploadRequested"
@dragstart="isDragSource = true"
@dragend="isDragSource = false"
/>
- <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid -->
- <div v-for="(n, i) in 16" :key="i" :class="$style.padding"></div>
- <MkButton v-show="moreFiles" ref="loadMoreFiles" @click="fetchMoreFiles">{{ i18n.ts.loadMore }}</MkButton>
</div>
- <div v-if="files.length == 0 && folders.length == 0 && !fetching" :class="$style.empty">
+ <MkButton v-if="foldersPaginator.canFetchOlder.value" primary rounded @click="foldersPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton>
+
+ <MkStickyContainer v-for="(item, i) in filesTimeline" :key="`${item.date.getFullYear()}/${item.date.getMonth() + 1}`">
+ <template #header>
+ <div :class="$style.date">
+ <span><i class="ti ti-chevron-down"></i> {{ item.date.getFullYear() }}/{{ item.date.getMonth() + 1 }}</span>
+ </div>
+ </template>
+
+ <TransitionGroup
+ tag="div"
+ :enterActiveClass="prefer.s.animation ? $style.transition_files_enterActive : ''"
+ :leaveActiveClass="prefer.s.animation ? $style.transition_files_leaveActive : ''"
+ :enterFromClass="prefer.s.animation ? $style.transition_files_enterFrom : ''"
+ :leaveToClass="prefer.s.animation ? $style.transition_files_leaveTo : ''"
+ :moveClass="prefer.s.animation ? $style.transition_files_move : ''"
+ :class="$style.files"
+ >
+ <XFile
+ v-for="file in item.items" :key="file.id"
+ :class="$style.file"
+ :file="file"
+ :folder="folder"
+ :isSelected="selectedFiles.some(x => x.id === file.id)"
+ @click="onFileClick($event, file)"
+ @dragstart="onFileDragstart(file, $event)"
+ @dragend="isDragSource = false"
+ />
+ </TransitionGroup>
+ </MkStickyContainer>
+ <MkButton v-show="filesPaginator.canFetchOlder.value" :class="$style.loadMore" primary rounded @click="filesPaginator.fetchOlder()">{{ i18n.ts.loadMore }}</MkButton>
+
+ <div v-if="filesPaginator.items.value.length == 0 && foldersPaginator.items.value.length == 0 && !fetching" :class="$style.empty">
<div v-if="draghover">{{ i18n.ts['empty-draghover'] }}</div>
<div v-if="!draghover && folder == null"><strong>{{ i18n.ts.emptyDrive }}</strong><br/>{{ i18n.ts['empty-drive-description'] }}</div>
<div v-if="!draghover && folder != null">{{ i18n.ts.emptyFolder }}</div>
</div>
</div>
<MkLoading v-if="fetching"/>
+ <div v-if="draghover" :class="$style.dropzone"></div>
</div>
- <div v-if="draghover" :class="$style.dropzone"></div>
+
+ <template #footer>
+ <div v-if="isEditMode" :class="$style.footer">
+ <MkButton primary rounded @click="moveFilesBulk()"><i class="ti ti-folder-symlink"></i> {{ i18n.ts.move }}...</MkButton>
+ </div>
+ </template>
</MkStickyContainer>
</template>
<script lang="ts" setup>
-import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
+import { nextTick, onActivated, onBeforeUnmount, onMounted, ref, useTemplateRef, watch, computed, TransitionGroup } from 'vue';
import * as Misskey from 'misskey-js';
import MkButton from './MkButton.vue';
import MkInfo from './MkInfo.vue';
@@ -111,14 +144,18 @@ import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { useStream } from '@/stream.js';
import { i18n } from '@/i18n.js';
-import { uploadFile, uploads } from '@/utility/upload.js';
import { claimAchievement } from '@/utility/achievements.js';
import { prefer } from '@/preferences.js';
-import { chooseFileFromPc } from '@/utility/select-file.js';
+import { chooseFileFromPcAndUpload, selectDriveFolder } from '@/utility/drive.js';
import { store } from '@/store.js';
+import { isSeparatorNeeded, getSeparatorInfo, makeDateGroupedTimelineComputedRef } from '@/utility/timeline-date-separate.js';
+import { usePagination } from '@/composables/use-pagination.js';
+import { globalEvents, useGlobalEvent } from '@/events.js';
+import { checkDragDataType, getDragData, setDragData } from '@/drag-and-drop.js';
+import { getDriveFileMenu } from '@/utility/get-drive-file-menu.js';
const props = withDefaults(defineProps<{
- initialFolder?: Misskey.entities.DriveFolder;
+ initialFolder?: Misskey.entities.DriveFolder['id'] | null;
type?: string;
multiple?: boolean;
select?: 'file' | 'folder' | null;
@@ -128,25 +165,13 @@ const props = withDefaults(defineProps<{
});
const emit = defineEmits<{
- (ev: 'selected', v: Misskey.entities.DriveFile | Misskey.entities.DriveFolder): void;
- (ev: 'change-selection', v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
- (ev: 'move-root'): void;
+ (ev: 'changeSelectedFiles', v: Misskey.entities.DriveFile[]): void;
+ (ev: 'changeSelectedFolders', v: (Misskey.entities.DriveFolder | null)[]): void;
(ev: 'cd', v: Misskey.entities.DriveFolder | null): void;
- (ev: 'open-folder', v: Misskey.entities.DriveFolder): void;
}>();
-const loadMoreFiles = useTemplateRef('loadMoreFiles');
-
const folder = ref<Misskey.entities.DriveFolder | null>(null);
-const files = ref<Misskey.entities.DriveFile[]>([]);
-const folders = ref<Misskey.entities.DriveFolder[]>([]);
-const moreFiles = ref(false);
-const moreFolders = ref(false);
const hierarchyFolders = ref<Misskey.entities.DriveFolder[]>([]);
-const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
-const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
-const uploadings = uploads;
-const connection = useStream().useChannel('drive');
// ドロップされようとしているか
const draghover = ref(false);
@@ -155,51 +180,87 @@ const draghover = ref(false);
// (自分自身の階層にドロップできないようにするためのフラグ)
const isDragSource = ref(false);
-const fetching = ref(true);
+const isEditMode = ref(false);
+
+const selectedFiles = ref<Misskey.entities.DriveFile[]>([]);
+const selectedFolders = ref<Misskey.entities.DriveFolder[]>([]);
+const isRootSelected = ref(false);
-const ilFilesObserver = new IntersectionObserver(
- (entries) => entries.some((entry) => entry.isIntersecting) && !fetching.value && moreFiles.value && fetchMoreFiles(),
-);
+watch(selectedFiles, () => {
+ emit('changeSelectedFiles', selectedFiles.value);
+});
+
+watch([selectedFolders, isRootSelected], () => {
+ emit('changeSelectedFolders', isRootSelected.value ? [null, ...selectedFolders.value] : selectedFolders.value);
+});
+
+const fetching = ref(true);
const sortModeSelect = ref<NonNullable<Misskey.entities.DriveFilesRequest['sort']>>('+createdAt');
+const filesPaginator = usePagination({
+ ctx: {
+ endpoint: 'drive/files',
+ limit: 30,
+ canFetchDetection: 'limit',
+ params: computed(() => ({
+ folderId: folder.value ? folder.value.id : null,
+ type: props.type,
+ sort: sortModeSelect.value,
+ })),
+ },
+ autoInit: false,
+ autoReInit: false,
+});
+
+const foldersPaginator = usePagination({
+ ctx: {
+ endpoint: 'drive/folders',
+ limit: 30,
+ canFetchDetection: 'limit',
+ params: computed(() => ({
+ folderId: folder.value ? folder.value.id : null,
+ })),
+ },
+ autoInit: false,
+ autoReInit: false,
+});
+
+const filesTimeline = makeDateGroupedTimelineComputedRef(filesPaginator.items, 'month');
+
watch(folder, () => emit('cd', folder.value));
watch(sortModeSelect, () => {
- fetch();
+ initialize();
});
-function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) {
- addFile(file, true);
+async function initialize() {
+ fetching.value = true;
+ await Promise.all([
+ foldersPaginator.init(),
+ filesPaginator.init(),
+ ]);
+ fetching.value = false;
}
-function onStreamDriveFileUpdated(file: Misskey.entities.DriveFile) {
- const current = folder.value ? folder.value.id : null;
- if (current !== file.folderId) {
- removeFile(file);
- } else {
- addFile(file, true);
+function onStreamDriveFileCreated(file: Misskey.entities.DriveFile) {
+ if (file.folderId === (folder.value?.id ?? null)) {
+ filesPaginator.prepend(file);
}
}
-function onStreamDriveFileDeleted(fileId: string) {
- removeFile(fileId);
-}
-
-function onStreamDriveFolderCreated(createdFolder: Misskey.entities.DriveFolder) {
- addFolder(createdFolder, true);
-}
+function onFileDragstart(file: Misskey.entities.DriveFile, ev: DragEvent) {
+ if (isEditMode.value) {
+ if (!selectedFiles.value.some(f => f.id === file.id)) {
+ selectedFiles.value.push(file);
+ }
-function onStreamDriveFolderUpdated(updatedFolder: Misskey.entities.DriveFolder) {
- const current = folder.value ? folder.value.id : null;
- if (current !== updatedFolder.parentId) {
- removeFolder(updatedFolder);
- } else {
- addFolder(updatedFolder, true);
+ if (ev.dataTransfer) {
+ ev.dataTransfer.effectAllowed = 'move';
+ setDragData(ev, 'driveFiles', selectedFiles.value);
+ }
}
-}
-function onStreamDriveFolderDeleted(folderId: string) {
- removeFolder(folderId);
+ isDragSource.value = true;
}
function onDragover(ev: DragEvent) {
@@ -213,9 +274,7 @@ function onDragover(ev: DragEvent) {
}
const isFile = ev.dataTransfer.items[0].kind === 'file';
- const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
- const isDriveFolder = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FOLDER_;
- if (isFile || isDriveFile || isDriveFolder) {
+ if (isFile || checkDragDataType(ev, ['driveFiles', 'driveFolders'])) {
switch (ev.dataTransfer.effectAllowed) {
case 'all':
case 'uninitialized':
@@ -254,109 +313,123 @@ function onDrop(ev: DragEvent) {
// ドロップされてきたものがファイルだったら
if (ev.dataTransfer.files.length > 0) {
- for (const file of Array.from(ev.dataTransfer.files)) {
- upload(file, folder.value);
- }
+ os.launchUploader(Array.from(ev.dataTransfer.files), {
+ folderId: folder.value?.id ?? null,
+ });
return;
}
//#region ドライブのファイル
- const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_);
- if (driveFile != null && driveFile !== '') {
- const file = JSON.parse(driveFile);
- if (files.value.some(f => f.id === file.id)) return;
- removeFile(file.id);
- misskeyApi('drive/files/update', {
- fileId: file.id,
- folderId: folder.value ? folder.value.id : null,
- });
+ {
+ const droppedData = getDragData(ev, 'driveFiles');
+ if (droppedData != null) {
+ misskeyApi('drive/files/move-bulk', {
+ fileIds: droppedData.map(f => f.id),
+ folderId: folder.value ? folder.value.id : null,
+ }).then(() => {
+ globalEvents.emit('driveFilesUpdated', droppedData.map(x => ({
+ ...x,
+ folderId: folder.value ? folder.value.id : null,
+ folder: folder.value,
+ })));
+ });
+ }
}
//#endregion
//#region ドライブのフォルダ
- const driveFolder = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FOLDER_);
- if (driveFolder != null && driveFolder !== '') {
- const droppedFolder = JSON.parse(driveFolder);
-
- // 移動先が自分自身ならreject
- if (folder.value && droppedFolder.id === folder.value.id) return false;
- if (folders.value.some(f => f.id === droppedFolder.id)) return false;
- removeFolder(droppedFolder.id);
- misskeyApi('drive/folders/update', {
- folderId: droppedFolder.id,
- parentId: folder.value ? folder.value.id : null,
- }).then(() => {
- // noop
- }).catch(err => {
- switch (err.code) {
- case 'RECURSIVE_NESTING':
- claimAchievement('driveFolderCircularReference');
- os.alert({
- type: 'error',
- title: i18n.ts.unableToProcess,
- text: i18n.ts.circularReferenceFolder,
- });
- break;
- default:
- os.alert({
- type: 'error',
- text: i18n.ts.somethingHappened,
- });
- }
- });
+ {
+ const droppedData = getDragData(ev, 'driveFolders');
+ if (droppedData != null) {
+ const droppedFolder = droppedData[0];
+ // 移動先が自分自身ならreject
+ if (folder.value && droppedFolder.id === folder.value.id) return false;
+ if (foldersPaginator.items.value.some(f => f.id === droppedFolder.id)) return false;
+ misskeyApi('drive/folders/update', {
+ folderId: droppedFolder.id,
+ parentId: folder.value ? folder.value.id : null,
+ }).then(() => {
+ globalEvents.emit('driveFoldersUpdated', [droppedFolder].map(x => ({
+ ...x,
+ parentId: folder.value ? folder.value.id : null,
+ parent: folder.value,
+ })));
+ }).catch(err => {
+ switch (err.code) {
+ case 'RECURSIVE_NESTING':
+ claimAchievement('driveFolderCircularReference');
+ os.alert({
+ type: 'error',
+ title: i18n.ts.unableToProcess,
+ text: i18n.ts.circularReferenceFolder,
+ });
+ break;
+ default:
+ os.alert({
+ type: 'error',
+ text: i18n.ts.somethingHappened,
+ });
+ }
+ });
+ }
}
//#endregion
}
-function urlUpload() {
- os.inputText({
+function onUploadRequested(files: File[], folder: Misskey.entities.DriveFolder | null) {
+ os.launchUploader(files, {
+ folderId: folder?.id ?? null,
+ });
+}
+
+async function urlUpload() {
+ const { canceled, result: url } = await os.inputText({
title: i18n.ts.uploadFromUrl,
type: 'url',
placeholder: i18n.ts.uploadFromUrlDescription,
- }).then(({ canceled, result: url }) => {
- if (canceled || !url) return;
- misskeyApi('drive/files/upload-from-url', {
- url: url,
- folderId: folder.value ? folder.value.id : undefined,
- });
+ });
+ if (canceled || !url) return;
- os.alert({
- title: i18n.ts.uploadFromUrlRequested,
- text: i18n.ts.uploadFromUrlMayTakeTime,
- });
+ await os.apiWithDialog('drive/files/upload-from-url', {
+ url: url,
+ folderId: folder.value ? folder.value.id : undefined,
+ });
+
+ os.alert({
+ title: i18n.ts.uploadFromUrlRequested,
+ text: i18n.ts.uploadFromUrlMayTakeTime,
});
}
-function createFolder() {
- os.inputText({
+async function createFolder() {
+ const { canceled, result: name } = await os.inputText({
title: i18n.ts.createFolder,
placeholder: i18n.ts.folderName,
- }).then(({ canceled, result: name }) => {
- if (canceled || name == null) return;
- misskeyApi('drive/folders/create', {
- name: name,
- parentId: folder.value ? folder.value.id : undefined,
- }).then(createdFolder => {
- addFolder(createdFolder, true);
- });
});
+ if (canceled || name == null) return;
+
+ const createdFolder = await os.apiWithDialog('drive/folders/create', {
+ name: name,
+ parentId: folder.value ? folder.value.id : undefined,
+ });
+
+ foldersPaginator.prepend(createdFolder);
}
-function renameFolder(folderToRename: Misskey.entities.DriveFolder) {
- os.inputText({
+async function renameFolder(folderToRename: Misskey.entities.DriveFolder) {
+ const { canceled, result: name } = await os.inputText({
title: i18n.ts.renameFolder,
placeholder: i18n.ts.inputNewFolderName,
default: folderToRename.name,
- }).then(({ canceled, result: name }) => {
- if (canceled) return;
- misskeyApi('drive/folders/update', {
- folderId: folderToRename.id,
- name: name,
- }).then(updatedFolder => {
- // FIXME: 画面を更新するために自分自身に移動
- move(updatedFolder);
- });
});
+ if (canceled) return;
+
+ const updatedFolder = await os.apiWithDialog('drive/folders/update', {
+ folderId: folderToRename.id,
+ name: name,
+ });
+
+ globalEvents.emit('driveFoldersUpdated', [updatedFolder]);
}
function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) {
@@ -364,7 +437,8 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) {
folderId: folderToDelete.id,
}).then(() => {
// 削除時に親フォルダに移動
- move(folderToDelete.parentId);
+ cd(folderToDelete.parentId);
+ globalEvents.emit('driveFoldersDeleted', [folderToDelete]);
}).catch(err => {
switch (err.id) {
case 'b0fc8a17-963c-405d-bfbc-859a487295e1':
@@ -383,28 +457,38 @@ function deleteFolder(folderToDelete: Misskey.entities.DriveFolder) {
});
}
-function upload(file: File, folderToUpload?: Misskey.entities.DriveFolder | null, keepOriginal?: boolean) {
- uploadFile(file, (folderToUpload && typeof folderToUpload === 'object') ? folderToUpload.id : null, undefined, keepOriginal).then(res => {
- addFile(res, true);
- });
-}
+function onFileClick(ev: MouseEvent, file: Misskey.entities.DriveFile) {
+ if (ev.shiftKey) {
+ isEditMode.value = true;
+ }
-function chooseFile(file: Misskey.entities.DriveFile) {
- const isAlreadySelected = selectedFiles.value.some(f => f.id === file.id);
- if (props.multiple) {
- if (isAlreadySelected) {
- selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id);
- } else {
- selectedFiles.value.push(file);
+ if (props.select === 'file' || isEditMode.value) {
+ const isAlreadySelected = selectedFiles.value.some(f => f.id === file.id);
+
+ if (isEditMode.value) {
+ if (isAlreadySelected) {
+ selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id);
+ } else {
+ selectedFiles.value.push(file);
+ }
+ return;
}
- emit('change-selection', selectedFiles.value);
- } else {
- if (isAlreadySelected) {
- emit('selected', file);
+
+ if (props.multiple) {
+ if (isAlreadySelected) {
+ selectedFiles.value = selectedFiles.value.filter(f => f.id !== file.id);
+ } else {
+ selectedFiles.value.push(file);
+ }
} else {
- selectedFiles.value = [file];
- emit('change-selection', [file]);
+ if (isAlreadySelected) {
+ //emit('selected', file);
+ } else {
+ selectedFiles.value = [file];
+ }
}
+ } else {
+ os.popupMenu(getDriveFileMenu(file, folder.value), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined);
}
}
@@ -416,23 +500,20 @@ function chooseFolder(folderToChoose: Misskey.entities.DriveFolder) {
} else {
selectedFolders.value.push(folderToChoose);
}
- emit('change-selection', selectedFolders.value);
} else {
if (isAlreadySelected) {
- emit('selected', folderToChoose);
+ //emit('selected', folderToChoose);
} else {
selectedFolders.value = [folderToChoose];
- emit('change-selection', [folderToChoose]);
}
}
}
function unchoseFolder(folderToUnchose: Misskey.entities.DriveFolder) {
selectedFolders.value = selectedFolders.value.filter(f => f.id !== folderToUnchose.id);
- emit('change-selection', selectedFolders.value);
}
-function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) {
+function cd(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFolder['id' | 'parentId']) {
if (!target) {
goRoot();
return;
@@ -455,168 +536,34 @@ function move(target?: Misskey.entities.DriveFolder | Misskey.entities.DriveFold
if (folderToMove.parent) dive(folderToMove.parent);
- emit('open-folder', folderToMove);
- fetch();
+ initialize();
});
}
-function addFolder(folderToAdd: Misskey.entities.DriveFolder, unshift = false) {
- const current = folder.value ? folder.value.id : null;
- if (current !== folderToAdd.parentId) return;
-
- if (folders.value.some(f => f.id === folderToAdd.id)) {
- const exist = folders.value.map(f => f.id).indexOf(folderToAdd.id);
- folders.value[exist] = folderToAdd;
- return;
- }
-
- if (unshift) {
- folders.value.unshift(folderToAdd);
- } else {
- folders.value.push(folderToAdd);
- }
-}
-
-function addFile(fileToAdd: Misskey.entities.DriveFile, unshift = false) {
- const current = folder.value ? folder.value.id : null;
- if (current !== fileToAdd.folderId) return;
-
- if (files.value.some(f => f.id === fileToAdd.id)) {
- const exist = files.value.map(f => f.id).indexOf(fileToAdd.id);
- files.value[exist] = fileToAdd;
- return;
- }
-
- if (unshift) {
- files.value.unshift(fileToAdd);
- } else {
- files.value.push(fileToAdd);
- }
-}
-
-function removeFolder(folderToRemove: Misskey.entities.DriveFolder | string) {
- const folderIdToRemove = typeof folderToRemove === 'object' ? folderToRemove.id : folderToRemove;
- folders.value = folders.value.filter(f => f.id !== folderIdToRemove);
-}
-
-function removeFile(file: Misskey.entities.DriveFile | string) {
- const fileId = typeof file === 'object' ? file.id : file;
- files.value = files.value.filter(f => f.id !== fileId);
-}
+async function moveFilesBulk() {
+ if (selectedFiles.value.length === 0) return;
-function appendFile(file: Misskey.entities.DriveFile) {
- addFile(file);
-}
+ const toFolder = await selectDriveFolder(folder.value ? folder.value.id : null);
-function appendFolder(folderToAppend: Misskey.entities.DriveFolder) {
- addFolder(folderToAppend);
-}
+ await os.apiWithDialog('drive/files/move-bulk', {
+ fileIds: selectedFiles.value.map(f => f.id),
+ folderId: toFolder[0] ? toFolder[0].id : null,
+ });
-/*
-function prependFile(file: Misskey.entities.DriveFile) {
- addFile(file, true);
+ globalEvents.emit('driveFilesUpdated', selectedFiles.value.map(x => ({
+ ...x,
+ folderId: toFolder[0] ? toFolder[0].id : null,
+ folder: toFolder[0] ?? null,
+ })));
}
-function prependFolder(folderToPrepend: Misskey.entities.DriveFolder) {
- addFolder(folderToPrepend, true);
-}
-*/
function goRoot() {
// 既にrootにいるなら何もしない
if (folder.value == null) return;
folder.value = null;
hierarchyFolders.value = [];
- emit('move-root');
- fetch();
-}
-
-async function fetch() {
- folders.value = [];
- files.value = [];
- moreFolders.value = false;
- moreFiles.value = false;
- fetching.value = true;
-
- const foldersMax = 30;
- const filesMax = 30;
-
- const foldersPromise = misskeyApi('drive/folders', {
- folderId: folder.value ? folder.value.id : null,
- limit: foldersMax + 1,
- }).then(fetchedFolders => {
- if (fetchedFolders.length === foldersMax + 1) {
- moreFolders.value = true;
- fetchedFolders.pop();
- }
- return fetchedFolders;
- });
-
- const filesPromise = misskeyApi('drive/files', {
- folderId: folder.value ? folder.value.id : null,
- type: props.type,
- limit: filesMax + 1,
- sort: sortModeSelect.value,
- }).then(fetchedFiles => {
- if (fetchedFiles.length === filesMax + 1) {
- moreFiles.value = true;
- fetchedFiles.pop();
- }
- return fetchedFiles;
- });
-
- const [fetchedFolders, fetchedFiles] = await Promise.all([foldersPromise, filesPromise]);
-
- for (const x of fetchedFolders) appendFolder(x);
- for (const x of fetchedFiles) appendFile(x);
-
- fetching.value = false;
-}
-
-function fetchMoreFolders() {
- fetching.value = true;
-
- const max = 30;
-
- misskeyApi('drive/folders', {
- folderId: folder.value ? folder.value.id : null,
- type: props.type,
- untilId: folders.value.at(-1)?.id,
- limit: max + 1,
- }).then(folders => {
- if (folders.length === max + 1) {
- moreFolders.value = true;
- folders.pop();
- } else {
- moreFolders.value = false;
- }
- for (const x of folders) appendFolder(x);
- fetching.value = false;
- });
-}
-
-function fetchMoreFiles() {
- fetching.value = true;
-
- const max = 30;
-
- // ファイル一覧取得
- misskeyApi('drive/files', {
- folderId: folder.value ? folder.value.id : null,
- type: props.type,
- untilId: files.value.at(-1)?.id,
- limit: max + 1,
- sort: sortModeSelect.value,
- }).then(files => {
- if (files.length === max + 1) {
- moreFiles.value = true;
- files.pop();
- } else {
- moreFiles.value = false;
- }
- for (const x of files) appendFile(x);
- fetching.value = false;
- });
+ initialize();
}
function getMenu() {
@@ -626,16 +573,13 @@ function getMenu() {
text: i18n.ts.addFile,
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,
icon: 'ti ti-upload',
action: () => {
- chooseFileFromPc(true, { uploadFolder: folder.value?.id, keepOriginal: true });
+ chooseFileFromPcAndUpload({
+ multiple: true,
+ folderId: folder.value?.id,
+ });
},
}, {
text: i18n.ts.fromUrl,
@@ -699,6 +643,11 @@ function getMenu() {
text: i18n.ts.createFolder,
icon: 'ti ti-folder-plus',
action: () => { createFolder(); },
+ }, { type: 'divider' }, {
+ type: 'switch',
+ text: i18n.ts.edit,
+ icon: 'ti ti-pointer',
+ ref: isEditMode,
});
return menu;
@@ -716,42 +665,95 @@ function closeTip() {
store.set('readDriveTip', true);
}
-onMounted(() => {
- if (prefer.s.enableInfiniteScroll && loadMoreFiles.value) {
- nextTick(() => {
- ilFilesObserver.observe(loadMoreFiles.value?.$el);
- });
+useGlobalEvent('driveFileCreated', (file) => {
+ if (file.folderId === (folder.value?.id ?? null)) {
+ filesPaginator.prepend(file);
+ }
+});
+
+useGlobalEvent('driveFilesUpdated', (files) => {
+ for (const f of files) {
+ if (filesPaginator.items.value.some(x => x.id === f.id)) {
+ if (f.folderId === (folder.value?.id ?? null)) {
+ filesPaginator.updateItem(f.id, () => f);
+ } else {
+ filesPaginator.removeItem(f.id);
+ }
+ } else {
+ if (f.folderId === (folder.value?.id ?? null)) {
+ filesPaginator.prepend(f);
+ }
+ }
+ }
+});
+
+useGlobalEvent('driveFilesDeleted', (files) => {
+ for (const f of files) {
+ filesPaginator.removeItem(f.id);
+ }
+});
+
+useGlobalEvent('driveFoldersUpdated', (folders) => {
+ for (const f of folders) {
+ if (foldersPaginator.items.value.some(x => x.id === f.id)) {
+ if (f.parentId === (folder.value?.id ?? null)) {
+ foldersPaginator.updateItem(f.id, () => f);
+ } else {
+ foldersPaginator.removeItem(f.id);
+ }
+ } else {
+ if (f.parentId === (folder.value?.id ?? null)) {
+ foldersPaginator.prepend(f);
+ }
+ }
+ }
+});
+
+useGlobalEvent('driveFoldersDeleted', (folders) => {
+ for (const f of folders) {
+ foldersPaginator.removeItem(f.id);
}
+});
+
+let connection: Misskey.ChannelConnection<Misskey.Channels['drive']> | null = null;
- connection.on('fileCreated', onStreamDriveFileCreated);
- connection.on('fileUpdated', onStreamDriveFileUpdated);
- connection.on('fileDeleted', onStreamDriveFileDeleted);
- connection.on('folderCreated', onStreamDriveFolderCreated);
- connection.on('folderUpdated', onStreamDriveFolderUpdated);
- connection.on('folderDeleted', onStreamDriveFolderDeleted);
+onMounted(() => {
+ if (store.s.realtimeMode) {
+ connection = useStream().useChannel('drive');
+ connection.on('fileCreated', onStreamDriveFileCreated);
+ }
if (props.initialFolder) {
- move(props.initialFolder);
+ cd(props.initialFolder);
} else {
- fetch();
+ initialize();
}
});
onActivated(() => {
- if (prefer.s.enableInfiniteScroll) {
- nextTick(() => {
- ilFilesObserver.observe(loadMoreFiles.value?.$el);
- });
- }
});
onBeforeUnmount(() => {
- connection.dispose();
- ilFilesObserver.disconnect();
+ if (connection != null) {
+ connection.dispose();
+ }
});
</script>
<style lang="scss" module>
+.transition_files_move,
+.transition_files_enterActive,
+.transition_files_leaveActive {
+ transition: all 0.2s ease;
+}
+.transition_files_enterFrom,
+.transition_files_leaveTo {
+ opacity: 0;
+}
+.transition_files_leaveActive {
+ position: absolute;
+}
+
.nav {
display: flex;
width: 100%;
@@ -806,9 +808,7 @@ onBeforeUnmount(() => {
}
.main {
- flex: 1;
- overflow: auto;
- padding: var(--MI-margin);
+ min-height: 100cqh;
user-select: none;
&.fetching {
@@ -816,30 +816,41 @@ onBeforeUnmount(() => {
opacity: 0.5;
pointer-events: none;
}
-
- &.uploading {
- height: calc(100% - 38px - 100px);
- }
}
.folders,
.files {
- display: flex;
- flex-wrap: wrap;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
+ grid-gap: 12px;
+ padding: 16px 32px;
}
-.folder,
-.file {
- flex-grow: 1;
- width: 128px;
- margin: 4px;
- box-sizing: border-box;
+@container (max-width: 600px) {
+ .folders,
+ .files {
+ padding: 16px;
+ }
}
-.padding {
- flex-grow: 1;
- pointer-events: none;
- width: 128px + 8px;
+.date {
+ padding: 8px 16px;
+ font-size: 90%;
+ -webkit-backdrop-filter: var(--MI-blur, blur(8px));
+ backdrop-filter: var(--MI-blur, blur(8px));
+ background-color: color(from var(--MI_THEME-bg) srgb r g b / 0.85);
+}
+
+.loadMore {
+ margin: 16px auto;
+}
+
+.footer {
+ padding: 8px 16px;
+ font-size: 90%;
+ -webkit-backdrop-filter: var(--MI-blur, blur(8px));
+ backdrop-filter: var(--MI-blur, blur(8px));
+ background-color: color(from var(--MI_THEME-bg) srgb r g b / 0.85);
}
.empty {
diff --git a/packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts b/packages/frontend/src/components/MkDriveFileSelectDialog.stories.impl.ts
index fe8f705165..a5073337cd 100644
--- a/packages/frontend/src/components/MkDriveSelectDialog.stories.impl.ts
+++ b/packages/frontend/src/components/MkDriveFileSelectDialog.stories.impl.ts
@@ -3,5 +3,5 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
-import MkDriveSelectDialog from './MkDriveSelectDialog.vue';
+import MkDriveSelectDialog from './MkDriveFileSelectDialog.vue';
void MkDriveSelectDialog;
diff --git a/packages/frontend/src/components/MkDriveSelectDialog.vue b/packages/frontend/src/components/MkDriveFileSelectDialog.vue
index 1b9455e3f3..50b68b3d0f 100644
--- a/packages/frontend/src/components/MkDriveSelectDialog.vue
+++ b/packages/frontend/src/components/MkDriveFileSelectDialog.vue
@@ -9,43 +9,41 @@ SPDX-License-Identifier: AGPL-3.0-only
:width="800"
:height="500"
:withOkButton="true"
- :okButtonDisabled="(type === 'file') && (selected.length === 0)"
+ :okButtonDisabled="selected.length === 0"
@click="cancel()"
@close="cancel()"
@ok="ok()"
@closed="emit('closed')"
>
<template #header>
- {{ multiple ? ((type === 'file') ? i18n.ts.selectFiles : i18n.ts.selectFolders) : ((type === 'file') ? i18n.ts.selectFile : i18n.ts.selectFolder) }}
- <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ number(selected.length) }})</span>
+ {{ multiple ? i18n.ts.selectFiles : i18n.ts.selectFile }}
+ <span v-if="selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span>
</template>
- <XDrive :multiple="multiple" :select="type" @changeSelection="onChangeSelection" @selected="ok()"/>
+ <MkDrive :multiple="multiple" select="file" :initialFolder="initialFolder" @changeSelectedFiles="onChangeSelection"/>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref, useTemplateRef } from 'vue';
import * as Misskey from 'misskey-js';
-import XDrive from '@/components/MkDrive.vue';
+import MkDrive from '@/components/MkDrive.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
-import number from '@/filters/number.js';
import { i18n } from '@/i18n.js';
withDefaults(defineProps<{
- type?: 'file' | 'folder';
+ initialFolder?: Misskey.entities.DriveFolder['id'] | null;
multiple: boolean;
}>(), {
- type: 'file',
});
const emit = defineEmits<{
- (ev: 'done', r?: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]): void;
+ (ev: 'done', r?: Misskey.entities.DriveFile[]): void;
(ev: 'closed'): void;
}>();
const dialog = useTemplateRef('dialog');
-const selected = ref<Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]>([]);
+const selected = ref<Misskey.entities.DriveFile[]>([]);
function ok() {
emit('done', selected.value);
@@ -57,7 +55,7 @@ function cancel() {
dialog.value?.close();
}
-function onChangeSelection(v: Misskey.entities.DriveFile[] | Misskey.entities.DriveFolder[]) {
+function onChangeSelection(v: Misskey.entities.DriveFile[]) {
selected.value = v;
}
</script>
diff --git a/packages/frontend/src/components/MkDriveFolderSelectDialog.vue b/packages/frontend/src/components/MkDriveFolderSelectDialog.vue
new file mode 100644
index 0000000000..2ebab1088f
--- /dev/null
+++ b/packages/frontend/src/components/MkDriveFolderSelectDialog.vue
@@ -0,0 +1,63 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+ ref="dialog"
+ :width="800"
+ :height="500"
+ :withOkButton="true"
+ :okButtonDisabled="selected.length === 0"
+ @click="cancel()"
+ @close="cancel()"
+ @ok="ok()"
+ @closed="emit('closed')"
+>
+ <template #header>
+ {{ multiple ? i18n.ts.selectFolders : i18n.ts.selectFolder }}
+ <span v-if="multiple && selected.length > 0" style="margin-left: 8px; opacity: 0.5;">({{ selected.length }})</span>
+ </template>
+ <MkDrive :multiple="multiple" select="folder" :initialFolder="initialFolder" @changeSelectedFolders="onChangeSelection"/>
+</MkModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { ref, useTemplateRef } from 'vue';
+import * as Misskey from 'misskey-js';
+import MkDrive from '@/components/MkDrive.vue';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import { i18n } from '@/i18n.js';
+
+withDefaults(defineProps<{
+ initialFolder?: Misskey.entities.DriveFolder['id'] | null;
+ multiple?: boolean;
+}>(), {
+ initialFolder: null,
+ multiple: false,
+});
+
+const emit = defineEmits<{
+ (ev: 'done', r?: Misskey.entities.DriveFolder[]): void;
+ (ev: 'closed'): void;
+}>();
+
+const dialog = useTemplateRef('dialog');
+
+const selected = ref<Misskey.entities.DriveFolder[]>([]);
+
+function ok() {
+ emit('done', selected.value);
+ dialog.value?.close();
+}
+
+function cancel() {
+ emit('done');
+ dialog.value?.close();
+}
+
+function onChangeSelection(v: Misskey.entities.DriveFolder[]) {
+ selected.value = v;
+}
+</script>
diff --git a/packages/frontend/src/components/MkDriveWindow.vue b/packages/frontend/src/components/MkDriveWindow.vue
index c0142ec76e..0b8d0bfb8a 100644
--- a/packages/frontend/src/components/MkDriveWindow.vue
+++ b/packages/frontend/src/components/MkDriveWindow.vue
@@ -14,19 +14,19 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header>
{{ i18n.ts.drive }}
</template>
- <XDrive :initialFolder="initialFolder"/>
+ <MkDrive :initialFolder="initialFolder"/>
</MkWindow>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
-import XDrive from '@/components/MkDrive.vue';
+import MkDrive from '@/components/MkDrive.vue';
import MkWindow from '@/components/MkWindow.vue';
import { i18n } from '@/i18n.js';
defineProps<{
- initialFolder?: Misskey.entities.DriveFolder;
+ initialFolder?: Misskey.entities.DriveFolder | null;
}>();
const emit = defineEmits<{
diff --git a/packages/frontend/src/components/MkFormDialog.file.vue b/packages/frontend/src/components/MkFormDialog.file.vue
index 0a902f3400..a11075c342 100644
--- a/packages/frontend/src/components/MkFormDialog.file.vue
+++ b/packages/frontend/src/components/MkFormDialog.file.vue
@@ -15,7 +15,7 @@ import * as Misskey from 'misskey-js';
import { computed, ref } from 'vue';
import { i18n } from '@/i18n.js';
import MkButton from '@/components/MkButton.vue';
-import { selectFile } from '@/utility/select-file.js';
+import { selectFile } from '@/utility/drive.js';
import { misskeyApi } from '@/utility/misskey-api.js';
const props = defineProps<{
diff --git a/packages/frontend/src/components/MkModalWindow.vue b/packages/frontend/src/components/MkModalWindow.vue
index 19989e375b..6b81e353e6 100644
--- a/packages/frontend/src/components/MkModalWindow.vue
+++ b/packages/frontend/src/components/MkModalWindow.vue
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template>
<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="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>
<span :class="$style.title">
<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>
</div>
<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>
</MkModal>
@@ -48,10 +51,6 @@ const emit = defineEmits<{
}>();
const modal = useTemplateRef('modal');
-const rootEl = useTemplateRef('rootEl');
-const headerEl = useTemplateRef('headerEl');
-const bodyWidth = ref(0);
-const bodyHeight = ref(0);
function close() {
modal.value?.close();
@@ -61,23 +60,6 @@ function onBgClick() {
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({
close,
});
@@ -143,7 +125,14 @@ defineExpose({
.body {
flex: 1;
overflow: auto;
- background: var(--MI_THEME-panel);
+ background: var(--MI_THEME-bg);
container-type: size;
}
+
+.footer {
+ padding: 8px 16px;
+ overflow: auto;
+ background: var(--MI_THEME-bg);
+ border-top: 1px solid var(--MI_THEME-divider);
+}
</style>
diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue
index 5114e98494..d07ee2a978 100644
--- a/packages/frontend/src/components/MkPostForm.vue
+++ b/packages/frontend/src/components/MkPostForm.vue
@@ -71,7 +71,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="maxTextLength - textLength < 100" :class="['_acrylic', $style.textCount, { [$style.textOver]: textLength > maxTextLength }]">{{ maxTextLength - textLength }}</div>
</div>
<input v-show="withHashtags" ref="hashtagsInputEl" v-model="hashtags" :class="$style.hashtags" :placeholder="i18n.ts.hashtags" list="hashtags">
- <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName" @replaceFile="replaceFile"/>
+ <XPostFormAttaches v-model="files" @detach="detachFile" @changeSensitive="updateFileSensitive" @changeName="updateFileName"/>
<MkPollEditor v-if="poll" v-model="poll" @destroyed="poll = null"/>
<MkNotePreview v-if="showPreview" :class="$style.preview" :text="text" :files="files" :poll="poll ?? undefined" :useCw="useCw" :cw="cw" :user="postAccount ?? $i"/>
<div v-if="showingOptions" style="padding: 8px 16px;">
@@ -120,14 +120,13 @@ import { formatTimeString } from '@/utility/format-time-string.js';
import { Autocomplete } from '@/utility/autocomplete.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/utility/misskey-api.js';
-import { selectFiles } from '@/utility/select-file.js';
+import { selectFiles } from '@/utility/drive.js';
import { store } from '@/store.js';
import MkInfo from '@/components/MkInfo.vue';
import { i18n } from '@/i18n.js';
import { instance } from '@/instance.js';
import { ensureSignin, notesCount, incNotesCount } from '@/i.js';
import { getAccounts, openAccountMenu as openAccountMenu_ } from '@/accounts.js';
-import { uploadFile } from '@/utility/upload.js';
import { deepClone } from '@/utility/clone.js';
import MkRippleEffect from '@/components/MkRippleEffect.vue';
import { miLocalStorage } from '@/local-storage.js';
@@ -138,6 +137,7 @@ import { prefer } from '@/preferences.js';
import { getPluginHandlers } from '@/plugin.js';
import { DI } from '@/di.js';
import { globalEvents } from '@/events.js';
+import { checkDragDataType, getDragData } from '@/drag-and-drop.js';
const $i = ensureSignin();
@@ -459,18 +459,6 @@ function updateFileName(file, name) {
files.value[files.value.findIndex(x => x.id === file.id)].name = name;
}
-function replaceFile(file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void {
- files.value[files.value.findIndex(x => x.id === file.id)] = newFile;
-}
-
-function upload(file: File, name?: string): void {
- if (props.mock) return;
-
- uploadFile(file, prefer.s.uploadFolder, name).then(res => {
- files.value.push(res);
- });
-}
-
function setVisibility() {
if (props.channel) {
visibility.value = 'public';
@@ -651,16 +639,25 @@ async function onPaste(ev: ClipboardEvent) {
if (props.mock) return;
if (!ev.clipboardData) return;
+ let pastedFiles: File[] = [];
for (const { item, i } of Array.from(ev.clipboardData.items, (data, x) => ({ item: data, i: x }))) {
if (item.kind === 'file') {
const file = item.getAsFile();
if (!file) continue;
const lio = file.name.lastIndexOf('.');
const ext = lio >= 0 ? file.name.slice(lio) : '';
- const formatted = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
- upload(file, formatted);
+ const formattedName = `${formatTimeString(new Date(file.lastModified), pastedFileName).replace(/{{number}}/g, `${i + 1}`)}${ext}`;
+ const renamedFile = new File([file], formattedName, { type: file.type });
+ pastedFiles.push(renamedFile);
}
}
+ if (pastedFiles.length > 0) {
+ ev.preventDefault();
+ os.launchUploader(pastedFiles, {}).then(driveFiles => {
+ files.value.push(...driveFiles);
+ });
+ return;
+ }
const paste = ev.clipboardData.getData('text');
@@ -693,7 +690,9 @@ async function onPaste(ev: ClipboardEvent) {
const fileName = formatTimeString(new Date(), pastedFileName).replace(/{{number}}/g, '0');
const file = new File([paste], `${fileName}.txt`, { type: 'text/plain' });
- upload(file, `${fileName}.txt`);
+ os.launchUploader([file], {}).then(driveFiles => {
+ files.value.push(...driveFiles);
+ });
});
}
}
@@ -701,8 +700,7 @@ async function onPaste(ev: ClipboardEvent) {
function onDragover(ev) {
if (!ev.dataTransfer.items[0]) return;
const isFile = ev.dataTransfer.items[0].kind === 'file';
- const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_;
- if (isFile || isDriveFile) {
+ if (isFile || checkDragDataType(ev, ['driveFiles'])) {
ev.preventDefault();
draghover.value = true;
switch (ev.dataTransfer.effectAllowed) {
@@ -738,16 +736,19 @@ function onDrop(ev: DragEvent): void {
// ファイルだったら
if (ev.dataTransfer && ev.dataTransfer.files.length > 0) {
ev.preventDefault();
- for (const x of Array.from(ev.dataTransfer.files)) upload(x);
+ os.launchUploader(Array.from(ev.dataTransfer.files), {}).then(driveFiles => {
+ files.value.push(...driveFiles);
+ });
return;
}
//#region ドライブのファイル
- const driveFile = ev.dataTransfer?.getData(_DATA_TRANSFER_DRIVE_FILE_);
- if (driveFile != null && driveFile !== '') {
- const file = JSON.parse(driveFile);
- files.value.push(file);
- ev.preventDefault();
+ {
+ const droppedData = getDragData(ev, 'driveFiles');
+ if (droppedData != null) {
+ files.value.push(...droppedData);
+ ev.preventDefault();
+ }
}
//#endregion
}
diff --git a/packages/frontend/src/components/MkPostFormAttaches.vue b/packages/frontend/src/components/MkPostFormAttaches.vue
index e8404cbd4f..dd594ef7f1 100644
--- a/packages/frontend/src/components/MkPostFormAttaches.vue
+++ b/packages/frontend/src/components/MkPostFormAttaches.vue
@@ -43,6 +43,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { i18n } from '@/i18n.js';
import { prefer } from '@/preferences.js';
import { DI } from '@/di.js';
+import { globalEvents } from '@/events.js';
const Sortable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default));
@@ -58,7 +59,6 @@ const emit = defineEmits<{
(ev: 'detach', id: string): void;
(ev: 'changeSensitive', file: Misskey.entities.DriveFile, isSensitive: boolean): void;
(ev: 'changeName', file: Misskey.entities.DriveFile, newName: string): void;
- (ev: 'replaceFile', file: Misskey.entities.DriveFile, newFile: Misskey.entities.DriveFile): void;
}>();
let menuShowing = false;
@@ -82,12 +82,13 @@ async function detachAndDeleteMedia(file: Misskey.entities.DriveFile) {
type: 'warning',
text: i18n.tsx.driveFileDeleteConfirm({ name: file.name }),
});
-
if (canceled) return;
- os.apiWithDialog('drive/files/delete', {
+ await os.apiWithDialog('drive/files/delete', {
fileId: file.id,
});
+
+ globalEvents.emit('driveFilesDeleted', [file]);
}
function toggleSensitive(file) {
@@ -142,13 +143,6 @@ async function describe(file: Misskey.entities.DriveFile) {
});
}
-async function crop(file: Misskey.entities.DriveFile): Promise<void> {
- if (mock) return;
-
- const newFile = await os.cropImage(file, { aspectRatio: NaN });
- emit('replaceFile', file, newFile);
-}
-
function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | KeyboardEvent): void {
if (menuShowing) return;
@@ -172,10 +166,6 @@ function showFileMenu(file: Misskey.entities.DriveFile, ev: MouseEvent | Keyboar
if (isImage) {
menuItems.push({
- text: i18n.ts.cropImage,
- icon: 'ti ti-crop',
- action: () : void => { crop(file); },
- }, {
text: i18n.ts.preview,
icon: 'ti ti-photo-search',
action: () => {
diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue
index d8dfbd1655..6c7bf6be6b 100644
--- a/packages/frontend/src/components/MkPreview.vue
+++ b/packages/frontend/src/components/MkPreview.vue
@@ -18,8 +18,8 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkRadio v-model="radio" value="pleroma">Pleroma</MkRadio>
</div>
<div :class="$style.preview__content1__button">
- <MkButton inline>This is</MkButton>
- <MkButton inline primary>the button</MkButton>
+ <MkButton inline>This is</MkButton>
+ <MkButton inline primary>the button</MkButton>
</div>
</div>
<div :class="$style.preview__content2" style="pointer-events: none;">
@@ -36,14 +36,15 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref } from 'vue';
+import * as config from '@@/js/config.js';
import MkButton from '@/components/MkButton.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkRadio from '@/components/MkRadio.vue';
import * as os from '@/os.js';
-import * as config from '@@/js/config.js';
import { $i } from '@/i.js';
+import { chooseDriveFile } from '@/utility/drive.js';
const text = ref('');
const flag = ref(true);
@@ -79,7 +80,9 @@ const openForm = async () => {
};
const openDrive = async () => {
- await os.selectDriveFile(false);
+ await chooseDriveFile({
+ multiple: false,
+ });
};
const selectUser = async () => {
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>
diff --git a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
index 30925b854c..4e96eff82e 100644
--- a/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
+++ b/packages/frontend/src/components/MkUserSetupDialog.Profile.vue
@@ -37,7 +37,6 @@ import MkInput from '@/components/MkInput.vue';
import MkTextarea from '@/components/MkTextarea.vue';
import FormSlot from '@/components/form/slot.vue';
import MkInfo from '@/components/MkInfo.vue';
-import { chooseFileFromPc } from '@/utility/select-file.js';
import * as os from '@/os.js';
import { ensureSignin } from '@/i.js';
@@ -49,7 +48,7 @@ const description = ref($i.description ?? '');
watch(name, () => {
os.apiWithDialog('i/update', {
// 空文字列をnullにしたいので??は使うな
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+
name: name.value || null,
}, undefined, {
'0b3f9f6a-2f4d-4b1f-9fb4-49d3a2fd7191': {
@@ -62,36 +61,37 @@ watch(name, () => {
watch(description, () => {
os.apiWithDialog('i/update', {
// 空文字列をnullにしたいので??は使うな
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+
description: description.value || null,
});
});
-function setAvatar(ev) {
- chooseFileFromPc(false).then(async (files) => {
- const file = files[0];
+async function setAvatar(ev) {
+ const files = await os.chooseFileFromPc({ multiple: false });
+ const file = files[0];
- let originalOrCropped = file;
+ let originalOrCropped = file;
- const { canceled } = await os.confirm({
- type: 'question',
- text: i18n.ts.cropImageAsk,
- okText: i18n.ts.cropYes,
- cancelText: i18n.ts.cropNo,
+ const { canceled } = await os.confirm({
+ type: 'question',
+ text: i18n.ts.cropImageAsk,
+ okText: i18n.ts.cropYes,
+ cancelText: i18n.ts.cropNo,
+ });
+
+ if (!canceled) {
+ originalOrCropped = await os.cropImageFile(file, {
+ aspectRatio: 1,
});
+ }
- if (!canceled) {
- originalOrCropped = await os.cropImage(file, {
- aspectRatio: 1,
- });
- }
+ const driveFile = (await os.launchUploader([originalOrCropped], { multiple: false }))[0];
- const i = await os.apiWithDialog('i/update', {
- avatarId: originalOrCropped.id,
- });
- $i.avatarId = i.avatarId;
- $i.avatarUrl = i.avatarUrl;
+ const i = await os.apiWithDialog('i/update', {
+ avatarId: driveFile.id,
});
+ $i.avatarId = i.avatarId;
+ $i.avatarUrl = i.avatarUrl;
}
</script>
diff --git a/packages/frontend/src/components/global/MkSystemIcon.vue b/packages/frontend/src/components/global/MkSystemIcon.vue
index 3454cdc9f2..d2ef0fb2d8 100644
--- a/packages/frontend/src/components/global/MkSystemIcon.vue
+++ b/packages/frontend/src/components/global/MkSystemIcon.vue
@@ -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]"/>
<circle cx="80" cy="80" r="56" style="--l:350;" :class="[$style.line, $style.animCircle]"/>
</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>
<script lang="ts" setup>
import {} from 'vue';
const props = defineProps<{
- type: 'info' | 'question' | 'success' | 'warn' | 'error';
+ type: 'info' | 'question' | 'success' | 'warn' | 'error' | 'waiting';
}>();
</script>
@@ -62,6 +66,10 @@ const props = defineProps<{
&.error {
color: var(--MI_THEME-error);
}
+
+ &.waiting {
+ color: var(--MI_THEME-accent);
+ }
}
.line {
@@ -87,6 +95,13 @@ const props = defineProps<{
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 {
opacity: 0;
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 {
0% {
opacity: 0;