Files
BRETT/app/pages/t/[id].vue
T
FliesentischKingandDevin 456776ea68 Shorten thread route from /thread to /t
All internal links (tiles, prev/next nav, comments, notifications,
breadcrumb, dropzone route check) point to /t/<id>. A global route
middleware permanently redirects old /thread/<id> URLs including
query string and comment anchor.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-04 15:24:32 +02:00

1182 lines
45 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 } 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 { Skeleton } from '@/components/ui/skeleton'
import { Spinner } from '@/components/ui/spinner'
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()
const { t, locale } = useI18n()
// 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) {
// useFetch-data ist ein shallowRef — nur eine Neuzuweisung triggert Reaktivität
if (data.value?.upload.id === uploadId) {
data.value = { ...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
})
// Benachrichtigung dieses Threads als gelesen markieren — beim Betreten und
// sobald sie eintrifft (Notifications laden async, neue kommen live dazu)
const { notifications, markRead } = useNotifications()
function markThreadRead() {
const n = notifications.value.find(n => n.uploadId === id.value && !n.isRead)
if (n) markRead(n.id)
}
onMounted(markThreadRead)
watch([notifications, id], markThreadRead)
// 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(t('comments.loginToReact'))
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(t('comments.reactionFailed'))
}
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,
} = useMediaVolume(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(t('comments.maxAttachments', { n: MAX_COMMENT_ATTACHMENTS }))
break
}
if (file.size > MAX_ATTACHMENT_SIZE) {
toast.error(t('comments.fileTooLarge', { name: file.name }))
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(t('comments.noMic'))
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, `${t('comments.voiceMessage')}.${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 ?? t('thread.tagsSaveFailed'))
}
finally {
tagSaving.value = false
}
}
async function toggleFavorite() {
if (!data.value || favBusy.value) return
if (!user.value) {
toast.error(t('thread.loginToFavorite'))
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(locale.value, { 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 ?? t('comments.sendFailed')
}
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(t('thread.deleteFailed'))
}
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">
{{ $t('thread.notFound') }}
</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 -translate-y-1/2 shadow opacity-80 hover:opacity-100"
as-child
>
<NuxtLink :to="`/t/${data.newerId}`" :aria-label="$t('thread.newer')">
<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 -translate-y-1/2 shadow opacity-80 hover:opacity-100"
as-child
>
<NuxtLink :to="`/t/${data.olderId}`" :aria-label="$t('thread.older')">
<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 ? $t('audio.pause') : $t('audio.play')"
@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-md 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-md text-white transition-colors hover:bg-white/15"
:aria-label="audioPlaying ? $t('audio.pause') : $t('audio.play')"
@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="$t('audio.seek')"
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-md text-white transition-colors hover:bg-white/15 sm:flex"
:aria-label="audioMuted ? $t('audio.unmute') : $t('audio.mute')"
@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="$t('audio.volume')"
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 ? $t('thread.favRemove') : $t('thread.favAdd')"
@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="$t('thread.deleteUpload')"
@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">
{{ $t('thread.deleteTitle') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('thread.deleteDesc') }}
</p>
<div class="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" :disabled="deleting" @click="deleteConfirmOpen = false">
{{ $t('common.cancel') }}
</Button>
<Button variant="destructive" size="sm" :disabled="deleting" @click="deleteUpload">
<Spinner v-if="deleting" />
{{ deleting ? $t('common.deleting') : $t('common.delete') }}
</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="$t('thread.removeTag', { tag: t })"
@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="$t('thread.addTag')"
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">
<Spinner v-if="tagSaving" />
{{ tagSaving ? $t('common.saving') : $t('common.save') }}
</Button>
<Button size="sm" variant="ghost" :disabled="tagSaving" @click="tagEditing = false">
{{ $t('common.cancel') }}
</Button>
</div>
</div>
<div v-else-if="data.upload.tags.length || canEditTags" class="flex flex-wrap items-center gap-2">
<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="cursor-pointer rounded-md border border-dashed border-muted-foreground/40 px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
:aria-label="$t('thread.editTags')"
@click="startTagEdit"
>
{{ $t('thread.addTagButton') }}
</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">
{{ $t('comments.title') }}<template v-if="data"> ({{ allComments.length }})</template>
</h2>
<ul v-if="!data" class="space-y-3" aria-hidden="true">
<li
v-for="i in 3"
:key="i"
class="relative overflow-hidden rounded-md border p-3"
>
<div class="mb-2 flex items-center gap-2.5">
<Skeleton class="size-10 shrink-0 rounded-md" />
<Skeleton class="h-5 w-32 rounded-md" />
<Skeleton class="h-3 w-16 rounded-md" />
</div>
<Skeleton class="h-4 w-full rounded-md" />
<Skeleton class="mt-1.5 h-4 w-3/4 rounded-md" />
</li>
</ul>
<ul v-else-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">{{ $t('comments.deleteComment') }}</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">
{{ $t('comments.none') }}
</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="$t('comments.placeholder')"
>
<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="$t('comments.attachFiles')"
@click="fileInputRef?.click()"
>
<Icon name="ph:folder-plus-fill" class="size-5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ $t('comments.attachFiles') }}</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 ? $t('comments.recordStop') : $t('comments.recordStart')"
@click="recording ? stopRecording() : startRecording()"
>
<Icon :name="recording ? 'ph:stop-fill' : 'ph:microphone-fill'" class="size-5" />
</button>
</TooltipTrigger>
<TooltipContent>{{ recording ? $t('comments.recordStop') : $t('comments.recordStart') }}</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="$t('comments.removeAttachment')"
@click="removePendingFile(i)"
>
<Icon name="ph:x" class="size-3.5" />
</button>
</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)">
<Spinner v-if="posting" />
{{ posting ? $t('comments.submitting') : $t('comments.submit') }}
</Button>
</div>
</form>
<p v-else class="mt-4 text-sm text-muted-foreground">
<NuxtLink to="/login" class="text-primary underline">
{{ $t('common.loginLink') }}
</NuxtLink>, {{ $t('comments.loginToComment') }}
</p>
</section>
</template>
</div>
</template>