Add point shop with timed upload effects, flair, name gradient, a mystery box and sticker slots

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-24 18:46:29 +02:00
co-authored by Devin
parent dace93c28a
commit 7d5f44b22d
26 changed files with 9047 additions and 8 deletions
+6
View File
@@ -192,6 +192,12 @@ async function openRandomThread() {
{{ $t('nav.profile') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem as-child>
<NuxtLink to="/shop" class="flex items-center gap-2">
<Icon name="ph:coins-fill" class="size-4" />
{{ $t('nav.shop') }}
</NuxtLink>
</DropdownMenuItem>
<DropdownMenuItem as-child>
<NuxtLink to="/settings" class="flex items-center gap-2">
<Icon name="ph:gear-fill" class="size-4" />
+9 -1
View File
@@ -14,6 +14,9 @@ interface TileUpload {
commentCount?: number
activityScore?: number
thumbnailSize?: ThumbnailSize
boostedUntil?: string | null
goldenUntil?: string | null
spotlight?: boolean
}
const props = withDefaults(defineProps<{
@@ -64,8 +67,13 @@ function prefetchOnHover() {
// und Breadcrumbs auf die Uploads dieses Nutzers
const navContext = useUploadNavContext()
const isBoosted = computed(() => !!props.upload.boostedUntil && new Date(props.upload.boostedUntil).getTime() > Date.now())
const isGolden = computed(() => !!props.upload.goldenUntil && new Date(props.upload.goldenUntil).getTime() > Date.now())
const spanClass = computed(() => {
if (uniform.value) return ''
// Shop-Boost forciert die groesste Thumbnail-Groesse (spiegelt den Server)
if (isBoosted.value) return 'col-span-2 row-span-2 sm:col-span-3 sm:row-span-3'
const score = props.upload.activityScore ?? props.upload.commentCount ?? 0
const size = limitsLoaded.value
? thumbnailSizeForScore(score, limits.value)
@@ -140,7 +148,7 @@ function openUpload(e: MouseEvent) {
>
<div
ref="tileMediaEl"
:class="cn('upload-tile-media relative h-full w-full overflow-hidden rounded-md', selected ? 'ring-4 ring-accent' : hasNotification && 'ring-4 ring-primary')"
:class="cn('upload-tile-media relative h-full w-full overflow-hidden rounded-md', selected ? 'ring-4 ring-accent' : hasNotification ? 'ring-4 ring-primary' : isGolden ? 'ring-2 ring-amber-400 shadow-[0_0_14px_2px_rgba(251,191,36,0.35)]' : upload.spotlight && 'ring-2 ring-accent')"
:style="lastViewedId === upload.id ? 'view-transition-name: upload-media' : undefined"
>
<NuxtImg
+7 -1
View File
@@ -46,6 +46,9 @@ const holoEffect = computed<CardHoloEffectKey | null>(() => {
const key = summary.value?.cardHoloEffect
return key && isCardHoloEffect(key) ? key : null
})
// Gekaufter Namensverlauf (Shop)
const nameStyle = computed(() => shopGradientNameStyle(color.value, summary.value?.nameGradient))
const banner = computed(() => {
const src = summary.value?.cardBannerUrl
const type = summary.value?.cardBannerType
@@ -142,7 +145,7 @@ function formatDateTime(value: string) {
<NuxtLink
:to="`/user/${userId}`"
class="truncate text-base font-medium hover:underline"
:style="color ? { color } : undefined"
:style="nameStyle"
>{{ summary?.name ?? userName }}</NuxtLink>
<Badge
v-for="role in summary?.roles"
@@ -150,6 +153,9 @@ function formatDateTime(value: string) {
variant="secondary"
class="px-1.5 py-0 text-[10px] uppercase tracking-wider"
>{{ role }}</Badge>
<Badge v-if="summary?.flair" class="px-1.5 py-0 text-[10px]">
{{ summary.flair }}
</Badge>
</div>
<p class="text-xs text-muted-foreground">
ID {{ userId }}
+2
View File
@@ -15,6 +15,8 @@ export interface UserSummary {
cardBannerType: 'image' | 'video' | null
createdAt: string
renamedUntil: string | null
flair: string | null
nameGradient: boolean
stats: {
uploads: number
comments: number
+425
View File
@@ -0,0 +1,425 @@
<script setup lang="ts">
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationNext,
PaginationPrevious,
} from '@/components/ui/pagination'
import { Skeleton } from '@/components/ui/skeleton'
import { toast } from 'vue-sonner'
import {
DISPLAY_NAME_COST,
DISPLAY_NAME_DURATION_MS,
FLAIR_COST,
FLAIR_DURATION_MS,
FLAIR_MAX_LENGTH,
MYSTERY_BOX_COST,
NAME_GRADIENT_COST,
NAME_GRADIENT_DURATION_MS,
SPOTLIGHT_MAX_ACTIVE,
STICKER_SLOT_COST,
UPLOAD_BOOST_COST,
UPLOAD_BOOST_DURATION_MS,
UPLOAD_GOLDEN_COST,
UPLOAD_GOLDEN_DURATION_MS,
UPLOAD_SPOTLIGHT_COST,
UPLOAD_SPOTLIGHT_DURATION_MS,
} from '~~/shared/utils/shop'
const { user, refresh } = useAuth()
const { t } = useI18n()
if (!user.value) {
await navigateTo('/')
}
interface ShopData {
balance: number
displayName: { name: string, expiresAt: string | null } | null
flair: { text: string, expiresAt: string } | null
nameGradientUntil: string | null
boostedUntil: string | null
goldenUntil: string | null
spotlightUntil: string | null
stickerSlots: { used: number, total: number, bonus: number }
spotlight: { active: number, max: number }
}
interface ShopUpload {
id: string
title: string
thumbUrl: string | null
}
const { data, status, refresh: refreshShop } = await useFetch<ShopData>('/api/shop')
const { data: uploadsData } = await useFetch<{ items: ShopUpload[] }>('/api/shop/uploads')
const ownUploads = computed(() => uploadsData.value?.items ?? [])
const spotlightPickerOpen = ref(false)
// Picker paginiert clientseitig - alle eigenen Uploads sind schon geladen
const PICKER_PAGE_SIZE = 24
const pickerPage = ref(1)
const pickerPageCount = computed(() => Math.max(1, Math.ceil(ownUploads.value.length / PICKER_PAGE_SIZE)))
const pagedUploads = computed(() =>
ownUploads.value.slice((pickerPage.value - 1) * PICKER_PAGE_SIZE, pickerPage.value * PICKER_PAGE_SIZE),
)
watch(spotlightPickerOpen, (open) => { if (open) pickerPage.value = 1 })
async function pickSpotlightUpload(uploadId: string) {
spotlightPickerOpen.value = false
await buy('upload_spotlight', { uploadId })
}
const hours = (ms: number) => Math.round(ms / 3_600_000)
const balance = computed(() => data.value?.balance ?? 0)
const activeName = computed(() => data.value?.displayName ?? null)
const activeFlair = computed(() => data.value?.flair ?? null)
const buying = ref<string | null>(null)
function until(until: string | null | undefined) {
return until ? new Date(until).toLocaleString() : ''
}
async function buy(item: string, body: Record<string, unknown> = {}) {
if (buying.value) return
buying.value = item
try {
const res = await $fetch<{ outcome?: { kind: string, detail?: string } | null }>('/api/shop/purchase', {
method: 'POST',
body: { item, ...body },
})
await Promise.all([refreshShop(), refresh()])
if (item === 'mystery_box' && res.outcome) {
toast.success(t(`shop.mystery.${res.outcome.kind}`, { n: res.outcome.detail ?? '' }))
}
else {
toast.success(t('shop.purchased'))
}
}
catch (e: any) {
toast.error(e?.data?.statusMessage || e?.statusMessage || t('shop.purchaseFailed'))
}
finally {
buying.value = null
}
}
// Anzeigename laeuft weiterhin ueber den eigenen Endpunkt (Namenskollisionen)
const newName = ref('')
const nameInput = ref<{ $el: HTMLElement } | null>(null)
async function buyDisplayName() {
if (buying.value || activeName.value) return
if (!newName.value.trim()) {
nameInput.value?.$el?.focus()
return
}
buying.value = 'display_name'
try {
await $fetch('/api/shop/display-name', {
method: 'POST',
body: { name: newName.value.trim() },
})
newName.value = ''
await Promise.all([refreshShop(), refresh()])
toast.success(t('shop.purchased'))
}
catch (e: any) {
toast.error(e?.data?.statusMessage || e?.statusMessage || t('shop.purchaseFailed'))
}
finally {
buying.value = null
}
}
// Nutzerweite Upload-Items: Key -> Payload-Feld + Item-Name
const uploadItem = {
boost: { item: 'upload_boost', field: 'boostedUntil' },
golden: { item: 'upload_golden', field: 'goldenUntil' },
spotlight: { item: 'upload_spotlight', field: 'spotlightUntil' },
} as const
type UploadItemKey = keyof typeof uploadItem
function uploadItemUntil(key: UploadItemKey) {
return data.value?.[uploadItem[key].field] ?? null
}
const flairText = ref('')
const mysterySpinning = computed(() => buying.value === 'mystery_box')
</script>
<template>
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<div class="flex flex-wrap items-baseline gap-x-4 gap-y-1">
<h1 class="text-xl font-semibold tracking-tight">{{ $t('shop.title') }}</h1>
<span class="text-sm text-muted-foreground">
{{ $t('shop.balance') }}: {{ balance }} {{ $t('misc.points') }}
</span>
</div>
<div v-if="status === 'pending'" class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Skeleton v-for="i in 6" :key="i" class="h-44 rounded-lg" />
</div>
<div v-else class="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<!-- Anzeigename -->
<div class="flex flex-col gap-3 rounded-lg border bg-card p-4">
<div class="flex items-start justify-between gap-2">
<div class="space-y-1">
<h2 class="font-medium tracking-tight">{{ $t('shop.displayNameTitle') }}</h2>
<p class="text-xs text-muted-foreground">
{{ $t('shop.displayNameDesc', { hours: hours(DISPLAY_NAME_DURATION_MS) }) }}
</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
<Icon name="ph:coins-fill" class="size-3" />
{{ DISPLAY_NAME_COST }}
</span>
</div>
<div v-if="activeName" class="space-y-1">
<p class="text-sm font-medium" :style="user?.color ? { color: user.color } : undefined">
{{ activeName.name }}
</p>
<div class="flex items-center justify-between gap-2">
<p class="text-xs text-muted-foreground">
{{ $t('shop.activeUntil', { time: until(activeName.expiresAt) || '–' }) }}
</p>
</div>
</div>
<div v-else class="mt-auto space-y-2">
<Input
ref="nameInput"
v-model="newName"
:placeholder="$t('shop.namePlaceholder')"
maxlength="50"
@keydown.enter="buyDisplayName"
/>
<Button class="w-full cursor-pointer font-semibold" :disabled="!!buying" @click="buyDisplayName">
{{ $t('shop.buy') }}
</Button>
</div>
</div>
<!-- Upload-Items: Boost / Goldener Rahmen / Spotlight -->
<div
v-for="key in (['boost', 'golden', 'spotlight'] as UploadItemKey[])"
:key="key"
class="flex flex-col gap-3 rounded-lg border bg-card p-4"
>
<div class="flex items-start justify-between gap-2">
<div class="space-y-1">
<h2 class="font-medium tracking-tight">{{ $t(`shop.${key}Title`) }}</h2>
<p class="text-xs text-muted-foreground">
{{ $t(`shop.${key}Desc`, { hours: key === 'boost' ? hours(UPLOAD_BOOST_DURATION_MS) : key === 'golden' ? hours(UPLOAD_GOLDEN_DURATION_MS) : hours(UPLOAD_SPOTLIGHT_DURATION_MS) }) }}
</p>
<p v-if="key === 'spotlight'" class="text-xs text-muted-foreground">
{{ $t('shop.spotlightSlots', { n: data?.spotlight.active ?? 0, max: SPOTLIGHT_MAX_ACTIVE }) }}
</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
<Icon name="ph:coins-fill" class="size-3" />
{{ key === 'boost' ? UPLOAD_BOOST_COST : key === 'golden' ? UPLOAD_GOLDEN_COST : UPLOAD_SPOTLIGHT_COST }}
</span>
</div>
<div class="mt-auto space-y-2">
<div v-if="uploadItemUntil(key)" class="flex items-center justify-between gap-2">
<p class="text-xs text-muted-foreground">
{{ $t('shop.activeUntil', { time: until(uploadItemUntil(key)) }) }}
</p>
</div>
<Button
class="w-full cursor-pointer font-semibold"
:disabled="!!buying || !!uploadItemUntil(key) || (key === 'spotlight' && ((data?.spotlight.active ?? 0) >= SPOTLIGHT_MAX_ACTIVE || !ownUploads.length))"
@click="key === 'spotlight' ? (spotlightPickerOpen = true) : buy(uploadItem[key].item)"
>
{{ $t('shop.buy') }}
</Button>
</div>
</div>
<!-- Flair-Badge -->
<div class="flex flex-col gap-3 rounded-lg border bg-card p-4">
<div class="flex items-start justify-between gap-2">
<div class="space-y-1">
<h2 class="font-medium tracking-tight">{{ $t('shop.flairTitle') }}</h2>
<p class="text-xs text-muted-foreground">
{{ $t('shop.flairDesc', { hours: hours(FLAIR_DURATION_MS) }) }}
</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
<Icon name="ph:coins-fill" class="size-3" />
{{ FLAIR_COST }}
</span>
</div>
<div v-if="activeFlair" class="space-y-1">
<p class="text-sm font-medium">{{ activeFlair.text }}</p>
<div class="flex items-center justify-between gap-2">
<p class="text-xs text-muted-foreground">
{{ $t('shop.activeUntil', { time: until(activeFlair.expiresAt) }) }}
</p>
</div>
</div>
<div v-else class="mt-auto space-y-2">
<Input
v-model="flairText"
:placeholder="$t('shop.flairPlaceholder')"
:maxlength="FLAIR_MAX_LENGTH"
@keydown.enter="flairText.trim() && buy('flair', { flair: flairText.trim() })"
/>
<Button
class="w-full cursor-pointer font-semibold"
:disabled="!!buying || !flairText.trim()"
@click="buy('flair', { flair: flairText.trim() })"
>
{{ $t('shop.buy') }}
</Button>
</div>
</div>
<!-- Namensverlauf -->
<div class="flex flex-col gap-3 rounded-lg border bg-card p-4">
<div class="flex items-start justify-between gap-2">
<div class="space-y-1">
<h2 class="font-medium tracking-tight">{{ $t('shop.gradientTitle') }}</h2>
<p class="text-xs text-muted-foreground">
{{ $t('shop.gradientDesc', { hours: hours(NAME_GRADIENT_DURATION_MS) }) }}
</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
<Icon name="ph:coins-fill" class="size-3" />
{{ NAME_GRADIENT_COST }}
</span>
</div>
<div v-if="data?.nameGradientUntil" class="flex items-center justify-between gap-2">
<p class="text-xs text-muted-foreground">
{{ $t('shop.activeUntil', { time: until(data.nameGradientUntil) }) }}
</p>
</div>
<Button
class="mt-auto w-full cursor-pointer font-semibold"
:disabled="!!buying || !!data?.nameGradientUntil"
@click="buy('name_gradient')"
>
{{ $t('shop.buy') }}
</Button>
</div>
<!-- Sticker-Slot -->
<div class="flex flex-col gap-3 rounded-lg border bg-card p-4">
<div class="flex items-start justify-between gap-2">
<div class="space-y-1">
<h2 class="font-medium tracking-tight">{{ $t('shop.stickerSlotTitle') }}</h2>
<p class="text-xs text-muted-foreground">
{{ $t('shop.stickerSlotDesc') }}
</p>
<p class="text-xs text-muted-foreground">
{{ $t('shop.stickerSlots', { n: data?.stickerSlots.used ?? 0, max: data?.stickerSlots.total ?? 0 }) }}
</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
<Icon name="ph:coins-fill" class="size-3" />
{{ STICKER_SLOT_COST }}
</span>
</div>
<Button
class="mt-auto w-full cursor-pointer font-semibold"
:disabled="!!buying"
@click="buy('sticker_slot')"
>
{{ $t('shop.buy') }}
</Button>
</div>
<!-- Glueckskiste -->
<div class="flex flex-col gap-3 rounded-lg border border-primary/40 bg-card p-4">
<div class="flex items-start justify-between gap-2">
<div class="space-y-1">
<h2 class="font-medium tracking-tight">{{ $t('shop.mysteryTitle') }}</h2>
<p class="text-xs text-muted-foreground">
{{ $t('shop.mysteryDesc') }}
</p>
</div>
<span class="inline-flex shrink-0 items-center gap-1 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
<Icon name="ph:coins-fill" class="size-3" />
{{ MYSTERY_BOX_COST }}
</span>
</div>
<Button
class="mt-auto w-full cursor-pointer font-semibold"
:disabled="!!buying"
@click="buy('mystery_box')"
>
<Icon v-if="mysterySpinning" name="ph:circle-notch-bold" class="size-4 animate-spin" />
{{ $t('shop.mysteryBuy') }}
</Button>
</div>
</div>
<!-- Spotlight-Upload-Auswahl: großes Dialogfenster mit Thumbnail-Grid -->
<Dialog v-model:open="spotlightPickerOpen">
<DialogContent class="flex max-h-[90vh] w-[95vw] flex-col overflow-hidden sm:max-w-6xl">
<DialogHeader>
<DialogTitle>{{ $t('shop.spotlightPickTitle') }}</DialogTitle>
<DialogDescription>{{ $t('shop.spotlightPickDesc') }}</DialogDescription>
</DialogHeader>
<div class="grid min-h-0 flex-1 grid-cols-4 gap-2 sm:grid-cols-5 lg:grid-cols-6">
<button
v-for="u in pagedUploads"
:key="u.id"
type="button"
class="group relative aspect-video cursor-pointer overflow-hidden rounded-md ring-accent transition hover:ring-2 focus-visible:ring-2 focus-visible:outline-none"
@click="pickSpotlightUpload(u.id)"
>
<NuxtImg
v-if="u.thumbUrl"
:src="u.thumbUrl"
:alt="u.title"
width="260"
height="146"
fit="cover"
class="h-full w-full object-cover"
/>
<span v-else class="flex h-full w-full items-center justify-center bg-muted px-1 text-xs text-muted-foreground">
{{ u.title }}
</span>
</button>
</div>
<Pagination
v-if="pickerPageCount > 1"
v-model:page="pickerPage"
:items-per-page="PICKER_PAGE_SIZE"
:total="ownUploads.length"
:sibling-count="1"
show-edges
class="justify-center pt-3"
>
<PaginationContent v-slot="{ items }">
<PaginationPrevious />
<template v-for="(item, i) in items" :key="i">
<PaginationItem
v-if="item.type === 'page'"
:value="item.value"
:is-active="item.value === pickerPage"
>
{{ item.value }}
</PaginationItem>
<PaginationEllipsis v-else />
</template>
<PaginationNext />
</PaginationContent>
</Pagination>
</DialogContent>
</Dialog>
</div>
</template>
+9 -2
View File
@@ -72,6 +72,8 @@ interface UserProfile {
cardBorderEffectColor: string | null
createdAt: string
renamedUntil: string | null
flair: string | null
nameGradient: boolean
}
stats: {
uploads: number
@@ -251,6 +253,8 @@ const profileBorderColor = computed(() => data.value?.user.cardBorderEffectColor
const joinDate = computed(() => data.value
? new Intl.DateTimeFormat(locale.value, { dateStyle: 'long' }).format(new Date(data.value.user.createdAt))
: '')
// Gekaufter Namensverlauf (Shop): Farbe -> Akzent als Gradient-Text
const nameStyle = computed(() => shopGradientNameStyle(data.value?.user.color, data.value?.user.nameGradient))
const renamedUntilLabel = computed(() => {
const until = data.value?.user.renamedUntil
return until
@@ -293,8 +297,8 @@ const timeAgo = useTimeAgo()
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<h1
class="truncate text-2xl font-semibold"
:style="data.user.color ? { color: data.user.color } : undefined"
class="truncate text-2xl font-semibold tracking-tight"
:style="nameStyle"
>
{{ data.user.name }}
</h1>
@@ -304,6 +308,9 @@ const timeAgo = useTimeAgo()
variant="secondary"
class="px-2 py-0.5 text-xs uppercase tracking-wider"
>{{ role }}</Badge>
<Badge v-if="data.user.flair" class="px-2 py-0.5 text-xs">
{{ data.user.flair }}
</Badge>
</div>
<p class="mt-1 text-sm text-muted-foreground">
ID {{ data.user.id }} · {{ $t('misc.memberSince', { date: joinDate }) }}
+16
View File
@@ -0,0 +1,16 @@
// Shop-Namensverlauf: animierter Gradient + Glow statt flacher Nutzerfarbe.
// Keyframes (name-gradient-shift / name-gradient-glow) liegen in tailwind.css.
export function shopGradientNameStyle(color: string | null | undefined, gradient?: boolean) {
if (!gradient) {
return color ? { color } : undefined
}
const from = color ?? 'var(--color-primary)'
return {
backgroundImage: `linear-gradient(90deg, ${from}, var(--color-accent), ${from})`,
backgroundSize: '200% 100%',
animation: 'name-gradient-shift 4s linear infinite, name-gradient-glow 2.5s ease-in-out infinite',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
color: 'transparent',
}
}
+45
View File
@@ -41,6 +41,7 @@
"admin": "Admin",
"thread": "Thread",
"profile": "Profil",
"shop": "Shop",
"settings": "Einstellungen",
"search": "Suchen…",
"searchAria": "Schnellsuche öffnen (Strg+K)",
@@ -393,6 +394,7 @@
"limitFields": {
"uploadMaxSizeMb": "Upload-Größe (MB)",
"stickerMaxSizeMb": "Sticker-Größe (MB)",
"userStickerSlots": "Sticker-Slots pro Nutzer",
"avatarMaxSizeMb": "Avatar-Größe (MB)",
"commentMaxLength": "Kommentar-Länge (Zeichen)",
"dmMaxLength": "Nachrichten-Länge (Zeichen)",
@@ -696,5 +698,48 @@
"following": "Gefolgt",
"followers": "Follower",
"followFailed": "Folgen fehlgeschlagen"
},
"shop": {
"title": "Punkte-Shop",
"balance": "Guthaben",
"displayNameTitle": "Anzeigename",
"displayNameDesc": "Ändere deinen öffentlich sichtbaren Namen für {hours} Stunden. Dein Nutzername bleibt dabei für andere unsichtbar.",
"namePlaceholder": "Neuer Anzeigename",
"buy": "Kaufen",
"insufficient": "Nicht genug Punkte",
"activeUntil": "Aktiv bis {time}",
"purchased": "Gekauft",
"purchaseFailed": "Kauf fehlgeschlagen",
"deactivate": "Ausschalten",
"deactivated": "Effekt ausgeschaltet",
"boostTitle": "Upload-Boost",
"boostDesc": "Alle deine Uploads werden für {hours} Stunden als große Kacheln im Grid angezeigt – egal wie viele Punkte die Threads haben.",
"goldenTitle": "Goldener Rahmen",
"goldenDesc": "Alle deine Uploads bekommen für {hours} Stunden einen leuchtenden goldenen Rahmen im Grid.",
"spotlightTitle": "Spotlight",
"spotlightDesc": "Ein ausgewählter Upload von dir erscheint für {hours} Stunden ganz vorne im Grid auf der Startseite.",
"spotlightPickTitle": "Spotlight-Upload wählen",
"spotlightPickDesc": "Dieses Upload wird für die Laufzeit ganz vorne im Grid der Startseite gezeigt.",
"spotlightSlots": "Belegte Plätze: {n} von {max}",
"flairTitle": "Flair-Badge",
"flairDesc": "Ein eigenes Badge neben deinem Namen im Profil und in der Hovercard – {hours} Stunden sichtbar.",
"flairPlaceholder": "Dein Flair-Text",
"gradientTitle": "Namensverlauf",
"gradientDesc": "Dein Name erscheint für {hours} Stunden mit einem Farbverlauf statt einer einfachen Farbe – im Profil und in der Hovercard.",
"stickerSlotTitle": "Sticker-Slot",
"stickerSlotDesc": "+1 dauerhafter Platz für eigene Sticker.",
"stickerSlots": "Belegt: {n} von {max} Slots",
"mysteryTitle": "Glückskiste",
"mysteryDesc": "Zufallsbelohnung: Boost, goldener Rahmen, Spotlight, Verlauf, Sticker-Slot, Jackpot – oder eine Niete.",
"mysteryBuy": "Öffnen",
"mystery": {
"boost": "Gewinn: Upload-Boost für einen zufälligen Upload!",
"golden": "Gewinn: Goldener Rahmen für einen zufälligen Upload!",
"spotlight": "Gewinn: Spotlight für einen zufälligen Upload!",
"gradient": "Gewinn: Namensverlauf für 72 Stunden!",
"sticker_slot": "Gewinn: +1 Sticker-Slot!",
"jackpot": "Jackpot! +{n} Punkte",
"niete": "Niete… immerhin ein schickes Badge für 24 Stunden."
}
}
}
+45
View File
@@ -41,6 +41,7 @@
"admin": "Admin",
"thread": "Thread",
"profile": "Profile",
"shop": "Shop",
"settings": "Settings",
"search": "Search…",
"searchAria": "Open quick search (Ctrl+K)",
@@ -393,6 +394,7 @@
"limitFields": {
"uploadMaxSizeMb": "Upload size (MB)",
"stickerMaxSizeMb": "Sticker size (MB)",
"userStickerSlots": "Sticker slots per user",
"avatarMaxSizeMb": "Avatar size (MB)",
"commentMaxLength": "Comment length (chars)",
"dmMaxLength": "Message length (chars)",
@@ -696,5 +698,48 @@
"following": "Following",
"followers": "Followers",
"followFailed": "Could not update follow"
},
"shop": {
"title": "Point Shop",
"balance": "Balance",
"displayNameTitle": "Display name",
"displayNameDesc": "Change your publicly visible name for {hours} hours. Your username stays hidden from others.",
"namePlaceholder": "New display name",
"buy": "Buy",
"insufficient": "Not enough points",
"activeUntil": "Active until {time}",
"purchased": "Purchased",
"purchaseFailed": "Purchase failed",
"deactivate": "Turn off",
"deactivated": "Effect turned off",
"boostTitle": "Upload boost",
"boostDesc": "All of your uploads are shown as large tiles in the grid for {hours} hours – no matter their score.",
"goldenTitle": "Golden frame",
"goldenDesc": "All of your uploads get a glowing golden frame in the grid for {hours} hours.",
"spotlightTitle": "Spotlight",
"spotlightDesc": "An upload of your choice appears at the front of the homepage grid for {hours} hours.",
"spotlightPickTitle": "Choose spotlight upload",
"spotlightPickDesc": "This upload will be shown at the front of the homepage grid for the duration.",
"spotlightSlots": "Slots taken: {n} of {max}",
"flairTitle": "Flair badge",
"flairDesc": "Your own badge next to your name on your profile and in the hover card – visible for {hours} hours.",
"flairPlaceholder": "Your flair text",
"gradientTitle": "Name gradient",
"gradientDesc": "Your name appears with a color gradient instead of a flat color for {hours} hours – on your profile and in the hover card.",
"stickerSlotTitle": "Sticker slot",
"stickerSlotDesc": "+1 permanent slot for your own stickers.",
"stickerSlots": "Used: {n} of {max} slots",
"mysteryTitle": "Mystery box",
"mysteryDesc": "Random reward: boost, golden frame, spotlight, gradient, sticker slot, jackpot – or a dud.",
"mysteryBuy": "Open",
"mystery": {
"boost": "Win: upload boost for a random upload!",
"golden": "Win: golden frame for a random upload!",
"spotlight": "Win: spotlight for a random upload!",
"gradient": "Win: name gradient for 72 hours!",
"sticker_slot": "Win: +1 sticker slot!",
"jackpot": "Jackpot! +{n} points",
"niete": "Dud… at least you get a fancy badge for 24 hours."
}
}
}
+40
View File
@@ -0,0 +1,40 @@
import { and, count, eq, gt } from 'drizzle-orm'
import { stickers, users } from '../../db/schema'
import { SPOTLIGHT_MAX_ACTIVE } from '../../../shared/utils/shop'
import { getUserPointBalance } from '../../utils/activityScore'
import { activeDisplayName } from '../../utils/users'
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const db = useDb()
const now = Date.now()
const active = (d: Date | null | undefined) => d && d.getTime() > now ? d.toISOString() : null
const [balance, [spotStats], [stickerStats], limits] = await Promise.all([
getUserPointBalance(user.id),
db.select({ count: count() }).from(users).where(gt(users.spotlightUntil, new Date())),
db.select({ count: count() }).from(stickers).where(and(eq(stickers.userId, user.id), eq(stickers.isStandard, false))),
getLimits(),
])
const name = activeDisplayName(user)
return {
balance,
displayName: name
? { name, expiresAt: user.displayNameExpiresAt?.toISOString() ?? null }
: null,
flair: user.flairExpiresAt && user.flairExpiresAt.getTime() > now && user.flair
? { text: user.flair, expiresAt: user.flairExpiresAt.toISOString() }
: null,
nameGradientUntil: active(user.nameGradientUntil),
boostedUntil: active(user.boostedUntil),
goldenUntil: active(user.goldenUntil),
spotlightUntil: active(user.spotlightUntil),
stickerSlots: {
used: stickerStats?.count ?? 0,
total: limits.userStickerSlots + (user.stickerBonusSlots ?? 0),
bonus: user.stickerBonusSlots ?? 0,
},
spotlight: { active: spotStats?.count ?? 0, max: SPOTLIGHT_MAX_ACTIVE },
}
})
+205
View File
@@ -0,0 +1,205 @@
import { and, count, eq, gt, sql } from 'drizzle-orm'
import { z } from 'zod'
import { pointPurchases, uploads, users } from '../../db/schema'
import {
FLAIR_COST,
FLAIR_DURATION_MS,
FLAIR_MAX_LENGTH,
MYSTERY_BOX_COST,
MYSTERY_JACKPOT_POINTS,
MYSTERY_NIETE_DURATION_MS,
MYSTERY_NIETE_FLAIR,
MYSTERY_OUTCOMES,
NAME_GRADIENT_COST,
NAME_GRADIENT_DURATION_MS,
SPOTLIGHT_MAX_ACTIVE,
STICKER_SLOT_COST,
UPLOAD_BOOST_COST,
UPLOAD_BOOST_DURATION_MS,
UPLOAD_GOLDEN_COST,
UPLOAD_GOLDEN_DURATION_MS,
UPLOAD_SPOTLIGHT_COST,
UPLOAD_SPOTLIGHT_DURATION_MS,
type MysteryOutcome,
} from '../../../shared/utils/shop'
import { getUserPointBalance } from '../../utils/activityScore'
const bodySchema = z.object({
item: z.enum(['upload_boost', 'upload_golden', 'upload_spotlight', 'flair', 'name_gradient', 'sticker_slot', 'mystery_box']),
uploadId: z.string().max(16).optional(),
flair: z.string().trim().min(1).max(FLAIR_MAX_LENGTH).optional(),
})
// Nutzerweite Upload-Items: gelten fuer alle eigenen Uploads gleichzeitig
const USER_EFFECTS = {
upload_boost: { cost: UPLOAD_BOOST_COST, ms: UPLOAD_BOOST_DURATION_MS, col: 'boostedUntil' },
upload_golden: { cost: UPLOAD_GOLDEN_COST, ms: UPLOAD_GOLDEN_DURATION_MS, col: 'goldenUntil' },
upload_spotlight: { cost: UPLOAD_SPOTLIGHT_COST, ms: UPLOAD_SPOTLIGHT_DURATION_MS, col: 'spotlightUntil' },
} as const
type UserEffect = keyof typeof USER_EFFECTS
// Kauf der Shop-Items. Ausgaben werden ueber point_purchases gebucht und
// reduzieren nur das Guthaben - Thread-Scores bleiben unveraendert.
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const { item, uploadId, flair } = bodySchema.parse(await readBody(event))
const db = useDb()
const now = Date.now()
const isActive = (d: Date | null | undefined) => !!d && d.getTime() > now
// Vorab-Validierung pro Item (bereits aktiv, Platz, Text vorhanden)
if (item in USER_EFFECTS) {
const { col } = USER_EFFECTS[item as UserEffect]
if (isActive(user[col])) {
throw createError({ statusCode: 400, statusMessage: 'Effekt ist bereits aktiv' })
}
if (item === 'upload_spotlight') {
const [spotStats] = await db.select({ count: count() }).from(users)
.where(gt(users.spotlightUntil, new Date()))
if ((spotStats?.count ?? 0) >= SPOTLIGHT_MAX_ACTIVE) {
throw createError({ statusCode: 400, statusMessage: `Alle Spotlight-Plätze sind belegt (max. ${SPOTLIGHT_MAX_ACTIVE})` })
}
// Spotlight zeigt ein bestimmtes eigenes Upload
if (!uploadId) {
throw createError({ statusCode: 400, statusMessage: 'Kein Upload ausgewählt' })
}
const [own] = await db.select({ id: uploads.id }).from(uploads)
.where(and(eq(uploads.id, uploadId), eq(uploads.userId, user.id)))
.limit(1)
if (!own) {
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
}
}
}
else if (item === 'flair') {
if (!flair) throw createError({ statusCode: 400, statusMessage: 'Kein Flair-Text angegeben' })
if (isActive(user.flairExpiresAt)) {
throw createError({ statusCode: 400, statusMessage: 'Es ist bereits ein Flair aktiv' })
}
}
else if (item === 'name_gradient' && isActive(user.nameGradientUntil)) {
throw createError({ statusCode: 400, statusMessage: 'Der Namensverlauf ist bereits aktiv' })
}
const costs: Record<typeof item, number> = {
upload_boost: UPLOAD_BOOST_COST,
upload_golden: UPLOAD_GOLDEN_COST,
upload_spotlight: UPLOAD_SPOTLIGHT_COST,
flair: FLAIR_COST,
name_gradient: NAME_GRADIENT_COST,
sticker_slot: STICKER_SLOT_COST,
mystery_box: MYSTERY_BOX_COST,
}
const balance = await getUserPointBalance(user.id)
const cost = costs[item]
if (balance < cost) {
throw createError({ statusCode: 400, statusMessage: `Nicht genug Punkte (benötigt: ${cost})` })
}
// Buchung + Effekt anwenden
await db.insert(pointPurchases).values({ userId: user.id, item, cost })
let outcome: { kind: string } | null = null
if (item in USER_EFFECTS) {
const { col, ms } = USER_EFFECTS[item as UserEffect]
await db.update(users).set({
[col]: new Date(now + ms),
...(item === 'upload_spotlight' ? { spotlightUploadId: uploadId! } : {}),
}).where(eq(users.id, user.id))
}
else if (item === 'flair') {
await db.update(users)
.set({ flair: flair!, flairExpiresAt: new Date(now + FLAIR_DURATION_MS) })
.where(eq(users.id, user.id))
}
else if (item === 'name_gradient') {
await db.update(users)
.set({ nameGradientUntil: new Date(now + NAME_GRADIENT_DURATION_MS) })
.where(eq(users.id, user.id))
}
else if (item === 'sticker_slot') {
await db.update(users)
.set({ stickerBonusSlots: sql`${users.stickerBonusSlots} + 1` })
.where(eq(users.id, user.id))
}
else if (item === 'mystery_box') {
outcome = await runMysteryBox(user.id)
}
return { item, outcome, balance: balance - cost }
})
// Gewichtete Zufallsbelohnung der Glueckskiste. Bereits aktive Effekte
// fallen auf die Niete zurueck.
async function runMysteryBox(userId: number): Promise<{ kind: string }> {
const db = useDb()
const totalWeight = MYSTERY_OUTCOMES.reduce((s, o) => s + o.weight, 0)
let roll = Math.random() * totalWeight
let picked: MysteryOutcome = 'niete'
for (const o of MYSTERY_OUTCOMES) {
roll -= o.weight
if (roll <= 0) { picked = o.kind; break }
}
const now = Date.now()
const [u] = await db.select({
boostedUntil: users.boostedUntil,
goldenUntil: users.goldenUntil,
spotlightUntil: users.spotlightUntil,
nameGradientUntil: users.nameGradientUntil,
flairExpiresAt: users.flairExpiresAt,
}).from(users).where(eq(users.id, userId))
const active = (d: Date | null | undefined) => !!d && d.getTime() > now
if (picked === 'boost' || picked === 'golden' || picked === 'spotlight') {
const col = picked === 'boost' ? 'boostedUntil' : picked === 'golden' ? 'goldenUntil' : 'spotlightUntil'
const ms = picked === 'boost' ? UPLOAD_BOOST_DURATION_MS : picked === 'golden' ? UPLOAD_GOLDEN_DURATION_MS : UPLOAD_SPOTLIGHT_DURATION_MS
if (picked === 'spotlight' && !await spotlightFree(db)) return niete(db, userId)
if (active(u?.[col])) return niete(db, userId)
// Beim Spotlight wird ein zufaelliges eigenes Upload eingeblendet
let spotlightUploadId: string | null = null
if (picked === 'spotlight') {
const [target] = await db.select({ id: uploads.id }).from(uploads)
.where(eq(uploads.userId, userId)).orderBy(sql`rand()`).limit(1)
if (!target) return niete(db, userId)
spotlightUploadId = target.id
}
await db.update(users).set({
[col]: new Date(now + ms),
...(spotlightUploadId ? { spotlightUploadId } : {}),
}).where(eq(users.id, userId))
return { kind: picked }
}
if (picked === 'gradient') {
if (active(u?.nameGradientUntil)) return niete(db, userId)
await db.update(users).set({ nameGradientUntil: new Date(now + NAME_GRADIENT_DURATION_MS) }).where(eq(users.id, userId))
return { kind: 'gradient' }
}
if (picked === 'sticker_slot') {
await db.update(users).set({ stickerBonusSlots: sql`${users.stickerBonusSlots} + 1` }).where(eq(users.id, userId))
return { kind: 'sticker_slot' }
}
if (picked === 'jackpot') {
// Negativer Buchungseintrag erhoeht das Guthaben
await db.insert(pointPurchases).values({ userId, item: 'mystery_jackpot', cost: -MYSTERY_JACKPOT_POINTS })
return { kind: 'jackpot' }
}
return niete(db, userId)
}
async function spotlightFree(db: ReturnType<typeof useDb>) {
const [stats] = await db.select({ count: count() }).from(users).where(gt(users.spotlightUntil, new Date()))
return (stats?.count ?? 0) < SPOTLIGHT_MAX_ACTIVE
}
// Trostpreis: sichtbares Niete-Flair fuer 24h (ersetzt kein aktives Flair)
async function niete(db: ReturnType<typeof useDb>, userId: number) {
const [u] = await db.select({ flairExpiresAt: users.flairExpiresAt }).from(users).where(eq(users.id, userId))
if (!u?.flairExpiresAt || u.flairExpiresAt.getTime() <= Date.now()) {
await db.update(users)
.set({ flair: MYSTERY_NIETE_FLAIR, flairExpiresAt: new Date(Date.now() + MYSTERY_NIETE_DURATION_MS) })
.where(eq(users.id, userId))
}
return { kind: 'niete' }
}
+32
View File
@@ -0,0 +1,32 @@
import { desc, eq } from 'drizzle-orm'
import { uploads } from '../../db/schema'
// Eigene Uploads des Nutzers fuer die Upload-Auswahl im Shop (z. B. fuer
// das Spotlight, bei dem ein bestimmtes Upload in der Reihe stehen soll).
export default defineEventHandler(async (event) => {
const user = await requireUser(event)
const db = useDb()
const rows = await db
.select({
id: uploads.id,
type: uploads.type,
fileName: uploads.fileName,
externalUrl: uploads.externalUrl,
previewImage: uploads.previewImage,
previewTitle: uploads.previewTitle,
originalName: uploads.originalName,
})
.from(uploads)
.where(eq(uploads.userId, user.id))
.orderBy(desc(uploads.createdAt))
return {
items: rows.map((row) => {
const url = row.externalUrl ?? `/uploads/${row.fileName}`
const embedId = row.type === 'youtube' && row.externalUrl ? getYoutubeId(row.externalUrl) : null
const thumbUrl = row.previewImage
?? (row.type === 'youtube' && embedId ? youtubeThumbUrl(embedId) : row.type === 'image' ? url : null)
return { id: row.id, title: row.previewTitle ?? row.originalName, thumbUrl }
}),
}
})
+14
View File
@@ -26,6 +26,20 @@ export default defineEventHandler(async (event) => {
if (asStandard) await requirePermission(event, 'sticker.standard')
const db = useDb()
// Slot-Limit fuer eigene Sticker (wie index.post.ts)
if (!asStandard) {
const limits = await getLimits()
const [[slotStats]] = await Promise.all([
db.select({ used: count() }).from(stickers)
.where(and(eq(stickers.userId, user.id), eq(stickers.isStandard, false))),
])
const allowed = limits.userStickerSlots + (user.stickerBonusSlots ?? 0)
if ((slotStats?.used ?? 0) >= allowed) {
throw createError({ statusCode: 400, statusMessage: `Sticker-Limit erreicht (${allowed})` })
}
}
const [upload] = await db.select().from(uploads).where(eq(uploads.id, uploadId)).limit(1)
if (!upload || !upload.fileName || upload.externalUrl) {
throw createError({ statusCode: 404, statusMessage: 'Upload nicht gefunden' })
+15
View File
@@ -32,6 +32,21 @@ export default defineEventHandler(async (event) => {
if (asStandard) await requirePermission(event, 'sticker.standard')
const db = useDb()
// Slot-Limit fuer eigene Sticker: Basis-Limit plus im Shop gekaufte
// Bonus-Slots. Standard-Sticker zaehlen nicht ins Kontingent.
if (!asStandard) {
const limits = await getLimits()
const [[slotStats]] = await Promise.all([
db.select({ used: count() }).from(stickers)
.where(and(eq(stickers.userId, user.id), eq(stickers.isStandard, false))),
])
const allowed = limits.userStickerSlots + (user.stickerBonusSlots ?? 0)
if ((slotStats?.used ?? 0) >= allowed) {
throw createError({ statusCode: 400, statusMessage: `Sticker-Limit erreicht (${allowed})` })
}
}
const existing = await db.select({ id: stickers.id }).from(stickers).where(eq(stickers.name, nameRaw)).limit(1)
if (existing.length) throw createError({ statusCode: 409, statusMessage: 'Sticker-Name bereits vergeben' })
+4 -1
View File
@@ -131,14 +131,17 @@ export default defineEventHandler(async (event) => {
uploadReactionCount: row.uploadReactionCount,
activityScore: row.activityScore,
thumbnailSize: row.thumbnailSize,
boostedUntil: row.boostedUntil && row.boostedUntil.getTime() > Date.now() ? row.boostedUntil.toISOString() : null,
goldenUntil: row.goldenUntil && row.goldenUntil.getTime() > Date.now() ? row.goldenUntil.toISOString() : null,
createdAt: row.createdAt,
isBot,
userId: row.userId ?? null,
userName: row.userName ?? BOT_NAME,
userColor: row.userColor ?? (isBot ? BOT_COLOR : null),
userAvatarUrl: avatarUrl(row.userAvatar ?? null),
spotlight: 'isSpotlight' in row && row.isSpotlight === true,
}
})
return { items, total: rows.length, pageCount: pages.length }
return { items, total: rows.length + spotlightRows.length, pageCount: pages.length }
})
+6 -1
View File
@@ -13,7 +13,7 @@ export default defineEventHandler(async (event) => {
const db = useDb()
const [user] = await db
.select({ id: users.id, name: publicUserName, color: users.color, bio: users.bio, avatar: users.avatar, cardEffect: users.cardEffect, cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette, cardBorderEffect: users.cardBorderEffect, cardBorderEffectColor: users.cardBorderEffectColor, cardBanner: users.cardBanner, cardBannerType: users.cardBannerType, createdAt: users.createdAt, displayNameExpiresAt: users.displayNameExpiresAt })
.select({ id: users.id, name: publicUserName, color: users.color, bio: users.bio, avatar: users.avatar, cardEffect: users.cardEffect, cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette, cardBorderEffect: users.cardBorderEffect, cardBorderEffectColor: users.cardBorderEffectColor, cardBanner: users.cardBanner, cardBannerType: users.cardBannerType, createdAt: users.createdAt, displayNameExpiresAt: users.displayNameExpiresAt, flair: users.flair, flairExpiresAt: users.flairExpiresAt, nameGradientUntil: users.nameGradientUntil })
.from(users)
.where(eq(users.id, userId))
.limit(1)
@@ -53,6 +53,11 @@ export default defineEventHandler(async (event) => {
renamedUntil: user.displayNameExpiresAt && user.displayNameExpiresAt.getTime() > Date.now()
? user.displayNameExpiresAt.toISOString()
: null,
// Shop-Kosmetik - nur solange die jeweilige Laufzeit noch aktiv ist
flair: user.flair && user.flairExpiresAt && user.flairExpiresAt.getTime() > Date.now()
? user.flair
: null,
nameGradient: !!user.nameGradientUntil && user.nameGradientUntil.getTime() > Date.now(),
},
stats: {
uploads: uploadStats?.count ?? 0,
+5 -1
View File
@@ -11,7 +11,7 @@ export default defineEventHandler(async (event) => {
const db = useDb()
const [user] = await db
.select({ id: users.id, name: publicUserName, color: users.color, bio: users.bio, avatar: users.avatar, cardEffect: users.cardEffect, cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette, cardBorderEffect: users.cardBorderEffect, cardBorderEffectColor: users.cardBorderEffectColor, cardHoloEffect: users.cardHoloEffect, cardBanner: users.cardBanner, cardBannerType: users.cardBannerType, createdAt: users.createdAt, displayNameExpiresAt: users.displayNameExpiresAt })
.select({ id: users.id, name: publicUserName, color: users.color, bio: users.bio, avatar: users.avatar, cardEffect: users.cardEffect, cardEffectColor: users.cardEffectColor, cardVignette: users.cardVignette, cardBorderEffect: users.cardBorderEffect, cardBorderEffectColor: users.cardBorderEffectColor, cardHoloEffect: users.cardHoloEffect, cardBanner: users.cardBanner, cardBannerType: users.cardBannerType, createdAt: users.createdAt, displayNameExpiresAt: users.displayNameExpiresAt, flair: users.flair, flairExpiresAt: users.flairExpiresAt, nameGradientUntil: users.nameGradientUntil })
.from(users)
.where(eq(users.id, userId))
.limit(1)
@@ -44,6 +44,10 @@ export default defineEventHandler(async (event) => {
renamedUntil: user.displayNameExpiresAt && user.displayNameExpiresAt.getTime() > Date.now()
? user.displayNameExpiresAt.toISOString()
: null,
flair: user.flair && user.flairExpiresAt && user.flairExpiresAt.getTime() > Date.now()
? user.flair
: null,
nameGradient: !!user.nameGradientUntil && user.nameGradientUntil.getTime() > Date.now(),
stats: {
uploads: uploadStats?.count ?? 0,
comments: commentStats?.count ?? 0,
@@ -0,0 +1,7 @@
ALTER TABLE `uploads` ADD `boosted_until` timestamp;--> statement-breakpoint
ALTER TABLE `uploads` ADD `golden_until` timestamp;--> statement-breakpoint
ALTER TABLE `uploads` ADD `spotlight_until` timestamp;--> statement-breakpoint
ALTER TABLE `users` ADD `flair` varchar(32);--> statement-breakpoint
ALTER TABLE `users` ADD `flair_expires_at` timestamp;--> statement-breakpoint
ALTER TABLE `users` ADD `name_gradient_until` timestamp;--> statement-breakpoint
ALTER TABLE `users` ADD `sticker_bonus_slots` int DEFAULT 0 NOT NULL;
@@ -0,0 +1,6 @@
ALTER TABLE `users` ADD `boosted_until` timestamp;--> statement-breakpoint
ALTER TABLE `users` ADD `golden_until` timestamp;--> statement-breakpoint
ALTER TABLE `users` ADD `spotlight_until` timestamp;--> statement-breakpoint
ALTER TABLE `uploads` DROP COLUMN `boosted_until`;--> statement-breakpoint
ALTER TABLE `uploads` DROP COLUMN `golden_until`;--> statement-breakpoint
ALTER TABLE `uploads` DROP COLUMN `spotlight_until`;
@@ -0,0 +1,2 @@
ALTER TABLE `users` ADD `spotlight_upload_id` varchar(8);--> statement-breakpoint
ALTER TABLE `users` ADD CONSTRAINT `users_spotlight_upload_id_uploads_id_fk` FOREIGN KEY (`spotlight_upload_id`) REFERENCES `uploads`(`id`) ON DELETE set null ON UPDATE no action;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -344,6 +344,27 @@
"when": 1792844851560,
"tag": "0048_bored_dracula",
"breakpoints": true
},
{
"idx": 49,
"version": "5",
"when": 1792849898249,
"tag": "0049_faithful_dark_beast",
"breakpoints": true
},
{
"idx": 50,
"version": "5",
"when": 1792850566018,
"tag": "0050_mushy_dreaming_celestial",
"breakpoints": true
},
{
"idx": 51,
"version": "5",
"when": 1792851032409,
"tag": "0051_sleepy_wrecking_crew",
"breakpoints": true
}
]
}
}
+11
View File
@@ -19,6 +19,17 @@ export const users = mysqlTable('users', {
cardHoloEffect: varchar('card_holo_effect', { length: 32 }),
cardBanner: varchar('card_banner', { length: 255 }),
cardBannerType: mysqlEnum('card_banner_type', ['image', 'video']),
// Shop-Käufe: kosmetisches Flair-Badge und Namensverlauf (zeitbasiert),
// plus dauerhaft gekaufte Zusatz-Slots für eigene Sticker
flair: varchar('flair', { length: 32 }),
flairExpiresAt: timestamp('flair_expires_at'),
nameGradientUntil: timestamp('name_gradient_until'),
stickerBonusSlots: int('sticker_bonus_slots').notNull().default(0),
// Shop-Käufe, die auf alle Uploads des Nutzers wirken (zeitbasiert)
boostedUntil: timestamp('boosted_until'),
goldenUntil: timestamp('golden_until'),
spotlightUntil: timestamp('spotlight_until'),
spotlightUploadId: varchar('spotlight_upload_id', { length: 8 }).references(() => uploads.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
+2
View File
@@ -4,6 +4,7 @@
export const LIMIT_DEFAULTS = {
uploadMaxSizeMb: 200,
stickerMaxSizeMb: 8,
userStickerSlots: 10,
avatarMaxSizeMb: 5,
commentMaxLength: 2000,
dmMaxLength: 2000,
@@ -27,6 +28,7 @@ export const LIMIT_KEYS = Object.keys(LIMIT_DEFAULTS) as LimitKey[]
export const LIMIT_MAX: Record<LimitKey, number> = {
uploadMaxSizeMb: 4096,
stickerMaxSizeMb: 200,
userStickerSlots: 1000,
avatarMaxSizeMb: 50,
commentMaxLength: 100_000,
dmMaxLength: 100_000,