forked from BRETT/BRETT
149 lines
4.9 KiB
Vue
149 lines
4.9 KiB
Vue
<script setup lang="ts">
|
|
import { mentionParts } from '~~/shared/utils/mentions'
|
|
import { getYoutubeId, youtubeEmbedUrl } from '~~/shared/utils/youtube'
|
|
import { getTwitchTarget, serializeTwitchTarget, twitchEmbedUrl } from '~~/shared/utils/twitch'
|
|
|
|
interface Part {
|
|
type: 'text' | 'link' | 'youtube' | 'twitch' | 'image'
|
|
text: string
|
|
href?: string
|
|
videoId?: string
|
|
twitchTarget?: string
|
|
}
|
|
|
|
const props = defineProps<{
|
|
content: string
|
|
compact?: boolean
|
|
}>()
|
|
|
|
// Twitch-Embeds brauchen den Hostname als parent-Parameter
|
|
const requestHost = useRequestURL().hostname
|
|
|
|
const URL_RE = /https?:\/\/[^\s<>"']+/g
|
|
const TRAILING_PUNCT = /[.,;:!?)\]]+$/
|
|
|
|
const IMAGE_EXT = /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i
|
|
|
|
function isImageUrl(href: string): boolean {
|
|
try {
|
|
return IMAGE_EXT.test(new URL(href).pathname)
|
|
}
|
|
catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
function hostnameFor(part: Part): string {
|
|
const raw = part.href ?? part.text
|
|
try {
|
|
return new URL(raw).hostname.replace(/^www\./, '')
|
|
}
|
|
catch {
|
|
return raw
|
|
}
|
|
}
|
|
|
|
const { data: mentionUsers } = useMentionUsers()
|
|
const parts = computed<Part[]>(() => {
|
|
const result: Part[] = []
|
|
let last = 0
|
|
for (const match of props.content.matchAll(URL_RE)) {
|
|
const raw = match[0]
|
|
const href = raw.replace(TRAILING_PUNCT, '')
|
|
const trailing = raw.slice(href.length)
|
|
if (match.index > last) {
|
|
result.push({ type: 'text', text: props.content.slice(last, match.index) })
|
|
}
|
|
const videoId = getYoutubeId(href)
|
|
const twitchTarget = videoId ? null : serializeTwitchTarget(getTwitchTarget(href))
|
|
if (videoId) {
|
|
result.push({ type: 'youtube', text: href, videoId })
|
|
}
|
|
else if (twitchTarget) {
|
|
result.push({ type: 'twitch', text: href, twitchTarget })
|
|
}
|
|
else if (isImageUrl(href)) {
|
|
result.push({ type: 'image', text: href, href })
|
|
}
|
|
else {
|
|
result.push({ type: 'link', text: href, href })
|
|
}
|
|
if (trailing) {
|
|
result.push({ type: 'text', text: trailing })
|
|
}
|
|
last = match.index + raw.length
|
|
}
|
|
if (last < props.content.length) {
|
|
result.push({ type: 'text', text: props.content.slice(last) })
|
|
}
|
|
return result
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div class="whitespace-pre-wrap break-words text-sm">
|
|
<template v-for="(part, index) in parts" :key="index">
|
|
<template v-if="part.type === 'text'">
|
|
<template v-for="(segment, segmentIndex) in mentionParts(part.text, mentionUsers)" :key="segmentIndex">
|
|
<UserHoverCard v-if="segment.user" :user-id="segment.user.id" :user-name="segment.user.name" :user-color="segment.user.color">
|
|
<NuxtLink :to="`/user/${segment.user.id}`" class="rounded-sm bg-accent px-0.5 font-medium text-accent-foreground hover:underline" @click.stop>{{ segment.text }}</NuxtLink>
|
|
</UserHoverCard>
|
|
<template v-else>{{ segment.text }}</template>
|
|
</template>
|
|
</template>
|
|
<!-- Kompakt-Modus (Sidebar): Icon + Hostname statt Embed/Card -->
|
|
<span
|
|
v-else-if="compact && part.type !== 'text'"
|
|
class="mx-0.5 inline-flex items-center gap-1 rounded-sm bg-muted px-1.5 py-0.5 align-middle text-xs text-muted-foreground"
|
|
>
|
|
<Icon v-if="part.type === 'youtube'" name="simple-icons:youtube" class="size-3.5" />
|
|
<Icon v-else-if="part.type === 'twitch'" name="simple-icons:twitch" class="size-3.5" />
|
|
<Icon name="ph:image-fill" v-else-if="part.type === 'image'" class="size-3.5" />
|
|
<Icon name="ph:link-fill" v-else class="size-3.5" />
|
|
{{ hostnameFor(part) }}
|
|
</span>
|
|
<template v-else-if="!compact">
|
|
<LinkPreviewCard
|
|
v-if="part.type === 'link'"
|
|
:url="part.href!"
|
|
/>
|
|
<div v-else-if="part.type === 'youtube'" class="mt-2 aspect-video overflow-hidden rounded-md">
|
|
<iframe
|
|
:src="youtubeEmbedUrl(part.videoId!)"
|
|
:title="part.text"
|
|
loading="lazy"
|
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
allowfullscreen
|
|
class="h-full w-full"
|
|
/>
|
|
</div>
|
|
<div v-else-if="part.type === 'twitch'" class="mt-2 aspect-video overflow-hidden rounded-md">
|
|
<iframe
|
|
:src="twitchEmbedUrl(part.twitchTarget!, requestHost) ?? ''"
|
|
:title="part.text"
|
|
loading="lazy"
|
|
allowfullscreen
|
|
class="h-full w-full"
|
|
/>
|
|
</div>
|
|
<a
|
|
v-else-if="part.type === 'image'"
|
|
:href="part.href"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="mt-2 block"
|
|
>
|
|
<NuxtImg
|
|
:src="part.href"
|
|
:alt="part.text"
|
|
loading="lazy"
|
|
class="max-h-60 rounded-md"
|
|
/>
|
|
</a>
|
|
<template v-else>{{ part.text }}</template>
|
|
</template>
|
|
<template v-else>{{ part.text }}</template>
|
|
</template>
|
|
</div>
|
|
</template>
|