439 lines
12 KiB
Vue
439 lines
12 KiB
Vue
<template>
|
|
<div
|
|
:class='[
|
|
"ueuse",
|
|
"flex", "w-full", "h-fit",
|
|
"rounded-lg", "gap-2",
|
|
]'
|
|
v-if="authState.isSignedIn"
|
|
ref="ueuseElement"
|
|
>
|
|
<RouterLink class="shrink-0 h-fit rounded-full" :to="`/@${data.account.userid}`">
|
|
<img
|
|
:class='[
|
|
"w-12", "h-12", "rounded-full", "cursor-pointer",
|
|
"block", "shrink-0", "object-cover", "ueuse-user-icon"
|
|
]'
|
|
:src="data.account.user_icon"
|
|
v-if="data.text !== ''"
|
|
/>
|
|
</RouterLink>
|
|
|
|
<div class="flex flex-col grow gap-1 min-w-0">
|
|
<div class="flex gap-1 ueuse-detail">
|
|
<RouterLink
|
|
:to="`/@${data.account.userid}`"
|
|
class="flex gap-1 ueuse-detail-account truncate"
|
|
>
|
|
<img
|
|
:class='[
|
|
"block", "w-6", "h-6", "rounded-full", "mr-1",
|
|
"shrink-0", "object-cover", "ueuse-user-icon"
|
|
]'
|
|
:src="data.account.user_icon"
|
|
v-if="isReuse && data.text === ''"
|
|
/>
|
|
|
|
<span class="font-bold ueuse-user-name truncate">
|
|
{{ data.account.username }}
|
|
</span>
|
|
<span class="ueuse-user-id text-(--text-color)/85 truncate">
|
|
@{{ data.account.userid }}
|
|
</span>
|
|
|
|
<Icon
|
|
icon="material-symbols:robot-2-rounded"
|
|
class="w-4 h-4 shrink-0 m-auto opacity-70"
|
|
v-if="data.account.is_bot"
|
|
/>
|
|
|
|
<Icon
|
|
icon="material-symbols:check-circle-rounded"
|
|
class="w-4 h-4 shrink-0 m-auto text-(--accent)"
|
|
v-if="/*Botかどうかを毎回取得してたらきりない*/false"
|
|
/>
|
|
</RouterLink>
|
|
|
|
<Icon
|
|
icon="material-symbols:repeat-rounded"
|
|
class="w-4 h-4 shrink-0 my-auto text-(--accent)"
|
|
v-if="isReuse && data.text === ''"
|
|
/>
|
|
|
|
<div class="ml-auto w-fit shrink-0">
|
|
{{ parsedDate }}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
:class='[
|
|
"flex", "flex-col",
|
|
"bg-(--accent)", "text-(--accent-in-text)",
|
|
"rounded-lg", "p-2", "gap-1",
|
|
"ueuse-nsfw",
|
|
]'
|
|
v-if="data.nsfw"
|
|
>
|
|
<div class="flex shrink items-center w-full gap-1">
|
|
<Icon
|
|
class="my-auto break-all"
|
|
icon="material-symbols:visibility-off-rounded"
|
|
/>
|
|
NSFWユーズ
|
|
|
|
<Button
|
|
@click="visibility = !visibility"
|
|
class="px-2! ml-auto text-sm border border-(--text-color)"
|
|
>
|
|
{{ visibility
|
|
? "非表示"
|
|
: "表示"
|
|
}}
|
|
</Button>
|
|
</div>
|
|
|
|
<div class="ueuse-nsfw-detail">
|
|
{{ detail }}
|
|
</div>
|
|
</div>
|
|
|
|
<div
|
|
class="flex flex-col gap-1 ueuse-content"
|
|
v-if="visibility"
|
|
>
|
|
<p class="ueuse-text break-all" v-if="parsedText">
|
|
<template v-for="(node, i) in parsedText" :key="i">
|
|
<component v-if="typeof node !== 'string'" :is="node" />
|
|
<span v-else>{{ node }}</span>
|
|
</template>
|
|
</p>
|
|
|
|
<div
|
|
class="grid gap-1 ueuse-media"
|
|
:class="mediaGridClass"
|
|
v-if="medias.length > 0"
|
|
>
|
|
<template
|
|
v-for="(media, i) in medias"
|
|
:key="media"
|
|
>
|
|
<Media
|
|
v-if="
|
|
i < 2 ||
|
|
(medias.length >= 4 &&
|
|
i < 4)
|
|
"
|
|
:src="media"
|
|
class="col-span-1 aspect-4/3"
|
|
@click="showLgMedia(media)"
|
|
/>
|
|
<Media
|
|
v-else
|
|
:src="media"
|
|
class="col-span-2 aspect-2/1"
|
|
@click="showLgMedia(media)"
|
|
/>
|
|
</template>
|
|
</div>
|
|
|
|
<div
|
|
:class='[
|
|
"flex", "flex-col", "p-2", "rounded-lg", "gap-1.5",
|
|
"border", "border-(--border-color)", "bg-(--bg-color)/70", "ueuse-abi",
|
|
]'
|
|
v-if="data.abi !== '' && data.abidatetime !== ''"
|
|
>
|
|
<span :class='[
|
|
"bg-(--accent)", "text-(--accent-in-text)",
|
|
"px-2", "py-1.5", "rounded-md", "font-bold", "flex",
|
|
]'>
|
|
<Icon
|
|
class="my-auto mr-1 w-5 h-5"
|
|
icon="material-symbols:add-notes-rounded"
|
|
/>
|
|
追記
|
|
</span>
|
|
|
|
<p class="ueuse-abi-content break-all" v-if="parsedAbiText">
|
|
<template v-for="(node, i) in parsedAbiText" :key="i">
|
|
<component v-if="typeof node !== 'string'" :is="node" />
|
|
<span v-else>{{ node }}</span>
|
|
</template>
|
|
</p>
|
|
|
|
<span class="text-sm text-(--text-color)/70">
|
|
{{ parsedAbiDate }}
|
|
</span>
|
|
</div>
|
|
|
|
<Ueuse
|
|
:data="reuseData"
|
|
:lgMedia="lgMedia"
|
|
:depth="depth + 1"
|
|
v-if="isReuse && reuseData"
|
|
:class='[
|
|
"mt-2",
|
|
{
|
|
"border": data.text !== "",
|
|
"border-(--border-color)": data.text !== "",
|
|
"p-2": data.text !== "",
|
|
},
|
|
]'
|
|
/>
|
|
</div>
|
|
|
|
<div
|
|
class="mt-2 flex flex-wrap gap-3 ueuse-operation"
|
|
v-if="data.text !== ''"
|
|
>
|
|
<Operation
|
|
@click="likeUpdate(data.uniqid)"
|
|
name="like"
|
|
icon="material-symbols:favorite-outline-rounded"
|
|
usedIcon="material-symbols:favorite-rounded"
|
|
:isUse="data.favorite.includes(authState.me.userid)"
|
|
:data="data.favorite_cnt"
|
|
/>
|
|
<Operation
|
|
name="reuse"
|
|
icon="material-symbols:repeat-rounded"
|
|
:data="data.reuse_cnt"
|
|
/>
|
|
<RouterLink
|
|
class="rounded-2xl"
|
|
:to="`/ueuse/${data.uniqid}`"
|
|
>
|
|
<Operation
|
|
name="reply"
|
|
icon="material-symbols:reply-rounded"
|
|
:data="data.reply_cnt"
|
|
/>
|
|
</RouterLink>
|
|
<Operation
|
|
name="more"
|
|
icon="material-symbols:more-horiz"
|
|
class="ml-auto"
|
|
@click="showMoreMenu"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts" setup>
|
|
import type ueuseModule from "better-uwuzu-sdk/types/1.6.8/types/modules/ueuse";
|
|
import { DateParse, parseTextToNodes } from "@/lib/parser";
|
|
import { RouterLink } from "vue-router";
|
|
import Button from "@/components/Button.vue";
|
|
import Media from "@/components/Media/index.vue";
|
|
import Ueuse from "@/components/Ueuse/index.vue";
|
|
import Operation from "@/components/Ueuse/Operation.vue";
|
|
import { Icon } from "@iconify/vue";
|
|
import uwuzu from "better-uwuzu-sdk";
|
|
import type ApiMap from "better-uwuzu-sdk/types/1.6.8/map";
|
|
import Parser from "better-uwuzu-sdk/1.6.8/parser";
|
|
import {
|
|
ref, inject, computed, watch,
|
|
onMounted, onBeforeUnmount,
|
|
type Reactive, type Ref, type Component,
|
|
} from "vue";
|
|
import { authState } from "@/lib/account";
|
|
import waitUntil from "@/lib/wait";
|
|
import { ueuseMoreMenu } from "@/components/Ueuse/More.vue";
|
|
|
|
const props = defineProps<{
|
|
data: ueuseModule;
|
|
lgMedia: Reactive<{
|
|
src: string;
|
|
}>;
|
|
depth?: number;
|
|
}>();
|
|
|
|
const depth = props.depth ?? 0;
|
|
const MAX_DEPTH = ref<number>(1);
|
|
|
|
const apiClient = ref<uwuzu<ApiMap>>();
|
|
|
|
const visibility = ref<boolean>(!props.data.nsfw);
|
|
const isReuse = props.data.reuseid !== "" && depth < MAX_DEPTH.value;
|
|
const reuseData = ref<ueuseModule>();
|
|
|
|
const now = inject<Ref<Date>>("now")!;
|
|
|
|
type ParsedNodes = (string | Component)[];
|
|
|
|
const parsedText = ref<ParsedNodes | null>(null);
|
|
const parsedAbiText = ref<ParsedNodes | null>(null);
|
|
|
|
const ueuseElement = ref<HTMLElement | null>(null);
|
|
const parsedDate = ref("");
|
|
const parsedAbiDate = ref("");
|
|
let observer: IntersectionObserver;
|
|
|
|
onMounted(() => {
|
|
if (!ueuseElement.value) return;
|
|
|
|
observer = new IntersectionObserver((entries) => {
|
|
for (const entry of entries) {
|
|
if (!entry.isIntersecting)
|
|
continue;
|
|
|
|
parsedDate.value ||= DateParse(props.data.datetime, now.value);
|
|
|
|
if (props.data.abidatetime) {
|
|
parsedAbiDate.value ||= DateParse(props.data.abidatetime, now.value);
|
|
}
|
|
|
|
if (
|
|
visibility.value &&
|
|
props.data.text &&
|
|
!parsedText.value
|
|
) {
|
|
parsedText.value = parseTextToNodes(props.data.text);
|
|
}
|
|
|
|
if (
|
|
visibility.value &&
|
|
props.data.abi &&
|
|
props.data.abidatetime &&
|
|
!parsedAbiText.value
|
|
) {
|
|
parsedAbiText.value = parseTextToNodes(props.data.abi);
|
|
}
|
|
|
|
observer.unobserve(entry.target);
|
|
}
|
|
}, {
|
|
rootMargin: "100px 0px",
|
|
});
|
|
|
|
observer.observe(ueuseElement.value);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
observer?.disconnect();
|
|
});
|
|
|
|
watch(visibility, (v) => {
|
|
if (!v)
|
|
return;
|
|
|
|
if (props.data.text !== "") {
|
|
parsedText.value ||= parseTextToNodes(props.data.text);
|
|
}
|
|
|
|
if (props.data.abi !== "") {
|
|
parsedAbiText.value ||= parseTextToNodes(props.data.abi);
|
|
}
|
|
});
|
|
|
|
const medias = props.data.media.photo.concat(props.data.media.video);
|
|
|
|
const mediaGridClass = computed(() => {
|
|
switch (medias.length) {
|
|
case 1:
|
|
return "grid-cols-1";
|
|
default:
|
|
return "grid-cols-2";
|
|
}
|
|
});
|
|
|
|
const detail = computed(() => {
|
|
const result: string[] = [];
|
|
|
|
if (props.data.text.trim() !== "" && props.data.reuseid === "") {
|
|
result.push(`${props.data.text.trim().length}文字の本文`);
|
|
}
|
|
|
|
if (medias.length > 0) {
|
|
result.push(`${medias.length}枚のメディア`)
|
|
}
|
|
|
|
if (props.data.abi.trim() !== "") {
|
|
result.push(`${props.data.abi.trim().length}文字の追記`);
|
|
}
|
|
|
|
if (props.data.replyid !== "") {
|
|
result.push("返信");
|
|
}
|
|
|
|
if (props.data.reuseid !== "") {
|
|
if (props.data.text === "") {
|
|
result.push("リユーズ");
|
|
} else {
|
|
result.push(`${props.data.text.trim().length}文字の引用`);
|
|
}
|
|
}
|
|
|
|
return result.join("・");
|
|
});
|
|
|
|
const showLgMedia = (src: string) => {
|
|
const ext = src.slice(src.lastIndexOf(".") + 1).toLowerCase();
|
|
|
|
const isPhoto = () => {
|
|
const videoExts = ["mp4", "mpeg", "mpg", "webm", "avi"];
|
|
return !videoExts.includes(ext);
|
|
};
|
|
|
|
if (!isPhoto())
|
|
return;
|
|
|
|
props.lgMedia.src = src;
|
|
}
|
|
|
|
const likeUpdate = async (uniqid: string) => {
|
|
await waitUntil(() => apiClient.value !== undefined, 100);
|
|
if (!apiClient.value) return false;
|
|
if (!authState.isSignedIn) return false;
|
|
|
|
MAX_DEPTH.value = authState.reuse_maxdepth;
|
|
|
|
try {
|
|
const chg = await apiClient.value.request("favorite/change", {
|
|
uniqid: uniqid,
|
|
});
|
|
|
|
if (chg.success) {
|
|
props.data.favorite = chg.favorite_list;
|
|
props.data.favorite_cnt = chg.favorite_list.length - 1;
|
|
}
|
|
|
|
return chg.success;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
const showMoreMenu = (event: MouseEvent) => {
|
|
if (!ueuseMoreMenu.visibility) {
|
|
ueuseMoreMenu.uniqid = props.data.uniqid;
|
|
ueuseMoreMenu.position = event.pageY;
|
|
ueuseMoreMenu.visibility = true;
|
|
}
|
|
}
|
|
|
|
(async () => {
|
|
if (!authState.isSignedIn)
|
|
return;
|
|
|
|
apiClient.value = new uwuzu<ApiMap>({
|
|
origin: authState.origin,
|
|
parser: Parser,
|
|
});
|
|
apiClient.value.token = authState.token;
|
|
|
|
if (isReuse) {
|
|
const ueuse = await apiClient.value.request("ueuse/get", {
|
|
uniqid: props.data.reuseid,
|
|
});
|
|
|
|
if (!ueuse.success) {
|
|
throw new Error(ueuse.error_code);
|
|
}
|
|
|
|
reuseData.value = ueuse.data[0];
|
|
}
|
|
})();
|
|
</script>
|