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
+50
View File
@@ -0,0 +1,50 @@
export interface AuthUser {
id: number
name: string
email: string | null
color: string | null
cardEffect: string | null
cardEffectColor: string | null
cardVignette: boolean
cardBorderEffect: string | null
cardBorderEffectColor: string | null
avatarUrl: string | null
roles: string[]
permissions: string[]
}
export function useAuth() {
const user = useState<AuthUser | null>('auth:user', () => null)
async function refresh() {
const requestFetch = useRequestFetch()
const res = await requestFetch<{ user: AuthUser | null }>('/api/auth/me')
user.value = res.user
}
async function login(name: string, password: string) {
const res = await $fetch<{ user: AuthUser }>('/api/auth/login', {
method: 'POST',
body: { name, password },
})
user.value = res.user
}
async function register(name: string, password: string, email?: string) {
const res = await $fetch<{ user: AuthUser }>('/api/auth/register', {
method: 'POST',
body: { name, password, email },
})
user.value = res.user
}
async function logout() {
await $fetch('/api/auth/logout', { method: 'POST' })
user.value = null
}
const isAdmin = computed(() => user.value?.roles.includes('admin') ?? false)
const can = (permission: string) => isAdmin.value || (user.value?.permissions.includes(permission) ?? false)
return { user, isAdmin, can, refresh, login, register, logout }
}
+194
View File
@@ -0,0 +1,194 @@
import type { Ref } from 'vue'
import { Mesh, Program, Renderer, Triangle } from 'ogl'
import { hexToRgbTuple, shadeHex } from '@/lib/color'
export interface CardShaderMouse {
/** 0..1, links → rechts */
x: number
/** 0..1, unten → oben (GL-Konvention) */
y: number
/** 0..1 weich eingedämpft — 1 solange der Zeiger über der Karte ist */
active: number
}
interface CardShaderOptions {
vertex: string
fragment: string
uniforms: Record<string, { value: unknown }>
/** Wird pro Frame vor dem Rendern aufgerufen — Uniforms hier aktualisieren */
frame?: (ctx: { t: number, program: Program, mouse: CardShaderMouse }) => void
/** Zeiger-Tracking aktivieren (mousemove am window, relativ zur Karte) */
mouse?: boolean
/** Renderbuffer relativ zur CSS-Größe (0.1–1) — Perf-Hebel für teure Shader */
renderScale?: number
/** Getter für die Kartenfarbe — bei Änderung werden die colorUniforms aktualisiert */
color?: () => string
/** uniform-Name → Shade-Offset (0 = Basisfarbe, <0 dunkler, >0 heller) */
colorUniforms?: Record<string, number>
}
/**
* Gemeinsames Setup für Vue-Bits-Shader-Karteneffekte:
* WebGL-Renderer → 2D-Canvas-Blit (Waterfox zeichnet WebGL-Layer nach Scrollen
* teils versetzt und ignoriert CSS-Clips), ResizeObserver, Pause wenn die Karte
* nicht sichtbar ist, plus uCardRes/uRadius-Uniforms für die Rounded-Rect-Maske.
* Erwartet premultiplied Output: `fragColor = vec4(col * a, a)` — die Maske
* wird bereits in den Shadern auf a angewendet.
*/
export function useCardShader(container: Ref<HTMLElement | null>, options: CardShaderOptions) {
let teardown: (() => void) | null = null
let programRef: Program | null = null
// Farbwechsel live in die Uniforms übernehmen — sonst zeigt die
// Settings-Vorschau die neue Farbe erst nach Remount
if (options.color && options.colorUniforms) {
watch(options.color, (c) => {
if (!programRef) return
for (const [name, shade] of Object.entries(options.colorUniforms!)) {
const u = programRef.uniforms[name]
if (u) u.value = new Float32Array(hexToRgbTuple(shadeHex(c, shade)))
}
})
}
onMounted(() => {
const ctn = container.value
if (!ctn) return
const renderer = new Renderer({
webgl: 2,
alpha: true,
premultipliedAlpha: true,
antialias: false,
dpr: Math.min(window.devicePixelRatio || 1, 2),
})
const gl = renderer.gl
gl.clearColor(0, 0, 0, 0)
gl.enable(gl.BLEND)
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA)
const outCanvas = document.createElement('canvas')
outCanvas.className = 'absolute inset-0 size-full rounded-[inherit]'
const outCtx = outCanvas.getContext('2d')
ctn.appendChild(outCanvas)
const geometry = new Triangle(gl)
// OGL legt ein uv-Attribut an — entfernen, wenn der Vertex-Shader es nicht nutzt
if (!options.vertex.includes('uv') && geometry.attributes.uv) {
delete geometry.attributes.uv
}
const program = new Program(gl, {
vertex: options.vertex,
fragment: options.fragment,
uniforms: {
uCardRes: { value: new Float32Array([1, 1]) },
uRadius: { value: 0 },
...options.uniforms,
},
})
const mesh = new Mesh(gl, { geometry, program })
programRef = program
const mouse: CardShaderMouse = { x: 0.5, y: 0.5, active: 0 }
const mouseTarget = { x: 0.5, y: 0.5, active: 0 }
const onPointerMove = (e: PointerEvent) => {
const rect = ctn.getBoundingClientRect()
if (!rect.width || !rect.height) return
const x = (e.clientX - rect.left) / rect.width
const y = 1 - (e.clientY - rect.top) / rect.height
const inside = x >= 0 && x <= 1 && y >= 0 && y <= 1
mouseTarget.x = Math.min(1, Math.max(0, x))
mouseTarget.y = Math.min(1, Math.max(0, y))
mouseTarget.active = inside ? 1 : 0
}
if (options.mouse) {
window.addEventListener('pointermove', onPointerMove, { passive: true })
}
const scale = Math.min(1, Math.max(0.1, options.renderScale ?? 1))
const setSize = () => {
const rect = ctn.getBoundingClientRect()
renderer.setSize(
Math.max(1, Math.floor(rect.width * scale)),
Math.max(1, Math.floor(rect.height * scale)),
)
const res = program.uniforms.uCardRes!.value as Float32Array
res[0] = gl.drawingBufferWidth
res[1] = gl.drawingBufferHeight
const cssRadius = Number.parseFloat(getComputedStyle(ctn).borderTopLeftRadius) || 0
program.uniforms.uRadius!.value = rect.width > 0
? cssRadius * (gl.drawingBufferWidth / rect.width)
: 0
outCanvas.width = gl.drawingBufferWidth
outCanvas.height = gl.drawingBufferHeight
}
const ro = new ResizeObserver(setSize)
ro.observe(ctn)
setSize()
let raf = 0
let visible = true
let pageVisible = document.visibilityState !== 'hidden'
const loop = (t: number) => {
if (!visible || !pageVisible) {
raf = 0
return
}
raf = requestAnimationFrame(loop)
mouse.x += 0.05 * (mouseTarget.x - mouse.x)
mouse.y += 0.05 * (mouseTarget.y - mouse.y)
mouse.active += 0.05 * (mouseTarget.active - mouse.active)
options.frame?.({ t, program, mouse })
renderer.render({ scene: mesh })
outCtx?.clearRect(0, 0, outCanvas.width, outCanvas.height)
outCtx?.drawImage(gl.canvas, 0, 0, outCanvas.width, outCanvas.height)
}
const start = () => {
if (!raf && visible && pageVisible) raf = requestAnimationFrame(loop)
}
const stop = () => {
if (raf) {
cancelAnimationFrame(raf)
raf = 0
}
}
const io = new IntersectionObserver(([entry]) => {
visible = entry?.isIntersecting ?? true
if (visible) start()
else stop()
})
io.observe(ctn)
const onVisibility = () => {
pageVisible = document.visibilityState !== 'hidden'
if (pageVisible) start()
else stop()
}
document.addEventListener('visibilitychange', onVisibility)
start()
teardown = () => {
stop()
ro.disconnect()
io.disconnect()
document.removeEventListener('visibilitychange', onVisibility)
if (options.mouse) window.removeEventListener('pointermove', onPointerMove)
if (outCanvas.parentNode === ctn) ctn.removeChild(outCanvas)
gl.getExtension('WEBGL_lose_context')?.loseContext()
}
})
onUnmounted(() => {
teardown?.()
teardown = null
})
}
+5
View File
@@ -0,0 +1,5 @@
const open = ref(false)
export function useCommandPalette() {
return { open }
}
+89
View File
@@ -0,0 +1,89 @@
export interface CommentReaction {
emoji: string
count: number
me: boolean
names: string[]
}
export interface ReactionEventPayload {
commentId: number
emoji: string
actorId: number
actorName: string
active: boolean
count: number
}
export const REACTION_EMOJIS = ['👍', '❤️', '😂', '😮', '😢', '🔥', '🎉', '👏', '😍', '🤔', '👎', '😅', '🙏', '💯', '🚀', '💀']
export function useCommentReactions() {
const map = useState<Record<number, CommentReaction[]>>('reactions:by-comment', () => ({}))
const { user } = useAuth()
function seed(commentId: number, reactions: CommentReaction[]) {
map.value = { ...map.value, [commentId]: reactions }
}
function reactionsFor(commentId: number): CommentReaction[] {
return map.value[commentId] ?? []
}
function applyEvent(e: ReactionEventPayload) {
const list = (map.value[e.commentId] ??= [])
let entry = list.find(r => r.emoji === e.emoji)
if (e.active) {
if (!entry) {
entry = { emoji: e.emoji, count: 0, me: false, names: [] }
list.push(entry)
}
if (!entry.names.includes(e.actorName)) {
entry.names.push(e.actorName)
}
}
else if (entry) {
entry.names = entry.names.filter(n => n !== e.actorName)
}
if (!entry) return
entry.count = e.count
if (e.actorId === user.value?.id) {
entry.me = e.active
}
if (entry.count <= 0) {
list.splice(list.indexOf(entry), 1)
}
}
async function toggle(commentId: number, emoji: string) {
const me = user.value
const before = map.value[commentId]?.find(r => r.emoji === emoji)
// Optimistisch umschalten — die Reaktion ist sofort sichtbar,
// die Server-Antwort korrigiert nur noch Zähler/Status
if (me) {
applyEvent({
commentId,
emoji,
actorId: me.id,
actorName: me.name,
active: !before?.me,
count: (before?.count ?? 0) + (before?.me ? -1 : 1),
})
}
try {
const res = await $fetch<{ active: boolean, count: number }>(`/api/comments/${commentId}/reactions`, {
method: 'POST',
body: { emoji },
})
if (me) {
applyEvent({ commentId, emoji, actorId: me.id, actorName: me.name, active: res.active, count: res.count })
}
}
catch (err) {
if (me) {
applyEvent({ commentId, emoji, actorId: me.id, actorName: me.name, active: !!before?.me, count: before?.count ?? 0 })
}
throw err
}
}
return { reactionsFor, seed, applyEvent, toggle }
}
+50
View File
@@ -0,0 +1,50 @@
export type GridMode = 'bento' | 'uniform'
export function useGridMode() {
const cookie = useCookie<GridMode>('grid-mode', {
default: () => 'bento',
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
})
// useState ist über alle Komponenten geteilt — die Cookie-Ref ist es nicht zuverlässig
const mode = useState<GridMode>('grid-mode', () => cookie.value)
const uniform = computed(() => mode.value === 'uniform')
const animating = ref(false)
async function toggle() {
const next: GridMode = uniform.value ? 'bento' : 'uniform'
if (!import.meta.client || animating.value) {
mode.value = next
cookie.value = next
return
}
// FLIP: alte Positionen messen, solange das DOM noch den alten Zustand zeigt
const tiles = Array.from(document.querySelectorAll<HTMLElement>('.upload-tile'))
const oldRects = tiles.map(el => el.getBoundingClientRect())
animating.value = true
mode.value = next
cookie.value = next
await nextTick()
tiles.forEach((el, i) => {
const oldR = oldRects[i]
const newR = el.getBoundingClientRect()
const dx = oldR.left - newR.left
const dy = oldR.top - newR.top
const sx = newR.width ? oldR.width / newR.width : 1
const sy = newR.height ? oldR.height / newR.height : 1
if (!dx && !dy && sx === 1 && sy === 1) return
el.animate(
[
{ transformOrigin: 'top left', transform: `translate(${dx}px, ${dy}px) scale(${sx}, ${sy})` },
{ transformOrigin: 'top left', transform: 'none' },
],
{ duration: 400, easing: 'cubic-bezier(0.2, 0, 0, 1)' },
)
})
setTimeout(() => {
animating.value = false
}, 400)
}
return { mode, uniform, toggle }
}
+17
View File
@@ -0,0 +1,17 @@
export function useLastViewedUpload() {
return useState<string | null>('last-viewed-upload', () => null)
}
export interface PendingUploadMedia {
id: string
type: 'image' | 'video' | 'audio' | 'youtube' | 'twitch' | 'link'
url: string | null
thumbUrl: string | null
}
// Beim Tile-Klick schon bekannte Mediendaten, damit die Thread-Seite
// vor Eintreffen der API-Antwort ein benanntes Element für die
// View-Transition rendern kann
export function usePendingUploadMedia() {
return useState<PendingUploadMedia | null>('pending-upload-media', () => null)
}
+24
View File
@@ -0,0 +1,24 @@
import type { CommentAttachment } from '~~/shared/utils/attachments'
export interface LiveComment {
id: number
content: string
createdAt: string
userName: string
userId: number
userColor: string | null
userAvatarUrl: string | null
uploadId: string
attachments?: CommentAttachment[]
}
export function useLiveComments() {
const liveComments = useState<LiveComment[]>('comments:live', () => [])
function pushLive(comment: LiveComment) {
if (liveComments.value.some(c => c.id === comment.id)) return
liveComments.value = [comment, ...liveComments.value].slice(0, 50)
}
return { liveComments, pushLive }
}
+9
View File
@@ -0,0 +1,9 @@
import type { MentionUser } from '~~/shared/utils/mentions'
export function useMentionUsers() {
return useFetch<MentionUser[]>('/api/users/mentions', {
key: 'mention-users',
default: () => [],
dedupe: 'defer',
})
}
+4
View File
@@ -0,0 +1,4 @@
// Message of the Day — globaler State, per WebSocket live aktualisiert
export function useMotd() {
return useState<string>('motd', () => '')
}
+58
View File
@@ -0,0 +1,58 @@
export interface AppNotification {
id: number
uploadId: string
commentId: number
isRead: boolean
isMention: boolean
createdAt: string
actorName: string
actorColor: string | null
content: string
uploadType: 'image' | 'video' | 'audio' | 'youtube' | 'twitch' | 'link'
url: string
thumbUrl: string | null
}
export function useNotifications() {
const notifications = useState<AppNotification[]>('notifications:list', () => [])
const unreadCount = useState<number>('notifications:unread', () => 0)
const unreadUploadIds = computed(() =>
new Set(notifications.value.filter(n => !n.isRead).map(n => n.uploadId)),
)
async function fetchAll() {
const data = await $fetch<{ notifications: AppNotification[], unreadCount: number }>('/api/notifications')
notifications.value = data.notifications
unreadCount.value = data.unreadCount
}
function addLive(notification: AppNotification, count?: number) {
const existing = notifications.value.some(n => n.uploadId === notification.uploadId && !n.isRead)
notifications.value = [notification, ...notifications.value.filter(n => n.uploadId !== notification.uploadId)].slice(0, 30)
unreadCount.value = count ?? (unreadCount.value + (existing ? 0 : 1))
}
async function markAllRead() {
if (!unreadCount.value) return
unreadCount.value = 0
notifications.value = notifications.value.map(n => ({ ...n, isRead: true }))
await $fetch('/api/notifications/read', { method: 'POST' })
}
async function markRead(id: number) {
const target = notifications.value.find(n => n.id === id)
notifications.value = notifications.value.filter(n => target ? n.uploadId !== target.uploadId : n.id !== id)
if (target && !target.isRead) {
unreadCount.value = Math.max(0, unreadCount.value - 1)
}
await $fetch(`/api/notifications/${id}/read`, { method: 'POST' })
}
function reset() {
notifications.value = []
unreadCount.value = 0
}
return { notifications, unreadCount, unreadUploadIds, fetchAll, addLive, markAllRead, markRead, reset }
}
+41
View File
@@ -0,0 +1,41 @@
import type { Ref } from 'vue'
import { useWindowSize } from '@vueuse/core'
// Spiegelt das Upload-Grid aus index.vue:
// grid-cols-4 sm:6 md:8 lg:12, auto-rows 6.5rem / sm:7rem / lg:7.5rem, gap-2
const PAGINATION_RESERVE_PX = 76 // Pagination (~36px) + mt-4 + Bottom-Padding
const FALLBACK_TOP_PX = 150 // SSR-Schätzung: Header + Padding + Filterzeile
interface PageSizeOptions {
// [base, sm (>=640px), lg (>=1024px)] — Zeilenhöhe in rem ohne Grid-Gap
rowHeights?: [number, number, number]
// Abstand zwischen Anker-Unterkante und Grid-Oberkante in px (mb-* der Zeile)
gapBelowAnchorPx?: number
}
export function usePageSize(anchor: Ref<HTMLElement | null | undefined>, options: PageSizeOptions = {}) {
const { rowHeights = [6.5, 7, 7.5], gapBelowAnchorPx = 8 } = options
const { width, height } = useWindowSize()
const cols = computed(() => {
const w = Number.isFinite(width.value) ? width.value : 1280
return w >= 1024 ? 12 : w >= 768 ? 8 : w >= 640 ? 6 : 4
})
// Zell-Budget: Wie viele 1x1-Zellen passen ohne Scrollen in den Viewport.
// Die Grid-Oberkante wird am DOM-Element (Filterzeile) gemessen statt
// Header/Padding zu schätzen.
const cells = computed(() => {
const w = Number.isFinite(width.value) ? width.value : 1280
const h = Number.isFinite(height.value) ? height.value : 800
const rem = import.meta.client
? Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
: 16
const rowH = (w >= 1024 ? rowHeights[2] : w >= 640 ? rowHeights[1] : rowHeights[0]) * rem + 8 // + grid gap
const top = (anchor.value?.getBoundingClientRect().bottom ?? FALLBACK_TOP_PX) + gapBelowAnchorPx
const rows = Math.max(1, Math.floor((h - top - PAGINATION_RESERVE_PX) / rowH))
return cols.value * rows
})
return { cells, cols }
}
+9
View File
@@ -0,0 +1,9 @@
export type ThemeName = 'standard' | 'discord' | 'cyberpunk' | 'spring' | 'terminal' | 'infoseer' | 'homebrew' | 'florense' | 'kawi-green' | 'clonz' | 'clawmorphism' | 'realmorphism' | 'amber' | 'neo' | 'neo-pink' | 'neo-mint'
export function useTheme() {
return useCookie<ThemeName>('color-mode', {
default: () => 'standard',
maxAge: 60 * 60 * 24 * 365,
sameSite: 'lax',
})
}
+21
View File
@@ -0,0 +1,21 @@
import { useNow } from '@vueuse/core'
// Auto-aktualisierende relative Zeit („vor 3 Min") — tickt alle 30s,
// damit offene Seiten ohne Reload aktuell bleiben
export function useTimeAgo() {
const now = useNow({ interval: 30_000 })
return (value: string) => {
const seconds = Math.max(0, Math.floor((now.value.getTime() - new Date(value).getTime()) / 1000))
if (seconds < 60) return 'gerade eben'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `vor ${minutes} Min`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `vor ${hours} Std`
const days = Math.floor(hours / 24)
if (days < 30) return `vor ${days} T`
const months = Math.floor(days / 30)
if (months < 12) return `vor ${months} Mon`
return `vor ${Math.floor(months / 12)} J`
}
}
+88
View File
@@ -0,0 +1,88 @@
export interface UploadReaction {
emoji: string
count: number
me: boolean
names: string[]
}
export interface UploadReactionEventPayload {
uploadId: string
emoji: string
actorId: number
actorName: string
active: boolean
count: number
}
export function useUploadReactions() {
const map = useState<Record<string, UploadReaction[]>>('reactions:by-upload', () => ({}))
const { user } = useAuth()
function seed(uploadId: string, reactions: UploadReaction[]) {
map.value = { ...map.value, [uploadId]: reactions }
}
function reactionsFor(uploadId: string): UploadReaction[] {
return map.value[uploadId] ?? []
}
function applyEvent(e: UploadReactionEventPayload) {
const list = (map.value[e.uploadId] ??= [])
let entry = list.find(r => r.emoji === e.emoji)
if (e.active) {
if (!entry) {
entry = { emoji: e.emoji, count: 0, me: false, names: [] }
list.push(entry)
}
if (!entry.names.includes(e.actorName)) {
entry.names.push(e.actorName)
}
}
else if (entry) {
entry.names = entry.names.filter(n => n !== e.actorName)
}
if (!entry) return
entry.count = e.count
if (e.actorId === user.value?.id) {
entry.me = e.active
}
if (entry.count <= 0) {
list.splice(list.indexOf(entry), 1)
}
}
async function toggle(uploadId: string, emoji: string) {
const me = user.value
const before = map.value[uploadId]?.find(r => r.emoji === emoji)
// Optimistisch umschalten — die Reaktion ist sofort sichtbar,
// die Server-Antwort korrigiert nur noch Zähler/Status
if (me) {
applyEvent({
uploadId,
emoji,
actorId: me.id,
actorName: me.name,
active: !before?.me,
count: (before?.count ?? 0) + (before?.me ? -1 : 1),
})
}
try {
const res = await $fetch<{ active: boolean, count: number, isSubscribed: boolean }>(`/api/uploads/${uploadId}/reactions`, {
method: 'POST',
body: { emoji },
})
if (me) {
applyEvent({ uploadId, emoji, actorId: me.id, actorName: me.name, active: res.active, count: res.count })
}
return res
}
catch (err) {
if (me) {
applyEvent({ uploadId, emoji, actorId: me.id, actorName: me.name, active: !!before?.me, count: before?.count ?? 0 })
}
throw err
}
}
return { reactionsFor, seed, applyEvent, toggle }
}
+45
View File
@@ -0,0 +1,45 @@
export interface UserSummary {
id: number
name: string
color: string | null
cardEffect: string | null
cardEffectColor: string | null
cardVignette: boolean
cardBorderEffect: string | null
cardBorderEffectColor: string | null
avatarUrl: string | null
createdAt: string
stats: {
uploads: number
comments: number
}
}
const inflight = new Map<number, Promise<void>>()
// Cache für User-Hover-Cards: gleiche Nutzer werden nicht doppelt geladen
export function useUserSummaries() {
const summaries = useState<Record<number, UserSummary>>('user-summaries', () => ({}))
function ensureSummary(id: number) {
if (summaries.value[id] || inflight.has(id)) return
inflight.set(id, (async () => {
try {
summaries.value[id] = await $fetch<UserSummary>(`/api/users/${id}/summary`)
}
catch {
// Fehler stillschweigend ignorieren — die Card zeigt dann nur die Basisdaten
}
finally {
inflight.delete(id)
}
})())
}
// Cache-Eintrag verwerfen, damit geänderte Effekte/Farben ohne Reload sichtbar werden
function invalidate(id: number) {
delete summaries.value[id]
}
return { summaries, ensureSummary, invalidate }
}