Files
BRETT/server/api/uploads/[id].get.ts
T
FliesentischKingandDevin 25a7867946 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>
2026-10-04 19:54:37 +02:00

195 lines
7.3 KiB
TypeScript

import { and, asc, desc, eq, gt, inArray, lt, or } from 'drizzle-orm'
import { commentAttachments, commentReactions, comments, favorites, tags, threadSubscriptions, uploads, uploadReactions, uploadTags, users } from '../../db/schema'
import type { CommentAttachment } from '~~/shared/utils/attachments'
export default defineEventHandler(async (event) => {
const id = getRouterParam(event, 'id') ?? ''
if (!/^[A-Za-z0-9]{1,16}$/.test(id)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige ID' })
}
const db = useDb()
const uploadRows = await db
.select({
id: uploads.id,
type: uploads.type,
fileName: uploads.fileName,
externalUrl: uploads.externalUrl,
originalName: uploads.originalName,
mimeType: uploads.mimeType,
previewImage: uploads.previewImage,
previewTitle: uploads.previewTitle,
audioMeta: uploads.audioMeta,
createdAt: uploads.createdAt,
userName: users.name,
userId: users.id,
userColor: users.color,
userAvatar: users.avatar,
userCardEffect: users.cardEffect,
userCardEffectColor: users.cardEffectColor,
userCardVignette: users.cardVignette,
userCardBorderEffect: users.cardBorderEffect,
userCardBorderEffectColor: users.cardBorderEffectColor,
})
.from(uploads)
.innerJoin(users, eq(uploads.userId, users.id))
.where(eq(uploads.id, id))
.limit(1)
const upload = uploadRows[0]
if (!upload) {
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
}
const commentRows = await db
.select({
id: comments.id,
content: comments.content,
createdAt: comments.createdAt,
userName: users.name,
userId: users.id,
userColor: users.color,
userAvatar: users.avatar,
})
.from(comments)
.innerJoin(users, eq(comments.userId, users.id))
.where(eq(comments.uploadId, id))
.orderBy(asc(comments.createdAt))
// Nachbarn in Grid-Reihenfolge (createdAt desc, id desc als Tiebreak).
// Kontext ?from=/user/<id> 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
? db
.select({
commentId: commentReactions.commentId,
emoji: commentReactions.emoji,
userId: commentReactions.userId,
userName: users.name,
})
.from(commentReactions)
.innerJoin(users, eq(commentReactions.userId, users.id))
.where(inArray(commentReactions.commentId, commentIds))
.orderBy(asc(commentReactions.createdAt))
: Promise.resolve([]),
db
.select({
emoji: uploadReactions.emoji,
userId: uploadReactions.userId,
userName: users.name,
})
.from(uploadReactions)
.innerJoin(users, eq(uploadReactions.userId, users.id))
.where(eq(uploadReactions.uploadId, id))
.orderBy(asc(uploadReactions.createdAt)),
db
.select({ id: users.id, name: users.name, color: users.color, avatar: users.avatar })
.from(favorites)
.innerJoin(users, eq(favorites.userId, users.id))
.where(eq(favorites.uploadId, id))
.orderBy(desc(favorites.createdAt)),
db
.select({ id: uploads.id })
.from(uploads)
.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(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),
db
.select({ name: tags.name })
.from(uploadTags)
.innerJoin(tags, eq(uploadTags.tagId, tags.id))
.where(eq(uploadTags.uploadId, id))
.orderBy(asc(tags.name)),
commentIds.length
? db
.select()
.from(commentAttachments)
.where(inArray(commentAttachments.commentId, commentIds))
.orderBy(asc(commentAttachments.id))
: Promise.resolve([]),
sessionUser
? db.select({ enabled: threadSubscriptions.enabled }).from(threadSubscriptions)
.where(and(eq(threadSubscriptions.uploadId, id), eq(threadSubscriptions.userId, sessionUser.id))).limit(1)
: Promise.resolve([]),
])
const attachmentsByComment = new Map<number, CommentAttachment[]>()
for (const a of attachmentRows) {
let list = attachmentsByComment.get(a.commentId)
if (!list) {
list = []
attachmentsByComment.set(a.commentId, list)
}
list.push({ id: a.id, name: a.originalName, url: `/uploads/attachments/${a.fileName}`, mimeType: a.mimeType, size: a.size, cover: a.cover, meta: a.meta as CommentAttachment['meta'] })
}
const reactionsByComment = new Map<number, { emoji: string, count: number, me: boolean, names: string[] }[]>()
for (const r of reactionRows) {
let list = reactionsByComment.get(r.commentId)
if (!list) {
list = []
reactionsByComment.set(r.commentId, list)
}
let entry = list.find(e => e.emoji === r.emoji)
if (!entry) {
entry = { emoji: r.emoji, count: 0, me: false, names: [] }
list.push(entry)
}
entry.count += 1
entry.names.push(r.userName)
if (r.userId === sessionUser?.id) entry.me = true
}
const uploadReactionList: { emoji: string, count: number, me: boolean, names: string[] }[] = []
for (const r of uploadReactionRows) {
let entry = uploadReactionList.find(e => e.emoji === r.emoji)
if (!entry) {
entry = { emoji: r.emoji, count: 0, me: false, names: [] }
uploadReactionList.push(entry)
}
entry.count += 1
entry.names.push(r.userName)
if (r.userId === sessionUser?.id) entry.me = true
}
const url = upload.externalUrl ?? `/uploads/${upload.fileName}`
const embedId = upload.type === 'youtube' && upload.externalUrl
? getYoutubeId(upload.externalUrl)
: upload.type === 'twitch' && upload.externalUrl
? serializeTwitchTarget(getTwitchTarget(upload.externalUrl))
: null
return {
upload: { ...upload, url, embedId, userAvatarUrl: avatarUrl(upload.userAvatar), tags: tagRows.map(t => t.name) },
comments: commentRows.map(c => ({ ...c, userAvatarUrl: avatarUrl(c.userAvatar), reactions: reactionsByComment.get(c.id) ?? [], attachments: attachmentsByComment.get(c.id) ?? [] })),
uploadReactions: uploadReactionList,
favoritedBy: favRows.map(f => ({ id: f.id, name: f.name, color: f.color, avatarUrl: avatarUrl(f.avatar) })),
isFavorited: sessionUser ? favRows.some(f => f.id === sessionUser.id) : false,
isSubscribed: subscriptionRows[0]?.enabled ?? false,
newerId: newerRows[0]?.id ?? null,
olderId: olderRows[0]?.id ?? null,
}
})