Feat:「ファイルの詳細」ページを追加 (#11995)

* (add) ファイルビューア

* Update Changelog

* 既存のAPIを利用

* run api extratctor

* Change i18n

* (add) ページに関する説明を追加

* Update CHANGELOG

* (fix) design, classes
This commit is contained in:
かっこかり 2023-10-10 10:43:43 +09:00 committed by GitHub
parent 9f33ce1cd0
commit af1087aed4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 432 additions and 5 deletions

View file

@ -21,6 +21,7 @@
### Changes ### Changes
- API: users/notes, notes/local-timeline で fileType 指定はできなくなりました - API: users/notes, notes/local-timeline で fileType 指定はできなくなりました
- API: notes/featured でページネーションは他APIと同様 untilId を使って行うようになりました - API: notes/featured でページネーションは他APIと同様 untilId を使って行うようになりました
- API: drive/files/attached-notes がページネーションに対応しました
### General ### General
- Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました - Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました
@ -31,6 +32,9 @@
- Feat: タイムラインがリアルタイム更新中に広告を挿入できるようになりました - Feat: タイムラインがリアルタイム更新中に広告を挿入できるようになりました
- デフォルトは無効 - デフォルトは無効
- 頻度はコントロールパネルから設定できます。運営中のサーバーのTLの流速を見て、最適な値を指定してください。 - 頻度はコントロールパネルから設定できます。運営中のサーバーのTLの流速を見て、最適な値を指定してください。
- Feat: 「ファイルの詳細」ページを追加
- ドライブのファイルの拡大プレビューができるように
- ファイルが添付されたノートの一覧が表示できるように
- Enhance: ソフトワードミュートとハードワードミュートは統合されました - Enhance: ソフトワードミュートとハードワードミュートは統合されました
- Enhance: モデレーションログ機能の強化 - Enhance: モデレーションログ機能の強化
- Enhance: ローカリゼーションの更新 - Enhance: ローカリゼーションの更新

9
locales/index.d.ts vendored
View file

@ -2294,6 +2294,15 @@ export interface Locale {
"deleteAd": string; "deleteAd": string;
"updateAd": string; "updateAd": string;
}; };
"_fileViewer": {
"title": string;
"type": string;
"size": string;
"url": string;
"uploadedAt": string;
"attachedNotes": string;
"thisPageCanBeSeenFromTheAuthor": string;
};
} }
declare const locales: { declare const locales: {
[lang: string]: Locale; [lang: string]: Locale;

View file

@ -2206,3 +2206,12 @@ _moderationLogTypes:
createAd: "広告を作成" createAd: "広告を作成"
deleteAd: "広告を削除" deleteAd: "広告を削除"
updateAd: "広告を更新" updateAd: "広告を更新"
_fileViewer:
title: "ファイルの詳細"
type: "ファイルタイプ"
size: "ファイルサイズ"
url: "URL"
uploadedAt: "追加日"
attachedNotes: "添付されているノート"
thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。"

View file

@ -6,6 +6,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { NotesRepository, DriveFilesRepository } from '@/models/_.js'; import type { NotesRepository, DriveFilesRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
@ -41,6 +42,9 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
fileId: { type: 'string', format: 'misskey:id' }, fileId: { type: 'string', format: 'misskey:id' },
}, },
required: ['fileId'], required: ['fileId'],
@ -56,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private queryService: QueryService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
// Fetch file // Fetch file
@ -68,9 +73,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchFile); throw new ApiError(meta.errors.noSuchFile);
} }
const notes = await this.notesRepository.createQueryBuilder('note') const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId);
.where(':file = ANY(note.fileIds)', { file: file.id }) query.andWhere(':file = ANY(note.fileIds)', { file: file.id });
.getMany();
const notes = await query.limit(ps.limit).getMany();
return await this.noteEntityService.packMany(notes, me, { return await this.noteEntityService.packMany(notes, me, {
detail: true, detail: true,

View file

@ -45,8 +45,11 @@ import bytes from '@/filters/bytes.js';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { useRouter } from '@/router.js';
import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js';
const router = useRouter();
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
file: Misskey.entities.DriveFile; file: Misskey.entities.DriveFile;
folder: Misskey.entities.DriveFolder | null; folder: Misskey.entities.DriveFolder | null;
@ -71,7 +74,7 @@ function onClick(ev: MouseEvent) {
if (props.selectMode) { if (props.selectMode) {
emit('chosen', props.file); emit('chosen', props.file);
} else { } else {
os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); router.push(`/my/drive/file/${props.file.id}`);
} }
} }

View 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>

View 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>

View 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>

View file

@ -467,6 +467,10 @@ export const routes = [{
path: '/my/drive', path: '/my/drive',
component: page(() => import('./pages/drive.vue')), component: page(() => import('./pages/drive.vue')),
loginRequired: true, loginRequired: true,
}, {
path: '/my/drive/file/:fileId',
component: page(() => import('./pages/drive.file.vue')),
loginRequired: true,
}, { }, {
path: '/my/follow-requests', path: '/my/follow-requests',
component: page(() => import('./pages/follow-requests.vue')), component: page(() => import('./pages/follow-requests.vue')),

View file

@ -27,7 +27,7 @@ function rename(file: Misskey.entities.DriveFile) {
function describe(file: Misskey.entities.DriveFile) { function describe(file: Misskey.entities.DriveFile) {
os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), {
default: file.comment != null ? file.comment : '', default: file.comment ?? '',
file: file, file: file,
}, { }, {
done: caption => { done: caption => {
@ -112,6 +112,11 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss
text: i18n.ts.download, text: i18n.ts.download,
icon: 'ti ti-download', icon: 'ti ti-download',
download: file.name, download: file.name,
}, null, {
type: 'link',
to: `/my/drive/file/${file.id}`,
text: i18n.ts._fileViewer.title,
icon: 'ti ti-file',
}, null, { }, null, {
text: i18n.ts.delete, text: i18n.ts.delete,
icon: 'ti ti-trash', icon: 'ti ti-trash',