Files
BRETT/app/pages/thread/[id].vue
T
2026-10-03 20:07:49 +02:00

1161 lines
44 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { useDropZone, useEventListener, useMediaControls } from '@vueuse/core'
import { toast } from 'vue-sonner'
import type { CommentReaction } from '@/composables/useCommentReactions'
import type { UploadReaction } from '@/composables/useUploadReactions'
import { youtubeEmbedUrl } from '~~/shared/utils/youtube'
import { twitchEmbedUrl } from '~~/shared/utils/twitch'
import { normalizeTagName } from '~~/shared/utils/tags'
import { formatFileSize, isImageAttachment, MAX_ATTACHMENT_SIZE, MAX_COMMENT_ATTACHMENTS } from '~~/shared/utils/attachments'
import type { CommentAttachment } from '~~/shared/utils/attachments'
import { isCardBorderEffect } from '~~/shared/utils/cardBorderEffects'
import { cn } from '@/lib/utils'
import CardBorderEffect from '@/components/effects/CardBorderEffect.vue'
import CommentContent from '@/components/CommentContent.vue'
import ImageLightbox from '@/components/ImageLightbox.vue'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import MentionTextarea from '@/components/MentionTextarea.vue'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
interface UploadDetail {
id: string
type: 'image' | 'video' | 'audio' | 'youtube' | 'twitch' | 'link'
url: string
embedId: string | null
previewImage: string | null
previewTitle: string | null
audioMeta: AudioMeta | null
originalName: string
userName: string
userId: number
userColor: string | null
userAvatarUrl: string | null
userCardEffect: string | null
userCardEffectColor: string | null
userCardVignette: boolean
userCardBorderEffect: string | null
userCardBorderEffectColor: string | null
createdAt: string
tags: string[]
}
interface CommentItem {
id: number
content: string
userName: string
userId: number
userColor: string | null
userAvatarUrl: string | null
createdAt: string
reactions?: CommentReaction[]
attachments?: CommentAttachment[]
}
interface FavUser {
id: number
name: string
color: string | null
avatarUrl: string | null
}
const route = useRoute()
const id = computed(() => route.params.id as string)
// Twitch-Embeds brauchen den Hostname als parent-Parameter
const requestHost = useRequestURL().hostname
const { user, can } = useAuth()
// Beim Client-Side-Nav nicht awaiten, damit die Seite sofort rendert und das
// benannte Medium-Element für die View-Transition im Snapshot vorhanden ist
const fetchResult = useFetch<{ upload: UploadDetail, comments: CommentItem[], favoritedBy: FavUser[], isFavorited: boolean, isSubscribed: boolean, uploadReactions: UploadReaction[], newerId: string | null, olderId: string | null }>(
() => `/api/uploads/${id.value}`,
)
if (import.meta.server) {
await fetchResult
}
const { data, error, refresh } = fetchResult
function setThreadSubscription(uploadId: string, enabled: boolean) {
if (data.value?.upload.id === uploadId) data.value.isSubscribed = enabled
}
// Vom Tile-Klick bekannte Mediendaten → sofort benanntes Element rendern
const pendingMedia = usePendingUploadMedia()
const pendingMediaForPage = computed(() => pendingMedia.value?.id === id.value ? pendingMedia.value : null)
// Medium: erst aus geladenen Daten, sonst aus den Klick-Daten — das benannte
// Wrapper-Element muss über beide Zustände hinweg stabil bleiben
const media = computed(() => {
const u = data.value?.upload
if (u) return { type: u.type, url: u.url, embedId: u.embedId, name: u.originalName, previewImage: u.previewImage, previewTitle: u.previewTitle, audioMeta: u.audioMeta }
const p = pendingMediaForPage.value
if (p) return { type: p.type, url: p.url, embedId: null, name: p.id, previewImage: p.thumbUrl, previewTitle: null, audioMeta: null }
return null
})
const mediaBoxClass = computed(() =>
media.value?.type === 'image' ? 'mx-auto w-fit max-w-full' : 'mx-auto w-full max-w-4xl',
)
// lastViewedId wird nur für den Vorwärts-Snapshot gebraucht (angeklickter Tile
// trägt den view-transition-name). Nach dem Betreten verwerfen — sonst morpht
// das Medium beim Zurücknavigieren aufs Grid wieder zurück
const lastViewedId = useLastViewedUpload()
onMounted(() => {
lastViewedId.value = null
})
// Medium laden, bevor die Seite fertig ist — sonst enthält der View-Transition-Snapshot eine leere Box
if (import.meta.client && data.value) {
if (data.value.upload.type === 'image') {
const pre = new Image()
pre.src = data.value.upload.url
try {
await Promise.race([pre.decode(), new Promise(r => setTimeout(r, 2000))])
}
catch { /* Bild lädt dann normal nach */ }
}
else if (data.value.upload.type === 'video') {
await new Promise<void>((resolve) => {
const v = document.createElement('video')
v.preload = 'auto'
v.muted = true
v.onloadeddata = () => resolve()
v.onerror = () => resolve()
v.src = data.value.upload.url
setTimeout(resolve, 2000)
})
}
}
const { liveComments } = useLiveComments()
const { seed: seedReactions } = useCommentReactions()
const { seed: seedUploadReactions, toggle: toggleUploadReaction } = useUploadReactions()
watch(() => data.value?.comments, (list) => {
for (const c of list ?? []) {
seedReactions(c.id, c.reactions ?? [])
}
}, { immediate: true })
watch(() => [data.value?.upload.id, data.value?.uploadReactions] as const, ([uploadId, list]) => {
if (uploadId) seedUploadReactions(uploadId, list ?? [])
}, { immediate: true })
// Reaction-Picker: öffnet sich per Hover-Intent über dem Herz,
// Klick aufs Herz bleibt weiterhin der Favorite-Toggle
const reactionPickerOpen = ref(false)
const reactionBusy = ref(false)
let reactionHoverTimer: ReturnType<typeof setTimeout> | undefined
let reactionCloseTimer: ReturnType<typeof setTimeout> | undefined
function startReactionHover() {
clearTimeout(reactionCloseTimer)
reactionHoverTimer = setTimeout(() => { reactionPickerOpen.value = true }, 550)
}
function scheduleReactionClose() {
clearTimeout(reactionHoverTimer)
reactionCloseTimer = setTimeout(() => { reactionPickerOpen.value = false }, 300)
}
function cancelReactionClose() {
clearTimeout(reactionHoverTimer)
clearTimeout(reactionCloseTimer)
}
onUnmounted(() => {
clearTimeout(reactionHoverTimer)
clearTimeout(reactionCloseTimer)
})
async function reactToUpload(emoji: string) {
if (!user.value) {
toast.error('Melde dich an, um zu reagieren.')
return
}
if (reactionBusy.value) return
reactionBusy.value = true
try {
const uploadId = id.value
const result = await toggleUploadReaction(uploadId, emoji)
setThreadSubscription(uploadId, result.isSubscribed)
reactionPickerOpen.value = false
}
catch {
toast.error('Reaktion fehlgeschlagen')
}
finally {
reactionBusy.value = false
}
}
const allComments = computed<CommentItem[]>(() => {
if (!data.value) return []
const known = new Set(data.value.comments.map(c => c.id))
const extra = liveComments.value
.filter(c => c.uploadId === id.value && !known.has(c.id))
.map(({ uploadId: _uploadId, ...c }) => c)
return [...data.value.comments, ...extra]
})
// Uploader-Border-Effekt (z. B. beam/electric) und Signaturfarbe
const uploaderCardBorderEffect = computed(() => {
const k = data.value?.upload.userCardBorderEffect
return k && isCardBorderEffect(k) ? k : null
})
const uploaderColor = computed(() => data.value?.upload.userColor ?? '#6366f1')
const uploaderBorderColor = computed(() => data.value?.upload.userCardBorderEffectColor ?? uploaderColor.value)
function isUploaderComment(comment: CommentItem) {
return comment.userId === data.value?.upload.userId
}
// Uploader-Kommentare heben sich über den Rahmen in der User-Farbe ab
const uploaderCommentStyle = computed(() => (
uploaderCardBorderEffect.value ? undefined : { borderColor: uploaderColor.value }
))
const audioRef = ref<HTMLAudioElement>()
const {
playing: audioPlaying,
currentTime: audioTime,
duration: audioDuration,
volume: audioVolume,
muted: audioMuted,
} = useMediaControls(audioRef)
function toggleAudio() {
audioPlaying.value = !audioPlaying.value
}
function seekAudio(event: Event) {
audioTime.value = Number((event.target as HTMLInputElement).value)
}
function formatAudioTime(sec: number) {
if (!Number.isFinite(sec)) return '0:00'
return `${Math.floor(sec / 60)}:${String(Math.floor(sec % 60)).padStart(2, '0')}`
}
interface AudioMeta {
title: string | null
artist: string | null
album: string | null
codec: string | null
bitrate: number | null
sampleRate: number | null
bitsPerSample: number | null
lossless: boolean
}
const audioMetaLine = computed(() => {
const m = media.value?.audioMeta
if (!m) return null
return [m.artist, m.title].filter(Boolean).join(' – ') || m.album || null
})
function audioCodecLabel(codec: string | null) {
if (!codec) return null
const layer = /layer\s*(\d)/i.exec(codec)
if (/mpeg/i.test(codec) && layer) return `MP${layer[1]}`
return codec.toUpperCase()
}
const audioQualityLine = computed(() => {
const m = media.value?.audioMeta
if (!m) return null
const parts: string[] = []
if (m.codec) parts.push(audioCodecLabel(m.codec)!)
if (m.bitsPerSample) parts.push(`${m.bitsPerSample} bit`)
if (m.bitrate) parts.push(`${Math.round(m.bitrate / 1000)} kbit/s`)
if (m.sampleRate) parts.push(`${(m.sampleRate / 1000).toFixed(1)} kHz`)
if (m.lossless) parts.push('lossless')
return parts.length ? parts.join(' · ') : null
})
const newComment = ref('')
const posting = ref(false)
const commentError = ref('')
const pendingFiles = ref<File[]>([])
const pendingPreviews = new Map<File, string>()
const commentFormRef = ref<HTMLElement>()
const fileInputRef = ref<HTMLInputElement>()
const { isOverDropZone: isOverCommentDrop } = useDropZone(commentFormRef, {
// dataTypes sieht nur MIME-Item-Typen — über checkValidity direkt auf file-Items prüfen
checkValidity: items => Array.from(items).some(i => i.kind === 'file'),
onDrop(files) {
if (files?.length) addPendingFiles(files)
},
})
// Nicht an die seitenweite Dropzone durchreichen
useEventListener(commentFormRef, ['dragenter', 'dragover', 'dragleave', 'drop'], e => e.stopPropagation())
function pendingPreview(file: File): string | null {
if (!isImageAttachment({ mimeType: file.type })) return null
let url = pendingPreviews.get(file)
if (!url) {
url = URL.createObjectURL(file)
pendingPreviews.set(file, url)
}
return url
}
function addPendingFiles(files: File[]) {
for (const file of files) {
if (pendingFiles.value.length >= MAX_COMMENT_ATTACHMENTS) {
toast.error(`Maximal ${MAX_COMMENT_ATTACHMENTS} Anhänge pro Kommentar`)
break
}
if (file.size > MAX_ATTACHMENT_SIZE) {
toast.error(`${file.name}: Datei ist zu groß (max. 200 MB)`)
continue
}
pendingFiles.value.push(file)
}
}
function removePendingFile(index: number) {
const file = pendingFiles.value[index]
const url = file && pendingPreviews.get(file)
if (url) {
URL.revokeObjectURL(url)
pendingPreviews.delete(file!)
}
if (file) voicePending.value.delete(file)
pendingFiles.value.splice(index, 1)
}
function clearPendingFiles() {
for (const url of pendingPreviews.values()) URL.revokeObjectURL(url)
pendingPreviews.clear()
pendingFiles.value = []
voicePending.value.clear()
if (fileInputRef.value) fileInputRef.value.value = ''
}
// Sprachnachrichten: MediaRecorder-Aufnahme, Ergebnis wird als 'voice'-Anhang gesendet
const voicePending = ref(new Set<File>())
const recording = ref(false)
const recordSeconds = ref(0)
let recorder: MediaRecorder | null = null
let recordStream: MediaStream | null = null
let recordTimer: ReturnType<typeof setInterval> | null = null
let recordChunks: Blob[] = []
let recordDiscarded = false
const MAX_RECORD_SECONDS = 300
const canRecord = typeof navigator !== 'undefined'
&& !!navigator.mediaDevices?.getUserMedia
&& typeof MediaRecorder !== 'undefined'
function recorderMimeType() {
for (const t of ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4'])
if (MediaRecorder.isTypeSupported(t)) return t
return ''
}
async function startRecording() {
if (!canRecord || recording.value || pendingFiles.value.length >= MAX_COMMENT_ATTACHMENTS) return
try {
recordStream = await navigator.mediaDevices.getUserMedia({ audio: true })
}
catch {
toast.error('Kein Zugriff aufs Mikrofon')
return
}
const mimeType = recorderMimeType()
recordChunks = []
recordDiscarded = false
recorder = new MediaRecorder(recordStream, mimeType ? { mimeType } : undefined)
recorder.ondataavailable = e => { if (e.data.size) recordChunks.push(e.data) }
recorder.onstop = () => {
recordStream?.getTracks().forEach(t => t.stop())
recordStream = null
if (!recordDiscarded && recordChunks.length && pendingFiles.value.length < MAX_COMMENT_ATTACHMENTS) {
const type = recorder?.mimeType || 'audio/webm'
const ext = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : 'webm'
const file = new File(recordChunks, `Sprachnachricht.${ext}`, { type })
pendingFiles.value.push(file)
voicePending.value.add(file)
}
recordChunks = []
recorder = null
}
recordSeconds.value = 0
recordTimer = setInterval(() => {
recordSeconds.value++
if (recordSeconds.value >= MAX_RECORD_SECONDS) stopRecording()
}, 1000)
recorder.start(250)
recording.value = true
}
function stopRecording() {
if (recordTimer) { clearInterval(recordTimer); recordTimer = null }
recording.value = false
recorder?.stop()
}
function cancelRecording() {
recordDiscarded = true
stopRecording()
}
onUnmounted(() => {
clearPendingFiles()
cancelRecording()
})
const lightboxOpen = ref(false)
const favBusy = ref(false)
// Tags hinzufügen darf jeder Eingeloggte — entfernen nur Uploader/tag.delete
const isUploader = computed(() => !!user.value && data.value?.upload.userId === user.value.id)
const canEditTags = computed(() => !!user.value)
const canDeleteTags = computed(() => isUploader.value || can('tag.delete'))
const originalTags = computed(() => new Set(data.value?.upload.tags ?? []))
const tagEditing = ref(false)
const tagDraft = ref<string[]>([])
const tagInput = ref('')
const tagSaving = ref(false)
const tagSuggestions = ref<{ name: string, count: number }[]>([])
function startTagEdit() {
tagDraft.value = [...(data.value?.upload.tags ?? [])]
tagInput.value = ''
tagEditing.value = true
}
function addDraftTag() {
const name = normalizeTagName(tagInput.value)
if (name && !tagDraft.value.includes(name)) tagDraft.value.push(name)
tagInput.value = ''
}
function onTagKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
addDraftTag()
}
}
let tagSuggestTimer: ReturnType<typeof setTimeout> | undefined
watch(tagInput, (q) => {
clearTimeout(tagSuggestTimer)
tagSuggestTimer = setTimeout(async () => {
try {
tagSuggestions.value = await $fetch('/api/tags', { query: { q } })
}
catch {
tagSuggestions.value = []
}
}, 150)
})
async function saveTags() {
if (tagSaving.value || !data.value) return
tagSaving.value = true
try {
const res = await $fetch<{ tags: string[] }>(`/api/uploads/${id.value}/tags`, {
method: 'PUT',
body: { tags: tagDraft.value },
})
data.value = { ...data.value, upload: { ...data.value.upload, tags: res.tags } }
tagEditing.value = false
}
catch (err) {
const e = err as { data?: { statusMessage?: string } }
toast.error(e.data?.statusMessage ?? 'Tags konnten nicht gespeichert werden')
}
finally {
tagSaving.value = false
}
}
async function toggleFavorite() {
if (!data.value || favBusy.value) return
if (!user.value) {
toast.error('Melde dich an, um zu favorisieren.')
return
}
favBusy.value = true
try {
const res = await $fetch<{ favorited: boolean, favoritedBy: FavUser[], isSubscribed: boolean }>(`/api/uploads/${id.value}/favorite`, { method: 'POST' })
data.value = { ...data.value, isFavorited: res.favorited, favoritedBy: res.favoritedBy, isSubscribed: res.isSubscribed }
}
finally {
favBusy.value = false
}
}
watch(id, () => {
newComment.value = ''
commentError.value = ''
clearPendingFiles()
lightboxOpen.value = false
tagEditing.value = false
if (import.meta.client) window.scrollTo({ top: 0 })
})
const timeAgo = useTimeAgo()
function formatDate(value: string) {
return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
}
async function submitComment() {
const hasFiles = pendingFiles.value.length > 0
if ((!newComment.value.trim() && !hasFiles) || posting.value) return
posting.value = true
commentError.value = ''
try {
let body: FormData | { content: string }
if (hasFiles) {
const fd = new FormData()
fd.append('content', newComment.value.trim())
for (const file of pendingFiles.value) fd.append(voicePending.value.has(file) ? 'voice' : 'files', file)
body = fd
}
else {
body = { content: newComment.value }
}
await $fetch(`/api/uploads/${id.value}/comments`, {
method: 'POST',
body,
})
newComment.value = ''
clearPendingFiles()
await refresh()
await refreshNuxtData('latest-comments')
await nextTick()
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' })
}
catch (err) {
const e = err as { data?: { statusMessage?: string, message?: string } }
commentError.value = e.data?.statusMessage ?? e.data?.message ?? 'Kommentar konnte nicht gesendet werden'
}
finally {
posting.value = false
}
}
const deleteConfirmOpen = ref(false)
const deleting = ref(false)
async function deleteUpload() {
deleting.value = true
try {
await $fetch(`/api/uploads/${id.value}`, { method: 'DELETE' })
await refreshNuxtData(['uploads', 'latest-comments'])
await navigateTo('/')
}
catch {
toast.error('Löschen fehlgeschlagen')
}
finally {
deleting.value = false
deleteConfirmOpen.value = false
}
}
async function deleteComment(commentId: number) {
await $fetch(`/api/comments/${commentId}`, { method: 'DELETE' })
await refresh()
await refreshNuxtData('latest-comments')
}
</script>
<template>
<div class="px-4 lg:px-6">
<div v-if="error" class="text-muted-foreground">
Upload nicht gefunden.
</div>
<template v-else>
<div class="relative">
<template v-if="data">
<Button
v-if="data.newerId"
variant="secondary"
size="icon"
class="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full shadow opacity-80 hover:opacity-100"
as-child
>
<NuxtLink :to="`/thread/${data.newerId}`" aria-label="Neuerer Upload">
<Icon name="ph:caret-left-fill" class="size-5" />
</NuxtLink>
</Button>
<Button
v-if="data.olderId"
variant="secondary"
size="icon"
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full shadow opacity-80 hover:opacity-100"
as-child
>
<NuxtLink :to="`/thread/${data.olderId}`" aria-label="Älterer Upload">
<Icon name="ph:caret-right-fill" class="size-5" />
</NuxtLink>
</Button>
</template>
<!-- Benannter Wrapper bleibt zwischen Ladezustand und geladenem
Zustand dasselbe Element — sonst verliert die View-Transition
ihre Quelle mitten in der Animation -->
<div :class="mediaBoxClass" style="view-transition-name: upload-media">
<iframe
v-if="media?.type === 'youtube' && media.embedId"
:src="youtubeEmbedUrl(media.embedId)"
:title="media.name"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
class="aspect-video w-full rounded-md"
/>
<iframe
v-else-if="media?.type === 'twitch' && media.embedId"
:src="twitchEmbedUrl(media.embedId, requestHost) ?? ''"
:title="media.name"
allowfullscreen
class="aspect-video w-full rounded-md"
/>
<img
v-else-if="media?.type === 'image' && media.url"
:src="media.url"
:alt="media.name"
class="max-h-[80vh] w-auto max-w-full cursor-zoom-in rounded-md"
@click="lightboxOpen = true"
>
<VideoPlayer
v-else-if="media?.type === 'video' && media.url"
:src="media.url"
/>
<div
v-else-if="media?.type === 'audio' && media.url"
class="relative w-full overflow-hidden rounded-md border bg-muted"
>
<template v-if="media.previewImage">
<img
:src="media.previewImage"
:alt="media.name"
class="absolute inset-0 size-full object-cover"
>
<div
aria-hidden="true"
class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/30 to-transparent"
/>
</template>
<div
class="group relative flex cursor-pointer items-center justify-center"
:class="media.previewImage ? 'aspect-square max-h-[70vh] w-full' : 'h-56 sm:h-64'"
role="button"
:aria-label="audioPlaying ? 'Pause' : 'Abspielen'"
@click="toggleAudio"
>
<Icon name="ph:music-note-fill" v-if="!media.previewImage" class="size-16 text-muted-foreground" />
<div
v-else
aria-hidden="true"
class="absolute flex size-16 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-sm transition-opacity"
:class="audioPlaying ? 'opacity-0 group-hover:opacity-100' : 'opacity-100'"
>
<Icon name="ph:pause-fill" v-if="audioPlaying" class="size-8" />
<Icon name="ph:play-fill" v-else class="size-8" />
</div>
<div
v-if="audioMetaLine || audioQualityLine"
class="pointer-events-none absolute inset-x-4 bottom-3 flex flex-col gap-0.5 text-left"
>
<span
v-if="audioMetaLine"
class="truncate text-lg font-semibold sm:text-xl"
:class="media.previewImage ? 'text-white drop-shadow-md' : ''"
>{{ audioMetaLine }}</span>
<span
v-if="audioQualityLine"
class="truncate text-sm"
:class="media.previewImage ? 'text-white/85 drop-shadow' : 'text-muted-foreground'"
>{{ audioQualityLine }}</span>
</div>
</div>
<audio ref="audioRef" :src="media.url" class="hidden" />
<div class="relative flex items-center gap-2.5 bg-black/60 px-3 py-2 backdrop-blur-sm">
<button
type="button"
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-white transition-colors hover:bg-white/15"
:aria-label="audioPlaying ? 'Pause' : 'Abspielen'"
@click="toggleAudio"
>
<Icon name="ph:pause-fill" v-if="audioPlaying" class="size-4" />
<Icon name="ph:play-fill" v-else class="size-4" />
</button>
<span class="shrink-0 text-xs tabular-nums text-white/80">{{ formatAudioTime(audioTime) }}</span>
<input
type="range"
min="0"
:max="Number.isFinite(audioDuration) ? audioDuration : 0"
step="0.1"
:value="audioTime"
aria-label="Wiedergabeposition"
class="min-w-0 flex-1 cursor-pointer accent-primary"
@input="seekAudio"
>
<span class="shrink-0 text-xs tabular-nums text-white/80">{{ formatAudioTime(audioDuration) }}</span>
<button
type="button"
class="hidden size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-white transition-colors hover:bg-white/15 sm:flex"
:aria-label="audioMuted ? 'Ton an' : 'Stumm'"
@click="audioMuted = !audioMuted"
>
<Icon name="ph:speaker-x-fill" v-if="audioMuted" class="size-4" />
<Icon name="ph:speaker-high-fill" v-else class="size-4" />
</button>
<input
v-model.number="audioVolume"
type="range"
min="0"
max="1"
step="0.05"
aria-label="Lautstärke"
class="hidden w-20 cursor-pointer accent-primary sm:block"
:class="audioMuted && 'opacity-40'"
>
</div>
</div>
<a
v-else-if="media?.type === 'link' && media.url"
:href="media.url"
target="_blank"
rel="noopener noreferrer"
class="block w-full overflow-hidden rounded-md border transition-colors hover:border-accent"
>
<img
v-if="media.previewImage"
:src="media.previewImage"
:alt="media.previewTitle ?? media.url"
class="max-h-[60vh] w-full object-cover"
>
<div class="flex items-center gap-3 p-4">
<Icon name="ph:link-fill" class="size-5 shrink-0 text-muted-foreground" />
<div class="min-w-0">
<p v-if="media.previewTitle" class="truncate font-medium">
{{ media.previewTitle }}
</p>
<p class="truncate break-all text-sm text-muted-foreground">
{{ media.url }}
</p>
</div>
</div>
</a>
<img
v-else-if="media?.previewImage"
:src="media.previewImage"
:alt="media.name"
class="aspect-video w-full rounded-md object-cover"
>
<div v-else class="aspect-video w-full rounded-md bg-muted" />
</div>
</div>
</template>
<template v-if="data">
<div
class="relative isolate mx-auto mt-4 flex w-full items-center gap-3 overflow-hidden rounded-md border px-4 py-3 lg:w-2/5"
:class="uploaderCardBorderEffect && 'border-transparent'"
>
<CardBorderEffect v-if="uploaderCardBorderEffect" :effect="uploaderCardBorderEffect" :color="uploaderBorderColor" class="-z-10" />
<UserHoverCard
:user-id="data.upload.userId"
:user-name="data.upload.userName"
:user-color="data.upload.userColor"
:user-avatar-url="data.upload.userAvatarUrl"
>
<NuxtLink :to="`/user/${data.upload.userId}`" class="flex min-w-0 items-center gap-3">
<NuxtImg
v-if="data.upload.userAvatarUrl"
:src="data.upload.userAvatarUrl"
:alt="data.upload.userName"
width="48"
height="48"
fit="cover"
class="size-12 shrink-0 rounded-lg"
/>
<span
v-else
class="flex size-12 shrink-0 items-center justify-center rounded-lg text-lg font-medium"
:style="data.upload.userColor ? { backgroundColor: data.upload.userColor, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
>{{ data.upload.userName.slice(0, 2).toUpperCase() }}</span>
<span class="grid text-left">
<span
class="text-lg font-semibold hover:underline"
:style="data.upload.userColor ? { color: data.upload.userColor } : undefined"
>{{ data.upload.userName }}</span>
<span class="text-xs text-muted-foreground">{{ formatDate(data.upload.createdAt) }}</span>
</span>
</NuxtLink>
</UserHoverCard>
<Popover v-model:open="reactionPickerOpen">
<PopoverAnchor as-child>
<button
type="button"
class="ml-auto flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
:disabled="favBusy"
:aria-label="data.isFavorited ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen'"
@click="toggleFavorite"
@pointerenter="startReactionHover"
@pointerleave="scheduleReactionClose"
>
<Icon name="ph:heart-fill" class="size-5" :class="data.isFavorited ? 'fill-primary text-primary' : ''" />
</button>
</PopoverAnchor>
<PopoverContent
side="top"
align="end"
class="w-auto p-2"
@pointerenter="cancelReactionClose"
@pointerleave="scheduleReactionClose"
>
<div class="grid grid-cols-8 gap-1">
<button
v-for="e in REACTION_EMOJIS"
:key="e"
type="button"
class="flex size-9 cursor-pointer items-center justify-center rounded-md text-xl transition-colors hover:bg-accent"
@click="reactToUpload(e)"
>
{{ e }}
</button>
</div>
</PopoverContent>
</Popover>
<ThreadSubscriptionButton
v-if="user"
:key="data.upload.id"
:upload-id="data.upload.id"
:enabled="data.isSubscribed"
@change="setThreadSubscription"
/>
<Popover v-if="can('upload.delete')" v-model:open="deleteConfirmOpen">
<PopoverAnchor as-child>
<Button
variant="ghost"
size="icon"
class="text-muted-foreground hover:text-destructive"
aria-label="Upload löschen"
@click="deleteConfirmOpen = true"
>
<Icon name="ph:trash-fill" class="size-4" />
</Button>
</PopoverAnchor>
<PopoverContent side="bottom" align="end" class="w-56">
<p class="text-sm font-medium">
Upload wirklich löschen?
</p>
<p class="mt-1 text-xs text-muted-foreground">
Der Beitrag und alle Kommentare werden entfernt.
</p>
<div class="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" :disabled="deleting" @click="deleteConfirmOpen = false">
Abbrechen
</Button>
<Button variant="destructive" size="sm" :disabled="deleting" @click="deleteUpload">
{{ deleting ? 'Löscht…' : 'Löschen' }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<UploadReactions :upload-id="data.upload.id" class="mx-auto mt-3 w-full lg:w-2/5" @subscription-change="setThreadSubscription" />
<div
v-if="data.favoritedBy.length"
class="mx-auto mt-3 flex w-full items-center gap-3 rounded-md border px-4 py-3 lg:w-2/5"
>
<Icon name="ph:heart-fill" class="size-4 shrink-0 fill-primary text-primary" />
<div class="flex items-center -space-x-1.5">
<UserHoverCard
v-for="fav in data.favoritedBy.slice(0, 15)"
:key="fav.id"
:user-id="fav.id"
:user-name="fav.name"
:user-color="fav.color"
:user-avatar-url="fav.avatarUrl"
>
<NuxtLink
:to="`/user/${fav.id}`"
class="block shrink-0"
>
<NuxtImg
v-if="fav.avatarUrl"
:src="fav.avatarUrl"
:alt="fav.name"
width="32"
height="32"
fit="cover"
class="size-8 rounded-sm ring-2 ring-background"
/>
<span
v-else
class="flex size-8 items-center justify-center rounded-sm text-xs font-medium ring-2 ring-background"
:style="fav.color ? { backgroundColor: fav.color, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
>{{ fav.name.slice(0, 2).toUpperCase() }}</span>
</NuxtLink>
</UserHoverCard>
<span
v-if="data.favoritedBy.length > 15"
class="flex size-8 items-center justify-center rounded-sm bg-muted text-xs font-medium text-muted-foreground ring-2 ring-background"
>+{{ data.favoritedBy.length - 15 }}</span>
</div>
</div>
<div class="mx-auto mt-3 w-full lg:w-2/5">
<div v-if="tagEditing" class="rounded-md border p-3">
<div class="flex flex-wrap items-center gap-2">
<span
v-for="t in tagDraft"
:key="t"
class="inline-flex items-center gap-1.5 rounded-md bg-muted px-2.5 py-1 text-sm"
>
{{ t }}
<button
v-if="canDeleteTags || !originalTags.has(t)"
type="button"
class="text-muted-foreground hover:text-foreground"
:aria-label="`Tag ${t} entfernen`"
@click="tagDraft = tagDraft.filter(x => x !== t)"
>
<Icon name="ph:x-fill" class="size-3.5" />
</button>
</span>
<input
v-model="tagInput"
list="tag-suggestions"
type="text"
placeholder="Tag hinzufügen…"
class="min-w-32 flex-1 bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground"
@keydown="onTagKeydown"
>
<datalist id="tag-suggestions">
<option v-for="s in tagSuggestions" :key="s.name" :value="s.name" />
</datalist>
</div>
<div class="mt-2.5 flex items-center gap-2">
<Button size="sm" :disabled="tagSaving" @click="saveTags">
{{ tagSaving ? 'Speichern…' : 'Speichern' }}
</Button>
<Button size="sm" variant="ghost" :disabled="tagSaving" @click="tagEditing = false">
Abbrechen
</Button>
</div>
</div>
<div v-else-if="data.upload.tags.length || canEditTags" class="flex flex-wrap items-center gap-2">
<Icon name="ph:tag-fill" class="size-4 shrink-0 text-muted-foreground" />
<NuxtLink
v-for="t in data.upload.tags"
:key="t"
:to="{ path: '/', query: { tag: t } }"
class="rounded-md bg-muted px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>{{ t }}</NuxtLink>
<button
v-if="canEditTags"
type="button"
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:text-foreground"
aria-label="Tags bearbeiten"
@click="startTagEdit"
>
<Icon name="ph:pencil-fill" class="size-4" />
</button>
</div>
</div>
<ImageLightbox
v-if="lightboxOpen && data.upload.type === 'image'"
:src="data.upload.url"
:alt="data.upload.originalName"
@close="lightboxOpen = false"
/>
<section class="mx-auto mt-6 w-full lg:w-2/5">
<h2 class="mb-3 font-semibold">
Kommentare ({{ allComments.length }})
</h2>
<ul v-if="allComments.length" class="space-y-3">
<li
v-for="comment in allComments"
:id="`comment-${comment.id}`"
:key="comment.id"
:class="cn(
'relative scroll-mt-16 overflow-hidden rounded-md border p-3',
isUploaderComment(comment) && uploaderCardBorderEffect && 'border-transparent',
)"
:style="isUploaderComment(comment) ? uploaderCommentStyle : undefined"
>
<CardBorderEffect v-if="isUploaderComment(comment) && uploaderCardBorderEffect" :effect="uploaderCardBorderEffect" :color="uploaderBorderColor" />
<div class="relative mb-2 flex items-center gap-2.5">
<NuxtImg
v-if="comment.userAvatarUrl"
:src="comment.userAvatarUrl"
:alt="comment.userName"
width="40"
height="40"
fit="cover"
class="size-10 shrink-0 rounded-md"
/>
<span
v-else
class="flex size-10 shrink-0 items-center justify-center rounded-md text-sm font-medium"
:style="comment.userColor ? { backgroundColor: comment.userColor, color: '#fff' } : { backgroundColor: 'var(--color-muted)' }"
>{{ comment.userName.slice(0, 2).toUpperCase() }}</span>
<UserHoverCard
:user-id="comment.userId"
:user-name="comment.userName"
:user-color="comment.userColor"
:user-avatar-url="comment.userAvatarUrl"
>
<NuxtLink
:to="`/user/${comment.userId}`"
class="text-lg font-medium hover:underline"
:style="comment.userColor ? { color: comment.userColor } : undefined"
>{{ comment.userName }}</NuxtLink>
</UserHoverCard>
<Tooltip>
<TooltipTrigger as-child>
<span class="text-xs text-muted-foreground">{{ timeAgo(comment.createdAt) }}</span>
</TooltipTrigger>
<TooltipContent>{{ formatDate(comment.createdAt) }}</TooltipContent>
</Tooltip>
<div class="ml-auto flex items-center gap-1 self-center">
<CommentReactionPicker v-if="user" :comment-id="comment.id" />
<button
v-if="can('comment.delete')"
type="button"
class="rounded-md p-1 text-muted-foreground hover:text-destructive"
@click="deleteComment(comment.id)"
>
<Icon name="ph:trash-fill" class="size-4" />
<span class="sr-only">Kommentar löschen</span>
</button>
</div>
</div>
<div class="relative">
<CommentContent :content="comment.content" />
<CommentAttachments v-if="comment.attachments?.length" :attachments="comment.attachments" />
<CommentReactions :comment-id="comment.id" />
</div>
</li>
</ul>
<p v-else class="text-sm text-muted-foreground">
Noch keine Kommentare.
</p>
<form
v-if="user"
ref="commentFormRef"
class="mt-4 space-y-2"
@submit.prevent="submitComment"
>
<div :class="cn('rounded-md transition-shadow', isOverCommentDrop && 'shadow-[0_0_0_2px_var(--color-primary)]')">
<MentionTextarea
v-model="newComment"
placeholder="Kommentar schreiben… @ zum Erwähnen, Dateien hierher ziehen"
>
<div class="absolute right-2 bottom-2 flex items-center gap-0.5">
<Tooltip>
<TooltipTrigger as-child>
<button
type="button"
class="flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent"
:disabled="posting || pendingFiles.length >= MAX_COMMENT_ATTACHMENTS"
aria-label="Dateien anhängen"
@click="fileInputRef?.click()"
>
<Icon name="ph:folder-plus-fill" class="size-5" />
</button>
</TooltipTrigger>
<TooltipContent>Dateien anhängen</TooltipContent>
</Tooltip>
<Tooltip v-if="canRecord">
<TooltipTrigger as-child>
<button
type="button"
:class="cn(
'flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent',
recording && 'text-destructive hover:text-destructive',
)"
:disabled="posting || pendingFiles.length >= MAX_COMMENT_ATTACHMENTS"
:aria-label="recording ? 'Aufnahme beenden' : 'Sprachnachricht aufnehmen'"
@click="recording ? stopRecording() : startRecording()"
>
<Icon :name="recording ? 'ph:stop-fill' : 'ph:microphone-fill'" class="size-5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ recording ? 'Aufnahme beenden' : 'Sprachnachricht aufnehmen' }}</TooltipContent>
</Tooltip>
</div>
</MentionTextarea>
<div v-if="pendingFiles.length" class="mt-2 flex flex-wrap gap-1.5">
<span
v-for="(file, i) in pendingFiles"
:key="i"
class="flex max-w-48 items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs"
>
<img
v-if="pendingPreview(file)"
:src="pendingPreview(file)!"
:alt="file.name"
class="size-6 shrink-0 rounded-sm object-cover"
>
<Icon
v-else
:name="voicePending.has(file) ? 'ph:microphone-fill' : 'ph:file-fill'"
class="size-3.5 shrink-0 text-muted-foreground"
/>
<span class="truncate" :title="file.name">{{ file.name }}</span>
<span class="shrink-0 text-muted-foreground">{{ formatFileSize(file.size) }}</span>
<button
type="button"
class="shrink-0 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-destructive"
aria-label="Anhang entfernen"
@click="removePendingFile(i)"
>
<Icon name="ph:x" class="size-3.5" />
</button>
</span>
</div>
<div
v-if="recording"
class="mt-2 flex items-center gap-2 rounded-md border border-destructive/40 bg-muted/50 px-2.5 py-1.5 text-xs"
>
<span class="relative flex size-2.5 shrink-0">
<span class="absolute inline-flex size-full animate-ping rounded-full bg-destructive opacity-75" />
<span class="relative inline-flex size-2.5 rounded-full bg-destructive" />
</span>
<span class="shrink-0 tabular-nums">{{ formatAudioTime(recordSeconds) }}</span>
<span class="text-muted-foreground">Aufnahme läuft…</span>
<span class="ml-auto text-muted-foreground">Zum Beenden Mikrofon klicken</span>
</div>
</div>
<div class="flex items-center gap-3">
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="addPendingFiles(Array.from(($event.target as HTMLInputElement).files ?? []))"
>
<span v-if="commentError" class="text-sm text-destructive">{{ commentError }}</span>
<Button type="submit" size="sm" class="ml-auto" :disabled="posting || (!newComment.trim() && !pendingFiles.length)">
Kommentieren
</Button>
</div>
</form>
<p v-else class="mt-4 text-sm text-muted-foreground">
<NuxtLink to="/login" class="text-primary underline">
Melde dich an
</NuxtLink>, um zu kommentieren.
</p>
</section>
</template>
</div>
</template>