forked from BRETT/BRETT
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:
+46
-26
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user