forked from BRETT/BRETT
i18n implemented
This commit is contained in:
+29
-28
@@ -34,6 +34,7 @@ interface AdminUser {
|
||||
}
|
||||
|
||||
const { user, can } = useAuth()
|
||||
const { t } = useI18n()
|
||||
const { data: rolesList, refresh: refreshRoles } = await useFetch<Role[]>('/api/admin/roles', { default: () => [] })
|
||||
const { data: usersList, refresh: refreshUsers } = await useFetch<AdminUser[]>('/api/admin/users', { default: () => [] })
|
||||
|
||||
@@ -47,12 +48,12 @@ const selectedRole = ref<Record<number, number | undefined>>({})
|
||||
const editedPermissions = ref<Record<number, string[]>>({})
|
||||
const savingRole = ref<number | null>(null)
|
||||
|
||||
const userColumns: ColumnDef<DataTableFeatures, AdminUser>[] = [
|
||||
{ accessorKey: 'name', header: 'Name' },
|
||||
{ accessorKey: 'email', header: 'E-Mail', cell: ({ getValue }) => getValue() ?? '—' },
|
||||
{ id: 'roles', header: 'Rollen', enableSorting: false, enableGlobalFilter: false },
|
||||
{ id: 'actions', header: 'Aktion', enableSorting: false, enableGlobalFilter: false },
|
||||
]
|
||||
const userColumns = computed<ColumnDef<DataTableFeatures, AdminUser>[]>(() => [
|
||||
{ accessorKey: 'name', header: t('common.name') },
|
||||
{ accessorKey: 'email', header: t('common.email'), cell: ({ getValue }) => getValue() ?? '—' },
|
||||
{ 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) {
|
||||
const q = query.toLowerCase()
|
||||
@@ -61,7 +62,7 @@ function userSearch(u: AdminUser, query: string) {
|
||||
|
||||
function errText(err: unknown) {
|
||||
const e = err as { data?: { statusMessage?: string, message?: string } }
|
||||
return e.data?.statusMessage ?? e.data?.message ?? 'Aktion fehlgeschlagen'
|
||||
return e.data?.statusMessage ?? e.data?.message ?? t('admin.actionFailed')
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
@@ -178,15 +179,15 @@ async function saveMotd() {
|
||||
<div class="grid gap-6 px-4 lg:grid-cols-2 lg:px-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Rollen</CardTitle>
|
||||
<CardDescription>Bestehende Rollen und neue anlegen (z. B. moderator). Die admin-Rolle hat immer alle Berechtigungen.</CardDescription>
|
||||
<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">{{ role.memberCount }} Nutzer</span>
|
||||
<span class="text-sm text-muted-foreground">{{ $t('admin.memberCount', { n: role.memberCount }) }}</span>
|
||||
<Button
|
||||
v-if="!SYSTEM_ROLES.includes(role.name as 'admin' | 'moderator') && can('roles.manage')"
|
||||
variant="ghost"
|
||||
@@ -195,7 +196,7 @@ async function saveMotd() {
|
||||
@click="deleteRole(role.id)"
|
||||
>
|
||||
<Icon name="ph:trash-fill" class="size-4" />
|
||||
<span class="sr-only">Rolle löschen</span>
|
||||
<span class="sr-only">{{ $t('admin.deleteRole') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1">
|
||||
@@ -209,7 +210,7 @@ async function saveMotd() {
|
||||
:disabled="role.name === 'admin' || !can('roles.manage') || savingRole === role.id"
|
||||
@update:model-value="togglePermission(role, perm.key, $event)"
|
||||
/>
|
||||
{{ perm.label }}
|
||||
{{ $t(`admin.perms.${perm.key}`) }}
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isDirty(role)" class="mt-2 flex items-center gap-2">
|
||||
@@ -218,7 +219,7 @@ async function saveMotd() {
|
||||
:disabled="savingRole === role.id"
|
||||
@click="savePermissions(role)"
|
||||
>
|
||||
{{ savingRole === role.id ? 'Speichern…' : 'Speichern' }}
|
||||
{{ savingRole === role.id ? $t('common.saving') : $t('common.save') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -226,15 +227,15 @@ async function saveMotd() {
|
||||
:disabled="savingRole === role.id"
|
||||
@click="resetPermissions(role)"
|
||||
>
|
||||
Zurücksetzen
|
||||
{{ $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="Neuer Rollenname" maxlength="50" />
|
||||
<Input v-model="newRoleName" :placeholder="$t('admin.newRole')" maxlength="50" />
|
||||
<Button type="submit" :disabled="!newRoleName.trim()">
|
||||
Anlegen
|
||||
{{ $t('admin.create') }}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
@@ -242,14 +243,14 @@ async function saveMotd() {
|
||||
|
||||
<Card v-if="can('users.manage')">
|
||||
<CardHeader>
|
||||
<CardTitle>Benutzer</CardTitle>
|
||||
<CardDescription>Rollen zuweisen oder entfernen.</CardDescription>
|
||||
<CardTitle>{{ $t('admin.users') }}</CardTitle>
|
||||
<CardDescription>{{ $t('admin.usersDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTable
|
||||
:columns="userColumns"
|
||||
:data="usersList"
|
||||
search-placeholder="Name oder E-Mail suchen…"
|
||||
:search-placeholder="$t('admin.searchUsers')"
|
||||
:search-fn="userSearch"
|
||||
>
|
||||
<template #cell-name="{ row }">
|
||||
@@ -266,10 +267,10 @@ async function saveMotd() {
|
||||
@click="removeRole(row.original.id, r.id)"
|
||||
>
|
||||
<Icon name="ph:x-fill" class="size-3" />
|
||||
<span class="sr-only">Rolle {{ r.name }} entfernen</span>
|
||||
<span class="sr-only">{{ $t('admin.removeRole', { name: r.name }) }}</span>
|
||||
</button>
|
||||
</Badge>
|
||||
<span v-if="!row.original.roles.length" class="text-xs text-muted-foreground">keine Rollen</span>
|
||||
<span v-if="!row.original.roles.length" class="text-xs text-muted-foreground">{{ $t('admin.noRoles') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
@@ -279,8 +280,8 @@ async function saveMotd() {
|
||||
: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="Rolle wählen">
|
||||
<SelectValue placeholder="Rolle wählen…" />
|
||||
<SelectTrigger size="sm" class="w-40" :aria-label="$t('admin.chooseRole')">
|
||||
<SelectValue :placeholder="$t('admin.chooseRole')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
@@ -300,7 +301,7 @@ async function saveMotd() {
|
||||
:disabled="!selectedRole[row.original.id]"
|
||||
@click="addRole(row.original.id)"
|
||||
>
|
||||
Zuweisen
|
||||
{{ $t('admin.assign') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -310,19 +311,19 @@ async function saveMotd() {
|
||||
|
||||
<Card v-if="can('site.manage')" class="lg:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle>Ansage</CardTitle>
|
||||
<CardDescription>Wird allen Besuchern direkt unter der Kopfzeile angezeigt und bei Änderung sofort aktualisiert. Leer lassen, um sie zu entfernen.</CardDescription>
|
||||
<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="Ansagetext…"
|
||||
:placeholder="$t('admin.motdPlaceholder')"
|
||||
maxlength="500"
|
||||
class="min-h-16"
|
||||
/>
|
||||
<Button type="submit" class="shrink-0" :disabled="motdSaving">
|
||||
{{ motdSaving ? 'Speichern…' : 'Speichern' }}
|
||||
{{ motdSaving ? $t('common.saving') : $t('common.save') }}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
+8
-7
@@ -38,6 +38,7 @@ interface UploadList {
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const FILTER_KEYS = ['tag', 'type', 'uploader', 'days'] as const
|
||||
const hasFilters = computed(() => FILTER_KEYS.some(k => typeof route.query[k] === 'string' && route.query[k]))
|
||||
|
||||
@@ -140,7 +141,7 @@ const { isOverDropZone } = useDropZone(dropTarget, {
|
||||
onDrop(files) {
|
||||
const valid = (files ?? []).filter(isMediaFile)
|
||||
if (valid.length && !uploading.value) uploadFiles(valid)
|
||||
else if (files?.length) toast.error('Nur Bilder, Videos und Audiodateien sind erlaubt')
|
||||
else if (files?.length) toast.error(t('upload.onlyMedia'))
|
||||
},
|
||||
})
|
||||
|
||||
@@ -154,7 +155,7 @@ useEventListener('drop', (e: DragEvent) => {
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
if (!user.value) {
|
||||
toast.error('Melde dich an, um hochzuladen.')
|
||||
toast.error(t('upload.loginToUploadToast'))
|
||||
return
|
||||
}
|
||||
uploading.value = true
|
||||
@@ -166,7 +167,7 @@ async function uploadFiles(files: File[]) {
|
||||
}))
|
||||
const failed = results.filter(r => r.status === 'rejected').length
|
||||
if (failed) {
|
||||
toast.error(`${failed} von ${files.length} Dateien fehlgeschlagen`)
|
||||
toast.error(t('upload.failedCount', { failed, total: files.length }))
|
||||
}
|
||||
await refreshNuxtData('uploads')
|
||||
await navigateTo('/')
|
||||
@@ -186,19 +187,19 @@ async function uploadFiles(files: File[]) {
|
||||
<div class="flex flex-col items-center gap-3 rounded-lg border-2 border-dashed border-primary bg-background p-10">
|
||||
<Icon name="ph:cloud-arrow-up-fill" class="size-12 text-primary" />
|
||||
<p class="text-lg font-medium">
|
||||
{{ uploading ? 'Lädt hoch…' : 'Zum Hochladen loslassen' }}
|
||||
{{ uploading ? $t('upload.uploading') : $t('upload.dropToUpload') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="filterRowEl" class="mb-2 flex items-center gap-2">
|
||||
<UploadFilters />
|
||||
<span v-if="hasFilters" class="text-sm text-muted-foreground">{{ total }} Uploads</span>
|
||||
<span v-if="hasFilters" class="text-sm text-muted-foreground">{{ $t('grid.count', { n: total }) }}</span>
|
||||
</div>
|
||||
<p v-if="pending" class="text-muted-foreground">
|
||||
Lade Uploads…
|
||||
{{ $t('grid.loading') }}
|
||||
</p>
|
||||
<p v-else-if="!uploads.length" class="text-muted-foreground">
|
||||
{{ hasFilters ? 'Keine Uploads für die aktiven Filter.' : 'Noch keine Uploads. Sei der Erste!' }}
|
||||
{{ hasFilters ? $t('grid.emptyFiltered') : $t('grid.empty') }}
|
||||
</p>
|
||||
|
||||
<div v-else class="grid grid-flow-dense grid-cols-4 gap-2 [grid-auto-rows:6.5rem] sm:grid-cols-6 sm:[grid-auto-rows:7rem] md:grid-cols-8 lg:grid-cols-12 lg:[grid-auto-rows:7.5rem]">
|
||||
|
||||
+9
-8
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const { login } = useAuth()
|
||||
const { t } = useI18n()
|
||||
const name = ref('')
|
||||
const password = ref('')
|
||||
const errorMessage = ref('')
|
||||
@@ -20,7 +21,7 @@ async function onSubmit() {
|
||||
}
|
||||
catch (err) {
|
||||
const e = err as { data?: { statusMessage?: string, message?: string } }
|
||||
errorMessage.value = e.data?.statusMessage ?? e.data?.message ?? 'Login fehlgeschlagen'
|
||||
errorMessage.value = e.data?.statusMessage ?? e.data?.message ?? t('auth.loginFailed')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -32,29 +33,29 @@ async function onSubmit() {
|
||||
<div class="mx-auto max-w-sm px-4 lg:px-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Login</CardTitle>
|
||||
<CardDescription>Melde dich mit Name und Passwort an.</CardDescription>
|
||||
<CardTitle>{{ $t('auth.loginTitle') }}</CardTitle>
|
||||
<CardDescription>{{ $t('auth.loginDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="name">Name</Label>
|
||||
<Label for="name">{{ $t('common.name') }}</Label>
|
||||
<Input id="name" v-model="name" autocomplete="username" required />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="password">Passwort</Label>
|
||||
<Label for="password">{{ $t('common.password') }}</Label>
|
||||
<Input id="password" v-model="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<p v-if="errorMessage" class="text-sm text-destructive">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<Button type="submit" class="w-full" :disabled="loading">
|
||||
Anmelden
|
||||
{{ $t('auth.login') }}
|
||||
</Button>
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
Noch kein Konto?
|
||||
{{ $t('auth.noAccount') }}
|
||||
<NuxtLink to="/register" class="text-primary underline">
|
||||
Registrieren
|
||||
{{ $t('auth.register') }}
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
+10
-9
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
const { register } = useAuth()
|
||||
const { t } = useI18n()
|
||||
const name = ref('')
|
||||
const password = ref('')
|
||||
const email = ref('')
|
||||
@@ -21,7 +22,7 @@ async function onSubmit() {
|
||||
}
|
||||
catch (err) {
|
||||
const e = err as { data?: { statusMessage?: string, message?: string } }
|
||||
errorMessage.value = e.data?.statusMessage ?? e.data?.message ?? 'Registrierung fehlgeschlagen'
|
||||
errorMessage.value = e.data?.statusMessage ?? e.data?.message ?? t('auth.registerFailed')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
@@ -33,33 +34,33 @@ async function onSubmit() {
|
||||
<div class="mx-auto max-w-sm px-4 lg:px-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Registrieren</CardTitle>
|
||||
<CardDescription>Name und Passwort reichen — E-Mail ist optional.</CardDescription>
|
||||
<CardTitle>{{ $t('auth.registerTitle') }}</CardTitle>
|
||||
<CardDescription>{{ $t('auth.registerDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<div class="space-y-1.5">
|
||||
<Label for="name">Name</Label>
|
||||
<Label for="name">{{ $t('common.name') }}</Label>
|
||||
<Input id="name" v-model="name" autocomplete="username" required minlength="3" maxlength="50" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="password">Passwort</Label>
|
||||
<Label for="password">{{ $t('common.password') }}</Label>
|
||||
<Input id="password" v-model="password" type="password" autocomplete="new-password" required minlength="6" />
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label for="email">E-Mail (optional)</Label>
|
||||
<Label for="email">{{ $t('auth.emailOptional') }}</Label>
|
||||
<Input id="email" v-model="email" type="email" autocomplete="email" />
|
||||
</div>
|
||||
<p v-if="errorMessage" class="text-sm text-destructive">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<Button type="submit" class="w-full" :disabled="loading">
|
||||
Konto erstellen
|
||||
{{ $t('auth.createAccount') }}
|
||||
</Button>
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
Schon registriert?
|
||||
{{ $t('auth.alreadyRegistered') }}
|
||||
<NuxtLink to="/login" class="text-primary underline">
|
||||
Login
|
||||
{{ $t('auth.loginTitle') }}
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
+46
-29
@@ -36,6 +36,7 @@ import {
|
||||
|
||||
const { user, refresh } = useAuth()
|
||||
const { invalidate } = useUserSummaries()
|
||||
const { t, locale, setLocale } = useI18n()
|
||||
|
||||
const color = ref(user.value?.color ?? '#6366f1')
|
||||
const cardEffect = ref<string>(user.value?.cardEffect ?? 'none')
|
||||
@@ -121,11 +122,11 @@ async function save() {
|
||||
if (user.value) invalidate(user.value.id)
|
||||
await refreshNuxtData()
|
||||
avatar.value = null
|
||||
toast.success('Einstellungen gespeichert')
|
||||
toast.success(t('settings.saved'))
|
||||
}
|
||||
catch (err) {
|
||||
const e = err as { data?: { statusMessage?: string } }
|
||||
toast.error(e.data?.statusMessage ?? 'Speichern fehlgeschlagen')
|
||||
toast.error(e.data?.statusMessage ?? t('settings.saveFailed'))
|
||||
}
|
||||
finally {
|
||||
saving.value = false
|
||||
@@ -137,34 +138,50 @@ async function save() {
|
||||
<div class="mx-auto w-full max-w-2xl px-4 lg:px-6">
|
||||
<Card v-if="user">
|
||||
<CardHeader>
|
||||
<CardTitle>Einstellungen</CardTitle>
|
||||
<CardDescription>Name-Farbe und Avatar anpassen.</CardDescription>
|
||||
<CardTitle>{{ $t('settings.title') }}</CardTitle>
|
||||
<CardDescription>{{ $t('settings.desc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form class="space-y-4" @submit.prevent="save">
|
||||
<div class="space-y-1.5">
|
||||
<Label>Name-Farbe</Label>
|
||||
<Label>{{ $t('settings.language') }}</Label>
|
||||
<Select :model-value="locale" @update:model-value="(v) => setLocale(v as string)">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="de">
|
||||
Deutsch
|
||||
</SelectItem>
|
||||
<SelectItem value="en">
|
||||
English
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('settings.nameColor') }}</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<ColorPicker :value="color" type="hex" @value-change="(val: any) => color = val.hex">
|
||||
<ColorPicker :value="(color as `#${string}`)" type="hex" @value-change="(val: any) => color = val.hex">
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 w-14 cursor-pointer rounded-md border border-input shadow-sm"
|
||||
:style="{ backgroundColor: color }"
|
||||
aria-label="Farbe auswählen"
|
||||
:aria-label="$t('settings.pickColor')"
|
||||
/>
|
||||
</ColorPicker>
|
||||
<span class="text-sm font-medium" :style="{ color }">{{ user.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>Hover-Card-Effekt</Label>
|
||||
<Label>{{ $t('settings.cardEffect') }}</Label>
|
||||
<Select v-model="cardEffect">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Kein Effekt" />
|
||||
<SelectValue :placeholder="$t('settings.noEffectPlaceholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
Keiner
|
||||
{{ $t('settings.noEffect') }}
|
||||
</SelectItem>
|
||||
<SelectItem v-for="e in CARD_EFFECTS" :key="e.key" :value="e.key">
|
||||
{{ e.label }}
|
||||
@@ -174,31 +191,31 @@ async function save() {
|
||||
<div v-if="cardEffect !== 'none'" class="flex flex-wrap items-center gap-x-5 gap-y-2 pt-1">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox v-model="cardEffectColorEnabled" />
|
||||
Eigene Effekt-Farbe
|
||||
{{ $t('settings.ownEffectColor') }}
|
||||
</label>
|
||||
<ColorPicker v-if="cardEffectColorEnabled" :value="cardEffectColor" type="hex" @value-change="(val: any) => cardEffectColor = val.hex">
|
||||
<ColorPicker v-if="cardEffectColorEnabled" :value="(cardEffectColor as `#${string}`)" type="hex" @value-change="(val: any) => cardEffectColor = val.hex">
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
|
||||
:style="{ backgroundColor: cardEffectColor }"
|
||||
aria-label="Effekt-Farbe auswählen"
|
||||
:aria-label="$t('settings.pickEffectColor')"
|
||||
/>
|
||||
</ColorPicker>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox v-model="cardVignette" />
|
||||
Vignette (Text lesbar halten)
|
||||
{{ $t('settings.vignette') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>Hover-Card-Border</Label>
|
||||
<Label>{{ $t('settings.cardBorder') }}</Label>
|
||||
<Select v-model="cardBorderEffect">
|
||||
<SelectTrigger class="w-full">
|
||||
<SelectValue placeholder="Kein Effekt" />
|
||||
<SelectValue :placeholder="$t('settings.noEffectPlaceholder')" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
Keiner
|
||||
{{ $t('settings.noEffect') }}
|
||||
</SelectItem>
|
||||
<SelectItem v-for="e in CARD_BORDER_EFFECTS" :key="e.key" :value="e.key">
|
||||
{{ e.label }}
|
||||
@@ -208,20 +225,20 @@ async function save() {
|
||||
<div v-if="cardBorderEffect !== 'none'" class="flex items-center gap-3 pt-1">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
||||
<Checkbox v-model="cardBorderEffectColorEnabled" />
|
||||
Eigene Border-Farbe
|
||||
{{ $t('settings.ownBorderColor') }}
|
||||
</label>
|
||||
<ColorPicker v-if="cardBorderEffectColorEnabled" :value="cardBorderEffectColor" type="hex" @value-change="(val: any) => cardBorderEffectColor = val.hex">
|
||||
<ColorPicker v-if="cardBorderEffectColorEnabled" :value="(cardBorderEffectColor as `#${string}`)" type="hex" @value-change="(val: any) => cardBorderEffectColor = val.hex">
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
|
||||
:style="{ backgroundColor: cardBorderEffectColor }"
|
||||
aria-label="Border-Farbe auswählen"
|
||||
:aria-label="$t('settings.pickBorderColor')"
|
||||
/>
|
||||
</ColorPicker>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showPreview" class="space-y-1.5">
|
||||
<Label>Vorschau</Label>
|
||||
<Label>{{ $t('settings.preview') }}</Label>
|
||||
<div class="relative h-24 overflow-hidden rounded-md border bg-popover">
|
||||
<component :is="previewEffect" v-if="previewEffect" :color="previewEffectColor" />
|
||||
<div v-if="previewEffect" class="absolute inset-0 bg-popover/60" />
|
||||
@@ -232,34 +249,34 @@ async function save() {
|
||||
<CardBorderEffect v-if="previewBorder" :effect="previewBorder" :color="previewBorderColor" />
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
So sieht deine Hover-Card für andere aus.
|
||||
{{ $t('settings.previewHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<Label>Avatar</Label>
|
||||
<Label>{{ $t('settings.avatar') }}</Label>
|
||||
<div v-if="user.avatarUrl && !avatar" class="mb-2 flex items-center gap-2">
|
||||
<NuxtImg
|
||||
:src="user.avatarUrl"
|
||||
alt="Aktueller Avatar"
|
||||
:alt="$t('settings.currentAvatar')"
|
||||
width="48"
|
||||
height="48"
|
||||
fit="cover"
|
||||
class="size-12 rounded-lg"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">Aktueller Avatar</span>
|
||||
<span class="text-xs text-muted-foreground">{{ $t('settings.currentAvatar') }}</span>
|
||||
</div>
|
||||
<FileDropzone v-model="avatar" accept="image/*" hint="Bilder, max. 5 MB" />
|
||||
<FileDropzone v-model="avatar" accept="image/*" :hint="t('upload.avatarHint')" />
|
||||
</div>
|
||||
<Button type="submit" class="w-full" :disabled="saving">
|
||||
{{ saving ? 'Speichert…' : 'Speichern' }}
|
||||
{{ saving ? $t('common.saving') : $t('common.save') }}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p v-else class="text-center text-muted-foreground">
|
||||
<NuxtLink to="/login" class="text-primary underline">
|
||||
Melde dich an
|
||||
</NuxtLink>, um deine Einstellungen zu ändern.
|
||||
{{ $t('common.loginLink') }}
|
||||
</NuxtLink>{{ $t('settings.loginToChange') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+45
-44
@@ -65,6 +65,7 @@ const id = computed(() => route.params.id as string)
|
||||
// Twitch-Embeds brauchen den Hostname als parent-Parameter
|
||||
const requestHost = useRequestURL().hostname
|
||||
const { user, can } = useAuth()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
// Beim Client-Side-Nav nicht awaiten, damit die Seite sofort rendert und das
|
||||
// benannte Medium-Element für die View-Transition im Snapshot vorhanden ist
|
||||
@@ -171,7 +172,7 @@ onUnmounted(() => {
|
||||
|
||||
async function reactToUpload(emoji: string) {
|
||||
if (!user.value) {
|
||||
toast.error('Melde dich an, um zu reagieren.')
|
||||
toast.error(t('comments.loginToReact'))
|
||||
return
|
||||
}
|
||||
if (reactionBusy.value) return
|
||||
@@ -183,7 +184,7 @@ async function reactToUpload(emoji: string) {
|
||||
reactionPickerOpen.value = false
|
||||
}
|
||||
catch {
|
||||
toast.error('Reaktion fehlgeschlagen')
|
||||
toast.error(t('comments.reactionFailed'))
|
||||
}
|
||||
finally {
|
||||
reactionBusy.value = false
|
||||
@@ -305,11 +306,11 @@ function pendingPreview(file: File): string | null {
|
||||
function addPendingFiles(files: File[]) {
|
||||
for (const file of files) {
|
||||
if (pendingFiles.value.length >= MAX_COMMENT_ATTACHMENTS) {
|
||||
toast.error(`Maximal ${MAX_COMMENT_ATTACHMENTS} Anhänge pro Kommentar`)
|
||||
toast.error(t('comments.maxAttachments', { n: MAX_COMMENT_ATTACHMENTS }))
|
||||
break
|
||||
}
|
||||
if (file.size > MAX_ATTACHMENT_SIZE) {
|
||||
toast.error(`${file.name}: Datei ist zu groß (max. 200 MB)`)
|
||||
toast.error(t('comments.fileTooLarge', { name: file.name }))
|
||||
continue
|
||||
}
|
||||
pendingFiles.value.push(file)
|
||||
@@ -362,7 +363,7 @@ async function startRecording() {
|
||||
recordStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
}
|
||||
catch {
|
||||
toast.error('Kein Zugriff aufs Mikrofon')
|
||||
toast.error(t('comments.noMic'))
|
||||
return
|
||||
}
|
||||
const mimeType = recorderMimeType()
|
||||
@@ -376,7 +377,7 @@ async function startRecording() {
|
||||
if (!recordDiscarded && recordChunks.length && pendingFiles.value.length < MAX_COMMENT_ATTACHMENTS) {
|
||||
const type = recorder?.mimeType || 'audio/webm'
|
||||
const ext = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : 'webm'
|
||||
const file = new File(recordChunks, `Sprachnachricht.${ext}`, { type })
|
||||
const file = new File(recordChunks, `${t('comments.voiceMessage')}.${ext}`, { type })
|
||||
pendingFiles.value.push(file)
|
||||
voicePending.value.add(file)
|
||||
}
|
||||
@@ -466,7 +467,7 @@ async function saveTags() {
|
||||
}
|
||||
catch (err) {
|
||||
const e = err as { data?: { statusMessage?: string } }
|
||||
toast.error(e.data?.statusMessage ?? 'Tags konnten nicht gespeichert werden')
|
||||
toast.error(e.data?.statusMessage ?? t('thread.tagsSaveFailed'))
|
||||
}
|
||||
finally {
|
||||
tagSaving.value = false
|
||||
@@ -476,7 +477,7 @@ async function saveTags() {
|
||||
async function toggleFavorite() {
|
||||
if (!data.value || favBusy.value) return
|
||||
if (!user.value) {
|
||||
toast.error('Melde dich an, um zu favorisieren.')
|
||||
toast.error(t('thread.loginToFavorite'))
|
||||
return
|
||||
}
|
||||
favBusy.value = true
|
||||
@@ -501,7 +502,7 @@ watch(id, () => {
|
||||
const timeAgo = useTimeAgo()
|
||||
|
||||
function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
|
||||
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(value))
|
||||
}
|
||||
|
||||
async function submitComment() {
|
||||
@@ -533,7 +534,7 @@ async function submitComment() {
|
||||
}
|
||||
catch (err) {
|
||||
const e = err as { data?: { statusMessage?: string, message?: string } }
|
||||
commentError.value = e.data?.statusMessage ?? e.data?.message ?? 'Kommentar konnte nicht gesendet werden'
|
||||
commentError.value = e.data?.statusMessage ?? e.data?.message ?? t('comments.sendFailed')
|
||||
}
|
||||
finally {
|
||||
posting.value = false
|
||||
@@ -551,7 +552,7 @@ async function deleteUpload() {
|
||||
await navigateTo('/')
|
||||
}
|
||||
catch {
|
||||
toast.error('Löschen fehlgeschlagen')
|
||||
toast.error(t('thread.deleteFailed'))
|
||||
}
|
||||
finally {
|
||||
deleting.value = false
|
||||
@@ -569,7 +570,7 @@ async function deleteComment(commentId: number) {
|
||||
<template>
|
||||
<div class="px-4 lg:px-6">
|
||||
<div v-if="error" class="text-muted-foreground">
|
||||
Upload nicht gefunden.
|
||||
{{ $t('thread.notFound') }}
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
@@ -582,7 +583,7 @@ async function deleteComment(commentId: number) {
|
||||
class="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full shadow opacity-80 hover:opacity-100"
|
||||
as-child
|
||||
>
|
||||
<NuxtLink :to="`/thread/${data.newerId}`" aria-label="Neuerer Upload">
|
||||
<NuxtLink :to="`/thread/${data.newerId}`" :aria-label="$t('thread.newer')">
|
||||
<Icon name="ph:caret-left-fill" class="size-5" />
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
@@ -593,7 +594,7 @@ async function deleteComment(commentId: number) {
|
||||
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full shadow opacity-80 hover:opacity-100"
|
||||
as-child
|
||||
>
|
||||
<NuxtLink :to="`/thread/${data.olderId}`" aria-label="Älterer Upload">
|
||||
<NuxtLink :to="`/thread/${data.olderId}`" :aria-label="$t('thread.older')">
|
||||
<Icon name="ph:caret-right-fill" class="size-5" />
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
@@ -647,7 +648,7 @@ async function deleteComment(commentId: number) {
|
||||
class="group relative flex cursor-pointer items-center justify-center"
|
||||
:class="media.previewImage ? 'aspect-square max-h-[70vh] w-full' : 'h-56 sm:h-64'"
|
||||
role="button"
|
||||
:aria-label="audioPlaying ? 'Pause' : 'Abspielen'"
|
||||
:aria-label="audioPlaying ? $t('audio.pause') : $t('audio.play')"
|
||||
@click="toggleAudio"
|
||||
>
|
||||
<Icon name="ph:music-note-fill" v-if="!media.previewImage" class="size-16 text-muted-foreground" />
|
||||
@@ -681,7 +682,7 @@ async function deleteComment(commentId: number) {
|
||||
<button
|
||||
type="button"
|
||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-white transition-colors hover:bg-white/15"
|
||||
:aria-label="audioPlaying ? 'Pause' : 'Abspielen'"
|
||||
:aria-label="audioPlaying ? $t('audio.pause') : $t('audio.play')"
|
||||
@click="toggleAudio"
|
||||
>
|
||||
<Icon name="ph:pause-fill" v-if="audioPlaying" class="size-4" />
|
||||
@@ -694,7 +695,7 @@ async function deleteComment(commentId: number) {
|
||||
:max="Number.isFinite(audioDuration) ? audioDuration : 0"
|
||||
step="0.1"
|
||||
:value="audioTime"
|
||||
aria-label="Wiedergabeposition"
|
||||
:aria-label="$t('audio.seek')"
|
||||
class="min-w-0 flex-1 cursor-pointer accent-primary"
|
||||
@input="seekAudio"
|
||||
>
|
||||
@@ -702,7 +703,7 @@ async function deleteComment(commentId: number) {
|
||||
<button
|
||||
type="button"
|
||||
class="hidden size-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-white transition-colors hover:bg-white/15 sm:flex"
|
||||
:aria-label="audioMuted ? 'Ton an' : 'Stumm'"
|
||||
:aria-label="audioMuted ? $t('audio.unmute') : $t('audio.mute')"
|
||||
@click="audioMuted = !audioMuted"
|
||||
>
|
||||
<Icon name="ph:speaker-x-fill" v-if="audioMuted" class="size-4" />
|
||||
@@ -714,7 +715,7 @@ async function deleteComment(commentId: number) {
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
aria-label="Lautstärke"
|
||||
:aria-label="$t('audio.volume')"
|
||||
class="hidden w-20 cursor-pointer accent-primary sm:block"
|
||||
:class="audioMuted && 'opacity-40'"
|
||||
>
|
||||
@@ -798,7 +799,7 @@ async function deleteComment(commentId: number) {
|
||||
type="button"
|
||||
class="ml-auto flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
||||
:disabled="favBusy"
|
||||
:aria-label="data.isFavorited ? 'Aus Favoriten entfernen' : 'Zu Favoriten hinzufügen'"
|
||||
:aria-label="data.isFavorited ? $t('thread.favRemove') : $t('thread.favAdd')"
|
||||
@click="toggleFavorite"
|
||||
@pointerenter="startReactionHover"
|
||||
@pointerleave="scheduleReactionClose"
|
||||
@@ -839,7 +840,7 @@ async function deleteComment(commentId: number) {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-muted-foreground hover:text-destructive"
|
||||
aria-label="Upload löschen"
|
||||
:aria-label="$t('thread.deleteUpload')"
|
||||
@click="deleteConfirmOpen = true"
|
||||
>
|
||||
<Icon name="ph:trash-fill" class="size-4" />
|
||||
@@ -847,17 +848,17 @@ async function deleteComment(commentId: number) {
|
||||
</PopoverAnchor>
|
||||
<PopoverContent side="bottom" align="end" class="w-56">
|
||||
<p class="text-sm font-medium">
|
||||
Upload wirklich löschen?
|
||||
{{ $t('thread.deleteTitle') }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Der Beitrag und alle Kommentare werden entfernt.
|
||||
{{ $t('thread.deleteDesc') }}
|
||||
</p>
|
||||
<div class="mt-3 flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" :disabled="deleting" @click="deleteConfirmOpen = false">
|
||||
Abbrechen
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" :disabled="deleting" @click="deleteUpload">
|
||||
{{ deleting ? 'Löscht…' : 'Löschen' }}
|
||||
{{ deleting ? $t('common.deleting') : $t('common.delete') }}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -920,7 +921,7 @@ async function deleteComment(commentId: number) {
|
||||
v-if="canDeleteTags || !originalTags.has(t)"
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
:aria-label="`Tag ${t} entfernen`"
|
||||
:aria-label="$t('thread.removeTag', { tag: t })"
|
||||
@click="tagDraft = tagDraft.filter(x => x !== t)"
|
||||
>
|
||||
<Icon name="ph:x-fill" class="size-3.5" />
|
||||
@@ -930,7 +931,7 @@ async function deleteComment(commentId: number) {
|
||||
v-model="tagInput"
|
||||
list="tag-suggestions"
|
||||
type="text"
|
||||
placeholder="Tag hinzufügen…"
|
||||
:placeholder="$t('thread.addTag')"
|
||||
class="min-w-32 flex-1 bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground"
|
||||
@keydown="onTagKeydown"
|
||||
>
|
||||
@@ -940,10 +941,10 @@ async function deleteComment(commentId: number) {
|
||||
</div>
|
||||
<div class="mt-2.5 flex items-center gap-2">
|
||||
<Button size="sm" :disabled="tagSaving" @click="saveTags">
|
||||
{{ tagSaving ? 'Speichern…' : 'Speichern' }}
|
||||
{{ tagSaving ? $t('common.saving') : $t('common.save') }}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" :disabled="tagSaving" @click="tagEditing = false">
|
||||
Abbrechen
|
||||
{{ $t('common.cancel') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -959,7 +960,7 @@ async function deleteComment(commentId: number) {
|
||||
v-if="canEditTags"
|
||||
type="button"
|
||||
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
||||
aria-label="Tags bearbeiten"
|
||||
:aria-label="$t('thread.editTags')"
|
||||
@click="startTagEdit"
|
||||
>
|
||||
<Icon name="ph:pencil-fill" class="size-4" />
|
||||
@@ -976,7 +977,7 @@ async function deleteComment(commentId: number) {
|
||||
|
||||
<section class="mx-auto mt-6 w-full lg:w-2/5">
|
||||
<h2 class="mb-3 font-semibold">
|
||||
Kommentare ({{ allComments.length }})
|
||||
{{ $t('comments.title') }} ({{ allComments.length }})
|
||||
</h2>
|
||||
|
||||
<ul v-if="allComments.length" class="space-y-3">
|
||||
@@ -1033,7 +1034,7 @@ async function deleteComment(commentId: number) {
|
||||
@click="deleteComment(comment.id)"
|
||||
>
|
||||
<Icon name="ph:trash-fill" class="size-4" />
|
||||
<span class="sr-only">Kommentar löschen</span>
|
||||
<span class="sr-only">{{ $t('comments.deleteComment') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1045,7 +1046,7 @@ async function deleteComment(commentId: number) {
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="text-sm text-muted-foreground">
|
||||
Noch keine Kommentare.
|
||||
{{ $t('comments.none') }}
|
||||
</p>
|
||||
|
||||
<form
|
||||
@@ -1057,7 +1058,7 @@ async function deleteComment(commentId: number) {
|
||||
<div :class="cn('rounded-md transition-shadow', isOverCommentDrop && 'shadow-[0_0_0_2px_var(--color-primary)]')">
|
||||
<MentionTextarea
|
||||
v-model="newComment"
|
||||
placeholder="Kommentar schreiben… @ zum Erwähnen, Dateien hierher ziehen"
|
||||
:placeholder="$t('comments.placeholder')"
|
||||
>
|
||||
<div class="absolute right-2 bottom-2 flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
@@ -1066,13 +1067,13 @@ async function deleteComment(commentId: number) {
|
||||
type="button"
|
||||
class="flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent"
|
||||
:disabled="posting || pendingFiles.length >= MAX_COMMENT_ATTACHMENTS"
|
||||
aria-label="Dateien anhängen"
|
||||
:aria-label="$t('comments.attachFiles')"
|
||||
@click="fileInputRef?.click()"
|
||||
>
|
||||
<Icon name="ph:folder-plus-fill" class="size-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Dateien anhängen</TooltipContent>
|
||||
<TooltipContent>{{ $t('comments.attachFiles') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip v-if="canRecord">
|
||||
<TooltipTrigger as-child>
|
||||
@@ -1083,13 +1084,13 @@ async function deleteComment(commentId: number) {
|
||||
recording && 'text-destructive hover:text-destructive',
|
||||
)"
|
||||
:disabled="posting || pendingFiles.length >= MAX_COMMENT_ATTACHMENTS"
|
||||
:aria-label="recording ? 'Aufnahme beenden' : 'Sprachnachricht aufnehmen'"
|
||||
:aria-label="recording ? $t('comments.recordStop') : $t('comments.recordStart')"
|
||||
@click="recording ? stopRecording() : startRecording()"
|
||||
>
|
||||
<Icon :name="recording ? 'ph:stop-fill' : 'ph:microphone-fill'" class="size-5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ recording ? 'Aufnahme beenden' : 'Sprachnachricht aufnehmen' }}</TooltipContent>
|
||||
<TooltipContent>{{ recording ? $t('comments.recordStop') : $t('comments.recordStart') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</MentionTextarea>
|
||||
@@ -1115,7 +1116,7 @@ async function deleteComment(commentId: number) {
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-destructive"
|
||||
aria-label="Anhang entfernen"
|
||||
:aria-label="$t('comments.removeAttachment')"
|
||||
@click="removePendingFile(i)"
|
||||
>
|
||||
<Icon name="ph:x" class="size-3.5" />
|
||||
@@ -1131,8 +1132,8 @@ async function deleteComment(commentId: number) {
|
||||
<span class="relative inline-flex size-2.5 rounded-full bg-destructive" />
|
||||
</span>
|
||||
<span class="shrink-0 tabular-nums">{{ formatAudioTime(recordSeconds) }}</span>
|
||||
<span class="text-muted-foreground">Aufnahme läuft…</span>
|
||||
<span class="ml-auto text-muted-foreground">Zum Beenden Mikrofon klicken</span>
|
||||
<span class="text-muted-foreground">{{ $t('comments.recording') }}</span>
|
||||
<span class="ml-auto text-muted-foreground">{{ $t('comments.recordHint') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -1145,14 +1146,14 @@ async function deleteComment(commentId: number) {
|
||||
>
|
||||
<span v-if="commentError" class="text-sm text-destructive">{{ commentError }}</span>
|
||||
<Button type="submit" size="sm" class="ml-auto" :disabled="posting || (!newComment.trim() && !pendingFiles.length)">
|
||||
Kommentieren
|
||||
{{ $t('comments.submit') }}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<p v-else class="mt-4 text-sm text-muted-foreground">
|
||||
<NuxtLink to="/login" class="text-primary underline">
|
||||
Melde dich an
|
||||
</NuxtLink>, um zu kommentieren.
|
||||
{{ $t('common.loginLink') }}
|
||||
</NuxtLink>, {{ $t('comments.loginToComment') }}
|
||||
</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
+11
-10
@@ -70,6 +70,7 @@ interface UserProfile {
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { locale } = useI18n()
|
||||
const userId = computed(() => route.params.id as string)
|
||||
|
||||
const { data, error } = await useFetch<UserProfile>(() => `/api/users/${userId.value}`)
|
||||
@@ -185,7 +186,7 @@ const profileColor = computed(() => data.value?.user.color ?? '#6366f1')
|
||||
const profileEffectColor = computed(() => data.value?.user.cardEffectColor ?? profileColor.value)
|
||||
const profileBorderColor = computed(() => data.value?.user.cardBorderEffectColor ?? profileColor.value)
|
||||
const joinDate = computed(() => data.value
|
||||
? new Intl.DateTimeFormat('de-DE', { dateStyle: 'long' }).format(new Date(data.value.user.createdAt))
|
||||
? new Intl.DateTimeFormat(locale.value, { dateStyle: 'long' }).format(new Date(data.value.user.createdAt))
|
||||
: '')
|
||||
|
||||
const timeAgo = useTimeAgo()
|
||||
@@ -194,7 +195,7 @@ const timeAgo = useTimeAgo()
|
||||
<template>
|
||||
<div class="px-4 lg:px-6">
|
||||
<p v-if="error" class="text-muted-foreground">
|
||||
Nutzer nicht gefunden.
|
||||
{{ $t('misc.userNotFound') }}
|
||||
</p>
|
||||
|
||||
<template v-else-if="data">
|
||||
@@ -232,7 +233,7 @@ const timeAgo = useTimeAgo()
|
||||
</p>
|
||||
<p class="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<Icon name="ph:calendar-fill" class="size-3.5" />
|
||||
Mitglied seit {{ joinDate }}
|
||||
{{ $t('misc.memberSince', { date: joinDate }) }}
|
||||
</p>
|
||||
<div class="mt-3 flex items-center gap-4 text-sm">
|
||||
<button
|
||||
@@ -242,7 +243,7 @@ const timeAgo = useTimeAgo()
|
||||
@click="view = 'uploads'"
|
||||
>
|
||||
<Icon name="ph:image-fill" class="size-4" />
|
||||
{{ data.stats.uploads }} Uploads
|
||||
{{ data.stats.uploads }} {{ $t('misc.uploadsCol') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -251,7 +252,7 @@ const timeAgo = useTimeAgo()
|
||||
@click="showComments"
|
||||
>
|
||||
<Icon name="ph:chat-fill" class="size-4" />
|
||||
{{ data.stats.comments }} Kommentare
|
||||
{{ data.stats.comments }} {{ $t('misc.commentsCol') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -260,14 +261,14 @@ const timeAgo = useTimeAgo()
|
||||
|
||||
<section class="mt-6">
|
||||
<h2 ref="sectionHeadEl" class="mb-3 font-semibold">
|
||||
{{ view === 'uploads' ? 'Uploads' : 'Kommentare' }}
|
||||
{{ view === 'uploads' ? $t('misc.uploadsCol') : $t('misc.commentsCol') }}
|
||||
</h2>
|
||||
<template v-if="view === 'uploads'">
|
||||
<p v-if="uploadsPending" class="text-sm text-muted-foreground">
|
||||
Lade Uploads…
|
||||
{{ $t('grid.loading') }}
|
||||
</p>
|
||||
<p v-else-if="!uploads.length" class="text-sm text-muted-foreground">
|
||||
Noch keine Uploads.
|
||||
{{ $t('misc.noUploads') }}
|
||||
</p>
|
||||
<div v-else class="grid grid-flow-dense grid-cols-4 gap-2 [grid-auto-rows:5rem] sm:grid-cols-6 sm:[grid-auto-rows:5.5rem] md:grid-cols-8 lg:grid-cols-12 lg:[grid-auto-rows:6rem]">
|
||||
<UploadTile v-for="upload in uploads" :key="upload.id" :upload="upload" />
|
||||
@@ -303,10 +304,10 @@ const timeAgo = useTimeAgo()
|
||||
|
||||
<template v-else>
|
||||
<p v-if="commentsPending" class="text-sm text-muted-foreground">
|
||||
Lade Kommentare…
|
||||
{{ $t('comments.loading') }}
|
||||
</p>
|
||||
<p v-else-if="!comments?.items.length" class="text-sm text-muted-foreground">
|
||||
Noch keine Kommentare.
|
||||
{{ $t('comments.none') }}
|
||||
</p>
|
||||
<div v-else class="flex max-w-2xl flex-col divide-y rounded-md border">
|
||||
<NuxtLink
|
||||
|
||||
+9
-7
@@ -25,6 +25,8 @@ interface ListedUser {
|
||||
}
|
||||
}
|
||||
|
||||
const { locale } = useI18n()
|
||||
|
||||
const { data: users } = await useFetch<ListedUser[]>('/api/users', {
|
||||
key: 'users-list',
|
||||
default: () => [],
|
||||
@@ -41,28 +43,28 @@ function cardBorderEffect(user: ListedUser): CardBorderEffectKey | null {
|
||||
const GRID_COLS = 'grid-cols-[2.5rem_minmax(0,1fr)_3rem_2.5rem_2.5rem] sm:grid-cols-[2.5rem_minmax(0,1fr)_3.5rem_4.5rem_4.5rem_10rem]'
|
||||
|
||||
function joinDate(value: string) {
|
||||
return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(value))
|
||||
return new Intl.DateTimeFormat(locale.value, { dateStyle: 'medium' }).format(new Date(value))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-4 lg:px-6">
|
||||
<h1 class="mb-4 text-xl font-semibold">
|
||||
Benutzer
|
||||
{{ $t('nav.users') }}
|
||||
</h1>
|
||||
|
||||
<p v-if="!users.length" class="text-sm text-muted-foreground">
|
||||
Noch keine Benutzer.
|
||||
{{ $t('misc.noUsers') }}
|
||||
</p>
|
||||
|
||||
<div v-else class="max-w-4xl overflow-x-auto">
|
||||
<div :class="cn('grid items-center gap-3 px-3 py-2 text-xs text-muted-foreground', GRID_COLS)">
|
||||
<span />
|
||||
<span>Name</span>
|
||||
<span>{{ $t('common.name') }}</span>
|
||||
<span class="text-right">ID</span>
|
||||
<span class="hidden text-right sm:block">Uploads</span>
|
||||
<span class="hidden text-right sm:block">Kommentare</span>
|
||||
<span class="hidden text-right sm:block">Dabei seit</span>
|
||||
<span class="hidden text-right sm:block">{{ $t('misc.uploadsCol') }}</span>
|
||||
<span class="hidden text-right sm:block">{{ $t('misc.commentsCol') }}</span>
|
||||
<span class="hidden text-right sm:block">{{ $t('misc.joined') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-1 flex flex-col gap-2">
|
||||
|
||||
Reference in New Issue
Block a user