Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1295 lines
49 KiB
Vue
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>
|