summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorかっこかり <67428053+kakkokari-gtyih@users.noreply.github.com>2023-10-10 10:43:43 +0900
committerGitHub <noreply@github.com>2023-10-10 10:43:43 +0900
commitaf1087aed4afd0a34206faaab93f7598bc10e4f2 (patch)
tree1a78985d9113362da0ae0d689d1eed11f238b8b1
parentfix of 0bb0c32908 (diff)
downloadsharkey-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.md4
-rw-r--r--locales/index.d.ts9
-rw-r--r--locales/ja-JP.yml9
-rw-r--r--packages/backend/src/server/api/endpoints/drive/files/attached-notes.ts12
-rw-r--r--packages/frontend/src/components/MkDrive.file.vue5
-rw-r--r--packages/frontend/src/pages/drive.file.info.vue302
-rw-r--r--packages/frontend/src/pages/drive.file.notes.vue33
-rw-r--r--packages/frontend/src/pages/drive.file.vue52
-rw-r--r--packages/frontend/src/router.ts4
-rw-r--r--packages/frontend/src/scripts/get-drive-file-menu.ts7
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,