Add admin-managed icons and animation effects for tags

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-25 00:34:15 +02:00
co-authored by Devin
parent 71d8432885
commit 013e4c8add
27 changed files with 3529 additions and 38 deletions
+75
View File
@@ -1877,3 +1877,78 @@ svg.iconify--ph {
filter: drop-shadow(0 0 6px var(--color-accent)) drop-shadow(0 0 16px var(--color-accent));
}
}
/* Tag-Deko-Effekte (Admin konfigurierbar) - glow/wiggle laufen als Klasse
auf der Pill, sparkle/confetti/shine als Overlay-Kinder */
@keyframes tag-glow {
0%, 100% {
box-shadow: 0 0 4px 1px var(--color-accent);
}
50% {
box-shadow: 0 0 14px 3px var(--color-accent);
}
}
.tag-fx-glow {
animation: tag-glow 2.2s ease-in-out infinite;
}
@keyframes tag-wiggle {
0%, 100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-2.5deg);
}
75% {
transform: rotate(2.5deg);
}
}
.tag-fx-wiggle {
animation: tag-wiggle 1.6s ease-in-out infinite;
}
/* Funkel-Partikel steigen ueber die Pill hinaus auf (wie Super-Reactions) */
@keyframes tag-sparkle {
0% {
transform: translateY(0) scale(0.4);
opacity: 0;
}
25% {
opacity: 1;
}
100% {
transform: translateY(-18px) scale(1);
opacity: 0;
}
}
.tag-fx-sparkle-bit {
animation: tag-sparkle 1.8s ease-in-out infinite;
line-height: 1;
}
/* Konfetti-Schnipsel fliegen aus der Pill-Mitte nach oben auseinander */
@keyframes tag-confetti {
0% {
transform: translate(0, 0) rotate(0deg);
opacity: 0;
}
15% {
opacity: 1;
}
100% {
transform: translate(var(--cx), var(--cy)) rotate(240deg);
opacity: 0;
}
}
.tag-fx-confetti-bit {
animation: tag-confetti 1.4s ease-out infinite;
}
/* Schimmer-Streifen wandert durch die Pill (im Overlay geclippt) */
@keyframes tag-shine {
from {
transform: translateX(-150%) skewX(-20deg);
}
to {
transform: translateX(350%) skewX(-20deg);
}
}
.tag-fx-shine-beam {
animation: tag-shine 2.4s ease-in-out infinite;
}
+3 -2
View File
@@ -2,7 +2,7 @@
import { useCommand } from '@/components/ui/command'
import { CommandGroup, CommandItem, CommandShortcut } from '@/components/ui/command'
interface TagResult { name: string, count: number }
interface TagResult { name: string, count: number, icon?: string | null }
interface ListedUser { id: number, name: string, color: string | null, avatarUrl: string | null }
const emit = defineEmits<{ select: [to: string] }>()
@@ -44,7 +44,8 @@ onMounted(async () => {
class="gap-2"
@select="emit('select', `/?tag=${encodeURIComponent(t.name)}`)"
>
<Icon name="ph:tag-fill" class="size-4 text-muted-foreground" />
<TagIcon v-if="t.icon" :icon="t.icon" class="size-4" />
<Icon v-else name="ph:tag-fill" class="size-4 text-muted-foreground" />
{{ t.name }}
<CommandShortcut class="tracking-normal">{{ t.count }}</CommandShortcut>
</CommandItem>
+54 -1
View File
@@ -5,7 +5,10 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import type { Sticker } from '~~/shared/utils/stickers'
// Reaction-Picker mit zwei Tabs: Unicode-Emojis oder eigene/Standard-Sticker.
// emit('select', wert, true) = Sticker-Name, false = Emoji-Text
// Mit showIcons kommt ein dritter Tab für die installierten Iconify-Sets
// dazu (für Tag-Icons im Admin; Icons emittieren sticker=false).
// emit('select', wert, true) = Sticker-Name, false = Emoji/Icon-Name
withDefaults(defineProps<{ showIcons?: boolean }>(), { showIcons: false })
const emit = defineEmits<{ select: [value: string, sticker: boolean] }>()
const { user } = useAuth()
@@ -24,6 +27,28 @@ function pick(e: Sticker) {
emit('select', e.name, true)
}
// Iconify-Tab: Server-Suche über die installierten Sets (ph, simple-icons)
const iconQuery = ref('')
const iconResults = ref<string[]>([])
const iconsLoaded = ref(false)
let iconTimer: ReturnType<typeof setTimeout> | undefined
watch(iconQuery, () => {
clearTimeout(iconTimer)
iconTimer = setTimeout(loadIcons, 200)
})
async function loadIcons() {
try {
const res = await $fetch<{ icons: string[] }>('/api/icons', { query: { q: iconQuery.value } })
iconResults.value = res.icons
iconsLoaded.value = true
}
catch {
iconResults.value = []
}
}
function pickIcon(name: string) {
emit('select', name, false)
}
</script>
<template>
@@ -37,6 +62,10 @@ function pick(e: Sticker) {
<Icon name="ph:sticker-fill" class="size-4" />
{{ $t('stickers.tabStickers') }}
</TabsTrigger>
<TabsTrigger v-if="showIcons" value="icons" class="flex-1 cursor-pointer gap-1.5" @click="!iconsLoaded && loadIcons()">
<Icon name="ph:shapes-fill" class="size-4" />
{{ $t('stickers.tabIcons') }}
</TabsTrigger>
</TabsList>
<TabsContent value="emoji" class="mt-0">
@@ -93,5 +122,29 @@ function pick(e: Sticker) {
</div>
</TabsContent>
<TabsContent v-if="showIcons" value="icons" class="space-y-2 p-2">
<Input
v-model="iconQuery"
:placeholder="$t('stickers.searchIcons')"
class="h-8 text-sm"
/>
<div class="max-h-80 overflow-y-auto">
<div class="grid grid-cols-7 gap-1">
<button
v-for="name in iconResults"
:key="name"
type="button"
class="flex size-10 cursor-pointer items-center justify-center rounded-md transition-colors hover:bg-accent"
:title="name"
@click="pickIcon(name)"
>
<Icon :name="name" class="size-5" />
</button>
</div>
<p v-if="iconsLoaded && !iconResults.length" class="px-1 py-1.5 text-xs text-muted-foreground">
{{ $t('stickers.empty') }}
</p>
</div>
</TabsContent>
</Tabs>
</template>
+4 -1
View File
@@ -5,7 +5,7 @@ import type { Sticker } from '~~/shared/utils/stickers'
const props = withDefaults(defineProps<{
sticker: Sticker
variant?: 'inline' | 'compact' | 'tile' | 'card' | 'reaction'
variant?: 'inline' | 'compact' | 'tile' | 'card' | 'reaction' | 'tag'
}>(), { variant: 'inline' })
// Skeleton bis zum ersten load/loadeddata - danach bleibt das Medium
@@ -31,6 +31,7 @@ const wrapClass = computed(() => ({
compact: 'relative my-0.5 inline-block align-middle',
inline: 'relative my-0.5 inline-block align-middle',
reaction: 'relative inline-block align-middle',
tag: 'relative inline-block align-middle',
}[props.variant]))
const skeletonClass = computed(() => ({
@@ -39,6 +40,7 @@ const skeletonClass = computed(() => ({
compact: 'block size-12 rounded-md',
inline: 'h-40 w-40 rounded-md',
reaction: 'block size-7 -my-1 rounded',
tag: 'block size-6 -my-0.5 rounded',
}[props.variant]))
const mediaClass = computed(() => ({
@@ -47,6 +49,7 @@ const mediaClass = computed(() => ({
compact: 'h-12 w-auto max-w-full rounded-md object-contain',
inline: 'h-40 w-auto max-w-full rounded-md object-contain',
reaction: 'block size-7 -my-1 rounded object-cover',
tag: 'block size-6 -my-0.5 rounded object-cover',
}[props.variant]))
</script>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
// Overlay-Effekte für dekorierte Tag-Pills. Partikel (sparkle/confetti)
// reichen bewusst über die Pill hinaus; shine läuft geclippt im Inneren.
// glow/wiggle sind Pill-Klassen (tagEffectClass), kein Overlay nötig.
defineProps<{ effect: string }>()
const SPARKLES = [
{ left: '12%', size: '10px', delay: '0s' },
{ left: '48%', size: '8px', delay: '0.6s' },
{ left: '82%', size: '12px', delay: '1.1s' },
]
const CONFETTI = [
{ x: '-18px', y: '-14px', c: 'var(--accent)', delay: '0s' },
{ x: '14px', y: '-18px', c: 'var(--primary)', delay: '0.25s' },
{ x: '-6px', y: '-22px', c: 'var(--accent)', delay: '0.5s' },
{ x: '20px', y: '-8px', c: 'var(--primary)', delay: '0.1s' },
{ x: '0px', y: '-26px', c: 'var(--accent)', delay: '0.65s' },
]
</script>
<template>
<span v-if="effect === 'sparkle'" class="pointer-events-none absolute inset-0" aria-hidden="true">
<span
v-for="(s, i) in SPARKLES"
:key="i"
class="tag-fx-sparkle-bit absolute top-1/2 text-accent"
:style="{ left: s.left, fontSize: s.size, animationDelay: s.delay }"
>✦</span>
</span>
<span v-else-if="effect === 'confetti'" class="pointer-events-none absolute inset-0" aria-hidden="true">
<span
v-for="(c, i) in CONFETTI"
:key="i"
class="tag-fx-confetti-bit absolute top-1/2 left-1/2 block size-[3px] rounded-[1px]"
:style="{ backgroundColor: c.c, animationDelay: c.delay, '--cx': c.x, '--cy': c.y }"
/>
</span>
<span v-else-if="effect === 'shine'" class="pointer-events-none absolute inset-0 overflow-hidden rounded-[inherit]" aria-hidden="true">
<span class="tag-fx-shine-beam absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-foreground/25 to-transparent" />
</span>
</template>
+30
View File
@@ -0,0 +1,30 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
// Tag-Icon: Iconify-Name („ph:x") -> Icon-Komponente, Sticker-Shortcode
// („:name:") -> StickerMedia, lokaler Pfad („/uploads/...") -> <img>,
// sonst Emoji-Text
const props = defineProps<{
icon: string
class?: string
}>()
const { byName } = useStickers()
const stickerName = computed(() => /^:([a-z0-9_]+):$/.exec(props.icon)?.[1] ?? null)
const sticker = computed(() => stickerName.value ? byName.value.get(stickerName.value) ?? null : null)
const isFile = computed(() => props.icon.startsWith('/'))
const isIconify = computed(() => !stickerName.value && !isFile.value && props.icon.includes(':'))
</script>
<template>
<StickerMedia v-if="sticker" :sticker="sticker" variant="tag" :class="props.class" />
<img
v-else-if="isFile"
:src="icon"
:class="cn('size-4 shrink-0 rounded-[2px] object-contain', props.class)"
alt=""
aria-hidden="true"
>
<Icon v-else-if="isIconify" :name="icon" :class="cn('size-3.5 shrink-0', props.class)" aria-hidden="true" />
<span v-else :class="cn('shrink-0 leading-none', props.class)" aria-hidden="true">{{ icon }}</span>
</template>
+3 -1
View File
@@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/badge'
interface TagSuggestion {
name: string
count: number
icon?: string | null
}
const props = withDefaults(defineProps<{
@@ -87,7 +88,8 @@ function onKeydown(e: KeyboardEvent) {
class="data-highlighted:bg-accent data-highlighted:text-accent-foreground flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none select-none"
@select.prevent="selectSuggestion(suggestion.name)"
>
<Icon name="ph:tag-fill" class="size-3.5 text-muted-foreground" />
<TagIcon v-if="suggestion.icon" :icon="suggestion.icon" />
<Icon v-else name="ph:tag-fill" class="size-3.5 text-muted-foreground" />
<span class="truncate">{{ suggestion.name }}</span>
<Badge variant="secondary" class="ml-auto px-1.5 py-0 font-normal">
{{ suggestion.count }}
+10 -2
View File
@@ -17,6 +17,7 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
import { cn } from '@/lib/utils'
import { parseDate } from '@internationalized/date'
import type { DateRange } from 'reka-ui'
import { tagEffectClass } from '~~/shared/utils/tags'
interface ListedUser {
id: number
@@ -30,6 +31,10 @@ const { t, locale } = useI18n()
const { user } = useAuth()
const filters = useUploadFilters()
const { tag: tagFilter, types: selectedTypes, uploaderId, dateFrom, dateTo, following: followingOnly } = filters
// Deko-Lookup für den aktiven Tag-Chip
const tagDeco = useTagDecorations()
onMounted(() => tagDeco.load())
const tagDecoForChip = computed(() => tagDeco.get(tagFilter.value))
const TYPE_OPTIONS = [
{ value: 'image', labelKey: 'filters.images', icon: 'ph:image-fill' },
@@ -224,9 +229,11 @@ function clearFilters() {
</PopoverContent>
</Popover>
<span v-if="tagFilter" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
<Icon name="ph:tag-fill" class="size-3.5" />
<span v-if="tagFilter" class="relative inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm" :class="tagEffectClass(tagDecoForChip?.effect)">
<TagIcon v-if="tagDecoForChip?.icon && tagDecoForChip.iconPosition === 'before'" :icon="tagDecoForChip.icon" />
<Icon v-else name="ph:tag-fill" class="size-3.5" />
{{ tagFilter }}
<TagIcon v-if="tagDecoForChip?.icon && tagDecoForChip.iconPosition === 'after'" :icon="tagDecoForChip.icon" />
<button
type="button"
class="text-muted-foreground transition-colors hover:text-foreground"
@@ -235,6 +242,7 @@ function clearFilters() {
>
<Icon name="ph:x-bold" class="size-3.5" />
</button>
<TagEffect v-if="tagDecoForChip?.effect" :effect="tagDecoForChip.effect" />
</span>
<span v-for="t in selectedTypes" :key="t" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
<Icon :name="typeIcon(t)" class="size-3.5" />
+33
View File
@@ -0,0 +1,33 @@
import type { DecoratedTag } from '~~/shared/utils/tags'
// Deko-Lookup (name -> icon/effect) für Stellen, die nur den Tag-Namen
// kennen (z. B. Filter-Chip). Wird einmalig lazy geladen; Admin-Änderungen
// lassen sich per refresh() nachziehen.
export function useTagDecorations() {
const map = useState<Map<string, Omit<DecoratedTag, 'name'>>>('tag-decorations', () => new Map())
const loaded = useState<boolean>('tag-decorations:loaded', () => false)
async function load() {
if (loaded.value) return
loaded.value = true
try {
const rows = await $fetch<DecoratedTag[]>('/api/tags/decorations')
for (const r of rows) {
map.value.set(r.name, { icon: r.icon, iconPosition: r.iconPosition, effect: r.effect })
}
}
catch {
loaded.value = false
}
}
async function refresh() {
loaded.value = false
map.value.clear()
await load()
}
const get = (name: string | null | undefined) => (name ? map.value.get(name) ?? null : null)
return { load, refresh, get }
}
+214
View File
@@ -3,6 +3,7 @@ import type { ColumnDef } from '@tanstack/vue-table'
import { PERMISSIONS, SYSTEM_ROLES } from '~~/shared/utils/permissions'
import { LIMIT_DEFAULTS, LIMIT_KEYS, LIMIT_MAX, type AppLimits, type LimitKey } from '~~/shared/utils/limits'
import { SHOP_PRICE_DEFAULTS, SHOP_PRICE_KEYS, SHOP_PRICE_MAX, type ShopPriceKey, type ShopPrices } from '~~/shared/utils/shop'
import { TAG_EFFECTS, TAG_ICON_POSITIONS, tagEffectClass, type TagEffectName, type TagIconPosition } from '~~/shared/utils/tags'
import type { DataTableFeatures } from '@/lib/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
@@ -440,6 +441,97 @@ async function resetPrice(key: ShopPriceKey) {
}
}
// Tag-Deko: pro Tag Icon (Emoji oder Iconify-Name), Icon-Position und
// Animationseffekt. 'none'/'-'-Sentinel werden beim Speichern zu null.
interface AdminTagRow {
id: number
name: string
count: number
icon: string
iconPosition: TagIconPosition
effect: TagEffectName | 'none'
}
const { data: adminTags } = await useFetch<AdminTagRow[]>(
'/api/admin/tags',
{
default: () => [],
immediate: can('tag.manage'),
transform: rows => rows.map(r => ({ ...r, icon: r.icon ?? '', effect: r.effect ?? 'none' }) as AdminTagRow),
},
)
const tagDecoSearch = ref('')
const filteredAdminTags = computed(() => {
const q = tagDecoSearch.value.trim().toLowerCase()
return q ? adminTags.value.filter(t => t.name.includes(q)) : adminTags.value
})
const tagDecoSaving = ref<number | null>(null)
// Welcher Tag hat den Icon-Picker offen / lädt gerade ein Icon hoch
const tagIconPicker = ref<number | null>(null)
const tagIconUploading = ref(false)
const tagIconUploadFor = ref<number | null>(null)
const iconFileInput = ref<HTMLInputElement | null>(null)
function pickTagIcon(tag: AdminTagRow, value: string, sticker: boolean) {
// Sticker werden als :name:-Shortcode gespeichert
tag.icon = sticker ? `:${value}:` : value
tagIconPicker.value = null
saveTagDeco(tag)
}
function startTagIconUpload(tag: AdminTagRow) {
tagIconUploadFor.value = tag.id
iconFileInput.value?.click()
}
async function onTagIconFile(e: Event) {
const input = e.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
const tag = adminTags.value.find(t => t.id === tagIconUploadFor.value)
tagIconUploadFor.value = null
if (!file || !tag) return
errorMessage.value = ''
tagIconUploading.value = true
try {
const fd = new FormData()
fd.append('file', file)
const res = await $fetch<{ url: string }>('/api/admin/tags/icon', { method: 'POST', body: fd })
tag.icon = res.url
tagIconPicker.value = null
await saveTagDeco(tag)
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
tagIconUploading.value = false
}
}
async function saveTagDeco(tag: AdminTagRow) {
if (tagDecoSaving.value === tag.id) return
tagDecoSaving.value = tag.id
errorMessage.value = ''
try {
await $fetch('/api/admin/tags', {
method: 'PUT',
body: {
id: tag.id,
icon: tag.icon || null,
iconPosition: tag.iconPosition,
effect: tag.effect === 'none' ? null : tag.effect,
},
})
// Deko-Lookup für Filter-Chips etc. aktualisieren
useTagDecorations().refresh()
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
tagDecoSaving.value = null
}
}
</script>
<template>
@@ -462,6 +554,10 @@ async function resetPrice(key: ShopPriceKey) {
<Icon name="ph:smiley-sticker-fill" />
{{ $t('admin.memes') }}
</TabsTrigger>
<TabsTrigger v-if="can('tag.manage')" value="tags" class="cursor-pointer">
<Icon name="ph:tag-fill" />
{{ $t('admin.tags') }}
</TabsTrigger>
<TabsTrigger v-if="can('site.manage')" value="site" class="cursor-pointer">
<Icon name="ph:globe-fill" />
{{ $t('admin.site') }}
@@ -1020,6 +1116,124 @@ async function resetPrice(key: ShopPriceKey) {
</Card>
</TabsContent>
<TabsContent v-if="can('tag.manage')" value="tags">
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.tags') }}</CardTitle>
<CardDescription>{{ $t('admin.tagsDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<Input
v-model="tagDecoSearch"
:placeholder="$t('admin.tagSearch')"
:aria-label="$t('admin.tagSearch')"
class="mb-4 max-w-72"
/>
<input
ref="iconFileInput"
type="file"
accept="image/svg+xml,image/png,image/jpeg,image/gif,image/webp,image/avif,image/bmp,.svg"
class="hidden"
@change="onTagIconFile"
>
<div class="space-y-3">
<div
v-for="tag in filteredAdminTags"
:key="tag.id"
class="flex flex-wrap items-center gap-2 sm:gap-3"
>
<span class="w-56 shrink-0 truncate text-sm font-medium" :title="tag.name">
{{ tag.name }}
<span class="ml-1.5 text-xs font-normal text-muted-foreground">({{ tag.count }})</span>
</span>
<!-- Live-Vorschau der Pill -->
<span
class="relative inline-flex items-center gap-1.5 rounded-md bg-muted px-2.5 py-1 text-sm"
:class="tagEffectClass(tag.effect === 'none' ? null : tag.effect)"
>
<TagIcon v-if="tag.icon && tag.iconPosition === 'before'" :icon="tag.icon" />
{{ tag.name }}
<TagIcon v-if="tag.icon && tag.iconPosition === 'after'" :icon="tag.icon" />
<TagEffect v-if="tag.effect !== 'none'" :effect="tag.effect" />
</span>
<Popover
:open="tagIconPicker === tag.id"
@update:open="(o: boolean) => { if (!o) tagIconPicker = null }"
>
<PopoverAnchor as-child>
<Input
v-model="tag.icon"
:placeholder="$t('admin.tagIconPlaceholder')"
:aria-label="$t('admin.tagIconPlaceholder')"
class="w-40"
maxlength="64"
@focus="tagIconPicker = tag.id"
@click="tagIconPicker = tag.id"
@change="saveTagDeco(tag)"
/>
</PopoverAnchor>
<PopoverContent class="w-auto p-0" align="start" @open-auto-focus.prevent>
<EmojiStickerPicker
show-icons
@select="(v: string, s: boolean) => pickTagIcon(tag, v, s)"
/>
<div class="border-t p-2">
<Button
variant="outline"
size="sm"
class="w-full cursor-pointer gap-1.5"
:disabled="tagIconUploading"
@click="startTagIconUpload(tag)"
>
<Spinner v-if="tagIconUploading" />
<Icon v-else name="ph:upload-simple" class="size-4" />
{{ $t('admin.tagIconUpload') }}
</Button>
<p class="mt-1.5 text-center text-[11px] text-muted-foreground">
{{ $t('admin.tagIconUploadHint') }}
</p>
</div>
</PopoverContent>
</Popover>
<Select
:model-value="tag.iconPosition"
:aria-label="$t('admin.iconPosLabel')"
@update:model-value="tag.iconPosition = $event as TagIconPosition; saveTagDeco(tag)"
>
<SelectTrigger class="w-36 cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="p in TAG_ICON_POSITIONS" :key="p" :value="p">
{{ $t(`admin.iconPos.${p}`) }}
</SelectItem>
</SelectContent>
</Select>
<Select
:model-value="tag.effect"
:aria-label="$t('admin.tagEffectLabel')"
@update:model-value="tag.effect = $event as AdminTagRow['effect']; saveTagDeco(tag)"
>
<SelectTrigger class="w-32 cursor-pointer">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">{{ $t('admin.tagEffects.none') }}</SelectItem>
<SelectItem v-for="e in TAG_EFFECTS" :key="e" :value="e">
{{ $t(`admin.tagEffects.${e}`) }}
</SelectItem>
</SelectContent>
</Select>
<Spinner v-if="tagDecoSaving === tag.id" />
</div>
</div>
<p v-if="!filteredAdminTags.length" class="text-sm text-muted-foreground">
{{ $t('admin.noTags') }}
</p>
</CardContent>
</Card>
</TabsContent>
<TabsContent v-if="can('site.manage')" value="prices">
<Card>
<CardHeader>
+25 -12
View File
@@ -6,7 +6,7 @@ import type { UploadReaction } from '@/composables/useUploadReactions'
import { mediaForThread, type PendingUploadMedia } from '@/composables/useLastViewedUpload'
import { getYoutubeId, youtubeEmbedUrl, youtubeThumbUrl } from '~~/shared/utils/youtube'
import { twitchEmbedUrl } from '~~/shared/utils/twitch'
import { normalizeTagName } from '~~/shared/utils/tags'
import { normalizeTagName, tagEffectClass, type DecoratedTag } from '~~/shared/utils/tags'
import { formatFileSize, isImageAttachment } from '~~/shared/utils/attachments'
import type { CommentAttachment } from '~~/shared/utils/attachments'
import { cn } from '@/lib/utils'
@@ -60,7 +60,7 @@ interface UploadDetail {
userCardBorderEffectColor: string | null
userNameGradient?: boolean
createdAt: string
tags: string[]
tags: DecoratedTag[]
lockComments?: boolean
lockReactions?: boolean
lockStickers?: boolean
@@ -931,15 +931,18 @@ async function setThreadLock(key: ThreadLockKey, value: boolean) {
}
const canEditTags = computed(() => !!user.value)
const canDeleteTags = computed(() => isUploader.value || can('tag.delete'))
const originalTags = computed(() => new Set(data.value?.upload.tags ?? []))
const originalTags = computed(() => new Set((data.value?.upload.tags ?? []).map(t => t.name)))
const tagEditing = ref(false)
const tagDraft = ref<string[]>([])
const tagInput = ref('')
const tagSaving = ref(false)
const tagSuggestions = ref<{ name: string, count: number }[]>([])
const tagSuggestions = ref<(DecoratedTag & { count: number })[]>([])
// Deko-Lookup für Draft-Chips (nur der Name ist bekannt)
const tagDeco = useTagDecorations()
onMounted(() => tagDeco.load())
function startTagEdit() {
tagDraft.value = [...(data.value?.upload.tags ?? [])]
tagDraft.value = (data.value?.upload.tags ?? []).map(t => t.name)
tagInput.value = ''
tagEditing.value = true
}
@@ -955,7 +958,7 @@ watch(tagInput, (q) => {
clearTimeout(tagSuggestTimer)
tagSuggestTimer = setTimeout(async () => {
try {
tagSuggestions.value = await $fetch<{ name: string, count: number }[]>('/api/tags', { query: { q } })
tagSuggestions.value = await $fetch<(DecoratedTag & { count: number })[]>('/api/tags', { query: { q } })
}
catch {
tagSuggestions.value = []
@@ -967,7 +970,7 @@ async function saveTags() {
if (tagSaving.value || !data.value) return
tagSaving.value = true
try {
const res = await $fetch<{ tags: string[] }>(`/api/uploads/${id.value}/tags`, {
const res = await $fetch<{ tags: DecoratedTag[] }>(`/api/uploads/${id.value}/tags`, {
method: 'PUT',
body: { tags: tagDraft.value },
})
@@ -1812,9 +1815,12 @@ async function deleteComment(commentId: number) {
<span
v-for="t in tagDraft"
:key="t"
class="inline-flex items-center gap-1.5 rounded-md bg-muted px-2.5 py-1 text-sm"
class="relative inline-flex items-center gap-1.5 rounded-md bg-muted px-2.5 py-1 text-sm"
:class="tagEffectClass(tagDeco.get(t)?.effect)"
>
<TagIcon v-if="tagDeco.get(t)?.icon && tagDeco.get(t)!.iconPosition === 'before'" :icon="tagDeco.get(t)!.icon!" />
{{ t }}
<TagIcon v-if="tagDeco.get(t)?.icon && tagDeco.get(t)!.iconPosition === 'after'" :icon="tagDeco.get(t)!.icon!" />
<button
v-if="canDeleteTags || !originalTags.has(t)"
type="button"
@@ -1824,6 +1830,7 @@ async function deleteComment(commentId: number) {
>
<Icon name="ph:x-bold" class="size-3.5" />
</button>
<TagEffect v-if="tagDeco.get(t)?.effect" :effect="tagDeco.get(t)!.effect!" />
</span>
<TagSuggestionInput
v-model="tagInput"
@@ -1847,10 +1854,16 @@ async function deleteComment(commentId: number) {
<div v-else-if="data.upload.tags.length || canEditTags" class="flex flex-wrap items-center gap-2">
<NuxtLink
v-for="t in data.upload.tags"
:key="t"
:to="{ path: '/', query: { tag: t } }"
class="rounded-md bg-muted px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>{{ t }}</NuxtLink>
:key="t.name"
:to="{ path: '/', query: { tag: t.name } }"
class="relative inline-flex items-center gap-1.5 rounded-md bg-muted px-2.5 py-1 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
:class="tagEffectClass(t.effect)"
>
<TagIcon v-if="t.icon && t.iconPosition === 'before'" :icon="t.icon" />
{{ t.name }}
<TagIcon v-if="t.icon && t.iconPosition === 'after'" :icon="t.icon" />
<TagEffect v-if="t.effect" :effect="t.effect" />
</NuxtLink>
<button
v-if="canEditTags"
type="button"
+28 -6
View File
@@ -179,7 +179,9 @@
"uploadFailed": "Sticker konnte nicht gespeichert werden",
"deleteFailed": "Sticker konnte nicht gelöscht werden",
"addFromUpload": "Als Sticker speichern",
"saved": "Sticker gespeichert"
"saved": "Sticker gespeichert",
"tabIcons": "Icons",
"searchIcons": "Icons suchen…"
},
"dm": {
"title": "Nachrichten",
@@ -425,8 +427,25 @@
"stickerSlot": "Sticker-Slot",
"mysteryBox": "Glückskiste"
},
"iconPos": {},
"tagEffects": {},
"tags": "Tags",
"tagsDesc": "Icons und Animationseffekte für Tags. Das Icon kann ein Emoji oder ein Iconify-Name (z. B. „ph:fire-fill“) sein und wird überall angezeigt, wo der Tag als Pill erscheint.",
"tagSearch": "Tags suchen…",
"tagIconPlaceholder": "Emoji, :sticker:, ph:name oder /uploads-Pfad",
"iconPosLabel": "Icon-Position",
"iconPos": {
"before": "Vor dem Text",
"after": "Nach dem Text"
},
"tagEffectLabel": "Effekt",
"tagEffects": {
"none": "Kein",
"sparkle": "Funkeln",
"confetti": "Konfetti",
"glow": "Glühen",
"wiggle": "Wackeln",
"shine": "Schimmer"
},
"noTags": "Keine Tags gefunden.",
"actionFailed": "Aktion fehlgeschlagen",
"perms": {
"admin": {
@@ -451,7 +470,8 @@
"delete": "Uploads löschen"
},
"tag": {
"delete": "Tags löschen"
"delete": "Tags löschen",
"manage": "Tags & Deko verwalten"
},
"sticker": {
"standard": "Standard-Sticker verwalten"
@@ -469,7 +489,9 @@
"guestAccess": "Gast-Zugriff",
"guestAccessDesc": "Gaeste duerfen die Seite ohne Login durchstoebern. Aus = jede Seite leitet auf den Login weiter.",
"registrationOpen": "Registrierung offen",
"registrationOpenDesc": "Neue Nutzer duerfen sich auf der Registrierungsseite ein Konto erstellen."
"registrationOpenDesc": "Neue Nutzer duerfen sich auf der Registrierungsseite ein Konto erstellen.",
"tagIconUpload": "Icon hochladen",
"tagIconUploadHint": "SVG, PNG, JPG, GIF, WebP oder AVIF (max. 512 KB)"
},
"palette": {
"title": "Schnellsuche",
@@ -765,4 +787,4 @@
"niete": "Niete… immerhin ein schickes Badge für 24 Stunden."
}
}
}
}
+28 -6
View File
@@ -179,7 +179,9 @@
"uploadFailed": "Sticker could not be saved",
"deleteFailed": "Sticker could not be deleted",
"addFromUpload": "Save as sticker",
"saved": "Sticker saved"
"saved": "Sticker saved",
"tabIcons": "Icons",
"searchIcons": "Search icons…"
},
"dm": {
"title": "Messages",
@@ -425,8 +427,25 @@
"stickerSlot": "Sticker slot",
"mysteryBox": "Mystery box"
},
"iconPos": {},
"tagEffects": {},
"tags": "Tags",
"tagsDesc": "Icons and animation effects for tags. The icon can be an emoji or an Iconify name (e.g. “ph:fire-fill”) and is shown wherever the tag appears as a pill.",
"tagSearch": "Search tags…",
"tagIconPlaceholder": "Emoji, :sticker:, ph:name or /uploads path",
"iconPosLabel": "Icon position",
"iconPos": {
"before": "Before text",
"after": "After text"
},
"tagEffectLabel": "Effect",
"tagEffects": {
"none": "None",
"sparkle": "Sparkle",
"confetti": "Confetti",
"glow": "Glow",
"wiggle": "Wiggle",
"shine": "Shine"
},
"noTags": "No tags found.",
"actionFailed": "Action failed",
"perms": {
"admin": {
@@ -451,7 +470,8 @@
"delete": "Delete uploads"
},
"tag": {
"delete": "Delete tags"
"delete": "Delete tags",
"manage": "Manage tags & decorations"
},
"sticker": {
"standard": "Manage standard stickers"
@@ -469,7 +489,9 @@
"guestAccess": "Guest access",
"guestAccessDesc": "Guests may browse the site without logging in. Off = every page redirects to the login.",
"registrationOpen": "Registration open",
"registrationOpenDesc": "New users may create an account on the registration page."
"registrationOpenDesc": "New users may create an account on the registration page.",
"tagIconUpload": "Upload icon",
"tagIconUploadHint": "SVG, PNG, JPG, GIF, WebP or AVIF (max. 512 KB)"
},
"palette": {
"title": "Quick search",
@@ -765,4 +787,4 @@
"niete": "Dud… at least you get a fancy badge for 24 hours."
}
}
}
}
+22
View File
@@ -0,0 +1,22 @@
import { count, desc, eq } from 'drizzle-orm'
import { tags, uploadTags } from '../../db/schema'
// Alle Tags mit Deko-Feldern für den Admin-Tab (Verwaltung von Icon/Effekt)
export default defineEventHandler(async (event) => {
await requirePermission(event, 'tag.manage')
const rows = await useDb()
.select({
id: tags.id,
name: tags.name,
count: count(uploadTags.uploadId),
icon: tags.icon,
iconPosition: tags.iconPosition,
effect: tags.effect,
})
.from(tags)
.leftJoin(uploadTags, eq(uploadTags.tagId, tags.id))
.groupBy(tags.id, tags.name, tags.icon, tags.iconPosition, tags.effect)
.orderBy(desc(count(uploadTags.uploadId)), tags.name)
return rows
})
+41
View File
@@ -0,0 +1,41 @@
import { eq } from 'drizzle-orm'
import { z } from 'zod'
import { tags } from '../../db/schema'
import { TAG_EFFECTS, TAG_ICON_POSITIONS } from '../../../shared/utils/tags'
// Alle Felder optional: nur mitgeschickte Keys werden aktualisiert.
// Explizites null bei icon/effect entfernt die Deko.
const bodySchema = z.object({
id: z.number().int().positive(),
// Iconify-Name („ph:x") oder Emoji; leer/null = kein Icon
icon: z.string().trim().max(64).nullable().optional(),
iconPosition: z.enum(TAG_ICON_POSITIONS).optional(),
effect: z.enum(TAG_EFFECTS).nullable().optional(),
})
// Tag-Deko setzen: Icon + Position + Animationseffekt
export default defineEventHandler(async (event) => {
await requirePermission(event, 'tag.manage')
const { id, icon, iconPosition, effect } = await readValidatedBody(event, bodySchema.parse)
const db = useDb()
const [row] = await db.select().from(tags).where(eq(tags.id, id)).limit(1)
if (!row) {
throw createError({ statusCode: 404, statusMessage: 'Tag nicht gefunden' })
}
const patch: Partial<typeof row> = {}
if (icon !== undefined) patch.icon = icon || null
if (iconPosition !== undefined) patch.iconPosition = iconPosition
if (effect !== undefined) patch.effect = effect
if (Object.keys(patch).length) {
await db.update(tags).set(patch).where(eq(tags.id, id))
}
return {
id,
icon: icon === undefined ? row.icon : (icon || null),
iconPosition: iconPosition ?? row.iconPosition,
effect: effect === undefined ? row.effect : effect,
}
})
+48
View File
@@ -0,0 +1,48 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const MIMES = new Set([
'image/png',
'image/jpeg',
'image/gif',
'image/webp',
'image/avif',
'image/bmp',
])
const MIME_EXT: Record<string, string> = {
'image/png': '.png',
'image/jpeg': '.jpg',
'image/gif': '.gif',
'image/webp': '.webp',
'image/avif': '.avif',
'image/bmp': '.bmp',
}
const MAX_SIZE = 512 * 1024
// Tag-Icon-Upload: SVG oder Rastergrafik, gespeichert unter uploads/tag-icons/.
// Der icon-Wert des Tags ist dann der /uploads/-Pfad (siehe TagIcon.vue).
export default defineEventHandler(async (event) => {
await requirePermission(event, 'tag.manage')
const parts = await readMultipartFormData(event)
const file = parts?.find(p => p.name === 'file' && p.filename && p.data?.length)
if (!file) {
throw createError({ statusCode: 400, statusMessage: 'Keine Datei übergeben' })
}
const mime = (file.type ?? '').toLowerCase()
// Manche Browser schicken SVG als text/xml oder leer - Endung zählt mit
const isSvg = mime === 'image/svg+xml' || file.filename!.toLowerCase().endsWith('.svg')
if (!isSvg && !MIMES.has(mime)) {
throw createError({ statusCode: 415, statusMessage: 'Nur SVG oder Rastergrafiken (PNG, JPG, GIF, WebP, AVIF, BMP) erlaubt' })
}
if (file.data.length > MAX_SIZE) {
throw createError({ statusCode: 413, statusMessage: 'Datei zu groß (max. 512 KB)' })
}
// SVG wird nur als <img> eingebettet (kein Script-Kontext) und die
// Auslieferung setzt nosniff - kein zusaetzliches Sanitizing noetig.
const fileName = `${generateUploadId()}${isSvg ? '.svg' : MIME_EXT[mime]}`
const dir = join(uploadDir(), 'tag-icons')
await mkdir(dir, { recursive: true })
await writeFile(join(dir, fileName), file.data)
return { url: `/uploads/tag-icons/${fileName}` }
})
+28
View File
@@ -0,0 +1,28 @@
import phIcons from '@iconify-json/ph/icons.json'
import simpleIcons from '@iconify-json/simple-icons/icons.json'
const LIMIT = 80
// Namensliste einmalig aufbauen (Set-Prefix + Icon-Name)
let allIcons: string[] | null = null
function iconNames(): string[] {
if (!allIcons) {
allIcons = [
...Object.keys(phIcons.icons).map(n => `ph:${n}`),
...Object.keys(simpleIcons.icons).map(n => `simple-icons:${n}`),
]
}
return allIcons
}
// Icon-Suche über die installierten Iconify-Sets (für den Picker im
// Admin-Bereich, z. B. Tag-Icons). Ohne Query die ersten Einträge.
export default defineEventHandler(async (event) => {
await requireUser(event)
const q = (getQuery(event).q as string | undefined)?.trim().toLowerCase() ?? ''
const names = iconNames()
const icons = q
? names.filter(n => n.includes(q)).slice(0, LIMIT)
: names.slice(0, LIMIT)
return { icons }
})
+12
View File
@@ -0,0 +1,12 @@
import { isNotNull, or } from 'drizzle-orm'
import { tags } from '../../db/schema'
// Deko-Lookup für alle dekorierten Tags (Icon oder Effekt gesetzt) - die
// Menge ist klein, der Client cached sie für Pill-Darstellung an allen Stellen.
export default defineEventHandler(async () => {
const rows = await useDb()
.select({ name: tags.name, icon: tags.icon, iconPosition: tags.iconPosition, effect: tags.effect })
.from(tags)
.where(or(isNotNull(tags.icon), isNotNull(tags.effect)))
return rows
})
+8 -2
View File
@@ -6,11 +6,17 @@ export default defineEventHandler(async (event) => {
const db = useDb()
const rows = await db
.select({ name: tags.name, count: count(uploadTags.uploadId) })
.select({
name: tags.name,
count: count(uploadTags.uploadId),
icon: tags.icon,
iconPosition: tags.iconPosition,
effect: tags.effect,
})
.from(tags)
.leftJoin(uploadTags, eq(uploadTags.tagId, tags.id))
.where(q ? like(tags.name, `${q}%`) : undefined)
.groupBy(tags.id, tags.name)
.groupBy(tags.id, tags.name, tags.icon, tags.iconPosition, tags.effect)
.orderBy(desc(count(uploadTags.uploadId)), tags.name)
.limit(15)
+2 -2
View File
@@ -165,7 +165,7 @@ export default defineEventHandler(async (event) => {
.orderBy(desc(uploads.createdAt), desc(uploads.id))
.limit(1),
db
.select({ name: tags.name })
.select({ name: tags.name, icon: tags.icon, iconPosition: tags.iconPosition, effect: tags.effect })
.from(uploadTags)
.innerJoin(tags, eq(uploadTags.tagId, tags.id))
.where(eq(uploadTags.uploadId, id))
@@ -270,7 +270,7 @@ export default defineEventHandler(async (event) => {
url,
embedId,
userAvatarUrl: avatarUrl(upload.userAvatar ?? null),
tags: tagRows.map(t => t.name),
tags: tagRows,
},
comments: commentRows.map(c => ({
id: c.id,
+9 -2
View File
@@ -52,7 +52,10 @@ export default defineEventHandler(async (event) => {
.onDuplicateKeyUpdate({ set: { name: sql`name` } })
}
const tagRows = names.length
? await db.select({ id: tags.id, name: tags.name }).from(tags).where(inArray(tags.name, names))
? await db
.select({ id: tags.id, name: tags.name, icon: tags.icon, iconPosition: tags.iconPosition, effect: tags.effect })
.from(tags)
.where(inArray(tags.name, names))
: []
await db.delete(uploadTags).where(eq(uploadTags.uploadId, id))
@@ -60,5 +63,9 @@ export default defineEventHandler(async (event) => {
await db.insert(uploadTags).values(tagRows.map(t => ({ uploadId: id, tagId: t.id })))
}
return { tags: tagRows.map(t => t.name).sort() }
return {
tags: tagRows
.map(({ name, icon, iconPosition, effect }) => ({ name, icon, iconPosition, effect }))
.sort((a, b) => a.name.localeCompare(b.name)),
}
})
@@ -0,0 +1,3 @@
ALTER TABLE `tags` ADD `icon` varchar(64);--> statement-breakpoint
ALTER TABLE `tags` ADD `icon_position` varchar(8) DEFAULT 'before' NOT NULL;--> statement-breakpoint
ALTER TABLE `tags` ADD `effect` varchar(32);
File diff suppressed because it is too large Load Diff
+7
View File
@@ -379,6 +379,13 @@
"when": 1792865727449,
"tag": "0053_chemical_kang",
"breakpoints": true
},
{
"idx": 54,
"version": "5",
"when": 1792868467205,
"tag": "0054_boring_kat_farrell",
"breakpoints": true
}
]
}
+5
View File
@@ -189,6 +189,11 @@ export const rolePermissions = mysqlTable('role_permissions', {
export const tags = mysqlTable('tags', {
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
name: varchar('name', { length: 64 }).notNull().unique(),
// Admin-vergebene Deko: Icon (Iconify-Name oder Emoji) vor/nach dem
// Tag-Text plus optionaler Animationseffekt auf der Pill
icon: varchar('icon', { length: 64 }),
iconPosition: varchar('icon_position', { length: 8 }).notNull().default('before'),
effect: varchar('effect', { length: 32 }),
createdAt: timestamp('created_at').defaultNow().notNull(),
})
+2 -1
View File
@@ -7,6 +7,7 @@ export const PERMISSIONS = [
{ key: 'thread.moderate', label: 'Thread-Einstellungen verwalten' },
{ key: 'upload.delete', label: 'Uploads löschen' },
{ key: 'tag.delete', label: 'Tags löschen' },
{ key: 'tag.manage', label: 'Tags & Deko verwalten' },
{ key: 'sticker.standard', label: 'Standard-Sticker verwalten' },
{ key: 'meme.standard', label: 'Standard-Meme-Vorlagen verwalten' },
{ key: 'hall.standard', label: 'Standard-Hallen verwalten' },
@@ -39,7 +40,7 @@ export async function seedPermissions(db: Queryable) {
await db.query(
`INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r
JOIN permissions p ON p.\`key\` IN ('comment.delete', 'thread.moderate', 'upload.delete', 'tag.delete', 'sticker.standard', 'meme.standard', 'hall.standard')
JOIN permissions p ON p.\`key\` IN ('comment.delete', 'thread.moderate', 'upload.delete', 'tag.delete', 'tag.manage', 'sticker.standard', 'meme.standard', 'hall.standard')
WHERE r.name = 'moderator'`,
)
}
+22
View File
@@ -21,3 +21,25 @@ export function normalizeTagList(values: string[], maxCount = MAX_TAGS_PER_UPLOA
}
return [...seen].slice(0, maxCount)
}
// Tag-Deko (Admin konfigurierbar): Icon links/rechts vom Tag-Text plus
// Animationseffekt auf der Pill. Icon ist ein Iconify-Name („ph:x") oder
// ein Emoji (wird als Text gerendert).
export const TAG_ICON_POSITIONS = ['before', 'after'] as const
export type TagIconPosition = typeof TAG_ICON_POSITIONS[number]
export const TAG_EFFECTS = ['sparkle', 'confetti', 'glow', 'wiggle', 'shine'] as const
export type TagEffectName = typeof TAG_EFFECTS[number]
export interface TagDecoration {
icon: string | null
iconPosition: TagIconPosition
effect: TagEffectName | null
}
export type DecoratedTag = TagDecoration & { name: string }
// CSS-Klasse für Effekte, die auf der Pill selbst laufen; Overlay-Effekte
// (sparkle/confetti/shine) rendert TagEffect.vue als Kind der Pill.
export function tagEffectClass(effect: string | null | undefined) {
return effect === 'glow' ? 'tag-fx-glow' : effect === 'wiggle' ? 'tag-fx-wiggle' : ''
}