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