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>