Initial Commit

This commit is contained in:
2026-10-03 20:07:49 +02:00
commit a1346c98b1
427 changed files with 59320 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
import { count, eq } from 'drizzle-orm'
import { comments, uploads, users } from '../../db/schema'
export default defineEventHandler(async (event) => {
const idParam = getRouterParam(event, 'id') ?? ''
if (!/^\d+$/.test(idParam)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Nutzer-ID' })
}
const userId = Number(idParam)
const db = useDb()
const [user] = await db
.select({ id: users.id, name: users.name, color: users.color, avatar: users.avatar, cardEffect: users.cardEffect, cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette, cardBorderEffect: users.cardBorderEffect, cardBorderEffectColor: users.cardBorderEffectColor, createdAt: users.createdAt })
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!user) {
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
}
const [uploadStats] = await db
.select({ count: count() })
.from(uploads)
.where(eq(uploads.userId, user.id))
const [commentStats] = await db
.select({ count: count() })
.from(comments)
.where(eq(comments.userId, user.id))
return {
user: {
id: user.id,
name: user.name,
color: user.color,
avatarUrl: avatarUrl(user.avatar),
cardEffect: user.cardEffect,
cardEffectColor: user.cardEffectColor, cardVignette: user.cardVignette,
cardBorderEffect: user.cardBorderEffect,
cardBorderEffectColor: user.cardBorderEffectColor,
createdAt: user.createdAt,
},
stats: {
uploads: uploadStats?.count ?? 0,
comments: commentStats?.count ?? 0,
},
}
})
+85
View File
@@ -0,0 +1,85 @@
import { asc, count, desc, eq, inArray } from 'drizzle-orm'
import { commentAttachments, comments, uploads, users } from '../../../db/schema'
import type { CommentAttachment } from '~~/shared/utils/attachments'
export default defineEventHandler(async (event) => {
const idParam = getRouterParam(event, 'id') ?? ''
if (!/^\d+$/.test(idParam)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Nutzer-ID' })
}
const userId = Number(idParam)
const q = getQuery(event)
const page = Math.max(1, Math.floor(Number(q.page)) || 1)
const perPage = Math.min(100, Math.max(1, Math.floor(Number(q.perPage)) || 20))
const db = useDb()
const [user] = await db
.select({ id: users.id })
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!user) {
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
}
const [totalRow, rows] = await Promise.all([
db.select({ count: count() }).from(comments).where(eq(comments.userId, userId)),
db
.select({
id: comments.id,
content: comments.content,
createdAt: comments.createdAt,
uploadId: uploads.id,
uploadType: uploads.type,
uploadFileName: uploads.fileName,
uploadExternalUrl: uploads.externalUrl,
uploadPreviewImage: uploads.previewImage,
})
.from(comments)
.innerJoin(uploads, eq(comments.uploadId, uploads.id))
.where(eq(comments.userId, userId))
.orderBy(desc(comments.createdAt))
.limit(perPage)
.offset((page - 1) * perPage),
])
const total = totalRow[0]?.count ?? 0
const commentIds = rows.map(r => r.id)
const attachmentRows = commentIds.length
? await db
.select()
.from(commentAttachments)
.where(inArray(commentAttachments.commentId, commentIds))
.orderBy(asc(commentAttachments.id))
: []
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 items = rows.map((row) => {
const url = row.uploadExternalUrl ?? `/uploads/${row.uploadFileName}`
const embedId = row.uploadType === 'youtube' && row.uploadExternalUrl ? getYoutubeId(row.uploadExternalUrl) : null
const uploadThumbUrl = row.uploadType === 'youtube' && embedId
? youtubeThumbUrl(embedId)
: row.uploadType === 'image' ? url : row.uploadType === 'link' || row.uploadType === 'twitch' || row.uploadType === 'audio' ? row.uploadPreviewImage : null
return {
id: row.id,
content: row.content,
createdAt: row.createdAt,
uploadId: row.uploadId,
uploadThumbUrl,
attachments: attachmentsByComment.get(row.id) ?? [],
}
})
return { items, total, pageCount: Math.max(1, Math.ceil(total / perPage)) }
})
+47
View File
@@ -0,0 +1,47 @@
import { count, eq } from 'drizzle-orm'
import { comments, uploads, users } from '../../../db/schema'
export default defineEventHandler(async (event) => {
const idParam = getRouterParam(event, 'id') ?? ''
if (!/^\d+$/.test(idParam)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Nutzer-ID' })
}
const userId = Number(idParam)
const db = useDb()
const [user] = await db
.select({ id: users.id, name: users.name, color: users.color, avatar: users.avatar, cardEffect: users.cardEffect, cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette, cardBorderEffect: users.cardBorderEffect, cardBorderEffectColor: users.cardBorderEffectColor, createdAt: users.createdAt })
.from(users)
.where(eq(users.id, userId))
.limit(1)
if (!user) {
throw createError({ statusCode: 404, statusMessage: 'Nutzer nicht gefunden' })
}
const [uploadStats] = await db
.select({ count: count() })
.from(uploads)
.where(eq(uploads.userId, user.id))
const [commentStats] = await db
.select({ count: count() })
.from(comments)
.where(eq(comments.userId, user.id))
return {
id: user.id,
name: user.name,
color: user.color,
cardEffect: user.cardEffect,
cardEffectColor: user.cardEffectColor, cardVignette: user.cardVignette,
cardBorderEffect: user.cardBorderEffect,
cardBorderEffectColor: user.cardBorderEffectColor,
avatarUrl: avatarUrl(user.avatar),
createdAt: user.createdAt,
stats: {
uploads: uploadStats?.count ?? 0,
comments: commentStats?.count ?? 0,
},
}
})
+47
View File
@@ -0,0 +1,47 @@
import { desc, eq } from 'drizzle-orm'
import { uploads, users } from '../../../db/schema'
// Upload-Liste eines Nutzers mit Viewport-Pagination (wie /api/uploads).
// thumbUrl wird hier serverseitig mitgeliefert — kein separater Meta-Call.
export default defineEventHandler(async (event) => {
const idParam = getRouterParam(event, 'id') ?? ''
if (!/^\d+$/.test(idParam)) {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Nutzer-ID' })
}
const userId = Number(idParam)
const q = getQuery(event)
const { page, cells, cols, uniform } = getGridPageParams(q)
const db = useDb()
const rows = await db
.select({
id: uploads.id,
type: uploads.type,
fileName: uploads.fileName,
externalUrl: uploads.externalUrl,
previewImage: uploads.previewImage,
commentCount: uploads.commentCount,
createdAt: uploads.createdAt,
userName: users.name,
userColor: users.color,
userAvatar: users.avatar,
})
.from(uploads)
.innerJoin(users, eq(uploads.userId, users.id))
.where(eq(uploads.userId, userId))
.orderBy(desc(uploads.createdAt))
const pages = paginateGrid(rows, r => gridTileDims(r.commentCount, uniform, cols), cells, cols)
const items = (pages[page - 1] ?? []).map((row) => {
const url = row.externalUrl ?? `/uploads/${row.fileName}`
const embedId = row.type === 'youtube' && row.externalUrl ? getYoutubeId(row.externalUrl) : null
const thumbUrl = row.type === 'youtube' && embedId
? youtubeThumbUrl(embedId)
: row.type === 'image' ? url : row.type === 'link' || row.type === 'twitch' || row.type === 'audio' ? row.previewImage : null
return { ...row, url, thumbUrl, userAvatarUrl: avatarUrl(row.userAvatar) }
})
return { items, total: rows.length, pageCount: pages.length }
})
+44
View File
@@ -0,0 +1,44 @@
import { asc, count } from 'drizzle-orm'
import { comments, uploads, users } from '../../db/schema'
export default defineEventHandler(async () => {
const db = useDb()
const rows = await db
.select({
id: users.id,
name: users.name,
color: users.color,
avatar: users.avatar,
cardEffect: users.cardEffect,
cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette,
cardBorderEffect: users.cardBorderEffect,
cardBorderEffectColor: users.cardBorderEffectColor,
createdAt: users.createdAt,
})
.from(users)
.orderBy(asc(users.id))
const [uploadStats, commentStats] = await Promise.all([
db.select({ userId: uploads.userId, count: count() }).from(uploads).groupBy(uploads.userId),
db.select({ userId: comments.userId, count: count() }).from(comments).groupBy(comments.userId),
])
const uploadCount = new Map(uploadStats.map(s => [s.userId, s.count]))
const commentCount = new Map(commentStats.map(s => [s.userId, s.count]))
return rows.map(u => ({
id: u.id,
name: u.name,
color: u.color,
avatarUrl: avatarUrl(u.avatar),
cardEffect: u.cardEffect,
cardEffectColor: u.cardEffectColor, cardVignette: u.cardVignette,
cardBorderEffect: u.cardBorderEffect,
cardBorderEffectColor: u.cardBorderEffectColor,
createdAt: u.createdAt,
stats: {
uploads: uploadCount.get(u.id) ?? 0,
comments: commentCount.get(u.id) ?? 0,
},
}))
})
+125
View File
@@ -0,0 +1,125 @@
import { randomUUID } from 'node:crypto'
import { mkdir, unlink, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { isCardBorderEffect } from '../../../shared/utils/cardBorderEffects'
import { isCardEffect } from '../../../shared/utils/cardEffects'
import { users } from '../../db/schema'
const AVATAR_MIME_EXT: Record<string, string> = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'image/avif': '.avif',
}
const MAX_AVATAR_SIZE = 5 * 1024 * 1024 // 5 MB
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const parts = await readMultipartFormData(event)
const colorField = parts?.find(p => p.name === 'color')?.data.toString('utf8').trim()
const cardEffectField = parts?.find(p => p.name === 'cardEffect')?.data.toString('utf8').trim()
const cardEffectColorField = parts?.find(p => p.name === 'cardEffectColor')?.data.toString('utf8').trim()
const cardVignetteField = parts?.find(p => p.name === 'cardVignette')?.data.toString('utf8').trim()
const cardBorderEffectField = parts?.find(p => p.name === 'cardBorderEffect')?.data.toString('utf8').trim()
const cardBorderEffectColorField = parts?.find(p => p.name === 'cardBorderEffectColor')?.data.toString('utf8').trim()
const avatarFile = parts?.find(p => p.name === 'avatar')
let color: string | null | undefined
if (colorField !== undefined) {
if (colorField === '') {
color = null
}
else if (/^#[0-9a-fA-F]{6}$/.test(colorField)) {
color = colorField.toLowerCase()
}
else {
throw createError({ statusCode: 400, statusMessage: 'Ungültige Farbe' })
}
}
let cardEffect: string | null | undefined
if (cardEffectField !== undefined) {
if (cardEffectField === '') {
cardEffect = null
}
else if (isCardEffect(cardEffectField)) {
cardEffect = cardEffectField
}
else {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Effekt' })
}
}
let cardBorderEffect: string | null | undefined
if (cardBorderEffectField !== undefined) {
if (cardBorderEffectField === '') {
cardBorderEffect = null
}
else if (isCardBorderEffect(cardBorderEffectField)) {
cardBorderEffect = cardBorderEffectField
}
else {
throw createError({ statusCode: 400, statusMessage: 'Ungültiger Border-Effekt' })
}
}
const parseOptionalColor = (field: string | undefined): string | null | undefined => {
if (field === undefined) return undefined
if (field === '') return null
if (/^#[0-9a-fA-F]{6}$/.test(field)) return field.toLowerCase()
throw createError({ statusCode: 400, statusMessage: 'Ungültige Effekt-Farbe' })
}
const cardEffectColor = parseOptionalColor(cardEffectColorField)
const cardBorderEffectColor = parseOptionalColor(cardBorderEffectColorField)
const cardVignette = cardVignetteField === undefined ? undefined : cardVignetteField === '1'
let avatarName: string | undefined
if (avatarFile?.data?.length && avatarFile.type) {
const ext = AVATAR_MIME_EXT[avatarFile.type]
if (!ext) {
throw createError({ statusCode: 415, statusMessage: 'Nur Bilder sind als Avatar erlaubt' })
}
if (avatarFile.data.length > MAX_AVATAR_SIZE) {
throw createError({ statusCode: 413, statusMessage: 'Avatar ist zu groß (max. 5 MB)' })
}
avatarName = `${randomUUID()}${ext}`
await mkdir(avatarDir(), { recursive: true })
await writeFile(join(avatarDir(), avatarName), avatarFile.data)
}
const db = useDb()
await db.update(users).set({
...(color !== undefined && { color }),
...(cardEffect !== undefined && { cardEffect }),
...(cardEffectColor !== undefined && { cardEffectColor }),
...(cardVignette !== undefined && { cardVignette }),
...(cardBorderEffect !== undefined && { cardBorderEffect }),
...(cardBorderEffectColor !== undefined && { cardBorderEffectColor }),
...(avatarName && { avatar: avatarName }),
}).where(eq(users.id, user.id))
if (avatarName && user.avatar) {
await unlink(join(avatarDir(), user.avatar)).catch(() => {})
}
const [updated] = await db.select().from(users).where(eq(users.id, user.id)).limit(1)
return {
user: {
id: updated!.id,
name: updated!.name,
email: updated!.email,
color: updated!.color,
cardEffect: updated!.cardEffect,
cardEffectColor: updated!.cardEffectColor,
cardVignette: updated!.cardVignette,
cardBorderEffect: updated!.cardBorderEffect,
cardBorderEffectColor: updated!.cardBorderEffectColor,
avatarUrl: avatarUrl(updated!.avatar),
roles: user.roles,
},
}
})
+7
View File
@@ -0,0 +1,7 @@
import { asc } from 'drizzle-orm'
import { users } from '../../db/schema'
export default defineEventHandler(() => useDb()
.select({ id: users.id, name: users.name, color: users.color })
.from(users)
.orderBy(asc(users.name)))