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:
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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 }) }}
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
})
|
||||
@@ -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' }
|
||||
}
|
||||
@@ -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 }
|
||||
}),
|
||||
}
|
||||
})
|
||||
@@ -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' })
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user