Thread page UX improvements

- Skeleton comment cards while the thread loads; comment count
  only rendered once data arrives
- Subscribe button updates optimistically (fixes shallowRef
  useFetch data not being reactive on in-place mutation)
- Add-tag control restyled as a tag chip, tag icon removed;
  recording indicator bar dropped in favor of the mic button state
- Nav arrows and audio controls follow the theme border radius;
  arrow z-index no longer overlays sticky header/MOTD
- Thread notifications are marked read on visit and on live arrival
- Audio upload player uses the shared media volume

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-04 03:06:32 +02:00
co-authored by Devin
parent fe9e723c72
commit f57fffb414
2 changed files with 52 additions and 28 deletions
+6 -2
View File
@@ -15,15 +15,19 @@ async function toggle() {
if (busy.value) return
busy.value = true
const uploadId = props.uploadId
const next = !props.enabled
// Optimistisch umschalten — wie bei den Reactions; der Server korrigiert nur noch
emit('change', uploadId, next)
try {
const result = await $fetch<{ enabled: boolean }>(`/api/uploads/${uploadId}/subscription`, {
method: 'PUT',
body: { enabled: !props.enabled },
body: { enabled: next },
})
emit('change', uploadId, result.enabled)
if (result.enabled !== next) emit('change', uploadId, result.enabled)
toast.success(result.enabled ? t('thread.subOn') : t('thread.subOff'))
}
catch {
emit('change', uploadId, !next)
toast.error(t('thread.subFailed'))
}
finally {
+46 -26
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { useDropZone, useEventListener, useMediaControls } from '@vueuse/core'
import { useDropZone, useEventListener } from '@vueuse/core'
import { toast } from 'vue-sonner'
import type { CommentReaction } from '@/composables/useCommentReactions'
import type { UploadReaction } from '@/composables/useUploadReactions'
@@ -16,6 +16,8 @@ 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'
@@ -78,7 +80,10 @@ if (import.meta.server) {
const { data, error, refresh } = fetchResult
function setThreadSubscription(uploadId: string, enabled: boolean) {
if (data.value?.upload.id === uploadId) data.value.isSubscribed = enabled
// 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
@@ -106,6 +111,16 @@ 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') {
@@ -224,7 +239,7 @@ const {
duration: audioDuration,
volume: audioVolume,
muted: audioMuted,
} = useMediaControls(audioRef)
} = useMediaVolume(audioRef)
function toggleAudio() {
audioPlaying.value = !audioPlaying.value
@@ -580,7 +595,7 @@ async function deleteComment(commentId: number) {
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"
class="absolute left-2 top-1/2 -translate-y-1/2 shadow opacity-80 hover:opacity-100"
as-child
>
<NuxtLink :to="`/thread/${data.newerId}`" :aria-label="$t('thread.newer')">
@@ -591,7 +606,7 @@ async function deleteComment(commentId: number) {
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"
class="absolute right-2 top-1/2 -translate-y-1/2 shadow opacity-80 hover:opacity-100"
as-child
>
<NuxtLink :to="`/thread/${data.olderId}`" :aria-label="$t('thread.older')">
@@ -655,7 +670,7 @@ async function deleteComment(commentId: number) {
<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="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" />
@@ -681,7 +696,7 @@ async function deleteComment(commentId: number) {
<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"
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"
>
@@ -702,7 +717,7 @@ async function deleteComment(commentId: number) {
<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"
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"
>
@@ -858,6 +873,7 @@ async function deleteComment(commentId: number) {
{{ $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>
@@ -941,6 +957,7 @@ async function deleteComment(commentId: number) {
</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">
@@ -949,7 +966,6 @@ async function deleteComment(commentId: number) {
</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"
@@ -959,11 +975,11 @@ async function deleteComment(commentId: number) {
<button
v-if="canEditTags"
type="button"
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:text-foreground"
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"
>
<Icon name="ph:pencil-fill" class="size-4" />
{{ $t('thread.addTagButton') }}
</button>
</div>
</div>
@@ -977,10 +993,25 @@ async function deleteComment(commentId: number) {
<section class="mx-auto mt-6 w-full lg:w-2/5">
<h2 class="mb-3 font-semibold">
{{ $t('comments.title') }} ({{ allComments.length }})
{{ $t('comments.title') }}<template v-if="data"> ({{ allComments.length }})</template>
</h2>
<ul v-if="allComments.length" class="space-y-3">
<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}`"
@@ -1123,18 +1154,6 @@ async function deleteComment(commentId: number) {
</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">{{ $t('comments.recording') }}</span>
<span class="ml-auto text-muted-foreground">{{ $t('comments.recordHint') }}</span>
</div>
</div>
<div class="flex items-center gap-3">
<input
@@ -1146,7 +1165,8 @@ async function deleteComment(commentId: number) {
>
<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)">
{{ $t('comments.submit') }}
<Spinner v-if="posting" />
{{ posting ? $t('comments.submitting') : $t('comments.submit') }}
</Button>
</div>
</form>