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