From 25a7867946017a5f802b4a890718ef8e9c1fc85c Mon Sep 17 00:00:00 2001 From: FliesentischKing Date: Sun, 4 Oct 2026 19:54:37 +0200 Subject: [PATCH] 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> --- app/components/SiteBreadcrumb.vue | 19 +++++++++++++++++-- app/components/UploadTile.vue | 10 ++++++++++ app/composables/useLastViewedUpload.ts | 14 ++++++++++++++ app/pages/t/[id].vue | 25 ++++++++++++++++++++++--- server/api/uploads/[id].get.ts | 25 ++++++++++++++++++------- 5 files changed, 81 insertions(+), 12 deletions(-) diff --git a/app/components/SiteBreadcrumb.vue b/app/components/SiteBreadcrumb.vue index d03f3a7..f0fde63 100644 --- a/app/components/SiteBreadcrumb.vue +++ b/app/components/SiteBreadcrumb.vue @@ -10,6 +10,7 @@ import { const { t } = useI18n() const route = useRoute() +const navContext = useUploadNavContext() interface Crumb { label: string @@ -20,7 +21,20 @@ const crumbs = computed(() => { const list: Crumb[] = [{ label: t('palette.home'), to: '/' }] const p = route.path if (p === '/') return [] - if (p.startsWith('/t/')) list.push({ label: t('nav.thread') }) + if (p.startsWith('/t/')) { + // Herkunft aus dem Nav-Kontext — wird von den Pfeilen weitergereicht. + // Gilt nur für die hinterlegte Upload-ID (Tile-Klick oder Pfeil-Seed); + // direkt geöffnete Threads (Notification, Reload) zeigen den schlichten + // Crumb ohne Profilkette + const ctx = navContext.value?.uploadId === route.params.id ? navContext.value : null + if (ctx?.fromPath) { + list.push( + { label: t('nav.users'), to: '/users' }, + { label: ctx.fromLabel ?? t('nav.profile'), to: ctx.fromPath }, + ) + } + list.push({ label: `${t('nav.thread')} ${route.params.id ?? ''}` }) + } else if (p === '/users') list.push({ label: t('nav.users') }) else if (p.startsWith('/user/')) { list.push({ label: t('nav.users'), to: '/users' }, { label: t('nav.profile') }) @@ -41,9 +55,10 @@ const crumbs = computed(() => { {{ crumb.label }} - + {{ crumb.label }} + {{ crumb.label }} diff --git a/app/components/UploadTile.vue b/app/components/UploadTile.vue index 6e383d1..3d1f9c1 100644 --- a/app/components/UploadTile.vue +++ b/app/components/UploadTile.vue @@ -37,8 +37,13 @@ const canPlaceholder = computed(() => { const indicator = useLoadingIndicator() const router = useRouter() +const route = useRoute() const navigating = ref(false) +// Herkunft im State merken (kein URL-Param): /user/ scopt Thread-Pfeile +// und Breadcrumbs auf die Uploads dieses Nutzers +const navContext = useUploadNavContext() + const spanClass = computed(() => { if (uniform.value) return '' const count = props.upload.commentCount ?? 0 @@ -81,6 +86,11 @@ async function openUpload(e: MouseEvent) { url: props.upload.url, thumbUrl: props.upload.thumbUrl ?? null, } + navContext.value = { + uploadId: props.upload.id, + fromPath: /^\/user\/\d+$/.test(route.path) ? route.path : null, + fromLabel: props.upload.userName ?? null, + } try { const threadFetch = $fetch(`/api/uploads/${props.upload.id}`) diff --git a/app/composables/useLastViewedUpload.ts b/app/composables/useLastViewedUpload.ts index fe74d17..8883249 100644 --- a/app/composables/useLastViewedUpload.ts +++ b/app/composables/useLastViewedUpload.ts @@ -15,3 +15,17 @@ export interface PendingUploadMedia { export function usePendingUploadMedia() { return useState('pending-upload-media', () => null) } + +export interface UploadNavContext { + uploadId: string + fromPath: string | null + fromLabel: string | null +} + +// Herkunft der Thread-Navigation (z. B. /user/): beim Tile-Klick gesetzt +// und von den Thread-Pfeilen für die Folgeseite weitergereicht. Gilt immer +// nur für die in uploadId hinterlegte Seite — ein direkt geöffneter Thread +// (Notification, Kommentar-Link, Reload) fällt auf die globale Reihenfolge +export function useUploadNavContext() { + return useState('upload-nav-context', () => null) +} diff --git a/app/pages/t/[id].vue b/app/pages/t/[id].vue index bf20994..45e0c8d 100644 --- a/app/pages/t/[id].vue +++ b/app/pages/t/[id].vue @@ -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 > - + @@ -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 > - + diff --git a/server/api/uploads/[id].get.ts b/server/api/uploads/[id].get.ts index 7e6be48..829c7cd 100644 --- a/server/api/uploads/[id].get.ts +++ b/server/api/uploads/[id].get.ts @@ -55,8 +55,13 @@ export default defineEventHandler(async (event) => { .where(eq(comments.uploadId, id)) .orderBy(asc(comments.createdAt)) - // Nachbarn in Grid-Reihenfolge (createdAt desc, id desc als Tiebreak) + // Nachbarn in Grid-Reihenfolge (createdAt desc, id desc als Tiebreak). + // Kontext ?from=/user/ scopt die Navigation auf die Uploads dieses + // Nutzers — so durchläuft man vom Profil aus nur dessen Uploads const sessionUser = await getSessionUser(event) + const from = String(getQuery(event).from ?? '') + const contextUserId = /^\/user\/(\d+)$/.exec(from)?.[1] + const scopedToUser = contextUserId ? eq(uploads.userId, Number(contextUserId)) : undefined const commentIds = commentRows.map(c => c.id) const [reactionRows, uploadReactionRows, favRows, newerRows, olderRows, tagRows, attachmentRows, subscriptionRows] = await Promise.all([ commentIds.length @@ -91,18 +96,24 @@ export default defineEventHandler(async (event) => { db .select({ id: uploads.id }) .from(uploads) - .where(or( - gt(uploads.createdAt, upload.createdAt), - and(eq(uploads.createdAt, upload.createdAt), gt(uploads.id, upload.id)), + .where(and( + or( + gt(uploads.createdAt, upload.createdAt), + and(eq(uploads.createdAt, upload.createdAt), gt(uploads.id, upload.id)), + ), + scopedToUser, )) .orderBy(asc(uploads.createdAt), asc(uploads.id)) .limit(1), db .select({ id: uploads.id }) .from(uploads) - .where(or( - lt(uploads.createdAt, upload.createdAt), - and(eq(uploads.createdAt, upload.createdAt), lt(uploads.id, upload.id)), + .where(and( + or( + lt(uploads.createdAt, upload.createdAt), + and(eq(uploads.createdAt, upload.createdAt), lt(uploads.id, upload.id)), + ), + scopedToUser, )) .orderBy(desc(uploads.createdAt), desc(uploads.id)) .limit(1),