diff options
| author | かっこかり <67428053+kakkokari-gtyih@users.noreply.github.com> | 2023-10-10 10:43:43 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-10-10 10:43:43 +0900 |
| commit | af1087aed4afd0a34206faaab93f7598bc10e4f2 (patch) | |
| tree | 1a78985d9113362da0ae0d689d1eed11f238b8b1 | |
| parent | fix of 0bb0c32908 (diff) | |
| download | sharkey-af1087aed4afd0a34206faaab93f7598bc10e4f2.tar.gz sharkey-af1087aed4afd0a34206faaab93f7598bc10e4f2.tar.bz2 sharkey-af1087aed4afd0a34206faaab93f7598bc10e4f2.zip | |
Feat:「ファイルの詳細」ページを追加 (#11995)
* (add) ファイルビューア
* Update Changelog
* 既存のAPIを利用
* run api extratctor
* Change i18n
* (add) ページに関する説明を追加
* Update CHANGELOG
* (fix) design, classes
| -rw-r--r-- | CHANGELOG.md | 4 | ||||
| -rw-r--r-- | locales/index.d.ts | 9 | ||||
| -rw-r--r-- | locales/ja-JP.yml | 9 | ||||
| -rw-r--r-- | packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts | 12 | ||||
| -rw-r--r-- | packages/frontend/src/components/MkDrive.file.vue | 5 | ||||
| -rw-r--r-- | packages/frontend/src/pages/drive.file.info.vue | 302 | ||||
| -rw-r--r-- | packages/frontend/src/pages/drive.file.notes.vue | 33 | ||||
| -rw-r--r-- | packages/frontend/src/pages/drive.file.vue | 52 | ||||
| -rw-r--r-- | packages/frontend/src/router.ts | 4 | ||||
| -rw-r--r-- | packages/frontend/src/scripts/get-drive-file-menu.ts | 7 |
10 files changed, 432 insertions, 5 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index d0c3af806a..4b6f208d03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ ### Changes - API: users/notes, notes/local-timeline で fileType 指定はできなくなりました - API: notes/featured でページネーションは他APIと同様 untilId を使って行うようになりました +- API: drive/files/attached-notes がページネーションに対応しました ### General - Feat: ユーザーごとに他ユーザーへの返信をタイムラインに含めるか設定可能になりました @@ -31,6 +32,9 @@ - Feat: タイムラインがリアルタイム更新中に広告を挿入できるようになりました - デフォルトは無効 - 頻度はコントロールパネルから設定できます。運営中のサーバーのTLの流速を見て、最適な値を指定してください。 +- Feat: 「ファイルの詳細」ページを追加 + - ドライブのファイルの拡大プレビューができるように + - ファイルが添付されたノートの一覧が表示できるように - Enhance: ソフトワードミュートとハードワードミュートは統合されました - Enhance: モデレーションログ機能の強化 - Enhance: ローカリゼーションの更新 diff --git a/locales/index.d.ts b/locales/index.d.ts index 8a429e3b66..5227caa083 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -2294,6 +2294,15 @@ export interface Locale { "deleteAd": string; "updateAd": string; }; + "_fileViewer": { + "title": string; + "type": string; + "size": string; + "url": string; + "uploadedAt": string; + "attachedNotes": string; + "thisPageCanBeSeenFromTheAuthor": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 52e06e720d..bf179ca733 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -2206,3 +2206,12 @@ _moderationLogTypes: createAd: "広告を作成" deleteAd: "広告を削除" updateAd: "広告を更新" + +_fileViewer: + title: "ファイルの詳細" + type: "ファイルタイプ" + size: "ファイルサイズ" + url: "URL" + uploadedAt: "追加日" + attachedNotes: "添付されているノート" + thisPageCanBeSeenFromTheAuthor: "このページは、このファイルをアップロードしたユーザーしか閲覧できません。" diff --git a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts index 779231a856..14a13b09c9 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts @@ -6,6 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, DriveFilesRepository } from '@/models/_.js'; +import { QueryService } from '@/core/QueryService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { DI } from '@/di-symbols.js'; import { ApiError } from '../../../error.js'; @@ -41,6 +42,9 @@ export const meta = { export const paramDef = { type: 'object', 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' }, }, required: ['fileId'], @@ -56,6 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- private notesRepository: NotesRepository, private noteEntityService: NoteEntityService, + private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { // Fetch file @@ -68,9 +73,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint- throw new ApiError(meta.errors.noSuchFile); } - const notes = await this.notesRepository.createQueryBuilder('note') - .where(':file = ANY(note.fileIds)', { file: file.id }) - .getMany(); + const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId); + query.andWhere(':file = ANY(note.fileIds)', { file: file.id }); + + const notes = await query.limit(ps.limit).getMany(); return await this.noteEntityService.packMany(notes, me, { detail: true, diff --git a/packages/frontend/src/components/MkDrive.file.vue b/packages/frontend/src/components/MkDrive.file.vue index e3f96724d9..96704996f9 100644 --- a/packages/frontend/src/components/MkDrive.file.vue +++ b/packages/frontend/src/components/MkDrive.file.vue @@ -45,8 +45,11 @@ import bytes from '@/filters/bytes.js'; import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { $i } from '@/account.js'; +import { useRouter } from '@/router.js'; import { getDriveFileMenu } from '@/scripts/get-drive-file-menu.js'; +const router = useRouter(); + const props = withDefaults(defineProps<{ file: Misskey.entities.DriveFile; folder: Misskey.entities.DriveFolder | null; @@ -71,7 +74,7 @@ function onClick(ev: MouseEvent) { if (props.selectMode) { emit('chosen', props.file); } else { - os.popupMenu(getDriveFileMenu(props.file, props.folder), (ev.currentTarget ?? ev.target ?? undefined) as HTMLElement | undefined); + router.push(`/my/drive/file/${props.file.id}`); } } diff --git a/packages/frontend/src/pages/drive.file.info.vue b/packages/frontend/src/pages/drive.file.info.vue new file mode 100644 index 0000000000..ae9256b8e3 --- /dev/null +++ b/packages/frontend/src/pages/drive.file.info.vue @@ -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> diff --git a/packages/frontend/src/pages/drive.file.notes.vue b/packages/frontend/src/pages/drive.file.notes.vue new file mode 100644 index 0000000000..ee1a0ee9b0 --- /dev/null +++ b/packages/frontend/src/pages/drive.file.notes.vue @@ -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> diff --git a/packages/frontend/src/pages/drive.file.vue b/packages/frontend/src/pages/drive.file.vue new file mode 100644 index 0000000000..2c1e5d20a7 --- /dev/null +++ b/packages/frontend/src/pages/drive.file.vue @@ -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> diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts index 294f66aaaf..6c33d0d8ee 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -468,6 +468,10 @@ export const routes = [{ component: page(() => import('./pages/drive.vue')), loginRequired: true, }, { + path: '/my/drive/file/:fileId', + component: page(() => import('./pages/drive.file.vue')), + loginRequired: true, +}, { path: '/my/follow-requests', component: page(() => import('./pages/follow-requests.vue')), loginRequired: true, diff --git a/packages/frontend/src/scripts/get-drive-file-menu.ts b/packages/frontend/src/scripts/get-drive-file-menu.ts index 0964108249..8b2144a22f 100644 --- a/packages/frontend/src/scripts/get-drive-file-menu.ts +++ b/packages/frontend/src/scripts/get-drive-file-menu.ts @@ -27,7 +27,7 @@ function rename(file: Misskey.entities.DriveFile) { function describe(file: Misskey.entities.DriveFile) { os.popup(defineAsyncComponent(() => import('@/components/MkFileCaptionEditWindow.vue')), { - default: file.comment != null ? file.comment : '', + default: file.comment ?? '', file: file, }, { done: caption => { @@ -113,6 +113,11 @@ export function getDriveFileMenu(file: Misskey.entities.DriveFile, folder?: Miss icon: 'ti ti-download', download: file.name, }, null, { + type: 'link', + to: `/my/drive/file/${file.id}`, + text: i18n.ts._fileViewer.title, + icon: 'ti ti-file', + }, null, { text: i18n.ts.delete, icon: 'ti ti-trash', danger: true, |