Files
BRETT/app/pages/admin.vue
T
FliesentischKingandDevin a421007af6 Add admin-configurable guest visibility lock and registration toggle
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-17 22:52:45 +02:00

851 lines
32 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 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 } from '@/components/ui/popover'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Slider } from '@/components/ui/slider'
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'
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 { 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 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 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
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
await refreshAll()
}
catch (err) {
errorMessage.value = errText(err)
}
finally {
removingRoleKey.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: Drafts sind Strings (Input), leeres Feld = 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, string>>({} as Record<LimitKey, string>)
const overriddenSet = ref<Set<LimitKey>>(new Set())
const limitsSaving = ref(false)
watch(limitsData, (d) => {
if (!d) return
limitDrafts.value = Object.fromEntries(LIMIT_KEYS.map(k => [k, String(d.limits[k])])) as Record<LimitKey, string>
overriddenSet.value = new Set(d.overridden)
}, { immediate: true })
// Slider-Maxima sind UI-Komfort (kurze, gut einstellbare Skala) - der Server
// akzeptiert bis LIMIT_MAX, über das Zahlenfeld erreichbar
const limitSliderMax: Record<LimitKey, number> = {
uploadMaxSizeMb: 1024,
stickerMaxSizeMb: 64,
avatarMaxSizeMb: 20,
commentMaxLength: 10_000,
dmMaxLength: 10_000,
commentMaxAttachments: 20,
dmMaxAttachments: 20,
maxTagsPerUpload: 50,
maxTagLength: 128,
voiceMaxSeconds: 900,
memeSizePx: 1600,
}
function limitDraftNumber(key: LimitKey) {
return Number(limitDrafts.value[key]) || LIMIT_DEFAULTS[key]
}
function setLimitDraft(key: LimitKey, v: number[] | undefined) {
if (v?.length) limitDrafts.value[key] = String(v[0])
}
function limitSliderMaxFor(key: LimitKey) {
return Math.max(limitSliderMax[key], limitDraftNumber(key))
}
const limitsDirty = computed(() =>
LIMIT_KEYS.some((key) => {
const draft = limitDrafts.value[key]?.trim() ?? ''
const effective = String(limitsData.value.limits[key])
if (draft === '') return overriddenSet.value.has(key)
return draft !== effective
}),
)
async function saveLimits() {
limitsSaving.value = true
errorMessage.value = ''
try {
const body: Record<string, number | null> = {}
for (const key of LIMIT_KEYS) {
const draft = (limitDrafts.value[key] ?? '').trim()
if (draft === '') {
if (overriddenSet.value.has(key)) body[key] = null
continue
}
const n = Number(draft)
if (!Number.isInteger(n) || n <= 0) {
errorMessage.value = t('admin.limitInvalid', { field: t(`admin.limitFields.${key}`) })
return
}
if (n !== limitsData.value.limits[key]) body[key] = n
}
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)
}
}
</script>
<template>
<div class="px-4 lg:px-6">
<Tabs default-value="roles" class="gap-4">
<TabsList>
<TabsTrigger value="roles" class="cursor-pointer">
{{ $t('admin.roles') }}
</TabsTrigger>
<TabsTrigger v-if="can('users.manage')" value="users" class="cursor-pointer">
{{ $t('admin.users') }}
</TabsTrigger>
<TabsTrigger v-if="can('sticker.standard')" value="stickers" class="cursor-pointer">
{{ $t('admin.stickers') }}
</TabsTrigger>
<TabsTrigger v-if="can('meme.standard')" value="memes" class="cursor-pointer">
{{ $t('admin.memes') }}
</TabsTrigger>
<TabsTrigger v-if="can('site.manage')" value="site" class="cursor-pointer">
{{ $t('admin.site') }}
</TabsTrigger>
<TabsTrigger v-if="can('site.manage')" value="limits" class="cursor-pointer">
{{ $t('admin.limits') }}
</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 border 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')" 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-fill" 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>
</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>
<ul class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<li
v-for="e in allStickers"
:key="e.id"
class="flex items-center gap-2.5 rounded-md border px-2.5 py-2"
>
<StickerMedia :sticker="e" variant="tile" />
<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>
<Badge v-if="e.isStandard" variant="secondary" class="shrink-0 px-1.5 text-[10px]">
{{ $t('admin.stickerStandard') }}
</Badge>
<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>
</li>
</ul>
<p v-if="!allStickers.length" class="text-sm text-muted-foreground">
{{ $t('admin.stickersEmpty') }}
</p>
</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>
<ul class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
<li
v-for="m in allMemeTemplates"
:key="m.id"
class="flex items-center gap-2.5 rounded-md border px-2.5 py-2"
>
<img :src="m.url" :alt="m.name" class="size-16 shrink-0 rounded-md object-cover">
<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-[10px]">
{{ $t('memes.badgeStandard') }}
</Badge>
<Badge v-else-if="m.isPublic" variant="secondary" class="px-1.5 py-0 text-[10px]">
{{ $t('memes.visibilityPublic') }}
</Badge>
<Badge v-else variant="outline" class="px-1.5 py-0 text-[10px]">
{{ $t('memes.badgePrivate') }}
</Badge>
</div>
</div>
<Button
variant="ghost"
size="icon-sm"
class="shrink-0 cursor-pointer"
:class="m.isStandard ? 'text-primary' : 'text-muted-foreground'"
:disabled="togglingMeme === m.id"
:title="m.isStandard ? $t('memes.unmarkStandard') : $t('memes.markStandard')"
@click="toggleAdminMemeStandard(m.id, !m.isStandard)"
>
<Icon name="ph:star-fill" class="size-4" />
</Button>
<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>
</li>
</ul>
<p v-if="!allMemeTemplates.length" class="text-sm text-muted-foreground">
{{ $t('admin.memesEmpty') }}
</p>
</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" 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')"
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>
<Slider
:model-value="[limitDraftNumber(key)]"
:min="1"
:max="limitSliderMaxFor(key)"
:step="1"
class="min-w-40 max-w-64 flex-1"
:aria-label="$t(`admin.limitFields.${key}`)"
@update:model-value="setLimitDraft(key, $event)"
/>
<Input
:id="`limit-${key}`"
v-model="limitDrafts[key]"
type="text"
inputmode="numeric"
class="w-24"
:placeholder="String(LIMIT_DEFAULTS[key])"
/>
<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>
</Tabs>
<p v-if="errorMessage" class="mt-4 text-sm text-destructive">
{{ errorMessage }}
</p>
</div>
</template>