From 1d6f43aa30a932b1b7539f417f19d0b239cde511 Mon Sep 17 00:00:00 2001
From: CyberRex <hspwinx86@gmail.com>
Date: Mon, 20 Mar 2023 12:58:06 +0900
Subject: [PATCH] feat: drive cleaner (#10366)

* feat: drive-cleaner

* Update CHANGELOG.md
---
 CHANGELOG.md                                  |   1 +
 locales/ja-JP.yml                             |   5 +
 .../src/server/api/endpoints/drive/files.ts   |  10 +
 .../src/pages/settings/drive-cleaner.vue      | 193 ++++++++++++++++++
 .../frontend/src/pages/settings/drive.vue     |   3 +
 packages/frontend/src/router.ts               |   4 +
 6 files changed, 216 insertions(+)
 create mode 100644 packages/frontend/src/pages/settings/drive-cleaner.vue

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6fb090f267..30b0ac018c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -43,6 +43,7 @@
 - Bookwyrmのユーザーのプロフィールページで「リモートで表示」をタップしても反応がない問題を修正
 - 非ログイン時の「Misskeyについて」の表示を修正
 - PC版にて「設定」「コントロールパネル」のリンクを2度以上続けてクリックした際に空白のページが表示される問題を修正
+- ドライブクリーナーを追加
 
 ### Server
 - OpenAPIエンドポイントを復旧
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index c4e86fc64a..54742cef96 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -977,6 +977,7 @@ notesSearchNotAvailable: "ノート検索は利用できません。"
 license: "ライセンス"
 unfavoriteConfirm: "お気に入り解除しますか?"
 myClips: "自分のクリップ"
+drivecleaner: "ドライブクリーナー"
 
 _achievements:
   earnedAt: "獲得日時"
@@ -1922,3 +1923,7 @@ _dialog:
 _disabledTimeline:
   title: "無効化されたタイムライン"
   description: "現在のロールでは、このタイムラインを使用することはできません。"
+
+_drivecleaner:
+  orderBySizeDesc: "サイズが大きい順"
+  orderByCreatedAtAsc: "追加日が古い順"
\ No newline at end of file
diff --git a/packages/backend/src/server/api/endpoints/drive/files.ts b/packages/backend/src/server/api/endpoints/drive/files.ts
index f6fad50fd9..4609307774 100644
--- a/packages/backend/src/server/api/endpoints/drive/files.ts
+++ b/packages/backend/src/server/api/endpoints/drive/files.ts
@@ -31,6 +31,7 @@ export const paramDef = {
 		untilId: { type: 'string', format: 'misskey:id' },
 		folderId: { type: 'string', format: 'misskey:id', nullable: true, default: null },
 		type: { type: 'string', nullable: true, pattern: /^[a-zA-Z\/\-*]+$/.toString().slice(1, -1) },
+		sort: { type: 'string', nullable: true, enum: ['+createdAt', '-createdAt', '+name', '-name', '+size', '-size'] },
 	},
 	required: [],
 } as const;
@@ -63,6 +64,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
 				}
 			}
 
+			switch (ps.sort) {
+				case '+createdAt': query.orderBy('file.createdAt', 'DESC'); break;
+				case '-createdAt': query.orderBy('file.createdAt', 'ASC'); break;
+				case '+name': query.orderBy('file.name', 'DESC'); break;
+				case '-name': query.orderBy('file.name', 'ASC'); break;
+				case '+size': query.orderBy('file.size', 'DESC'); break;
+				case '-size': query.orderBy('file.size', 'ASC'); break;
+			}
+
 			const files = await query.take(ps.limit).getMany();
 
 			return await this.driveFileEntityService.packMany(files, { detail: false, self: true });
diff --git a/packages/frontend/src/pages/settings/drive-cleaner.vue b/packages/frontend/src/pages/settings/drive-cleaner.vue
new file mode 100644
index 0000000000..ce8ab214e4
--- /dev/null
+++ b/packages/frontend/src/pages/settings/drive-cleaner.vue
@@ -0,0 +1,193 @@
+<template>
+<MkSelect v-model="sortModeSelect">
+	<template #label>{{ i18n.ts.sort }}</template>
+	<option v-for="x in sortOptions" :key="x.value" :value="x.value">{{ x.displayName }}</option>
+</MkSelect>
+<br>
+<div v-if="!fetching" class="_gap_m">
+	<MkPagination v-slot="{items}" :pagination="pagination" class="driveitem list">
+		<div
+			v-for="file in items"
+			:key="file.id"
+		>
+			<MkA
+				v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${dateString(file.createdAt)}`"
+				class="_button"
+				:to="`${file.url}`"
+				behavior="browser"
+				@contextmenu.stop="$event => onContextMenu($event, file.id)"
+			>
+				<div class="file">
+					<div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div>
+					<MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/>
+					<div class="body">
+						<div style="margin-bottom: 4px;">
+							{{ file.name }}
+						</div>
+						<div>
+							<span style="margin-right: 1em;">{{ file.type }}</span>
+							<span>{{ bytes(file.size) }}</span>
+						</div>
+						<div>
+							<span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span>
+						</div>
+						<div v-if="sortModeSelect === 'sizeDesc'">
+							<div class="uawsfosz">
+								<div class="meter"><div :style="genUsageBar(file.size)"></div></div>
+							</div>
+						</div>
+					</div>
+				</div>
+			</MkA>
+		</div>
+	</MkPagination>
+</div>
+<div v-else class="gap_m">
+	{{ i18n.ts.checking }} <MkEllipsis/>
+</div>
+</template>
+
+<script setup lang="ts">
+import { ref, watch } from 'vue';
+import tinycolor from 'tinycolor2';
+import * as os from '@/os';
+import MkPagination from '@/components/MkPagination.vue';
+import MkDriveFileThumbnail from '@/components/MkDriveFileThumbnail.vue';
+import { i18n } from '@/i18n';
+import bytes from '@/filters/bytes';
+import { dateString } from '@/filters/date';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import MkSelect from '@/components/MkSelect.vue';
+
+let sortMode = '+size';
+const pagination = {
+	endpoint: 'drive/files' as const,
+	limit: 10,
+	params: { sort: sortMode },
+};
+
+const sortOptions = [
+	{ value: 'sizeDesc', displayName: i18n.ts._drivecleaner.orderBySizeDesc },
+	{ value: 'createdAtAsc', displayName: i18n.ts._drivecleaner.orderByCreatedAtAsc },
+];
+
+const capacity = ref<number>(0);
+const usage = ref<number>(0);
+const fetching = ref(true);
+const sortModeSelect = ref('sizeDesc');
+
+fetchDriveInfo();
+
+watch(fetching, () => {
+	if (fetching.value) {
+		fetchDriveInfo();
+	}
+});
+
+watch(sortModeSelect, () => {
+	switch (sortModeSelect.value) {
+		case 'sizeDesc':
+			sortMode = '+size';
+			fetching.value = true;
+			break;
+		
+		case 'createdAtAsc':
+			sortMode = '-createdAt';
+			fetching.value = true;
+			break;
+	}
+});
+
+function fetchDriveInfo(): void {
+	os.api('drive').then(info => {
+		capacity.value = info.capacity;
+		usage.value = info.usage;
+		fetching.value = false;
+	});
+}
+
+function genUsageBar(fsize: number): object {
+	return {
+		width: `${fsize / usage.value * 100}%`,
+		background: tinycolor({ h: 180 - (fsize / usage.value * 180), s: 0.7, l: 0.5 }),
+	};
+}
+
+function onContextMenu(ev: MouseEvent, fileId: string): void {
+	const target = ev.target as HTMLElement;
+	const items = [
+		{
+			text: i18n.ts.delete,
+			icon: 'ti ti-trash-x',
+			danger: true,
+			action: async (): Promise<void> => {
+				const res = await os.confirm({
+					type: 'warning',
+					title: i18n.ts.delete,
+					text: i18n.ts.deleteConfirm,
+				});
+				if (!res.canceled) {
+					await os.apiWithDialog('drive/files/delete', { fileId: fileId });
+					fetching.value = true;
+				}
+			},
+		},
+	];
+	ev.preventDefault();
+	os.popupMenu(items, target, {
+		viaKeyboard: false,
+	});
+}
+
+definePageMetadata({
+	title: i18n.ts.drivecleaner,
+	icon: 'ti ti-trash',
+});
+</script>
+
+<style lang="scss" scoped>
+
+@use "sass:math";
+
+.file {
+	display: flex;
+	width: 100%;
+	box-sizing: border-box;
+	text-align: left;
+	align-items: center;
+	margin-bottom: 16px;
+
+	&:hover {
+		color: var(--accent);
+	}
+
+	> .thumbnail {
+		width: 128px;
+		height: 128px;
+	}
+
+	> .body {
+		margin-left: 0.3em;
+		padding: 8px;
+		flex: 1;
+
+		@media (max-width: 500px) {
+			font-size: 14px;
+		}
+	}
+}
+
+.uawsfosz {
+	> .meter {
+		$size: 12px;
+		background: rgba(0, 0, 0, 0.1);
+		border-radius: math.div($size, 2);
+		overflow: hidden;
+
+		> div {
+			height: $size;
+			border-radius: math.div($size, 2);
+		}
+	}
+}
+</style>
diff --git a/packages/frontend/src/pages/settings/drive.vue b/packages/frontend/src/pages/settings/drive.vue
index a23bdfe69e..d3fb422e01 100644
--- a/packages/frontend/src/pages/settings/drive.vue
+++ b/packages/frontend/src/pages/settings/drive.vue
@@ -32,6 +32,9 @@
 				<template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template>
 				<template #suffixIcon><i class="ti ti-folder"></i></template>
 			</FormLink>
+			<FormLink to="/settings/drive/cleaner">
+				{{ i18n.ts.drivecleaner }}
+			</FormLink>
 			<MkSwitch v-model="keepOriginalUploading">
 				<template #label>{{ i18n.ts.keepOriginalUploading }}</template>
 				<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template>
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index 590c5765fd..c8077edd28 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -65,6 +65,10 @@ export const routes = [{
 		path: '/drive',
 		name: 'drive',
 		component: page(() => import('./pages/settings/drive.vue')),
+	}, {
+		path: '/drive/cleaner',
+		name: 'drive',
+		component: page(() => import('./pages/settings/drive-cleaner.vue')),
 	}, {
 		path: '/notifications',
 		name: 'notifications',