Feat:「ファイルの詳細」ページを追加 (#11995)
* (add) ファイルビューア * Update Changelog * 既存のAPIを利用 * run api extratctor * Change i18n * (add) ページに関する説明を追加 * Update CHANGELOG * (fix) design, classes
This commit is contained in:
		
							
								
								
									
										302
									
								
								packages/frontend/src/pages/drive.file.info.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										302
									
								
								packages/frontend/src/pages/drive.file.info.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,302 @@
 | 
			
		||||
<!--
 | 
			
		||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
-->
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div class="_gaps">
 | 
			
		||||
	<MkInfo>{{ i18n.ts._fileViewer.thisPageCanBeSeenFromTheAuthor }}</MkInfo>
 | 
			
		||||
	<MkLoading v-if="fetching"/>
 | 
			
		||||
	<div v-else-if="file" class="_gaps">
 | 
			
		||||
		<div :class="$style.filePreviewRoot">
 | 
			
		||||
			<MkMediaList :mediaList="[file]"></MkMediaList>
 | 
			
		||||
		</div>
 | 
			
		||||
		<div :class="$style.fileQuickActionsRoot">
 | 
			
		||||
			<button class="_button" :class="$style.fileNameEditBtn" @click="rename()">
 | 
			
		||||
				<h2 class="_nowrap" :class="$style.fileName">{{ file.name }}</h2>
 | 
			
		||||
				<i class="ti ti-pencil" :class="$style.fileNameEditIcon"></i>
 | 
			
		||||
			</button>
 | 
			
		||||
			<div :class="$style.fileQuickActionsOthers">
 | 
			
		||||
				<button v-tooltip="i18n.ts.createNoteFromTheFile" class="_button" :class="$style.fileQuickActionsOthersButton" @click="postThis()">
 | 
			
		||||
					<i class="ti ti-pencil"></i>
 | 
			
		||||
				</button>
 | 
			
		||||
				<button v-if="isImage" v-tooltip="i18n.ts.cropImage" class="_button" :class="$style.fileQuickActionsOthersButton" @click="crop()">
 | 
			
		||||
					<i class="ti ti-crop"></i>
 | 
			
		||||
				</button>
 | 
			
		||||
				<button v-if="file.isSensitive" v-tooltip="i18n.ts.unmarkAsSensitive" class="_button" :class="$style.fileQuickActionsOthersButton" @click="toggleSensitive()">
 | 
			
		||||
					<i class="ti ti-eye"></i>
 | 
			
		||||
				</button>
 | 
			
		||||
				<button v-else v-tooltip="i18n.ts.markAsSensitive" class="_button" :class="$style.fileQuickActionsOthersButton" @click="toggleSensitive()">
 | 
			
		||||
					<i class="ti ti-eye-exclamation"></i>
 | 
			
		||||
				</button>
 | 
			
		||||
				<a v-tooltip="i18n.ts.download" :href="file.url" :download="file.name" class="_button" :class="$style.fileQuickActionsOthersButton">
 | 
			
		||||
					<i class="ti ti-download"></i>
 | 
			
		||||
				</a>
 | 
			
		||||
				<button v-tooltip="i18n.ts.delete" class="_button" :class="[$style.fileQuickActionsOthersButton, $style.danger]" @click="deleteFile()">
 | 
			
		||||
					<i class="ti ti-trash"></i>
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
		<div>
 | 
			
		||||
			<button class="_button" :class="$style.fileAltEditBtn" @click="describe()">
 | 
			
		||||
				<MkKeyValue>
 | 
			
		||||
					<template #key>{{ i18n.ts.description }}</template>
 | 
			
		||||
					<template #value>{{ file.comment ? file.comment : `(${i18n.ts.none})` }}<i class="ti ti-pencil" :class="$style.fileAltEditIcon"></i></template>
 | 
			
		||||
				</MkKeyValue>
 | 
			
		||||
			</button>
 | 
			
		||||
			<MkKeyValue :class="$style.fileMetaDataChildren">
 | 
			
		||||
				<template #key>{{ i18n.ts._fileViewer.uploadedAt }}</template>
 | 
			
		||||
				<template #value><MkTime :time="file.createdAt" mode="detail"/></template>
 | 
			
		||||
			</MkKeyValue>
 | 
			
		||||
			<MkKeyValue :class="$style.fileMetaDataChildren">
 | 
			
		||||
				<template #key>{{ i18n.ts._fileViewer.type }}</template>
 | 
			
		||||
				<template #value>{{ file.type }}</template>
 | 
			
		||||
			</MkKeyValue>
 | 
			
		||||
			<MkKeyValue :class="$style.fileMetaDataChildren">
 | 
			
		||||
				<template #key>{{ i18n.ts._fileViewer.size }}</template>
 | 
			
		||||
				<template #value>{{ bytes(file.size) }}</template>
 | 
			
		||||
			</MkKeyValue>
 | 
			
		||||
		</div>
 | 
			
		||||
	</div>
 | 
			
		||||
	<div v-else class="_fullinfo">
 | 
			
		||||
		<img :src="infoImageUrl" class="_ghost"/>
 | 
			
		||||
		<div>{{ i18n.ts.nothing }}</div>
 | 
			
		||||
	</div>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script setup lang="ts">
 | 
			
		||||
import { ref, computed, defineAsyncComponent, onMounted } from 'vue';
 | 
			
		||||
import * as Misskey from 'misskey-js';
 | 
			
		||||
import MkInfo from '@/components/MkInfo.vue';
 | 
			
		||||
import MkMediaList from '@/components/MkMediaList.vue';
 | 
			
		||||
import MkKeyValue from '@/components/MkKeyValue.vue';
 | 
			
		||||
import bytes from '@/filters/bytes.js';
 | 
			
		||||
import { infoImageUrl } from '@/instance.js';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import * as os from '@/os.js';
 | 
			
		||||
import { useRouter } from '@/router.js';
 | 
			
		||||
 | 
			
		||||
const router = useRouter();
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	fileId: string;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
const fetching = ref(true);
 | 
			
		||||
const file = ref<Misskey.entities.DriveFile>();
 | 
			
		||||
const isImage = computed(() => file.value?.type.startsWith('image/'));
 | 
			
		||||
 | 
			
		||||
async function fetch() {
 | 
			
		||||
	fetching.value = true;
 | 
			
		||||
 | 
			
		||||
	file.value = await os.api('drive/files/show', {
 | 
			
		||||
		fileId: props.fileId,
 | 
			
		||||
	}).catch((err) => {
 | 
			
		||||
		console.error(err);
 | 
			
		||||
		return undefined;
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	fetching.value = false;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function postThis() {
 | 
			
		||||
	if (!file.value) return;
 | 
			
		||||
 | 
			
		||||
	os.post({
 | 
			
		||||
		initialFiles: [file.value],
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function crop() {
 | 
			
		||||
	if (!file.value) return;
 | 
			
		||||
 | 
			
		||||
	os.cropImage(file.value, {
 | 
			
		||||
		aspectRatio: NaN,
 | 
			
		||||
		uploadFolder: file.value.folderId ?? null,
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function toggleSensitive() {
 | 
			
		||||
	if (!file.value) return;
 | 
			
		||||
 | 
			
		||||
	os.apiWithDialog('drive/files/update', {
 | 
			
		||||
		fileId: file.value.id,
 | 
			
		||||
		isSensitive: !file.value.isSensitive,
 | 
			
		||||
	}).then(async () => {
 | 
			
		||||
		await fetch();
 | 
			
		||||
	}).catch(err => {
 | 
			
		||||
		os.alert({
 | 
			
		||||
			type: 'error',
 | 
			
		||||
			title: i18n.ts.error,
 | 
			
		||||
			text: err.message,
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function rename() {
 | 
			
		||||
	if (!file.value) return;
 | 
			
		||||
 | 
			
		||||
	os.inputText({
 | 
			
		||||
		title: i18n.ts.renameFile,
 | 
			
		||||
		placeholder: i18n.ts.inputNewFileName,
 | 
			
		||||
		default: file.value.name,
 | 
			
		||||
	}).then(({ canceled, result: name }) => {
 | 
			
		||||
		if (canceled) return;
 | 
			
		||||
		os.apiWithDialog('drive/files/update', {
 | 
			
		||||
			fileId: file.value.id,
 | 
			
		||||
			name: name,
 | 
			
		||||
		}).then(async () => {
 | 
			
		||||
			await fetch();
 | 
			
		||||
		});
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function describe() {
 | 
			
		||||
	if (!file.value) return;
 | 
			
		||||
 | 
			
		||||
	os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
 | 
			
		||||
		default: file.value.comment ?? '',
 | 
			
		||||
		file: file.value,
 | 
			
		||||
	}, {
 | 
			
		||||
		done: caption => {
 | 
			
		||||
			os.apiWithDialog('drive/files/update', {
 | 
			
		||||
				fileId: file.value.id,
 | 
			
		||||
				comment: caption.length === 0 ? null : caption,
 | 
			
		||||
			}).then(async () => {
 | 
			
		||||
				await fetch();
 | 
			
		||||
			});
 | 
			
		||||
		},
 | 
			
		||||
	}, 'closed');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function deleteFile() {
 | 
			
		||||
	if (!file.value) return;
 | 
			
		||||
 | 
			
		||||
	const { canceled } = await os.confirm({
 | 
			
		||||
		type: 'warning',
 | 
			
		||||
		text: i18n.t('driveFileDeleteConfirm', { name: file.value.name }),
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	if (canceled) return;
 | 
			
		||||
	await os.apiWithDialog('drive/files/delete', {
 | 
			
		||||
		fileId: file.value.id,
 | 
			
		||||
	});
 | 
			
		||||
 | 
			
		||||
	router.push('/my/drive');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
	await fetch();
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<style lang="scss" module>
 | 
			
		||||
 | 
			
		||||
.filePreviewRoot {
 | 
			
		||||
	background: var(--panel);
 | 
			
		||||
	border-radius: var(--radius);
 | 
			
		||||
	// MkMediaList 内の上部マージン 4px
 | 
			
		||||
	padding: calc(1rem - 4px) 1rem 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fileQuickActionsRoot {
 | 
			
		||||
	display: flex;
 | 
			
		||||
	flex-direction: column;
 | 
			
		||||
	gap: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@container (min-width: 500px) {
 | 
			
		||||
	.fileQuickActionsRoot {
 | 
			
		||||
		flex-direction: row;
 | 
			
		||||
		align-items: center;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fileQuickActionsOthers {
 | 
			
		||||
	margin-left: auto;
 | 
			
		||||
	margin-right: 1rem;
 | 
			
		||||
	display: flex;
 | 
			
		||||
	gap: 8px;
 | 
			
		||||
 | 
			
		||||
	.fileQuickActionsOthersButton {
 | 
			
		||||
		padding: .5rem;
 | 
			
		||||
		border-radius: 99rem;
 | 
			
		||||
 | 
			
		||||
		&:hover,
 | 
			
		||||
		&:focus-visible {
 | 
			
		||||
			background-color: var(--accentedBg);
 | 
			
		||||
			color: var(--accent);
 | 
			
		||||
			text-decoration: none;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		&.danger {
 | 
			
		||||
			color: #ff2a2a;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		&.danger:hover,
 | 
			
		||||
		&.danger:focus-visible {
 | 
			
		||||
			background-color: rgba(255, 42, 42, .15);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fileNameEditBtn {
 | 
			
		||||
	padding: .5rem 1rem;
 | 
			
		||||
	display: flex;
 | 
			
		||||
	align-items: center;
 | 
			
		||||
	min-width: 0;
 | 
			
		||||
	font-weight: 700;
 | 
			
		||||
	border-radius: var(--radius);
 | 
			
		||||
	font-size: .8rem;
 | 
			
		||||
 | 
			
		||||
	>.fileNameEditIcon {
 | 
			
		||||
		color: transparent;
 | 
			
		||||
		visibility: hidden;
 | 
			
		||||
		padding-left: .5rem;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	>.fileName {
 | 
			
		||||
		margin: 0;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	&:hover {
 | 
			
		||||
		background-color: var(--accentedBg);
 | 
			
		||||
 | 
			
		||||
		>.fileName,
 | 
			
		||||
		>.fileNameEditIcon {
 | 
			
		||||
			visibility: visible;
 | 
			
		||||
			color: var(--accent);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fileMetaDataChildren {
 | 
			
		||||
	padding: .5rem 1rem;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.fileAltEditBtn {
 | 
			
		||||
	text-align: start;
 | 
			
		||||
	display: block;
 | 
			
		||||
	width: 100%;
 | 
			
		||||
	padding: .5rem 1rem;
 | 
			
		||||
	border-radius: var(--radius);
 | 
			
		||||
 | 
			
		||||
	.fileAltEditIcon {
 | 
			
		||||
		display: inline-block;
 | 
			
		||||
		color: transparent;
 | 
			
		||||
		visibility: hidden;
 | 
			
		||||
		padding-left: .5rem;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	&:hover {
 | 
			
		||||
		color: var(--accent);
 | 
			
		||||
		background-color: var(--accentedBg);
 | 
			
		||||
 | 
			
		||||
		.fileAltEditIcon {
 | 
			
		||||
			color: var(--accent);
 | 
			
		||||
			visibility: visible;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
							
								
								
									
										33
									
								
								packages/frontend/src/pages/drive.file.notes.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										33
									
								
								packages/frontend/src/pages/drive.file.notes.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,33 @@
 | 
			
		||||
<!--
 | 
			
		||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
-->
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<div class="_gaps">
 | 
			
		||||
	<MkInfo>{{ i18n.ts._fileViewer.thisPageCanBeSeenFromTheAuthor }}</MkInfo>
 | 
			
		||||
	<MkNotes ref="tlComponent" :pagination="pagination"/>
 | 
			
		||||
</div>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { ref, computed } from 'vue';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import { Paging } from '@/components/MkPagination.vue';
 | 
			
		||||
import MkInfo from '@/components/MkInfo.vue';
 | 
			
		||||
import MkNotes from '@/components/MkNotes.vue';
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	fileId: string;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
const realFileId = computed(() => props.fileId);
 | 
			
		||||
 | 
			
		||||
const pagination = ref<Paging>({
 | 
			
		||||
	endpoint: 'drive/files/attached-notes',
 | 
			
		||||
	limit: 10,
 | 
			
		||||
	params: {
 | 
			
		||||
		fileId: realFileId.value,
 | 
			
		||||
	},
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
							
								
								
									
										52
									
								
								packages/frontend/src/pages/drive.file.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								packages/frontend/src/pages/drive.file.vue
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,52 @@
 | 
			
		||||
<!--
 | 
			
		||||
SPDX-FileCopyrightText: syuilo and other misskey contributors
 | 
			
		||||
SPDX-License-Identifier: AGPL-3.0-only
 | 
			
		||||
-->
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
<MkStickyContainer>
 | 
			
		||||
	<template #header>
 | 
			
		||||
		<MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/>
 | 
			
		||||
	</template>
 | 
			
		||||
 | 
			
		||||
	<MkSpacer v-if="tab === 'info'" :contentMax="800">
 | 
			
		||||
		<XFileInfo :fileId="fileId"/>
 | 
			
		||||
	</MkSpacer>
 | 
			
		||||
 | 
			
		||||
	<MkSpacer v-else-if="tab === 'notes'" :contentMax="800">
 | 
			
		||||
		<XNotes :fileId="fileId"/>
 | 
			
		||||
	</MkSpacer>
 | 
			
		||||
</MkStickyContainer>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<script lang="ts" setup>
 | 
			
		||||
import { computed, ref, defineAsyncComponent } from 'vue';
 | 
			
		||||
import { i18n } from '@/i18n.js';
 | 
			
		||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
 | 
			
		||||
 | 
			
		||||
const props = defineProps<{
 | 
			
		||||
	fileId: string;
 | 
			
		||||
}>();
 | 
			
		||||
 | 
			
		||||
const XFileInfo = defineAsyncComponent(() => import('./drive.file.info.vue'));
 | 
			
		||||
const XNotes = defineAsyncComponent(() => import('./drive.file.notes.vue'));
 | 
			
		||||
 | 
			
		||||
const tab = ref('info');
 | 
			
		||||
 | 
			
		||||
const headerActions = computed(() => []);
 | 
			
		||||
 | 
			
		||||
const headerTabs = computed(() => [{
 | 
			
		||||
	key: 'info',
 | 
			
		||||
	title: i18n.ts.info,
 | 
			
		||||
	icon: 'ti ti-info-circle',
 | 
			
		||||
}, {
 | 
			
		||||
	key: 'notes',
 | 
			
		||||
	title: i18n.ts._fileViewer.attachedNotes,
 | 
			
		||||
	icon: 'ti ti-pencil',
 | 
			
		||||
}]);
 | 
			
		||||
 | 
			
		||||
definePageMetadata(computed(() => ({
 | 
			
		||||
	title: i18n.ts._fileViewer.title,
 | 
			
		||||
	icon: 'ti ti-file',
 | 
			
		||||
})));
 | 
			
		||||
</script>
 | 
			
		||||
		Reference in New Issue
	
	Block a user