forked from BRETT/BRETT
Initial Commit
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
const open = ref(false)
|
||||
|
||||
export function useCommandPalette() {
|
||||
return { open }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Message of the Day — globaler State, per WebSocket live aktualisiert
|
||||
export function useMotd() {
|
||||
return useState<string>('motd', () => '')
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user