Files
BRETT/app/pages/admin.vue
T
FliesentischKingandDevin 013e4c8add 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>
2026-10-25 00:34:15 +02:00

1295 lines
49 KiB
Vue

<script setup lang="ts">
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'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverAnchor, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { NumberField, NumberFieldContent, NumberFieldDecrement, NumberFieldIncrement, NumberFieldInput } from '@/components/ui/number-field'
import { Spinner } from '@/components/ui/spinner'
import { Switch } from '@/components/ui/switch'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
definePageMeta({ middleware: 'admin' })
interface Role {
id: number
name: string
memberCount: number
permissions: string[]
}
interface AdminUser {
id: number
name: string
roles: { id: number, name: string }[]
}
const { user, can } = useAuth()
const { invalidate: invalidateUserSummary } = useUserSummaries()
const { t } = useI18n()
useHead({ title: computed(() => t('nav.admin')) })
const { data: rolesList, refresh: refreshRoles } = await useFetch<Role[]>('/api/admin/roles', { default: () => [] })
const { data: usersList, refresh: refreshUsers } = await useFetch<AdminUser[]>('/api/admin/users', { default: () => [] })
const newRoleName = ref('')
const motdDraft = ref('')
const motdSaving = ref(false)
// Site-Flags (Guest-Access, Registrierung) - sofort speichern beim Umschalten
const { site, ensureLoaded: ensureSiteLoaded, applyRemote: applySiteRemote } = useSiteSettings()
const siteFlagSaving = ref<string | null>(null)
const siteFlagError = ref('')
if (can('site.manage')) await ensureSiteLoaded()
async function setSiteFlag(key: 'guestAccess' | 'registrationOpen', value: boolean) {
if (siteFlagSaving.value) return
siteFlagSaving.value = key
siteFlagError.value = ''
try {
const res = await $fetch<{ site: typeof site.value }>('/api/admin/site', {
method: 'PUT',
body: { [key]: value },
})
applySiteRemote(res.site)
}
catch (err) {
const e = err as { data?: { statusMessage?: string, message?: string } }
siteFlagError.value = e.data?.statusMessage ?? e.data?.message ?? t('common.error')
}
finally {
siteFlagSaving.value = null
}
}
const { data: motdData } = await useFetch<{ message: string }>('/api/motd', { default: () => ({ message: '' }) })
motdDraft.value = motdData.value.message
// Sticker-Verwaltung: alle Sticker auflisten, löschen nur hier (sticker.standard)
const { stickers: allStickers, refresh: refreshStickers } = useStickers()
const standardStickers = computed(() => allStickers.value.filter(s => s.isStandard))
const userStickers = computed(() => allStickers.value.filter(s => !s.isStandard))
const stickerDeleteConfirm = ref<number | null>(null)
const deletingSticker = ref<number | null>(null)
async function deleteSticker(stickerId: number) {
deletingSticker.value = stickerId
try {
await $fetch(`/api/stickers/${stickerId}`, { method: 'DELETE' })
stickerDeleteConfirm.value = null
await refreshStickers()
}
catch {
errorMessage.value = t('admin.actionFailed')
}
finally {
deletingSticker.value = null
}
}
// Meme-Vorlagen-Verwaltung (meme.standard)
const { templates: allMemeTemplates, refresh: refreshMemeTemplates } = useMemeTemplates()
const standardMemes = computed(() => allMemeTemplates.value.filter(m => m.isStandard))
const userMemes = computed(() => allMemeTemplates.value.filter(m => !m.isStandard))
const memeDeleteConfirm = ref<number | null>(null)
const deletingMeme = ref<number | null>(null)
const togglingMeme = ref<number | null>(null)
async function toggleAdminMemeStandard(tplId: number, nextStandard: boolean) {
togglingMeme.value = tplId
try {
await $fetch(`/api/memes/${tplId}`, { method: 'PATCH', body: { isStandard: nextStandard } })
await refreshMemeTemplates()
}
catch {
errorMessage.value = t('admin.actionFailed')
}
finally {
togglingMeme.value = null
}
}
async function deleteMemeTemplateAdmin(tplId: number) {
deletingMeme.value = tplId
try {
await $fetch(`/api/memes/${tplId}`, { method: 'DELETE' })
memeDeleteConfirm.value = null
await refreshMemeTemplates()
}
catch {
errorMessage.value = t('admin.actionFailed')
}
finally {
deletingMeme.value = null
}
}
const errorMessage = ref('')
const selectedRole = ref<Record<number, number | undefined>>({})
const editedPermissions = ref<Record<number, string[]>>({})
const savingRole = ref<number | null>(null)
const roleDeleteConfirm = ref<number | null>(null)
const deletingRoleId = ref<number | null>(null)
const roleRemoveConfirm = ref<string | null>(null)
const removingRoleKey = ref<string | null>(null)
const userColumns = computed<ColumnDef<DataTableFeatures, AdminUser>[]>(() => [
{ accessorKey: 'name', header: t('common.name') },
{ id: 'roles', header: t('admin.rolesCol'), enableSorting: false, enableGlobalFilter: false },
{ id: 'actions', header: t('admin.actionCol'), enableSorting: false, enableGlobalFilter: false },
])
function userSearch(u: AdminUser, query: string) {
return u.name.toLowerCase().includes(query.toLowerCase())
}
function errText(err: unknown) {
const e = err as { data?: { statusMessage?: string, message?: string } }
return e.data?.statusMessage ?? e.data?.message ?? t('admin.actionFailed')
}
async function refreshAll() {
await Promise.all([refreshRoles(), refreshUsers()])
}
async function createRole() {
if (!newRoleName.value.trim()) return
errorMessage.value = ''
try {
await $fetch('/api/admin/roles', { method: 'POST', body: { name: newRoleName.value.trim() } })
newRoleName.value = ''
await refreshRoles()
}
catch (err) {
errorMessage.value = errText(err)
}
}
async function deleteRole(roleId: number) {
errorMessage.value = ''
deletingRoleId.value = roleId
try {
await $fetch(`/api/admin/roles/${roleId}`, { method: 'DELETE' })
roleDeleteConfirm.value = null
await refreshAll()
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
deletingRoleId.value = null
}
}
async function addRole(userId: number) {
const roleId = selectedRole.value[userId]
if (!roleId) return
errorMessage.value = ''
try {
await $fetch(`/api/admin/users/${userId}/roles`, { method: 'POST', body: { roleId } })
selectedRole.value[userId] = undefined
invalidateUserSummary(userId)
await refreshAll()
}
catch (err) {
errorMessage.value = errText(err)
}
}
async function removeRole(userId: number, roleId: number) {
errorMessage.value = ''
removingRoleKey.value = `${userId}-${roleId}`
try {
await $fetch(`/api/admin/users/${userId}/roles/${roleId}`, { method: 'DELETE' })
roleRemoveConfirm.value = null
invalidateUserSummary(userId)
await refreshAll()
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
removingRoleKey.value = null
}
}
// Punkte verschenken (Testen/Moderation): negative Buchung in point_purchases
const pointsGiftUser = ref<number | null>(null)
const pointsGiftAmount = ref<Record<number, number | undefined>>({})
const pointsGifting = ref<number | null>(null)
async function giftPoints(userId: number) {
const amount = pointsGiftAmount.value[userId]
if (!amount || !Number.isInteger(amount) || amount < 1) return
errorMessage.value = ''
pointsGifting.value = userId
try {
await $fetch(`/api/admin/users/${userId}/points`, { method: 'POST', body: { amount } })
pointsGiftUser.value = null
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
pointsGifting.value = null
}
}
function permissionsDraft(role: Role) {
return editedPermissions.value[role.id] ?? role.permissions
}
function isDirty(role: Role) {
const draft = editedPermissions.value[role.id]
return !!draft && (draft.length !== role.permissions.length || draft.some(k => !role.permissions.includes(k)))
}
function togglePermission(role: Role, key: string, checked: boolean | 'indeterminate') {
const current = permissionsDraft(role)
editedPermissions.value[role.id] = checked === true
? [...new Set([...current, key])]
: current.filter(k => k !== key)
}
async function savePermissions(role: Role) {
const draft = editedPermissions.value[role.id]
if (!draft) return
errorMessage.value = ''
savingRole.value = role.id
try {
await $fetch(`/api/admin/roles/${role.id}/permissions`, { method: 'PUT', body: { permissions: draft } })
delete editedPermissions.value[role.id]
await refreshRoles()
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
savingRole.value = null
}
}
function resetPermissions(role: Role) {
delete editedPermissions.value[role.id]
}
async function saveMotd() {
motdSaving.value = true
errorMessage.value = ''
try {
const res = await $fetch<{ message: string }>('/api/admin/motd', {
method: 'PUT',
body: { message: motdDraft.value },
})
motdDraft.value = res.message
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
motdSaving.value = false
}
}
// Limits: leeres Zahlenfeld = Override löschen → Default
const { data: limitsData, refresh: refreshLimits } = await useFetch<{ limits: AppLimits, overridden: LimitKey[] }>(
'/api/admin/limits',
{ default: () => ({ limits: { ...LIMIT_DEFAULTS }, overridden: [] }), immediate: can('site.manage') },
)
const limitDrafts = ref<Record<LimitKey, number | undefined>>({} as Record<LimitKey, number | undefined>)
const overriddenSet = ref<Set<LimitKey>>(new Set())
const limitsSaving = ref(false)
watch(limitsData, (d) => {
if (!d) return
limitDrafts.value = { ...d.limits }
overriddenSet.value = new Set(d.overridden)
}, { immediate: true })
const limitsDirty = computed(() =>
LIMIT_KEYS.some((key) => {
const draft = limitDrafts.value[key]
if (draft === undefined) return overriddenSet.value.has(key)
return draft !== limitsData.value.limits[key]
}),
)
async function saveLimits() {
limitsSaving.value = true
errorMessage.value = ''
try {
const body: Record<string, number | null> = {}
const nextLimits = { ...limitsData.value.limits }
let thumbnailLimitsTouched = false
for (const key of LIMIT_KEYS) {
const draft = limitDrafts.value[key]
const isThumbnailLimit = key === 'thumbnailMediumPoints' || key === 'thumbnailLargePoints'
if (draft === undefined) {
nextLimits[key] = LIMIT_DEFAULTS[key]
if (overriddenSet.value.has(key)) body[key] = null
thumbnailLimitsTouched ||= isThumbnailLimit
continue
}
const n = draft
if (!Number.isInteger(n) || n <= 0) {
errorMessage.value = t('admin.limitInvalid', { field: t(`admin.limitFields.${key}`) })
return
}
nextLimits[key] = n
thumbnailLimitsTouched ||= isThumbnailLimit && n !== limitsData.value.limits[key]
if (n !== limitsData.value.limits[key]) body[key] = n
}
if (thumbnailLimitsTouched && nextLimits.thumbnailMediumPoints >= nextLimits.thumbnailLargePoints) {
errorMessage.value = t('admin.limitOrder')
return
}
if (Object.keys(body).length) {
await $fetch<{ limits: AppLimits }>('/api/admin/limits', { method: 'PUT', body })
}
await refreshLimits()
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
limitsSaving.value = false
}
}
async function resetLimit(key: LimitKey) {
errorMessage.value = ''
try {
await $fetch('/api/admin/limits', { method: 'PUT', body: { [key]: null } })
await refreshLimits()
}
catch (err) {
errorMessage.value = errText(err)
}
}
// Shop-Preise: leeres Zahlenfeld = Override löschen → Default
const { data: pricesData, refresh: refreshPrices } = await useFetch<{ prices: ShopPrices, overridden: ShopPriceKey[] }>(
'/api/admin/prices',
{ default: () => ({ prices: { ...SHOP_PRICE_DEFAULTS }, overridden: [] }), immediate: can('site.manage') },
)
const priceDrafts = ref<Record<ShopPriceKey, number | undefined>>({} as Record<ShopPriceKey, number | undefined>)
const overriddenPriceSet = ref<Set<ShopPriceKey>>(new Set())
const pricesSaving = ref(false)
watch(pricesData, (d) => {
if (!d) return
priceDrafts.value = { ...d.prices }
overriddenPriceSet.value = new Set(d.overridden)
}, { immediate: true })
const pricesDirty = computed(() =>
SHOP_PRICE_KEYS.some((key) => {
const draft = priceDrafts.value[key]
if (draft === undefined) return overriddenPriceSet.value.has(key)
return draft !== pricesData.value.prices[key]
}),
)
async function savePrices() {
pricesSaving.value = true
errorMessage.value = ''
try {
const body: Record<string, number | null> = {}
for (const key of SHOP_PRICE_KEYS) {
const draft = priceDrafts.value[key]
if (draft === undefined) {
if (overriddenPriceSet.value.has(key)) body[key] = null
continue
}
const n = draft
if (!Number.isInteger(n) || n <= 0) {
errorMessage.value = t('admin.limitInvalid', { field: t(`admin.priceFields.${key}`) })
return
}
if (n !== pricesData.value.prices[key]) body[key] = n
}
if (Object.keys(body).length) {
await $fetch<{ prices: ShopPrices }>('/api/admin/prices', { method: 'PUT', body })
}
await refreshPrices()
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
pricesSaving.value = false
}
}
async function resetPrice(key: ShopPriceKey) {
errorMessage.value = ''
try {
await $fetch('/api/admin/prices', { method: 'PUT', body: { [key]: null } })
await refreshPrices()
}
catch (err) {
errorMessage.value = errText(err)
}
}
// 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>
<div class="mx-auto w-full max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
<Tabs default-value="roles" class="gap-4">
<TabsList>
<TabsTrigger value="roles" class="cursor-pointer">
<Icon name="ph:shield-fill" />
{{ $t('admin.roles') }}
</TabsTrigger>
<TabsTrigger v-if="can('users.manage')" value="users" class="cursor-pointer">
<Icon name="ph:users-fill" />
{{ $t('admin.users') }}
</TabsTrigger>
<TabsTrigger v-if="can('sticker.standard')" value="stickers" class="cursor-pointer">
<Icon name="ph:sticker-fill" />
{{ $t('admin.stickers') }}
</TabsTrigger>
<TabsTrigger v-if="can('meme.standard')" value="memes" class="cursor-pointer">
<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') }}
</TabsTrigger>
<TabsTrigger v-if="can('site.manage')" value="limits" class="cursor-pointer">
<Icon name="ph:sliders-horizontal-fill" />
{{ $t('admin.limits') }}
</TabsTrigger>
<TabsTrigger v-if="can('site.manage')" value="prices" class="cursor-pointer">
<Icon name="ph:coins-fill" />
{{ $t('admin.prices') }}
</TabsTrigger>
</TabsList>
<TabsContent value="roles">
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.roles') }}</CardTitle>
<CardDescription>{{ $t('admin.rolesDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<ul class="space-y-2">
<li v-for="role in rolesList" :key="role.id" class="rounded-md bg-muted/40 px-3 py-2">
<div class="flex items-center gap-3">
<span class="font-medium">{{ role.name }}</span>
<span class="text-sm text-muted-foreground">{{ $t('admin.memberCount', { n: role.memberCount }) }}</span>
<Popover
v-if="!SYSTEM_ROLES.includes(role.name as 'admin' | 'moderator') && can('roles.manage')"
:open="roleDeleteConfirm === role.id"
@update:open="v => { if (!deletingRoleId) roleDeleteConfirm = v ? role.id : null }"
>
<PopoverAnchor as-child>
<Button
variant="ghost"
size="icon-sm"
class="ml-auto text-muted-foreground"
@click="roleDeleteConfirm = role.id"
>
<Icon name="ph:trash-fill" class="size-4" />
<span class="sr-only">{{ $t('admin.deleteRole') }}</span>
</Button>
</PopoverAnchor>
<PopoverContent side="bottom" align="end" class="w-56">
<p class="text-sm font-medium">
{{ $t('admin.deleteRoleTitle') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('admin.deleteRoleDesc') }}
</p>
<div class="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" :disabled="deletingRoleId === role.id" @click="roleDeleteConfirm = null">
{{ $t('common.cancel') }}
</Button>
<Button variant="destructive" size="sm" :disabled="deletingRoleId === role.id" @click="deleteRole(role.id)">
<Spinner v-if="deletingRoleId === role.id" />
{{ deletingRoleId === role.id ? $t('common.deleting') : $t('common.delete') }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
<div class="mt-2 flex flex-col gap-1.5">
<label
v-for="perm in PERMISSIONS"
:key="perm.key"
class="flex items-center gap-1.5 text-sm"
>
<Checkbox
:model-value="role.name === 'admin' || permissionsDraft(role).includes(perm.key)"
:disabled="role.name === 'admin' || !can('roles.manage') || savingRole === role.id"
@update:model-value="togglePermission(role, perm.key, $event)"
/>
{{ $t(`admin.perms.${perm.key}`) }}
</label>
</div>
<div v-if="isDirty(role)" class="mt-2 flex items-center gap-2">
<Button
size="sm"
:disabled="savingRole === role.id"
@click="savePermissions(role)"
>
<Spinner v-if="savingRole === role.id" />
{{ savingRole === role.id ? $t('common.saving') : $t('common.save') }}
</Button>
<Button
size="sm"
variant="outline"
:disabled="savingRole === role.id"
@click="resetPermissions(role)"
>
{{ $t('common.reset') }}
</Button>
</div>
</li>
</ul>
<form v-if="can('roles.manage')" class="flex gap-2" @submit.prevent="createRole">
<Input v-model="newRoleName" :placeholder="$t('admin.newRole')" :aria-label="$t('admin.newRole')" maxlength="50" />
<Button type="submit" :disabled="!newRoleName.trim()">
{{ $t('admin.create') }}
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent v-if="can('users.manage')" value="users">
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.users') }}</CardTitle>
<CardDescription>{{ $t('admin.usersDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<DataTable
:columns="userColumns"
:data="usersList"
:search-placeholder="$t('admin.searchUsers')"
:search-fn="userSearch"
>
<template #cell-name="{ row }">
<span class="font-medium">{{ row.original.name }}</span>
</template>
<template #cell-roles="{ row }">
<div class="flex flex-wrap items-center gap-1.5">
<Badge v-for="r in row.original.roles" :key="r.id" variant="secondary" class="gap-1">
{{ r.name }}
<Popover
v-if="!(r.name === 'admin' && row.original.id === user?.id)"
:open="roleRemoveConfirm === `${row.original.id}-${r.id}`"
@update:open="v => { if (!removingRoleKey) roleRemoveConfirm = v ? `${row.original.id}-${r.id}` : null }"
>
<PopoverAnchor as-child>
<button
type="button"
class="opacity-60 hover:opacity-100"
@click="roleRemoveConfirm = `${row.original.id}-${r.id}`"
>
<Icon name="ph:x-bold" class="size-3" />
<span class="sr-only">{{ $t('admin.removeRole', { name: r.name }) }}</span>
</button>
</PopoverAnchor>
<PopoverContent side="bottom" align="end" class="w-56">
<p class="text-sm font-medium">
{{ $t('admin.removeRoleTitle') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('admin.removeRoleDesc', { user: row.original.name, role: r.name }) }}
</p>
<div class="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" :disabled="removingRoleKey === `${row.original.id}-${r.id}`" @click="roleRemoveConfirm = null">
{{ $t('common.cancel') }}
</Button>
<Button variant="destructive" size="sm" :disabled="removingRoleKey === `${row.original.id}-${r.id}`" @click="removeRole(row.original.id, r.id)">
<Spinner v-if="removingRoleKey === `${row.original.id}-${r.id}`" />
{{ removingRoleKey === `${row.original.id}-${r.id}` ? $t('common.deleting') : $t('common.delete') }}
</Button>
</div>
</PopoverContent>
</Popover>
</Badge>
<span v-if="!row.original.roles.length" class="text-xs text-muted-foreground">{{ $t('admin.noRoles') }}</span>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center gap-2">
<Select
:model-value="selectedRole[row.original.id]"
:disabled="!rolesList.some(r => !row.original.roles.some(ur => ur.id === r.id))"
@update:model-value="selectedRole[row.original.id] = $event === undefined ? undefined : Number($event)"
>
<SelectTrigger size="sm" class="w-40" :aria-label="$t('admin.chooseRole')">
<SelectValue :placeholder="$t('admin.chooseRole')" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectItem
v-for="role in rolesList.filter(r => !row.original.roles.some(ur => ur.id === r.id))"
:key="role.id"
:value="role.id"
>
{{ role.name }}
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Button
size="sm"
variant="outline"
:disabled="!selectedRole[row.original.id]"
@click="addRole(row.original.id)"
>
{{ $t('admin.assign') }}
</Button>
<Popover
:open="pointsGiftUser === row.original.id"
@update:open="v => { if (pointsGifting !== row.original.id) pointsGiftUser = v ? row.original.id : null }"
>
<PopoverAnchor as-child>
<span class="inline-flex">
<Tooltip>
<TooltipTrigger as-child>
<Button
size="sm"
variant="outline"
:aria-label="$t('admin.giftPoints')"
@click="pointsGiftUser = row.original.id"
>
<Icon name="ph:coins-fill" />
</Button>
</TooltipTrigger>
<TooltipContent>
{{ $t('admin.giftPoints') }}
</TooltipContent>
</Tooltip>
</span>
</PopoverAnchor>
<PopoverContent side="bottom" align="end" class="w-56">
<p class="text-sm font-medium">
{{ $t('admin.giftPointsTitle') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('admin.giftPointsDesc', { user: row.original.name }) }}
</p>
<div class="mt-3 flex gap-2">
<NumberField
v-model="pointsGiftAmount[row.original.id]"
:min="1"
:max="100000"
class="min-w-0 flex-1"
>
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput :aria-label="$t('admin.pointsAmount')" />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
<Button
size="sm"
:disabled="pointsGifting === row.original.id || !pointsGiftAmount[row.original.id] || (pointsGiftAmount[row.original.id] ?? 0) < 1"
@click="giftPoints(row.original.id)"
>
<Spinner v-if="pointsGifting === row.original.id" />
{{ $t('admin.gift') }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</template>
</DataTable>
</CardContent>
</Card>
</TabsContent>
<TabsContent v-if="can('sticker.standard')" value="stickers">
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.stickers') }}</CardTitle>
<CardDescription>{{ $t('admin.stickersDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div
v-for="group in [
{ key: 'standard', title: $t('admin.stickersStandardSection'), empty: $t('admin.stickersStandardEmpty'), list: standardStickers },
{ key: 'user', title: $t('admin.stickersUserSection'), empty: $t('admin.stickersUserEmpty'), list: userStickers },
]"
:key="group.key"
class="space-y-3"
>
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold">{{ group.title }}</h3>
<Badge variant="secondary" class="px-1.5 py-0 text-xs tabular-nums">{{ group.list.length }}</Badge>
</div>
<ul v-if="group.list.length" class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
<li
v-for="e in group.list"
:key="e.id"
class="flex flex-col gap-2 rounded-md bg-muted/40 p-2"
>
<StickerMedia :sticker="e" variant="card" />
<div class="flex items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-medium">:{{ e.name }}:</span>
<span class="truncate text-xs text-muted-foreground">{{ e.userName ?? '-' }}</span>
</div>
<Popover
:open="stickerDeleteConfirm === e.id"
@update:open="v => { if (!deletingSticker) stickerDeleteConfirm = v ? e.id : null }"
>
<PopoverAnchor as-child>
<Button
variant="ghost"
size="icon-sm"
class="shrink-0 cursor-pointer text-muted-foreground hover:text-destructive"
:aria-label="$t('stickers.delete')"
@click="stickerDeleteConfirm = e.id"
>
<Icon name="ph:trash-fill" class="size-4" />
</Button>
</PopoverAnchor>
<PopoverContent side="bottom" align="end" class="w-56">
<p class="text-sm font-medium">
{{ $t('stickers.deleteTitle') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('stickers.deleteDesc', { name: e.name }) }}
</p>
<div class="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" :disabled="deletingSticker === e.id" @click="stickerDeleteConfirm = null">
{{ $t('common.cancel') }}
</Button>
<Button variant="destructive" size="sm" :disabled="deletingSticker === e.id" @click="deleteSticker(e.id)">
<Spinner v-if="deletingSticker === e.id" />
{{ deletingSticker === e.id ? $t('common.deleting') : $t('common.delete') }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</li>
</ul>
<p v-else class="text-sm text-muted-foreground">
{{ group.empty }}
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent v-if="can('meme.standard')" value="memes">
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.memes') }}</CardTitle>
<CardDescription>{{ $t('admin.memesDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-6">
<div
v-for="group in [
{ key: 'standard', title: $t('admin.memesStandardSection'), empty: $t('admin.memesStandardEmpty'), list: standardMemes },
{ key: 'user', title: $t('admin.memesUserSection'), empty: $t('admin.memesUserEmpty'), list: userMemes },
]"
:key="group.key"
class="space-y-3"
>
<div class="flex items-center gap-2">
<h3 class="text-sm font-semibold">{{ group.title }}</h3>
<Badge variant="secondary" class="px-1.5 py-0 text-xs tabular-nums">{{ group.list.length }}</Badge>
</div>
<ul v-if="group.list.length" class="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
<li
v-for="m in group.list"
:key="m.id"
class="flex flex-col gap-2 rounded-md bg-muted/40 p-2"
>
<img :src="m.url" :alt="m.name" class="aspect-square w-full rounded-md bg-muted/40 object-contain">
<div class="flex items-start justify-between gap-1.5">
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-medium">{{ m.name }}</span>
<span class="truncate text-xs text-muted-foreground">{{ m.userName ?? '-' }}</span>
<div class="mt-1 flex flex-wrap gap-1">
<Badge v-if="m.isStandard" variant="default" class="px-1.5 py-0 text-xs">
{{ $t('memes.badgeStandard') }}
</Badge>
<Badge v-else-if="m.isPublic" variant="secondary" class="px-1.5 py-0 text-xs">
{{ $t('memes.visibilityPublic') }}
</Badge>
<Badge v-else variant="outline" class="px-1.5 py-0 text-xs">
{{ $t('memes.badgePrivate') }}
</Badge>
</div>
</div>
<div class="flex shrink-0 items-center">
<Tooltip>
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon-sm"
class="shrink-0 cursor-pointer"
:class="m.isStandard ? 'text-primary' : 'text-muted-foreground'"
:disabled="togglingMeme === m.id"
:aria-label="m.isStandard ? $t('memes.unmarkStandard') : $t('memes.markStandard')"
@click="toggleAdminMemeStandard(m.id, !m.isStandard)"
>
<Icon name="ph:star-fill" class="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
{{ m.isStandard ? $t('memes.unmarkStandard') : $t('memes.markStandard') }}
</TooltipContent>
</Tooltip>
<Popover
:open="memeDeleteConfirm === m.id"
@update:open="v => { if (!deletingMeme) memeDeleteConfirm = v ? m.id : null }"
>
<PopoverAnchor as-child>
<Button
variant="ghost"
size="icon-sm"
class="shrink-0 cursor-pointer text-muted-foreground hover:text-destructive"
:aria-label="$t('common.delete')"
@click="memeDeleteConfirm = m.id"
>
<Icon name="ph:trash-fill" class="size-4" />
</Button>
</PopoverAnchor>
<PopoverContent side="bottom" align="end" class="w-56">
<p class="text-sm font-medium">
{{ $t('memes.deleteTitle') }}
</p>
<p class="mt-1 text-xs text-muted-foreground">
{{ $t('memes.deleteDesc', { name: m.name }) }}
</p>
<div class="mt-3 flex justify-end gap-2">
<Button variant="ghost" size="sm" :disabled="deletingMeme === m.id" @click="memeDeleteConfirm = null">
{{ $t('common.cancel') }}
</Button>
<Button variant="destructive" size="sm" :disabled="deletingMeme === m.id" @click="deleteMemeTemplateAdmin(m.id)">
<Spinner v-if="deletingMeme === m.id" />
{{ deletingMeme === m.id ? $t('common.deleting') : $t('common.delete') }}
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</li>
</ul>
<p v-else class="text-sm text-muted-foreground">
{{ group.empty }}
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent v-if="can('site.manage')" value="site">
<div class="space-y-4">
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.visibility') }}</CardTitle>
<CardDescription>{{ $t('admin.visibilityDesc') }}</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="flex items-start justify-between gap-4">
<div class="space-y-0.5">
<Label for="site-guest-access" class="text-sm font-medium">
{{ $t('admin.guestAccess') }}
</Label>
<p class="text-sm text-muted-foreground">
{{ $t('admin.guestAccessDesc') }}
</p>
</div>
<Switch
id="site-guest-access"
:model-value="site.guestAccess"
:disabled="siteFlagSaving === 'guestAccess'"
class="mt-0.5 shrink-0 cursor-pointer"
@update:model-value="setSiteFlag('guestAccess', $event)"
/>
</div>
<div class="flex items-start justify-between gap-4">
<div class="space-y-0.5">
<Label for="site-registration-open" class="text-sm font-medium">
{{ $t('admin.registrationOpen') }}
</Label>
<p class="text-sm text-muted-foreground">
{{ $t('admin.registrationOpenDesc') }}
</p>
</div>
<Switch
id="site-registration-open"
:model-value="site.registrationOpen"
:disabled="siteFlagSaving === 'registrationOpen'"
class="mt-0.5 shrink-0 cursor-pointer"
@update:model-value="setSiteFlag('registrationOpen', $event)"
/>
</div>
<p v-if="siteFlagError" role="alert" class="text-sm text-destructive">
{{ siteFlagError }}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.motd') }}</CardTitle>
<CardDescription>{{ $t('admin.motdDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<form class="flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="saveMotd">
<Textarea
v-model="motdDraft"
:placeholder="$t('admin.motdPlaceholder')"
:aria-label="$t('admin.motd')"
maxlength="500"
class="min-h-16"
/>
<Button type="submit" class="shrink-0" :disabled="motdSaving">
<Spinner v-if="motdSaving" />
{{ motdSaving ? $t('common.saving') : $t('common.save') }}
</Button>
</form>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent v-if="can('site.manage')" value="limits">
<Card>
<CardHeader>
<CardTitle>{{ $t('admin.limits') }}</CardTitle>
<CardDescription>{{ $t('admin.limitsDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-3" @submit.prevent="saveLimits">
<div
v-for="key in LIMIT_KEYS"
:key="key"
class="flex flex-wrap items-center gap-2 sm:gap-3"
>
<label :for="`limit-${key}`" class="w-56 shrink-0 text-sm font-medium">
{{ $t(`admin.limitFields.${key}`) }}
</label>
<NumberField
v-model="limitDrafts[key]"
:min="1"
:max="LIMIT_MAX[key]"
:step="1"
class="w-36"
>
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput :id="`limit-${key}`" :placeholder="String(LIMIT_DEFAULTS[key])" :aria-label="$t(`admin.limitFields.${key}`)" />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
<span class="text-xs text-muted-foreground">
{{ $t('admin.limitDefault', { n: LIMIT_DEFAULTS[key] }) }}
</span>
<Button
v-if="overriddenSet.has(key)"
type="button"
variant="ghost"
size="sm"
@click="resetLimit(key)"
>
{{ $t('admin.limitReset') }}
</Button>
</div>
<div class="pt-2">
<Button type="submit" :disabled="limitsSaving || !limitsDirty">
<Spinner v-if="limitsSaving" />
{{ limitsSaving ? $t('common.saving') : $t('common.save') }}
</Button>
</div>
</form>
</CardContent>
</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>
<CardTitle>{{ $t('admin.prices') }}</CardTitle>
<CardDescription>{{ $t('admin.pricesDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<form class="space-y-3" @submit.prevent="savePrices">
<div
v-for="key in SHOP_PRICE_KEYS"
:key="key"
class="flex flex-wrap items-center gap-2 sm:gap-3"
>
<label :for="`price-${key}`" class="w-56 shrink-0 text-sm font-medium">
{{ $t(`admin.priceFields.${key}`) }}
</label>
<NumberField
v-model="priceDrafts[key]"
:min="1"
:max="SHOP_PRICE_MAX"
:step="1"
class="w-36"
>
<NumberFieldContent>
<NumberFieldDecrement />
<NumberFieldInput :id="`price-${key}`" :placeholder="String(SHOP_PRICE_DEFAULTS[key])" :aria-label="$t(`admin.priceFields.${key}`)" />
<NumberFieldIncrement />
</NumberFieldContent>
</NumberField>
<span class="text-xs text-muted-foreground">
{{ $t('admin.limitDefault', { n: SHOP_PRICE_DEFAULTS[key] }) }}
</span>
<Button
v-if="overriddenPriceSet.has(key)"
type="button"
variant="ghost"
size="sm"
@click="resetPrice(key)"
>
{{ $t('admin.limitReset') }}
</Button>
</div>
<div class="pt-2">
<Button type="submit" :disabled="pricesSaving || !pricesDirty">
<Spinner v-if="pricesSaving" />
{{ pricesSaving ? $t('common.saving') : $t('common.save') }}
</Button>
</div>
</form>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<p v-if="errorMessage" role="alert" class="mt-4 text-sm text-destructive">
{{ errorMessage }}
</p>
</div>
</template>