feat (client): add "Media" tab to user page

https://maniakey.com/notes/9rsfrmqm1a
66a2d2fa5c

Co-authored-by: naskya <m@naskya.net>
This commit is contained in:
tmorio 2024-04-07 16:26:34 +09:00 committed by naskya
parent d0901d77ab
commit ea357b71a0
No known key found for this signature in database
GPG Key ID: 712D413B3A9FED5C
7 changed files with 375 additions and 1 deletions

View File

@ -708,6 +708,7 @@ display: "Display"
copy: "Copy"
metrics: "Metrics"
overview: "Overview"
media: "Media"
logs: "Logs"
delayed: "Delayed"
database: "Database"

View File

@ -638,6 +638,7 @@ display: "表示"
copy: "コピー"
metrics: "メトリクス"
overview: "概要"
media: "メディア"
logs: "ログ"
delayed: "遅延"
database: "データベース"

View File

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

View File

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

View File

@ -152,7 +152,7 @@ interface Tab {
}
const props = defineProps<{
tabs?: Tab[];
tabs?: Tab[] | null;
tab?: string;
actions?: {
text: string;

View File

@ -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
? [

View File

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