Keep thread navigation within the originating upload list

Record where an upload was opened from in a new useUploadNavContext
state. When a tile is clicked on a user profile, the thread page passes
that context to the upload detail API, which scopes the newer/older
neighbors to that user's uploads. Arrow clicks reseed the context for
the next thread, so the chain stays within the source list without a
URL parameter. Threads opened directly (notification, reload) fall back
to the global order. The breadcrumb reads the same context and shows
the profile path for profile-originated threads, now including the
thread id in the final crumb.

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 19:54:37 +02:00
co-authored by Devin
parent f760479b2b
commit 25a7867946
5 changed files with 81 additions and 12 deletions
+22 -3
View File
@@ -69,16 +69,35 @@ const requestHost = useRequestURL().hostname
const { user, can } = useAuth()
const { t, locale } = useI18n()
// Herkunftskontext aus dem State (kein URL-Param): gilt nur, wenn er für
// genau diesen Thread gesetzt wurde — Tile-Klick oder weitergereichter
// Pfeil. Der interne API-Call bekommt ihn als from-Param mit
const navContext = useUploadNavContext()
const activeContext = computed(() => navContext.value?.uploadId === id.value ? navContext.value : null)
const apiFrom = computed(() => activeContext.value?.fromPath ? `?from=${encodeURIComponent(activeContext.value.fromPath)}` : '')
// 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}`,
() => `/api/uploads/${id.value}${apiFrom.value}`,
)
if (import.meta.server) {
await fetchResult
}
const { data, error, refresh } = fetchResult
// Kontext für den Folgethread neu säen: uploadId wird auf das Ziel gesetzt,
// fromPath/fromLabel wandern mit — so bleibt die Pfeil-Kette in der
// Herkunftsliste, ohne dass der Param in der URL auftaucht
function seedNavContext(nextId: string | null) {
if (!nextId) return
navContext.value = {
uploadId: nextId,
fromPath: activeContext.value?.fromPath ?? null,
fromLabel: activeContext.value?.fromLabel ?? null,
}
}
function setThreadSubscription(uploadId: string, enabled: boolean) {
// useFetch-data ist ein shallowRef — nur eine Neuzuweisung triggert Reaktivität
if (data.value?.upload.id === uploadId) {
@@ -598,7 +617,7 @@ async function deleteComment(commentId: number) {
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')">
<NuxtLink :to="`/t/${data.newerId}`" :aria-label="$t('thread.newer')" @click="seedNavContext(data.newerId)">
<Icon name="ph:caret-left-fill" class="size-5" />
</NuxtLink>
</Button>
@@ -609,7 +628,7 @@ async function deleteComment(commentId: number) {
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')">
<NuxtLink :to="`/t/${data.olderId}`" :aria-label="$t('thread.older')" @click="seedNavContext(data.olderId)">
<Icon name="ph:caret-right-fill" class="size-5" />
</NuxtLink>
</Button>