From ea357b71a0894eb3f0f9ef032449d23b3325acd3 Mon Sep 17 00:00:00 2001
From: tmorio <20278135+tmorio@users.noreply.github.com>
Date: Sun, 7 Apr 2024 16:26:34 +0900
Subject: [PATCH] feat (client): add "Media" tab to user page

https://maniakey.com/notes/9rsfrmqm1a
https://codeberg.org/tmorio/firefish/commit/66a2d2fa5cbd4c49cdbbc0952a9566bf1f149232

Co-authored-by: naskya <m@naskya.net>
---
 locales/en-US.yml                             |   1 +
 locales/ja-JP.yml                             |   1 +
 .../client/src/components/MkNoteMedia.vue     | 291 ++++++++++++++++++
 .../client/src/components/MkNoteMediaList.vue |  24 ++
 .../src/components/global/MkPageHeader.vue    |   2 +-
 packages/client/src/pages/user/index.vue      |   7 +
 packages/client/src/pages/user/media-list.vue |  50 +++
 7 files changed, 375 insertions(+), 1 deletion(-)
 create mode 100644 packages/client/src/components/MkNoteMedia.vue
 create mode 100644 packages/client/src/components/MkNoteMediaList.vue
 create mode 100644 packages/client/src/pages/user/media-list.vue

diff --git a/locales/en-US.yml b/locales/en-US.yml
index 9dba9a4363..6e85c64ddf 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -708,6 +708,7 @@ display: "Display"
 copy: "Copy"
 metrics: "Metrics"
 overview: "Overview"
+media: "Media"
 logs: "Logs"
 delayed: "Delayed"
 database: "Database"
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index d48ab036b5..3ae4e5b093 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -638,6 +638,7 @@ display: "表示"
 copy: "コピー"
 metrics: "メトリクス"
 overview: "概要"
+media: "メディア"
 logs: "ログ"
 delayed: "遅延"
 database: "データベース"
diff --git a/packages/client/src/components/MkNoteMedia.vue b/packages/client/src/components/MkNoteMedia.vue
new file mode 100644
index 0000000000..d6bdea5ecf
--- /dev/null
+++ b/packages/client/src/components/MkNoteMedia.vue
@@ -0,0 +1,291 @@
+<template>
+  <div v-size="{ max: [350] }" class="media">
+    <button v-if="hide" class="hidden" @click="hide = false">
+      <ImgWithBlurhash
+          :hash="media.blurhash"
+          :title="media.comment"
+          :alt="media.comment"
+      />
+      <div class="text">
+        <div class="wrapper">
+          <b style="display: block"
+          ><i :class="icon('ph-warning')"></i>
+            {{ i18n.ts.sensitive }}</b
+          >
+          <span style="display: block">{{
+              i18n.ts.clickToShow
+            }}</span>
+        </div>
+      </div>
+    </button>
+    <template v-else>
+      <MkA :to="notePage(note)">
+        <div v-if="media.type.startsWith('image')">
+        <ImgWithBlurhash
+            :hash="media.blurhash"
+            :src="url"
+            :alt="media.comment"
+            :type="media.type"
+            :cover="false"
+            :largest-dimension="largestDimension"
+        />
+      </div>
+        <VuePlyr
+          v-if="media.type.startsWith('video')"
+          ref="plyr"
+          :options="{
+					controls: [],
+					disableContextMenu: false,
+				}"
+      >
+        <video
+            :poster="media.thumbnailUrl"
+            :aria-label="media.comment ?? undefined"
+            preload="none"
+            controls
+            playsinline
+            @contextmenu.stop
+        >
+          <source :src="media.url" :type="mediaType" />
+        </video>
+      </VuePlyr>
+      </MkA>
+    </template>
+		<div class="buttons">
+			<button
+				v-if="media.comment"
+				v-tooltip.noLabel="
+					`${i18n.ts.alt}: ${
+						media.comment.length > 200
+							? media.comment.trim().slice(0, 200) + '...'
+							: media.comment.trim()
+					}`
+				"
+				:aria-label="i18n.ts.alt"
+				class="_button"
+				@click.stop="captionPopup"
+			>
+				<i :class="icon('ph-subtitles')"></i>
+			</button>
+			<button
+				v-if="!hide"
+				v-tooltip="i18n.ts.hide"
+				class="_button"
+				@click.stop="hide = true"
+			>
+				<i :class="icon('ph-eye-slash')"></i>
+			</button>
+		</div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, watch } from "vue";
+import VuePlyr from "vue-plyr";
+import "vue-plyr/dist/vue-plyr.css";
+import type { entities } from "firefish-js";
+import { getStaticImageUrl } from "@/scripts/get-static-image-url";
+import ImgWithBlurhash from "@/components/MkImgWithBlurhash.vue";
+import { defaultStore } from "@/store";
+import { i18n } from "@/i18n";
+import * as os from "@/os";
+import icon from "@/scripts/icon";
+import { notePage } from "@/filters/note";
+
+const props = defineProps<{
+	note: entities.Note;
+	media: entities.DriveFile;
+	loadRawFiles?: boolean;
+}>();
+
+const hide = ref(true);
+
+const plyr = ref();
+
+const url =
+	props.loadRawFiles || defaultStore.state.loadRawImages
+		? props.media.url
+		: defaultStore.state.disableShowingAnimatedImages &&
+				props.media.type.startsWith("image")
+			? getStaticImageUrl(props.media.thumbnailUrl)
+			: props.media.thumbnailUrl;
+
+const mediaType = computed(() => {
+	return props.media.type === "video/quicktime"
+		? "video/mp4"
+		: props.media.type;
+});
+
+let largestDimension: "width" | "height";
+
+if (
+	props.media.type.startsWith("image") &&
+	props.media.properties?.width &&
+	props.media.properties?.height
+) {
+	largestDimension =
+		props.media.properties.width > props.media.properties.height
+			? "width"
+			: "height";
+}
+function captionPopup() {
+	os.alert({
+		type: "info",
+		text: props.media.comment,
+		isPlaintext: true,
+	});
+}
+
+// Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする
+watch(
+	() => props.media,
+	() => {
+		hide.value =
+			defaultStore.state.nsfw === "force"
+				? true
+				: props.media.isSensitive && defaultStore.state.nsfw !== "ignore";
+	},
+	{
+		deep: true,
+		immediate: true,
+	},
+);
+</script>
+
+<style lang="scss" scoped>
+.hidden {
+  all: unset;
+  position: relative;
+  width: 100%;
+  height: 100%;
+
+  > .text {
+    position: relative;
+    width: 100%;
+    height: 100%;
+    z-index: 1;
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    padding: 30px;
+    box-sizing: border-box;
+    background: rgba(0, 0, 0, 0.5);
+
+    > .wrapper {
+      display: table-cell;
+      text-align: center;
+      font-size: 0.8em;
+      color: #fff;
+    }
+  }
+
+  &:focus-visible {
+    border: 2px solid var(--accent);
+  }
+}
+
+.media {
+  position: relative;
+  background: var(--bg);
+
+  --plyr-color-main: var(--accent);
+
+  > .buttons {
+    display: flex;
+    gap: 4px;
+    position: absolute;
+    border-radius: 6px;
+    overflow: hidden;
+    top: 12px;
+    right: 12px;
+		z-index: 1;
+    > * {
+      background-color: var(--accentedBg);
+      -webkit-backdrop-filter: var(--blur, blur(15px));
+      backdrop-filter: var(--blur, blur(15px));
+      color: var(--accent);
+      font-size: 0.8em;
+      padding: 6px 8px;
+      text-align: center;
+    }
+  }
+
+  > a {
+    display: flex;
+    cursor: zoom-in;
+    overflow: hidden;
+    width: 100%;
+    height: 100%;
+    background-position: center;
+    background-size: contain;
+    background-repeat: no-repeat;
+    box-sizing: border-box;
+    justify-content: center;
+    align-items: center;
+
+    &:focus-visible {
+      border: 2px solid var(--accent);
+    }
+
+    > .gif {
+      background-color: var(--fg);
+      border-radius: 6px;
+      color: var(--accentLighten);
+      display: inline-block;
+      font-size: 14px;
+      font-weight: bold;
+      left: 12px;
+      opacity: 0.5;
+      padding: 0 6px;
+      text-align: center;
+      top: 12px;
+      pointer-events: none;
+    }
+  }
+  :deep(.plyr__controls) {
+    contain: strict;
+    height: 24px;
+    box-sizing: content-box;
+  }
+  :deep(.plyr__volume) {
+    display: flex;
+    min-width: max-content;
+    width: 110px;
+    transition: width 0.2s cubic-bezier(0, 0, 0, 1);
+    [data-plyr="volume"] {
+      width: 0;
+      flex-grow: 1;
+      transition:
+          margin 0.3s,
+          opacity 0.2s 0.2s;
+    }
+    &:not(:hover):not(:focus-within) {
+      width: 0px;
+      transition: width 0.2s;
+      [data-plyr="volume"] {
+        margin-inline: 0px;
+        opacity: 0;
+        transition:
+            margin 0.3s,
+            opacity 0.1s;
+      }
+    }
+  }
+  &.max-width_350px {
+    :deep(.plyr:not(:fullscreen)) {
+      min-width: unset !important;
+      .plyr__control--overlaid,
+      .plyr__progress__container,
+      .plyr__volume,
+      [data-plyr="download"] {
+        display: none;
+      }
+    }
+    :deep(.plyr__time) {
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+  }
+}
+</style>
diff --git a/packages/client/src/components/MkNoteMediaList.vue b/packages/client/src/components/MkNoteMediaList.vue
new file mode 100644
index 0000000000..879ef8d673
--- /dev/null
+++ b/packages/client/src/components/MkNoteMediaList.vue
@@ -0,0 +1,24 @@
+<template>
+	<template v-for="file in note.files">
+    <XNoteMedia
+        v-if="
+							file.type.startsWith('video') ||
+							file.type.startsWith('image')
+						"
+        :key="file.id"
+        :class="{ image: file.type.startsWith('image') }"
+        :data-id="file.id"
+        :media="file"
+        :note="note"
+    />
+	</template>
+</template>
+
+<script lang="ts" setup>
+import type { entities } from "firefish-js";
+import XNoteMedia from "@/components/MkNoteMedia.vue";
+
+defineProps<{
+	note: entities.Note;
+}>();
+</script>
diff --git a/packages/client/src/components/global/MkPageHeader.vue b/packages/client/src/components/global/MkPageHeader.vue
index 5525780a9f..7f6e13131a 100644
--- a/packages/client/src/components/global/MkPageHeader.vue
+++ b/packages/client/src/components/global/MkPageHeader.vue
@@ -152,7 +152,7 @@ interface Tab {
 }
 
 const props = defineProps<{
-	tabs?: Tab[];
+	tabs?: Tab[] | null;
 	tab?: string;
 	actions?: {
 		text: string;
diff --git a/packages/client/src/pages/user/index.vue b/packages/client/src/pages/user/index.vue
index 6ce00f5a10..3e079f95cb 100644
--- a/packages/client/src/pages/user/index.vue
+++ b/packages/client/src/pages/user/index.vue
@@ -17,6 +17,7 @@
 						@refresh="fetchUser()"
 					/>
 					<XReactions v-else-if="tab === 'reactions'" :user="user" />
+					<XMediaList v-else-if="tab === 'media'" :user="user"/>
 					<XClips v-else-if="tab === 'clips'" :user="user" />
 					<XPages v-else-if="tab === 'pages'" :user="user" />
 					<XGallery v-else-if="tab === 'gallery'" :user="user" />
@@ -40,6 +41,7 @@ import icon from "@/scripts/icon";
 
 const XHome = defineAsyncComponent(() => import("./home.vue"));
 const XReactions = defineAsyncComponent(() => import("./reactions.vue"));
+const XMediaList = defineAsyncComponent(() => import("./media-list.vue"));
 const XClips = defineAsyncComponent(() => import("./clips.vue"));
 const XPages = defineAsyncComponent(() => import("./pages.vue"));
 const XGallery = defineAsyncComponent(() => import("./gallery.vue"));
@@ -86,6 +88,11 @@ const headerTabs = computed(() =>
 					title: i18n.ts.overview,
 					icon: `${icon("ph-user")}`,
 				},
+				{
+					key: "media",
+					title: i18n.ts.media,
+					icon: `${icon("ph-grid-four")}`,
+				},
 				...((isSignedIn && me.id === user.value.id) ||
 				user.value.publicReactions
 					? [
diff --git a/packages/client/src/pages/user/media-list.vue b/packages/client/src/pages/user/media-list.vue
new file mode 100644
index 0000000000..92bfa38db3
--- /dev/null
+++ b/packages/client/src/pages/user/media-list.vue
@@ -0,0 +1,50 @@
+<template>
+	<MkSpacer :contentMax="1100">
+		<div :class="$style.root">
+			<MkPagination v-slot="{items}" :pagination="pagination">
+				<div :class="$style.stream">
+					<MkNoteMediaList v-for="note in items" :note="note"/>
+				</div>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</template>
+
+<script lang="ts" setup>
+import MkNoteMediaList from "@/components/MkNoteMediaList.vue";
+import MkPagination from "@/components/MkPagination.vue";
+import { computed } from "vue";
+import type { entities } from "firefish-js";
+
+const props = defineProps<{
+	user: entities.UserDetailed;
+}>();
+
+const pagination = {
+	endpoint: "users/notes" as const,
+	limit: 10,
+	params: computed(() => ({
+		userId: props.user.id,
+		withFiles: true,
+	})),
+};
+</script>
+
+<style lang="scss" module>
+.root {
+	padding: 8px;
+}
+
+.stream {
+	display: grid;
+	grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
+  grid-auto-rows: 160px;
+	grid-gap: 6px;
+}
+
+@media (max-width: 720px) {
+	.stream {
+		grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
+	}
+}
+</style>