forked from BRETT/BRETT
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>
195 lines
7.3 KiB
TypeScript
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,
|
|
}
|
|
})
|