forked from BRETT/BRETT
Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1175 lines
44 KiB
Vue
1175 lines
44 KiB
Vue
<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">
|
||
<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="cursor-pointer 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>
|
||
|
||
<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>
|