forked from BRETT/BRETT
i18n implemented
This commit is contained in:
@@ -15,3 +15,4 @@
|
|||||||
- Kommentar-Reactions: `useCommentReactions` (State-Map pro Comment-Id), Emoji-Liste `REACTION_EMOJIS`.
|
- Kommentar-Reactions: `useCommentReactions` (State-Map pro Comment-Id), Emoji-Liste `REACTION_EMOJIS`.
|
||||||
- Thread-Benachrichtigungen: `threadSubscriptions` speichert Abos und explizite Deaktivierungen. Erstellen, Kommentieren, Favorisieren und Post-Reactions rufen `ensureThreadSubscription()` auf; ein deaktiviertes Abo darf dadurch nicht reaktiviert werden. `notifyComment()` benachrichtigt aktive Abonnenten und erwähnte Nutzer, respektiert Deaktivierungen auch bei Erwähnungen und fasst Meldungen pro Nutzer/Thread zusammen.
|
- Thread-Benachrichtigungen: `threadSubscriptions` speichert Abos und explizite Deaktivierungen. Erstellen, Kommentieren, Favorisieren und Post-Reactions rufen `ensureThreadSubscription()` auf; ein deaktiviertes Abo darf dadurch nicht reaktiviert werden. `notifyComment()` benachrichtigt aktive Abonnenten und erwähnte Nutzer, respektiert Deaktivierungen auch bei Erwähnungen und fasst Meldungen pro Nutzer/Thread zusammen.
|
||||||
- Berechtigungen: Katalog in `shared/utils/permissions.ts` (`PERMISSIONS`), Server-Check per `requirePermission(event, key)` in `server/utils/auth.ts` — `admin`-Rolle ist implizit wildcard. Client-seitig `can(key)` aus `useAuth`. Seeding via `seedPermissions()` im Migrations-Plugin/Script.
|
- Berechtigungen: Katalog in `shared/utils/permissions.ts` (`PERMISSIONS`), Server-Check per `requirePermission(event, key)` in `server/utils/auth.ts` — `admin`-Rolle ist implizit wildcard. Client-seitig `can(key)` aus `useAuth`. Seeding via `seedPermissions()` im Migrations-Plugin/Script.
|
||||||
|
- i18n: `@nuxtjs/i18n`, Locales `de` (Default) + `en` in `i18n/locales/*.json` — beide Dateien immer gleiche Key-Struktur halten. Alle UI-Strings über `$t()`/`t()`, nie hardcoden. `strategy: 'no_prefix'`, Locale-Cookie `i18n_locale`. Permission-Labels im Admin über `admin.perms.<key>`; Effekt-/Theme-Namen bleiben Eigennamen.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
An imageboard-style upload and discussion platform built with **Nuxt 4** and **shadcn-vue**. Users can upload files or URLs, comment in threads (including file attachments and voice messages), respond with Discord-style reactions, subscribe to threads, and pick from over 20 themes.
|
An imageboard-style upload and discussion platform built with **Nuxt 4** and **shadcn-vue**. Users can upload files or URLs, comment in threads (including file attachments and voice messages), respond with Discord-style reactions, subscribe to threads, and pick from over 20 themes.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Uploads:** Drag & drop (page dropzone + upload popover), file or URL upload, max. 200 MB. Types: **images** (generated thumbnails), **videos** (embedded Plyr player), **audio** (MP3/FLAC with embedded cover art and music metadata via `music-metadata`, dedicated player dialog), **YouTube** and **Twitch embeds**, plus **link preview cards**
|
- **Uploads:** Drag & drop (page dropzone + upload popover), file or URL upload, max. 200 MB. Types: **images** (generated thumbnails), **videos** (embedded Plyr player), **audio** (MP3/FLAC with embedded cover art and music metadata via `music-metadata`, dedicated player dialog), **YouTube** and **Twitch embeds**, plus **link preview cards**
|
||||||
|
|||||||
+2
-1
@@ -3,8 +3,9 @@ import 'vue-sonner/style.css'
|
|||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
|
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
const { locale } = useI18n()
|
||||||
useHead({
|
useHead({
|
||||||
htmlAttrs: { 'data-theme': theme },
|
htmlAttrs: { 'data-theme': theme, lang: locale },
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ function formatTime(sec: number) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
||||||
:aria-label="playing ? 'Pause' : 'Abspielen'"
|
:aria-label="playing ? $t('audio.pause') : $t('audio.play')"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
>
|
>
|
||||||
<Icon name="ph:pause-fill" v-if="playing" class="size-4" />
|
<Icon name="ph:pause-fill" v-if="playing" class="size-4" />
|
||||||
@@ -90,7 +90,7 @@ function formatTime(sec: number) {
|
|||||||
:max="Number.isFinite(duration) ? duration : 0"
|
:max="Number.isFinite(duration) ? duration : 0"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
:value="currentTime"
|
:value="currentTime"
|
||||||
aria-label="Wiedergabeposition"
|
:aria-label="$t('audio.seek')"
|
||||||
class="min-w-0 flex-1 cursor-pointer accent-primary"
|
class="min-w-0 flex-1 cursor-pointer accent-primary"
|
||||||
@input="seek"
|
@input="seek"
|
||||||
>
|
>
|
||||||
@@ -101,7 +101,7 @@ function formatTime(sec: number) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
||||||
:aria-label="muted ? 'Ton an' : 'Stumm'"
|
:aria-label="muted ? $t('audio.unmute') : $t('audio.mute')"
|
||||||
@click="muted = !muted"
|
@click="muted = !muted"
|
||||||
>
|
>
|
||||||
<Icon name="ph:speaker-x-fill" v-if="muted" class="size-4" />
|
<Icon name="ph:speaker-x-fill" v-if="muted" class="size-4" />
|
||||||
@@ -113,7 +113,7 @@ function formatTime(sec: number) {
|
|||||||
min="0"
|
min="0"
|
||||||
max="1"
|
max="1"
|
||||||
step="0.05"
|
step="0.05"
|
||||||
aria-label="Lautstärke"
|
:aria-label="$t('audio.volume')"
|
||||||
class="w-16 min-w-0 cursor-pointer accent-primary sm:w-20"
|
class="w-16 min-w-0 cursor-pointer accent-primary sm:w-20"
|
||||||
:class="muted && 'opacity-40'"
|
:class="muted && 'opacity-40'"
|
||||||
>
|
>
|
||||||
@@ -121,7 +121,7 @@ function formatTime(sec: number) {
|
|||||||
<Button variant="outline" size="sm" class="shrink-0" as-child>
|
<Button variant="outline" size="sm" class="shrink-0" as-child>
|
||||||
<a :href="src" :download="name">
|
<a :href="src" :download="name">
|
||||||
<Icon name="ph:download-fill" class="size-4" />
|
<Icon name="ph:download-fill" class="size-4" />
|
||||||
Herunterladen
|
{{ $t('audio.download') }}
|
||||||
</a>
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,44 +30,44 @@ async function go(to: string) {
|
|||||||
<template>
|
<template>
|
||||||
<CommandDialog
|
<CommandDialog
|
||||||
v-model:open="open"
|
v-model:open="open"
|
||||||
title="Schnellsuche"
|
:title="$t('palette.title')"
|
||||||
description="Seiten, Tags oder Uploader suchen"
|
:description="$t('palette.desc')"
|
||||||
class="rounded-md!"
|
class="rounded-md!"
|
||||||
>
|
>
|
||||||
<CommandInput placeholder="Seiten, Tags oder Uploader suchen…" />
|
<CommandInput :placeholder="$t('palette.placeholder')" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>Keine Ergebnisse.</CommandEmpty>
|
<CommandEmpty>{{ $t('palette.empty') }}</CommandEmpty>
|
||||||
|
|
||||||
<CommandGroup heading="Seiten">
|
<CommandGroup :heading="$t('palette.pages')">
|
||||||
<CommandItem value="Startseite" class="gap-2" @select="go('/')">
|
<CommandItem :value="$t('palette.home')" class="gap-2" @select="go('/')">
|
||||||
<Icon name="ph:house-fill" class="size-4 text-muted-foreground" />
|
<Icon name="ph:house-fill" class="size-4 text-muted-foreground" />
|
||||||
Startseite
|
{{ $t('palette.home') }}
|
||||||
<CommandShortcut>/</CommandShortcut>
|
<CommandShortcut>/</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem value="Benutzer" class="gap-2" @select="go('/users')">
|
<CommandItem :value="$t('palette.users')" class="gap-2" @select="go('/users')">
|
||||||
<Icon name="ph:users-fill" class="size-4 text-muted-foreground" />
|
<Icon name="ph:users-fill" class="size-4 text-muted-foreground" />
|
||||||
Benutzer
|
{{ $t('palette.users') }}
|
||||||
<CommandShortcut>/users</CommandShortcut>
|
<CommandShortcut>/users</CommandShortcut>
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem v-if="user" value="Mein Profil" class="gap-2" @select="go(`/user/${user.id}`)">
|
<CommandItem v-if="user" :value="$t('palette.myProfile')" class="gap-2" @select="go(`/user/${user.id}`)">
|
||||||
<Icon name="ph:user-fill" class="size-4 text-muted-foreground" />
|
<Icon name="ph:user-fill" class="size-4 text-muted-foreground" />
|
||||||
Mein Profil
|
{{ $t('palette.myProfile') }}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem v-if="user" value="Einstellungen" class="gap-2" @select="go('/settings')">
|
<CommandItem v-if="user" :value="$t('palette.settings')" class="gap-2" @select="go('/settings')">
|
||||||
<Icon name="ph:gear-fill" class="size-4 text-muted-foreground" />
|
<Icon name="ph:gear-fill" class="size-4 text-muted-foreground" />
|
||||||
Einstellungen
|
{{ $t('palette.settings') }}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem v-if="can('admin.access')" value="Admin-Bereich" class="gap-2" @select="go('/admin')">
|
<CommandItem v-if="can('admin.access')" :value="$t('palette.adminArea')" class="gap-2" @select="go('/admin')">
|
||||||
<Icon name="ph:shield-fill" class="size-4 text-muted-foreground" />
|
<Icon name="ph:shield-fill" class="size-4 text-muted-foreground" />
|
||||||
Admin-Bereich
|
{{ $t('palette.adminArea') }}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem v-if="!user" value="Login" class="gap-2" @select="go('/login')">
|
<CommandItem v-if="!user" :value="$t('palette.login')" class="gap-2" @select="go('/login')">
|
||||||
<Icon name="ph:sign-in-fill" class="size-4 text-muted-foreground" />
|
<Icon name="ph:sign-in-fill" class="size-4 text-muted-foreground" />
|
||||||
Login
|
{{ $t('palette.login') }}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem v-if="!user" value="Registrieren" class="gap-2" @select="go('/register')">
|
<CommandItem v-if="!user" :value="$t('palette.register')" class="gap-2" @select="go('/register')">
|
||||||
<Icon name="ph:user-plus-fill" class="size-4 text-muted-foreground" />
|
<Icon name="ph:user-plus-fill" class="size-4 text-muted-foreground" />
|
||||||
Registrieren
|
{{ $t('palette.register') }}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ onMounted(async () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CommandGroup v-if="tags.length" heading="Tags">
|
<CommandGroup v-if="tags.length" :heading="$t('palette.tags')">
|
||||||
<CommandItem
|
<CommandItem
|
||||||
v-for="t in tags"
|
v-for="t in tags"
|
||||||
:key="t.name"
|
:key="t.name"
|
||||||
@@ -50,7 +50,7 @@ onMounted(async () => {
|
|||||||
</CommandItem>
|
</CommandItem>
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
|
||||||
<CommandGroup heading="Uploader">
|
<CommandGroup :heading="$t('palette.uploaders')">
|
||||||
<CommandItem
|
<CommandItem
|
||||||
v-for="u in users"
|
v-for="u in users"
|
||||||
:key="u.id"
|
:key="u.id"
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ function fileIcon(a: CommentAttachment) {
|
|||||||
</div>
|
</div>
|
||||||
<template v-if="hasFileAttachments">
|
<template v-if="hasFileAttachments">
|
||||||
<div class="mb-1.5 flex items-center gap-2">
|
<div class="mb-1.5 flex items-center gap-2">
|
||||||
<span class="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">Anhänge</span>
|
<span class="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">{{ $t('comments.attachments') }}</span>
|
||||||
<Separator class="flex-1" />
|
<Separator class="flex-1" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
@@ -80,7 +80,7 @@ function fileIcon(a: CommentAttachment) {
|
|||||||
:key="a.id"
|
:key="a.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="flex max-w-48 cursor-pointer items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs transition-colors hover:border-primary hover:bg-accent"
|
class="flex max-w-48 cursor-pointer items-center gap-1.5 rounded-md border bg-muted/50 px-2 py-1 text-xs transition-colors hover:border-primary hover:bg-accent"
|
||||||
:title="`${a.name} abspielen`"
|
:title="$t('comments.playAttachment', { name: a.name })"
|
||||||
@click.stop="audioAtt = a"
|
@click.stop="audioAtt = a"
|
||||||
>
|
>
|
||||||
<img v-if="a.cover" :src="a.cover" :alt="a.name" class="size-5 shrink-0 rounded-sm object-cover">
|
<img v-if="a.cover" :src="a.cover" :alt="a.name" class="size-5 shrink-0 rounded-sm object-cover">
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ const parts = computed<Part[]>(() => {
|
|||||||
</template>
|
</template>
|
||||||
<!-- Kompakt-Modus (Sidebar): Icon + Hostname statt Embed/Card -->
|
<!-- Kompakt-Modus (Sidebar): Icon + Hostname statt Embed/Card -->
|
||||||
<span
|
<span
|
||||||
v-else-if="compact && part.type !== 'text'"
|
v-else-if="compact"
|
||||||
class="mx-0.5 inline-flex items-center gap-1 rounded-sm bg-muted px-1.5 py-0.5 align-middle text-xs text-muted-foreground"
|
class="mx-0.5 inline-flex items-center gap-1 rounded-sm bg-muted px-1.5 py-0.5 align-middle text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
<Icon v-if="part.type === 'youtube'" name="simple-icons:youtube" class="size-3.5" />
|
<Icon v-if="part.type === 'youtube'" name="simple-icons:youtube" class="size-3.5" />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover
|
|||||||
const props = defineProps<{ commentId: number }>()
|
const props = defineProps<{ commentId: number }>()
|
||||||
|
|
||||||
const { toggle } = useCommentReactions()
|
const { toggle } = useCommentReactions()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
@@ -17,7 +18,7 @@ async function pick(emoji: string) {
|
|||||||
await toggle(props.commentId, emoji)
|
await toggle(props.commentId, emoji)
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
toast.error('Reaktion fehlgeschlagen')
|
toast.error(t('comments.reactionFailed'))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
@@ -31,7 +32,7 @@ async function pick(emoji: string) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="cursor-pointer self-center rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
class="cursor-pointer self-center rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||||
aria-label="Reaktion hinzufügen"
|
:aria-label="$t('comments.addReaction')"
|
||||||
>
|
>
|
||||||
<Icon name="ph:smiley-fill" class="size-5" />
|
<Icon name="ph:smiley-fill" class="size-5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const props = defineProps<{ commentId: number }>()
|
|||||||
|
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { reactionsFor, toggle } = useCommentReactions()
|
const { reactionsFor, toggle } = useCommentReactions()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const pending = ref<string | null>(null)
|
const pending = ref<string | null>(null)
|
||||||
@@ -21,7 +22,7 @@ const burstNonce = ref(0)
|
|||||||
|
|
||||||
async function pick(emoji: string) {
|
async function pick(emoji: string) {
|
||||||
if (!user.value) {
|
if (!user.value) {
|
||||||
toast.error('Melde dich an, um zu reagieren.')
|
toast.error(t('comments.loginToReact'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
@@ -33,7 +34,7 @@ async function pick(emoji: string) {
|
|||||||
await toggle(props.commentId, emoji)
|
await toggle(props.commentId, emoji)
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
toast.error('Reaktion fehlgeschlagen')
|
toast.error(t('comments.reactionFailed'))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const mergedComments = computed(() => {
|
|||||||
<Sidebar side="right" collapsible="none" class="sticky top-0 hidden h-svh shrink-0 overflow-hidden rounded-l-md border-l lg:flex">
|
<Sidebar side="right" collapsible="none" class="sticky top-0 hidden h-svh shrink-0 overflow-hidden rounded-l-md border-l lg:flex">
|
||||||
<SidebarContent class="px-2 py-2">
|
<SidebarContent class="px-2 py-2">
|
||||||
<p v-if="!mergedComments.length" class="px-2 py-1 text-sm text-muted-foreground">
|
<p v-if="!mergedComments.length" class="px-2 py-1 text-sm text-muted-foreground">
|
||||||
Noch keine Kommentare.
|
{{ $t('comments.none') }}
|
||||||
</p>
|
</p>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-for="comment in mergedComments"
|
v-for="comment in mergedComments"
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ const globalFilter = computed(() => String(table.atoms.globalFilter.get() ?? '')
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</template>
|
</template>
|
||||||
<TableEmpty v-else :colspan="columns.length">
|
<TableEmpty v-else :colspan="columns.length">
|
||||||
Keine Einträge.
|
{{ $t('misc.noEntries') }}
|
||||||
</TableEmpty>
|
</TableEmpty>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
@@ -98,7 +98,7 @@ const globalFilter = computed(() => String(table.atoms.globalFilter.get() ?? '')
|
|||||||
|
|
||||||
<div v-if="pageCount > 1" class="flex items-center justify-between">
|
<div v-if="pageCount > 1" class="flex items-center justify-between">
|
||||||
<span class="text-sm text-muted-foreground">
|
<span class="text-sm text-muted-foreground">
|
||||||
Seite {{ pageIndex + 1 }} von {{ pageCount }}
|
{{ $t('misc.pageOf', { page: pageIndex + 1, total: pageCount }) }}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -107,7 +107,7 @@ const globalFilter = computed(() => String(table.atoms.globalFilter.get() ?? '')
|
|||||||
:disabled="!table.getCanPreviousPage()"
|
:disabled="!table.getCanPreviousPage()"
|
||||||
@click="table.previousPage()"
|
@click="table.previousPage()"
|
||||||
>
|
>
|
||||||
Zurück
|
{{ $t('common.previous') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -115,7 +115,7 @@ const globalFilter = computed(() => String(table.atoms.globalFilter.get() ?? '')
|
|||||||
:disabled="!table.getCanNextPage()"
|
:disabled="!table.getCanNextPage()"
|
||||||
@click="table.nextPage()"
|
@click="table.nextPage()"
|
||||||
>
|
>
|
||||||
Weiter
|
{{ $t('common.next') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ const props = withDefaults(defineProps<{
|
|||||||
hint?: string
|
hint?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
accept: 'image/*,video/*,audio/*,.mp3,.flac',
|
accept: 'image/*,video/*,audio/*,.mp3,.flac',
|
||||||
hint: 'Bilder, Videos & Audio, max. 200 MB',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const hintText = computed(() => props.hint ?? t('upload.dropzoneHint'))
|
||||||
|
|
||||||
const dropzoneRef = ref<HTMLElement>()
|
const dropzoneRef = ref<HTMLElement>()
|
||||||
const inputRef = ref<HTMLInputElement>()
|
const inputRef = ref<HTMLInputElement>()
|
||||||
const previewUrl = ref<string | null>(null)
|
const previewUrl = ref<string | null>(null)
|
||||||
@@ -25,7 +27,7 @@ const { isOverDropZone } = useDropZone(dropzoneRef, {
|
|||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
const file = files?.find(isMediaFile)
|
const file = files?.find(isMediaFile)
|
||||||
if (file) select(file)
|
if (file) select(file)
|
||||||
else if (files?.length) toast.error('Nur Bilder, Videos und Audiodateien sind erlaubt')
|
else if (files?.length) toast.error(t('upload.onlyMedia'))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -94,14 +96,14 @@ onUnmounted(() => {
|
|||||||
@click.stop="select(null)"
|
@click.stop="select(null)"
|
||||||
>
|
>
|
||||||
<Icon name="ph:x-fill" class="size-4" />
|
<Icon name="ph:x-fill" class="size-4" />
|
||||||
Entfernen
|
{{ $t('common.remove') }}
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Icon name="ph:cloud-arrow-up-fill" class="size-8 text-muted-foreground" />
|
<Icon name="ph:cloud-arrow-up-fill" class="size-8 text-muted-foreground" />
|
||||||
<span>Datei hierher ziehen oder klicken</span>
|
<span>{{ $t('upload.dropzoneText') }}</span>
|
||||||
<span class="text-xs text-muted-foreground">{{ hint }}</span>
|
<span class="text-xs text-muted-foreground">{{ hintText }}</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ onKeyStroke('Escape', () => emit('close'))
|
|||||||
@click="emit('close')"
|
@click="emit('close')"
|
||||||
>
|
>
|
||||||
<Icon name="ph:x-fill" class="size-5" />
|
<Icon name="ph:x-fill" class="size-5" />
|
||||||
<span class="sr-only">Schließen</span>
|
<span class="sr-only">{{ $t('common.close') }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
v-model="model"
|
v-model="model"
|
||||||
:placeholder="props.placeholder"
|
:placeholder="props.placeholder"
|
||||||
class="min-h-32 pb-9"
|
class="min-h-32 pb-9"
|
||||||
aria-label="Kommentar"
|
:aria-label="$t('comments.commentAria')"
|
||||||
aria-autocomplete="list"
|
aria-autocomplete="list"
|
||||||
:aria-expanded="open"
|
:aria-expanded="open"
|
||||||
:aria-controls="open ? listId : undefined"
|
:aria-controls="open ? listId : undefined"
|
||||||
@@ -92,7 +92,7 @@ function onKeydown(event: KeyboardEvent) {
|
|||||||
@close-auto-focus.prevent
|
@close-auto-focus.prevent
|
||||||
@interact-outside="event => { if (event.target === textarea) event.preventDefault() }"
|
@interact-outside="event => { if (event.target === textarea) event.preventDefault() }"
|
||||||
>
|
>
|
||||||
<div :id="listId" role="listbox" aria-label="Benutzer erwähnen">
|
<div :id="listId" role="listbox" :aria-label="$t('comments.mentionAria')">
|
||||||
<button
|
<button
|
||||||
v-for="(user, index) in suggestions"
|
v-for="(user, index) in suggestions"
|
||||||
:id="`${listId}-${index}`"
|
:id="`${listId}-${index}`"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async function goTo(n: AppNotification) {
|
|||||||
<template>
|
<template>
|
||||||
<Popover v-model:open="open">
|
<Popover v-model:open="open">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost" size="icon" class="relative" aria-label="Benachrichtigungen">
|
<Button variant="ghost" size="icon" class="relative" :aria-label="$t('notifications.aria')">
|
||||||
<Icon name="ph:bell-fill" class="size-5" />
|
<Icon name="ph:bell-fill" class="size-5" />
|
||||||
<span
|
<span
|
||||||
v-if="unreadCount"
|
v-if="unreadCount"
|
||||||
@@ -32,7 +32,7 @@ async function goTo(n: AppNotification) {
|
|||||||
<PopoverContent align="end" class="w-80 gap-0 p-0">
|
<PopoverContent align="end" class="w-80 gap-0 p-0">
|
||||||
<div class="max-h-96 overflow-y-auto">
|
<div class="max-h-96 overflow-y-auto">
|
||||||
<p v-if="!notifications.length" class="px-4 py-6 text-center text-sm text-muted-foreground">
|
<p v-if="!notifications.length" class="px-4 py-6 text-center text-sm text-muted-foreground">
|
||||||
Keine Benachrichtigungen.
|
{{ $t('notifications.empty') }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
v-for="n in notifications"
|
v-for="n in notifications"
|
||||||
@@ -69,7 +69,7 @@ async function goTo(n: AppNotification) {
|
|||||||
class="truncate font-medium"
|
class="truncate font-medium"
|
||||||
:style="n.actorColor ? { color: n.actorColor } : undefined"
|
:style="n.actorColor ? { color: n.actorColor } : undefined"
|
||||||
>{{ n.actorName }}</span>
|
>{{ n.actorName }}</span>
|
||||||
<span class="shrink-0 text-muted-foreground">{{ n.isMention ? 'hat dich erwähnt' : 'hat kommentiert' }}</span>
|
<span class="shrink-0 text-muted-foreground">{{ n.isMention ? $t('notifications.mentioned') : $t('notifications.commented') }}</span>
|
||||||
<span class="ml-auto shrink-0 text-xs text-muted-foreground">{{ timeAgo(n.createdAt) }}</span>
|
<span class="ml-auto shrink-0 text-xs text-muted-foreground">{{ timeAgo(n.createdAt) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-0.5 line-clamp-2 break-words text-muted-foreground">
|
<p class="mt-0.5 line-clamp-2 break-words text-muted-foreground">
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ async function onLogout() {
|
|||||||
<Button variant="ghost" as-child>
|
<Button variant="ghost" as-child>
|
||||||
<NuxtLink to="/users" class="flex items-center gap-1.5">
|
<NuxtLink to="/users" class="flex items-center gap-1.5">
|
||||||
<Icon name="ph:users-fill" class="size-4" />
|
<Icon name="ph:users-fill" class="size-4" />
|
||||||
Benutzer
|
{{ $t('nav.users') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-if="can('admin.access')" variant="ghost" as-child>
|
<Button v-if="can('admin.access')" variant="ghost" as-child>
|
||||||
@@ -56,12 +56,12 @@ async function onLogout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="ml-auto flex h-9 cursor-pointer items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
class="ml-auto flex h-9 cursor-pointer items-center gap-2 rounded-md border bg-muted/40 px-3 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||||
aria-label="Schnellsuche öffnen (Strg+K)"
|
:aria-label="$t('nav.searchAria')"
|
||||||
@click="paletteOpen = true"
|
@click="paletteOpen = true"
|
||||||
>
|
>
|
||||||
<Icon name="ph:magnifying-glass-fill" class="size-4" />
|
<Icon name="ph:magnifying-glass-fill" class="size-4" />
|
||||||
<span class="hidden text-left sm:inline">Suchen…</span>
|
<span class="hidden text-left sm:inline">{{ $t('nav.search') }}</span>
|
||||||
<kbd class="hidden rounded-sm border bg-background px-1.5 text-xs sm:inline">Strg K</kbd>
|
<kbd class="hidden rounded-sm border bg-background px-1.5 text-xs sm:inline">{{ $t('nav.searchShortcut') }}</kbd>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -75,7 +75,7 @@ async function onLogout() {
|
|||||||
<Icon name="ph:squares-four-fill" v-else class="size-5" />
|
<Icon name="ph:squares-four-fill" v-else class="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{{ uniform ? 'Bento-Layout' : 'Einheitliche Größe' }}</TooltipContent>
|
<TooltipContent>{{ uniform ? $t('nav.gridBento') : $t('nav.gridUniform') }}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<ThemeSelector />
|
<ThemeSelector />
|
||||||
<template v-if="user">
|
<template v-if="user">
|
||||||
@@ -104,29 +104,29 @@ async function onLogout() {
|
|||||||
<DropdownMenuItem as-child>
|
<DropdownMenuItem as-child>
|
||||||
<NuxtLink :to="`/user/${user.id}`" class="flex items-center gap-2">
|
<NuxtLink :to="`/user/${user.id}`" class="flex items-center gap-2">
|
||||||
<Icon name="ph:user-fill" class="size-4" />
|
<Icon name="ph:user-fill" class="size-4" />
|
||||||
Profil
|
{{ $t('nav.profile') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem as-child>
|
<DropdownMenuItem as-child>
|
||||||
<NuxtLink to="/settings" class="flex items-center gap-2">
|
<NuxtLink to="/settings" class="flex items-center gap-2">
|
||||||
<Icon name="ph:gear-fill" class="size-4" />
|
<Icon name="ph:gear-fill" class="size-4" />
|
||||||
Einstellungen
|
{{ $t('nav.settings') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem variant="destructive" class="flex items-center gap-2" @click="onLogout">
|
<DropdownMenuItem variant="destructive" class="flex items-center gap-2" @click="onLogout">
|
||||||
<Icon name="ph:sign-out-fill" class="size-4" />
|
<Icon name="ph:sign-out-fill" class="size-4" />
|
||||||
Ausloggen
|
{{ $t('auth.logout') }}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Button variant="ghost" as-child>
|
<Button variant="ghost" as-child>
|
||||||
<NuxtLink to="/login">Login</NuxtLink>
|
<NuxtLink to="/login">{{ $t('auth.login') }}</NuxtLink>
|
||||||
</Button>
|
</Button>
|
||||||
<Button as-child>
|
<Button as-child>
|
||||||
<NuxtLink to="/register">Registrieren</NuxtLink>
|
<NuxtLink to="/register">{{ $t('auth.register') }}</NuxtLink>
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|||||||
const props = defineProps<{ uploadId: string, enabled: boolean }>()
|
const props = defineProps<{ uploadId: string, enabled: boolean }>()
|
||||||
const emit = defineEmits<{ change: [uploadId: string, enabled: boolean] }>()
|
const emit = defineEmits<{ change: [uploadId: string, enabled: boolean] }>()
|
||||||
const busy = shallowRef(false)
|
const busy = shallowRef(false)
|
||||||
|
const { t } = useI18n()
|
||||||
const label = computed(() => props.enabled
|
const label = computed(() => props.enabled
|
||||||
? 'Benachrichtigungen für diesen Thread deaktivieren'
|
? t('thread.subscribeOff')
|
||||||
: 'Benachrichtigungen für diesen Thread aktivieren')
|
: t('thread.subscribeOn'))
|
||||||
|
|
||||||
async function toggle() {
|
async function toggle() {
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
@@ -20,10 +21,10 @@ async function toggle() {
|
|||||||
body: { enabled: !props.enabled },
|
body: { enabled: !props.enabled },
|
||||||
})
|
})
|
||||||
emit('change', uploadId, result.enabled)
|
emit('change', uploadId, result.enabled)
|
||||||
toast.success(result.enabled ? 'Thread-Benachrichtigungen aktiviert' : 'Thread-Benachrichtigungen deaktiviert')
|
toast.success(result.enabled ? t('thread.subOn') : t('thread.subOff'))
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
toast.error('Abonnement konnte nicht geändert werden')
|
toast.error(t('thread.subFailed'))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
|
|||||||
@@ -25,21 +25,22 @@ interface ListedUser {
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: 'image', label: 'Bilder', icon: 'ph:image-fill' },
|
{ value: 'image', labelKey: 'filters.images', icon: 'ph:image-fill' },
|
||||||
{ value: 'video', label: 'Videos', icon: 'ph:video-camera-fill' },
|
{ value: 'video', labelKey: 'filters.videos', icon: 'ph:video-camera-fill' },
|
||||||
{ value: 'audio', label: 'Audio', icon: 'ph:music-note-fill' },
|
{ value: 'audio', labelKey: 'filters.audio', icon: 'ph:music-note-fill' },
|
||||||
{ value: 'youtube', label: 'YouTube', icon: 'simple-icons:youtube' },
|
{ value: 'youtube', labelKey: null, icon: 'simple-icons:youtube' },
|
||||||
{ value: 'twitch', label: 'Twitch', icon: 'simple-icons:twitch' },
|
{ value: 'twitch', labelKey: null, icon: 'simple-icons:twitch' },
|
||||||
{ value: 'link', label: 'Links', icon: 'ph:link-fill' },
|
{ value: 'link', labelKey: 'filters.links', icon: 'ph:link-fill' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const DAY_OPTIONS = [
|
const DAY_OPTIONS = [
|
||||||
{ value: '1', label: 'Heute' },
|
{ value: '1', labelKey: 'filters.today' },
|
||||||
{ value: '7', label: '7 Tage' },
|
{ value: '7', labelKey: 'filters.days7' },
|
||||||
{ value: '30', label: '30 Tage' },
|
{ value: '30', labelKey: 'filters.days30' },
|
||||||
{ value: '365', label: 'Letztes Jahr' },
|
{ value: '365', labelKey: 'filters.lastYear' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
@@ -79,7 +80,10 @@ const daysModel = computed<string>({
|
|||||||
})
|
})
|
||||||
const daysOption = computed(() => DAY_OPTIONS.find(d => d.value === daysModel.value))
|
const daysOption = computed(() => DAY_OPTIONS.find(d => d.value === daysModel.value))
|
||||||
|
|
||||||
const typeLabel = (value: string) => TYPE_OPTIONS.find(t => t.value === value)?.label ?? value
|
const typeLabel = (value: string) => {
|
||||||
|
const option = TYPE_OPTIONS.find(o => o.value === value)
|
||||||
|
return option ? (option.labelKey ? t(option.labelKey) : option.value) : value
|
||||||
|
}
|
||||||
const typeIcon = (value: string) => TYPE_OPTIONS.find(t => t.value === value)?.icon ?? 'ph:image-fill'
|
const typeIcon = (value: string) => TYPE_OPTIONS.find(t => t.value === value)?.icon ?? 'ph:image-fill'
|
||||||
|
|
||||||
const activeCount = computed(() =>
|
const activeCount = computed(() =>
|
||||||
@@ -111,36 +115,36 @@ function clearFilters() {
|
|||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="outline" size="sm" class="gap-1.5">
|
<Button variant="outline" size="sm" class="gap-1.5">
|
||||||
<Icon name="ph:sliders-horizontal-fill" class="size-4" />
|
<Icon name="ph:sliders-horizontal-fill" class="size-4" />
|
||||||
Filter
|
{{ $t('filters.filter') }}
|
||||||
<Badge v-if="activeCount" variant="secondary" class="ml-0.5 px-1.5 text-xs">{{ activeCount }}</Badge>
|
<Badge v-if="activeCount" variant="secondary" class="ml-0.5 px-1.5 text-xs">{{ activeCount }}</Badge>
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-72" align="start">
|
<PopoverContent class="w-72" align="start">
|
||||||
<FieldGroup class="gap-4">
|
<FieldGroup class="gap-4">
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Typ</FieldLabel>
|
<FieldLabel>{{ $t('filters.type') }}</FieldLabel>
|
||||||
<ToggleGroup v-model="selectedTypes" type="multiple" variant="outline" :spacing="2" class="flex-wrap justify-start">
|
<ToggleGroup v-model="selectedTypes" type="multiple" variant="outline" :spacing="2" class="flex-wrap justify-start">
|
||||||
<ToggleGroupItem
|
<ToggleGroupItem
|
||||||
v-for="t in TYPE_OPTIONS"
|
v-for="o in TYPE_OPTIONS"
|
||||||
:key="t.value"
|
:key="o.value"
|
||||||
:value="t.value"
|
:value="o.value"
|
||||||
:aria-label="t.label"
|
:aria-label="typeLabel(o.value)"
|
||||||
class="gap-1.5 rounded-md px-2.5"
|
class="gap-1.5 rounded-md px-2.5"
|
||||||
>
|
>
|
||||||
<Icon :name="t.icon" class="size-4" />
|
<Icon :name="o.icon" class="size-4" />
|
||||||
{{ t.label }}
|
{{ typeLabel(o.value) }}
|
||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Uploader</FieldLabel>
|
<FieldLabel>{{ $t('filters.uploader') }}</FieldLabel>
|
||||||
<Popover v-model:open="uploaderOpen">
|
<Popover v-model:open="uploaderOpen">
|
||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="outline" role="combobox" :aria-expanded="uploaderOpen" class="w-full justify-between font-normal">
|
<Button variant="outline" role="combobox" :aria-expanded="uploaderOpen" class="w-full justify-between font-normal">
|
||||||
<span class="flex items-center gap-1.5 truncate">
|
<span class="flex items-center gap-1.5 truncate">
|
||||||
<Icon name="ph:user-fill" class="size-4 shrink-0 text-muted-foreground" />
|
<Icon name="ph:user-fill" class="size-4 shrink-0 text-muted-foreground" />
|
||||||
<span :style="selectedUser?.color ? { color: selectedUser.color } : undefined" class="truncate">
|
<span :style="selectedUser?.color ? { color: selectedUser.color } : undefined" class="truncate">
|
||||||
{{ selectedUser?.name ?? 'Alle Uploader' }}
|
{{ selectedUser?.name ?? $t('filters.allUploaders') }}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<Icon name="ph:caret-up-down-fill" class="size-4 shrink-0 text-muted-foreground" />
|
<Icon name="ph:caret-up-down-fill" class="size-4 shrink-0 text-muted-foreground" />
|
||||||
@@ -148,13 +152,13 @@ function clearFilters() {
|
|||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent class="w-64 p-0">
|
<PopoverContent class="w-64 p-0">
|
||||||
<Command>
|
<Command>
|
||||||
<CommandInput placeholder="Nutzer suchen…" />
|
<CommandInput :placeholder="$t('filters.searchUser')" />
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>Kein Nutzer gefunden.</CommandEmpty>
|
<CommandEmpty>{{ $t('filters.noUser') }}</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
<CommandItem value="alle" class="gap-2" @select="selectUser(null)">
|
<CommandItem value="alle" class="gap-2" @select="selectUser(null)">
|
||||||
<Icon name="ph:check-fill" :class="cn('size-4', !uploaderId ? 'opacity-100' : 'opacity-0')" />
|
<Icon name="ph:check-fill" :class="cn('size-4', !uploaderId ? 'opacity-100' : 'opacity-0')" />
|
||||||
Alle Uploader
|
{{ $t('filters.allUploaders') }}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
<CommandItem
|
<CommandItem
|
||||||
v-for="u in uploaders"
|
v-for="u in uploaders"
|
||||||
@@ -187,19 +191,19 @@ function clearFilters() {
|
|||||||
</Popover>
|
</Popover>
|
||||||
</Field>
|
</Field>
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>Zeitraum</FieldLabel>
|
<FieldLabel>{{ $t('filters.period') }}</FieldLabel>
|
||||||
<Select v-model="daysModel">
|
<Select v-model="daysModel">
|
||||||
<SelectTrigger class="w-full">
|
<SelectTrigger class="w-full">
|
||||||
<SelectValue placeholder="Alle" />
|
<SelectValue :placeholder="$t('filters.allTime')" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">Gesamter Zeitraum</SelectItem>
|
<SelectItem value="all">{{ $t('filters.allTime') }}</SelectItem>
|
||||||
<SelectItem v-for="d in DAY_OPTIONS" :key="d.value" :value="d.value">{{ d.label }}</SelectItem>
|
<SelectItem v-for="d in DAY_OPTIONS" :key="d.value" :value="d.value">{{ $t(d.labelKey) }}</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Button variant="ghost" size="sm" :disabled="!activeCount" class="self-start" @click="clearFilters">
|
<Button variant="ghost" size="sm" :disabled="!activeCount" class="self-start" @click="clearFilters">
|
||||||
Zurücksetzen
|
{{ $t('common.reset') }}
|
||||||
</Button>
|
</Button>
|
||||||
</FieldGroup>
|
</FieldGroup>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -211,7 +215,7 @@ function clearFilters() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-muted-foreground transition-colors hover:text-foreground"
|
class="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
aria-label="Tag-Filter entfernen"
|
:aria-label="$t('filters.removeTag')"
|
||||||
@click="setQuery({ tag: undefined })"
|
@click="setQuery({ tag: undefined })"
|
||||||
>
|
>
|
||||||
<Icon name="ph:x-fill" class="size-3.5" />
|
<Icon name="ph:x-fill" class="size-3.5" />
|
||||||
@@ -223,7 +227,7 @@ function clearFilters() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-muted-foreground transition-colors hover:text-foreground"
|
class="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
:aria-label="`Typ-Filter ${typeLabel(t)} entfernen`"
|
:aria-label="$t('filters.removeType', { label: typeLabel(t) })"
|
||||||
@click="selectedTypes = selectedTypes.filter(v => v !== t)"
|
@click="selectedTypes = selectedTypes.filter(v => v !== t)"
|
||||||
>
|
>
|
||||||
<Icon name="ph:x-fill" class="size-3.5" />
|
<Icon name="ph:x-fill" class="size-3.5" />
|
||||||
@@ -235,7 +239,7 @@ function clearFilters() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-muted-foreground transition-colors hover:text-foreground"
|
class="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
aria-label="Uploader-Filter entfernen"
|
:aria-label="$t('filters.removeUploader')"
|
||||||
@click="setQuery({ uploader: undefined })"
|
@click="setQuery({ uploader: undefined })"
|
||||||
>
|
>
|
||||||
<Icon name="ph:x-fill" class="size-3.5" />
|
<Icon name="ph:x-fill" class="size-3.5" />
|
||||||
@@ -243,11 +247,11 @@ function clearFilters() {
|
|||||||
</span>
|
</span>
|
||||||
<span v-if="daysOption" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
|
<span v-if="daysOption" class="inline-flex items-center gap-1.5 rounded-sm bg-muted px-2.5 py-1 text-sm">
|
||||||
<Icon name="ph:calendar-fill" class="size-3.5" />
|
<Icon name="ph:calendar-fill" class="size-3.5" />
|
||||||
{{ daysOption.label }}
|
{{ daysOption ? $t(daysOption.labelKey) : '' }}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="text-muted-foreground transition-colors hover:text-foreground"
|
class="text-muted-foreground transition-colors hover:text-foreground"
|
||||||
aria-label="Zeitraum-Filter entfernen"
|
:aria-label="$t('filters.removePeriod')"
|
||||||
@click="setQuery({ days: undefined })"
|
@click="setQuery({ days: undefined })"
|
||||||
>
|
>
|
||||||
<Icon name="ph:x-fill" class="size-3.5" />
|
<Icon name="ph:x-fill" class="size-3.5" />
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '@/components/ui/popover'
|
} from '@/components/ui/popover'
|
||||||
|
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const { t } = useI18n()
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const file = ref<File | null>(null)
|
const file = ref<File | null>(null)
|
||||||
const url = ref('')
|
const url = ref('')
|
||||||
@@ -43,7 +44,7 @@ async function onSubmit() {
|
|||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const e = err as { data?: { statusMessage?: string, message?: string } }
|
const e = err as { data?: { statusMessage?: string, message?: string } }
|
||||||
errorMessage.value = e.data?.statusMessage ?? e.data?.message ?? 'Upload fehlgeschlagen'
|
errorMessage.value = e.data?.statusMessage ?? e.data?.message ?? t('upload.failed')
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -56,39 +57,39 @@ async function onSubmit() {
|
|||||||
<PopoverTrigger as-child>
|
<PopoverTrigger as-child>
|
||||||
<Button variant="ghost">
|
<Button variant="ghost">
|
||||||
<Icon name="ph:cloud-arrow-up-fill" class="size-4" />
|
<Icon name="ph:cloud-arrow-up-fill" class="size-4" />
|
||||||
Hochladen
|
{{ $t('upload.upload') }}
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent align="start" class="w-160">
|
<PopoverContent align="start" class="w-160">
|
||||||
<template v-if="user">
|
<template v-if="user">
|
||||||
<PopoverHeader>
|
<PopoverHeader>
|
||||||
<PopoverTitle>Hochladen</PopoverTitle>
|
<PopoverTitle>{{ $t('upload.upload') }}</PopoverTitle>
|
||||||
<PopoverDescription>Datei oder URL — Bilder, Videos, Audio (MP3/FLAC), YouTube- und Twitch-Links.</PopoverDescription>
|
<PopoverDescription>{{ $t('upload.desc') }}</PopoverDescription>
|
||||||
</PopoverHeader>
|
</PopoverHeader>
|
||||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>Datei</Label>
|
<Label>{{ $t('upload.file') }}</Label>
|
||||||
<FileDropzone v-model="file" :disabled="!!url.trim()" />
|
<FileDropzone v-model="file" :disabled="!!url.trim()" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label for="upload-url">oder URL</Label>
|
<Label for="upload-url">{{ $t('upload.orUrl') }}</Label>
|
||||||
<Input id="upload-url" v-model="url" type="url" placeholder="https://…" :disabled="!!file" />
|
<Input id="upload-url" v-model="url" type="url" placeholder="https://…" :disabled="!!file" />
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
Direkte Bild-/Video-URLs werden gespeichert, YouTube- und Twitch-Links eingebettet.
|
{{ $t('upload.urlHint') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="errorMessage" class="text-sm text-destructive">
|
<p v-if="errorMessage" class="text-sm text-destructive">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
<Button type="submit" class="w-full" :disabled="loading || (!file && !url.trim())">
|
<Button type="submit" class="w-full" :disabled="loading || (!file && !url.trim())">
|
||||||
{{ loading ? 'Lädt hoch…' : 'Hochladen' }}
|
{{ loading ? $t('upload.uploading') : $t('upload.upload') }}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</template>
|
||||||
<p v-else class="text-sm text-muted-foreground">
|
<p v-else class="text-sm text-muted-foreground">
|
||||||
<NuxtLink to="/login" class="text-primary underline" @click="open = false">
|
<NuxtLink to="/login" class="text-primary underline" @click="open = false">
|
||||||
Melde dich an
|
{{ $t('common.loginLink') }}
|
||||||
</NuxtLink>, um Dateien hochzuladen.
|
</NuxtLink>{{ $t('upload.loginToUpload') }}
|
||||||
</p>
|
</p>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const emit = defineEmits<{ subscriptionChange: [uploadId: string, enabled: boole
|
|||||||
|
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { reactionsFor, toggle } = useUploadReactions()
|
const { reactionsFor, toggle } = useUploadReactions()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const busy = ref(false)
|
const busy = ref(false)
|
||||||
const pending = ref<string | null>(null)
|
const pending = ref<string | null>(null)
|
||||||
@@ -22,7 +23,7 @@ const burstNonce = ref(0)
|
|||||||
|
|
||||||
async function pick(emoji: string) {
|
async function pick(emoji: string) {
|
||||||
if (!user.value) {
|
if (!user.value) {
|
||||||
toast.error('Melde dich an, um zu reagieren.')
|
toast.error(t('comments.loginToReact'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (busy.value) return
|
if (busy.value) return
|
||||||
@@ -36,7 +37,7 @@ async function pick(emoji: string) {
|
|||||||
emit('subscriptionChange', uploadId, result.isSubscribed)
|
emit('subscriptionChange', uploadId, result.isSubscribed)
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
toast.error('Reaktion fehlgeschlagen')
|
toast.error(t('comments.reactionFailed'))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { toast } from 'vue-sonner'
|
import { toast } from 'vue-sonner'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface TileUpload {
|
interface TileUpload {
|
||||||
id: string
|
id: string
|
||||||
@@ -14,6 +15,7 @@ interface TileUpload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{ upload: TileUpload }>()
|
const props = defineProps<{ upload: TileUpload }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const { unreadUploadIds } = useNotifications()
|
const { unreadUploadIds } = useNotifications()
|
||||||
const { uniform } = useGridMode()
|
const { uniform } = useGridMode()
|
||||||
@@ -98,7 +100,7 @@ async function openUpload(e: MouseEvent) {
|
|||||||
catch {
|
catch {
|
||||||
indicator.finish({ error: true })
|
indicator.finish({ error: true })
|
||||||
navigating.value = false
|
navigating.value = false
|
||||||
toast.error('Thread konnte nicht geladen werden')
|
toast.error(t('upload.threadLoadFailed'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +119,7 @@ async function openUpload(e: MouseEvent) {
|
|||||||
@click="openUpload"
|
@click="openUpload"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="upload-tile-media relative h-full w-full overflow-hidden rounded-md"
|
:class="cn('upload-tile-media relative h-full w-full overflow-hidden rounded-md', hasNotification && 'ring-4 ring-primary')"
|
||||||
:style="lastViewedId === upload.id ? 'view-transition-name: upload-media' : undefined"
|
:style="lastViewedId === upload.id ? 'view-transition-name: upload-media' : undefined"
|
||||||
>
|
>
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { summaries, ensureSummary } = useUserSummaries()
|
const { summaries, ensureSummary } = useUserSummaries()
|
||||||
|
const { locale } = useI18n()
|
||||||
const summary = computed(() => summaries.value[props.userId])
|
const summary = computed(() => summaries.value[props.userId])
|
||||||
const avatar = computed(() => summary.value?.avatarUrl ?? props.userAvatarUrl ?? null)
|
const avatar = computed(() => summary.value?.avatarUrl ?? props.userAvatarUrl ?? null)
|
||||||
const color = computed(() => summary.value?.color ?? props.userColor ?? null)
|
const color = computed(() => summary.value?.color ?? props.userColor ?? null)
|
||||||
@@ -37,7 +38,7 @@ function onOpen(open: boolean) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(value: string) {
|
function formatDate(value: string) {
|
||||||
return new Date(value).toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })
|
return new Date(value).toLocaleDateString(locale.value, { day: '2-digit', month: 'short', year: 'numeric' })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -78,18 +79,18 @@ function formatDate(value: string) {
|
|||||||
</p>
|
</p>
|
||||||
<p v-if="summary" class="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
<p v-if="summary" class="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Icon name="ph:calendar-fill" class="size-3" />
|
<Icon name="ph:calendar-fill" class="size-3" />
|
||||||
seit {{ formatDate(summary.createdAt) }}
|
{{ $t('misc.since', { date: formatDate(summary.createdAt) }) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="summary" class="relative mt-3 flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
<div v-if="summary" class="relative mt-3 flex items-center gap-4 border-t pt-3 text-xs text-muted-foreground">
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
<Icon name="ph:cloud-arrow-up-fill" class="size-3.5" />
|
<Icon name="ph:cloud-arrow-up-fill" class="size-3.5" />
|
||||||
{{ summary.stats.uploads }} Uploads
|
{{ summary.stats.uploads }} {{ $t('misc.uploadsCol') }}
|
||||||
</span>
|
</span>
|
||||||
<span class="flex items-center gap-1.5">
|
<span class="flex items-center gap-1.5">
|
||||||
<Icon name="ph:chat-fill" class="size-3.5" />
|
<Icon name="ph:chat-fill" class="size-3.5" />
|
||||||
{{ summary.stats.comments }} Kommentare
|
{{ summary.stats.comments }} {{ $t('misc.commentsCol') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<CardBorderEffect v-if="borderEffect" :effect="borderEffect" :color="summary?.cardBorderEffectColor ?? color ?? '#6366f1'" />
|
<CardBorderEffect v-if="borderEffect" :effect="borderEffect" :color="summary?.cardBorderEffectColor ?? color ?? '#6366f1'" />
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function formatTime(sec: number) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
class="flex size-8 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
||||||
:aria-label="playing ? 'Pause' : 'Abspielen'"
|
:aria-label="playing ? $t('audio.pause') : $t('audio.play')"
|
||||||
@click="toggle"
|
@click="toggle"
|
||||||
>
|
>
|
||||||
<Icon name="ph:pause-fill" v-if="playing" class="size-4" />
|
<Icon name="ph:pause-fill" v-if="playing" class="size-4" />
|
||||||
@@ -47,7 +47,7 @@ function formatTime(sec: number) {
|
|||||||
:max="Number.isFinite(duration) ? duration : 0"
|
:max="Number.isFinite(duration) ? duration : 0"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
:value="currentTime"
|
:value="currentTime"
|
||||||
aria-label="Wiedergabeposition"
|
:aria-label="$t('audio.seek')"
|
||||||
class="min-w-0 flex-1 cursor-pointer accent-primary"
|
class="min-w-0 flex-1 cursor-pointer accent-primary"
|
||||||
@input="seek"
|
@input="seek"
|
||||||
>
|
>
|
||||||
@@ -55,7 +55,7 @@ function formatTime(sec: number) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full transition-colors hover:bg-accent"
|
||||||
:aria-label="muted ? 'Ton an' : 'Stumm'"
|
:aria-label="muted ? $t('audio.unmute') : $t('audio.mute')"
|
||||||
@click="muted = !muted"
|
@click="muted = !muted"
|
||||||
>
|
>
|
||||||
<Icon name="ph:speaker-x-fill" v-if="muted" class="size-3.5" />
|
<Icon name="ph:speaker-x-fill" v-if="muted" class="size-3.5" />
|
||||||
@@ -67,7 +67,7 @@ function formatTime(sec: number) {
|
|||||||
min="0"
|
min="0"
|
||||||
max="1"
|
max="1"
|
||||||
step="0.05"
|
step="0.05"
|
||||||
aria-label="Lautstärke"
|
:aria-label="$t('audio.volume')"
|
||||||
class="w-14 min-w-0 shrink-0 cursor-pointer accent-primary"
|
class="w-14 min-w-0 shrink-0 cursor-pointer accent-primary"
|
||||||
:class="muted && 'opacity-40'"
|
:class="muted && 'opacity-40'"
|
||||||
>
|
>
|
||||||
@@ -75,8 +75,8 @@ function formatTime(sec: number) {
|
|||||||
:href="src"
|
:href="src"
|
||||||
:download="name"
|
:download="name"
|
||||||
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||||
aria-label="Herunterladen"
|
:aria-label="$t('audio.download')"
|
||||||
title="Herunterladen"
|
:title="$t('audio.download')"
|
||||||
>
|
>
|
||||||
<Icon name="ph:download-fill" class="size-3.5" />
|
<Icon name="ph:download-fill" class="size-3.5" />
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const props = defineProps<{
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav
|
<nav
|
||||||
aria-label="breadcrumb"
|
:aria-label="$t('misc.breadcrumb')"
|
||||||
data-slot="breadcrumb"
|
data-slot="breadcrumb"
|
||||||
:class="cn('', props.class)"
|
:class="cn('', props.class)"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -18,6 +18,6 @@ const props = defineProps<{
|
|||||||
<slot>
|
<slot>
|
||||||
<Icon name="ph:dots-three-fill" />
|
<Icon name="ph:dots-three-fill" />
|
||||||
</slot>
|
</slot>
|
||||||
<span class="sr-only">More</span>
|
<span class="sr-only">{{ $t('misc.more') }}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ function updateAlpha(event: MouseEvent | TouchEvent) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="ring-offset-background h-5 w-5 shrink-0 cursor-pointer rounded ring-2 ring-transparent ring-offset-1 transition-all duration-100 hover:ring-current"
|
class="ring-offset-background h-5 w-5 shrink-0 cursor-pointer rounded ring-2 ring-transparent ring-offset-1 transition-all duration-100 hover:ring-current"
|
||||||
:style="{ backgroundColor: color }"
|
:style="{ backgroundColor: color }"
|
||||||
:aria-label="`Set color to ${color}`"
|
:aria-label="$t('misc.setColor', { color })"
|
||||||
@click="setColorFromHex(color)"
|
@click="setColorFromHex(color)"
|
||||||
@keydown.enter="setColorFromHex(color)"
|
@keydown.enter="setColorFromHex(color)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
>
|
>
|
||||||
<Button variant="ghost" class="absolute top-4 right-4 bg-secondary" size="icon-sm">
|
<Button variant="ghost" class="absolute top-4 right-4 bg-secondary" size="icon-sm">
|
||||||
<Icon name="ph:x-fill" />
|
<Icon name="ph:x-fill" />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">{{ $t('common.close') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
class="absolute top-4 right-4 p-0.5 transition-colors rounded-md hover:bg-secondary"
|
||||||
>
|
>
|
||||||
<Icon name="ph:x-fill" class="w-4 h-4" />
|
<Icon name="ph:x-fill" class="w-4 h-4" />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">{{ $t('common.close') }}</span>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogOverlay>
|
</DialogOverlay>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const delegatedProps = reactiveOmit(props, 'class')
|
|||||||
>
|
>
|
||||||
<slot>
|
<slot>
|
||||||
<Icon name="ph:dots-three-fill" />
|
<Icon name="ph:dots-three-fill" />
|
||||||
<span class="sr-only">Weitere Seiten</span>
|
<span class="sr-only">{{ $t('pagination.more') }}</span>
|
||||||
</slot>
|
</slot>
|
||||||
</PaginationEllipsis>
|
</PaginationEllipsis>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ const forwarded = useForwardProps(delegatedProps)
|
|||||||
<template>
|
<template>
|
||||||
<PaginationFirst
|
<PaginationFirst
|
||||||
data-slot="pagination-first"
|
data-slot="pagination-first"
|
||||||
aria-label="Erste Seite"
|
:aria-label="$t('pagination.first')"
|
||||||
:class="cn(buttonVariants({ variant: 'ghost', size }), '', props.class)"
|
:class="cn(buttonVariants({ variant: 'ghost', size }), '', props.class)"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
>
|
>
|
||||||
<slot>
|
<slot>
|
||||||
<Icon name="ph:caret-double-left-fill" data-icon="inline-start" />
|
<Icon name="ph:caret-double-left-fill" data-icon="inline-start" />
|
||||||
<span class="hidden sm:block">Erste</span>
|
<span class="hidden sm:block">{{ $t('pagination.firstShort') }}</span>
|
||||||
</slot>
|
</slot>
|
||||||
</PaginationFirst>
|
</PaginationFirst>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const delegatedProps = reactiveOmit(props, 'class', 'size', 'isActive')
|
|||||||
<template>
|
<template>
|
||||||
<PaginationListItem
|
<PaginationListItem
|
||||||
data-slot="pagination-item"
|
data-slot="pagination-item"
|
||||||
:aria-label="`Seite ${value}`"
|
:aria-label="$t('pagination.page', { page: value })"
|
||||||
v-bind="delegatedProps"
|
v-bind="delegatedProps"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
buttonVariants({
|
buttonVariants({
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ const forwarded = useForwardProps(delegatedProps)
|
|||||||
<template>
|
<template>
|
||||||
<PaginationLast
|
<PaginationLast
|
||||||
data-slot="pagination-last"
|
data-slot="pagination-last"
|
||||||
aria-label="Letzte Seite"
|
:aria-label="$t('pagination.last')"
|
||||||
:class="cn(buttonVariants({ variant: 'ghost', size }), '', props.class)"
|
:class="cn(buttonVariants({ variant: 'ghost', size }), '', props.class)"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
>
|
>
|
||||||
<slot>
|
<slot>
|
||||||
<span class="hidden sm:block">Letzte</span>
|
<span class="hidden sm:block">{{ $t('pagination.lastShort') }}</span>
|
||||||
<Icon name="ph:caret-double-right-fill" data-icon="inline-end" />
|
<Icon name="ph:caret-double-right-fill" data-icon="inline-end" />
|
||||||
</slot>
|
</slot>
|
||||||
</PaginationLast>
|
</PaginationLast>
|
||||||
|
|||||||
@@ -22,12 +22,12 @@ const forwarded = useForwardProps(delegatedProps)
|
|||||||
<template>
|
<template>
|
||||||
<PaginationNext
|
<PaginationNext
|
||||||
data-slot="pagination-next"
|
data-slot="pagination-next"
|
||||||
aria-label="Nächste Seite"
|
:aria-label="$t('pagination.next')"
|
||||||
:class="cn(buttonVariants({ variant: 'ghost', size }), 'pr-1.5!', props.class)"
|
:class="cn(buttonVariants({ variant: 'ghost', size }), 'pr-1.5!', props.class)"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
>
|
>
|
||||||
<slot>
|
<slot>
|
||||||
<span class="hidden sm:block">Weiter</span>
|
<span class="hidden sm:block">{{ $t('common.next') }}</span>
|
||||||
<Icon name="ph:caret-right-fill" data-icon="inline-end" class="cn-rtl-flip" />
|
<Icon name="ph:caret-right-fill" data-icon="inline-end" class="cn-rtl-flip" />
|
||||||
</slot>
|
</slot>
|
||||||
</PaginationNext>
|
</PaginationNext>
|
||||||
|
|||||||
@@ -22,13 +22,13 @@ const forwarded = useForwardProps(delegatedProps)
|
|||||||
<template>
|
<template>
|
||||||
<PaginationPrev
|
<PaginationPrev
|
||||||
data-slot="pagination-previous"
|
data-slot="pagination-previous"
|
||||||
aria-label="Vorherige Seite"
|
:aria-label="$t('pagination.prev')"
|
||||||
:class="cn(buttonVariants({ variant: 'ghost', size }), 'pl-1.5!', props.class)"
|
:class="cn(buttonVariants({ variant: 'ghost', size }), 'pl-1.5!', props.class)"
|
||||||
v-bind="forwarded"
|
v-bind="forwarded"
|
||||||
>
|
>
|
||||||
<slot>
|
<slot>
|
||||||
<Icon name="ph:caret-left-fill" data-icon="inline-start" class="cn-rtl-flip" />
|
<Icon name="ph:caret-left-fill" data-icon="inline-start" class="cn-rtl-flip" />
|
||||||
<span class="hidden sm:block">Zurück</span>
|
<span class="hidden sm:block">{{ $t('common.previous') }}</span>
|
||||||
</slot>
|
</slot>
|
||||||
</PaginationPrev>
|
</PaginationPrev>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const forwarded = useForwardPropsEmits(delegatedProps, emits)
|
|||||||
>
|
>
|
||||||
<Button variant="ghost" class="absolute top-4 right-4 bg-secondary" size="icon-sm">
|
<Button variant="ghost" class="absolute top-4 right-4 bg-secondary" size="icon-sm">
|
||||||
<Icon name="ph:x-fill" />
|
<Icon name="ph:x-fill" />
|
||||||
<span class="sr-only">Close</span>
|
<span class="sr-only">{{ $t('common.close') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DialogClose>
|
</DialogClose>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<SheetHeader class="sr-only">
|
<SheetHeader class="sr-only">
|
||||||
<SheetTitle>Sidebar</SheetTitle>
|
<SheetTitle>{{ $t('misc.sidebar') }}</SheetTitle>
|
||||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
<SheetDescription>{{ $t('misc.sidebarDesc') }}</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<div class="flex h-full w-full flex-col">
|
<div class="flex h-full w-full flex-col">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ const { toggleSidebar } = useSidebar()
|
|||||||
<button
|
<button
|
||||||
data-sidebar="rail"
|
data-sidebar="rail"
|
||||||
data-slot="sidebar-rail"
|
data-slot="sidebar-rail"
|
||||||
aria-label="Toggle Sidebar"
|
:aria-label="$t('misc.toggleSidebar')"
|
||||||
:tabindex="-1"
|
:tabindex="-1"
|
||||||
title="Toggle Sidebar"
|
:title="$t('misc.toggleSidebar')"
|
||||||
:class="cn(
|
:class="cn(
|
||||||
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-0.5 sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
|
'hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-0.5 sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2',
|
||||||
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
'in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize',
|
||||||
|
|||||||
@@ -22,6 +22,6 @@ const { toggleSidebar } = useSidebar()
|
|||||||
@click="toggleSidebar"
|
@click="toggleSidebar"
|
||||||
>
|
>
|
||||||
<Icon name="ph:sidebar-fill" class="cn-rtl-flip" />
|
<Icon name="ph:sidebar-fill" class="cn-rtl-flip" />
|
||||||
<span class="sr-only">Toggle Sidebar</span>
|
<span class="sr-only">{{ $t('misc.toggleSidebar') }}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ import { useNow } from '@vueuse/core'
|
|||||||
// damit offene Seiten ohne Reload aktuell bleiben
|
// damit offene Seiten ohne Reload aktuell bleiben
|
||||||
export function useTimeAgo() {
|
export function useTimeAgo() {
|
||||||
const now = useNow({ interval: 30_000 })
|
const now = useNow({ interval: 30_000 })
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
return (value: string) => {
|
return (value: string) => {
|
||||||
const seconds = Math.max(0, Math.floor((now.value.getTime() - new Date(value).getTime()) / 1000))
|
const seconds = Math.max(0, Math.floor((now.value.getTime() - new Date(value).getTime()) / 1000))
|
||||||
if (seconds < 60) return 'gerade eben'
|
if (seconds < 60) return t('timeAgo.justNow')
|
||||||
const minutes = Math.floor(seconds / 60)
|
const minutes = Math.floor(seconds / 60)
|
||||||
if (minutes < 60) return `vor ${minutes} Min`
|
if (minutes < 60) return t('timeAgo.min', { n: minutes })
|
||||||
const hours = Math.floor(minutes / 60)
|
const hours = Math.floor(minutes / 60)
|
||||||
if (hours < 24) return `vor ${hours} Std`
|
if (hours < 24) return t('timeAgo.hour', { n: hours })
|
||||||
const days = Math.floor(hours / 24)
|
const days = Math.floor(hours / 24)
|
||||||
if (days < 30) return `vor ${days} T`
|
if (days < 30) return t('timeAgo.day', { n: days })
|
||||||
const months = Math.floor(days / 30)
|
const months = Math.floor(days / 30)
|
||||||
if (months < 12) return `vor ${months} Mon`
|
if (months < 12) return t('timeAgo.month', { n: months })
|
||||||
return `vor ${Math.floor(months / 12)} J`
|
return t('timeAgo.year', { n: Math.floor(months / 12) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-28
@@ -34,6 +34,7 @@ interface AdminUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { user, can } = useAuth()
|
const { user, can } = useAuth()
|
||||||
|
const { t } = useI18n()
|
||||||
const { data: rolesList, refresh: refreshRoles } = await useFetch<Role[]>('/api/admin/roles', { default: () => [] })
|
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 { 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 editedPermissions = ref<Record<number, string[]>>({})
|
||||||
const savingRole = ref<number | null>(null)
|
const savingRole = ref<number | null>(null)
|
||||||
|
|
||||||
const userColumns: ColumnDef<DataTableFeatures, AdminUser>[] = [
|
const userColumns = computed<ColumnDef<DataTableFeatures, AdminUser>[]>(() => [
|
||||||
{ accessorKey: 'name', header: 'Name' },
|
{ accessorKey: 'name', header: t('common.name') },
|
||||||
{ accessorKey: 'email', header: 'E-Mail', cell: ({ getValue }) => getValue() ?? '—' },
|
{ accessorKey: 'email', header: t('common.email'), cell: ({ getValue }) => getValue() ?? '—' },
|
||||||
{ id: 'roles', header: 'Rollen', enableSorting: false, enableGlobalFilter: false },
|
{ id: 'roles', header: t('admin.rolesCol'), enableSorting: false, enableGlobalFilter: false },
|
||||||
{ id: 'actions', header: 'Aktion', enableSorting: false, enableGlobalFilter: false },
|
{ id: 'actions', header: t('admin.actionCol'), enableSorting: false, enableGlobalFilter: false },
|
||||||
]
|
])
|
||||||
|
|
||||||
function userSearch(u: AdminUser, query: string) {
|
function userSearch(u: AdminUser, query: string) {
|
||||||
const q = query.toLowerCase()
|
const q = query.toLowerCase()
|
||||||
@@ -61,7 +62,7 @@ function userSearch(u: AdminUser, query: string) {
|
|||||||
|
|
||||||
function errText(err: unknown) {
|
function errText(err: unknown) {
|
||||||
const e = err as { data?: { statusMessage?: string, message?: string } }
|
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() {
|
async function refreshAll() {
|
||||||
@@ -178,15 +179,15 @@ async function saveMotd() {
|
|||||||
<div class="grid gap-6 px-4 lg:grid-cols-2 lg:px-6">
|
<div class="grid gap-6 px-4 lg:grid-cols-2 lg:px-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Rollen</CardTitle>
|
<CardTitle>{{ $t('admin.roles') }}</CardTitle>
|
||||||
<CardDescription>Bestehende Rollen und neue anlegen (z. B. moderator). Die admin-Rolle hat immer alle Berechtigungen.</CardDescription>
|
<CardDescription>{{ $t('admin.rolesDesc') }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="space-y-4">
|
<CardContent class="space-y-4">
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
<li v-for="role in rolesList" :key="role.id" class="rounded-md border px-3 py-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">
|
<div class="flex items-center gap-3">
|
||||||
<span class="font-medium">{{ role.name }}</span>
|
<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
|
<Button
|
||||||
v-if="!SYSTEM_ROLES.includes(role.name as 'admin' | 'moderator') && can('roles.manage')"
|
v-if="!SYSTEM_ROLES.includes(role.name as 'admin' | 'moderator') && can('roles.manage')"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -195,7 +196,7 @@ async function saveMotd() {
|
|||||||
@click="deleteRole(role.id)"
|
@click="deleteRole(role.id)"
|
||||||
>
|
>
|
||||||
<Icon name="ph:trash-fill" class="size-4" />
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1">
|
<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"
|
:disabled="role.name === 'admin' || !can('roles.manage') || savingRole === role.id"
|
||||||
@update:model-value="togglePermission(role, perm.key, $event)"
|
@update:model-value="togglePermission(role, perm.key, $event)"
|
||||||
/>
|
/>
|
||||||
{{ perm.label }}
|
{{ $t(`admin.perms.${perm.key}`) }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="isDirty(role)" class="mt-2 flex items-center gap-2">
|
<div v-if="isDirty(role)" class="mt-2 flex items-center gap-2">
|
||||||
@@ -218,7 +219,7 @@ async function saveMotd() {
|
|||||||
:disabled="savingRole === role.id"
|
:disabled="savingRole === role.id"
|
||||||
@click="savePermissions(role)"
|
@click="savePermissions(role)"
|
||||||
>
|
>
|
||||||
{{ savingRole === role.id ? 'Speichern…' : 'Speichern' }}
|
{{ savingRole === role.id ? $t('common.saving') : $t('common.save') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -226,15 +227,15 @@ async function saveMotd() {
|
|||||||
:disabled="savingRole === role.id"
|
:disabled="savingRole === role.id"
|
||||||
@click="resetPermissions(role)"
|
@click="resetPermissions(role)"
|
||||||
>
|
>
|
||||||
Zurücksetzen
|
{{ $t('common.reset') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<form v-if="can('roles.manage')" class="flex gap-2" @submit.prevent="createRole">
|
<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()">
|
<Button type="submit" :disabled="!newRoleName.trim()">
|
||||||
Anlegen
|
{{ $t('admin.create') }}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -242,14 +243,14 @@ async function saveMotd() {
|
|||||||
|
|
||||||
<Card v-if="can('users.manage')">
|
<Card v-if="can('users.manage')">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Benutzer</CardTitle>
|
<CardTitle>{{ $t('admin.users') }}</CardTitle>
|
||||||
<CardDescription>Rollen zuweisen oder entfernen.</CardDescription>
|
<CardDescription>{{ $t('admin.usersDesc') }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<DataTable
|
<DataTable
|
||||||
:columns="userColumns"
|
:columns="userColumns"
|
||||||
:data="usersList"
|
:data="usersList"
|
||||||
search-placeholder="Name oder E-Mail suchen…"
|
:search-placeholder="$t('admin.searchUsers')"
|
||||||
:search-fn="userSearch"
|
:search-fn="userSearch"
|
||||||
>
|
>
|
||||||
<template #cell-name="{ row }">
|
<template #cell-name="{ row }">
|
||||||
@@ -266,10 +267,10 @@ async function saveMotd() {
|
|||||||
@click="removeRole(row.original.id, r.id)"
|
@click="removeRole(row.original.id, r.id)"
|
||||||
>
|
>
|
||||||
<Icon name="ph:x-fill" class="size-3" />
|
<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>
|
</button>
|
||||||
</Badge>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #cell-actions="{ row }">
|
<template #cell-actions="{ row }">
|
||||||
@@ -279,8 +280,8 @@ async function saveMotd() {
|
|||||||
:disabled="!rolesList.some(r => !row.original.roles.some(ur => ur.id === r.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)"
|
@update:model-value="selectedRole[row.original.id] = $event === undefined ? undefined : Number($event)"
|
||||||
>
|
>
|
||||||
<SelectTrigger size="sm" class="w-40" aria-label="Rolle wählen">
|
<SelectTrigger size="sm" class="w-40" :aria-label="$t('admin.chooseRole')">
|
||||||
<SelectValue placeholder="Rolle wählen…" />
|
<SelectValue :placeholder="$t('admin.chooseRole')" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
@@ -300,7 +301,7 @@ async function saveMotd() {
|
|||||||
:disabled="!selectedRole[row.original.id]"
|
:disabled="!selectedRole[row.original.id]"
|
||||||
@click="addRole(row.original.id)"
|
@click="addRole(row.original.id)"
|
||||||
>
|
>
|
||||||
Zuweisen
|
{{ $t('admin.assign') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -310,19 +311,19 @@ async function saveMotd() {
|
|||||||
|
|
||||||
<Card v-if="can('site.manage')" class="lg:col-span-2">
|
<Card v-if="can('site.manage')" class="lg:col-span-2">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Ansage</CardTitle>
|
<CardTitle>{{ $t('admin.motd') }}</CardTitle>
|
||||||
<CardDescription>Wird allen Besuchern direkt unter der Kopfzeile angezeigt und bei Änderung sofort aktualisiert. Leer lassen, um sie zu entfernen.</CardDescription>
|
<CardDescription>{{ $t('admin.motdDesc') }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form class="flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="saveMotd">
|
<form class="flex flex-col gap-2 sm:flex-row sm:items-end" @submit.prevent="saveMotd">
|
||||||
<Textarea
|
<Textarea
|
||||||
v-model="motdDraft"
|
v-model="motdDraft"
|
||||||
placeholder="Ansagetext…"
|
:placeholder="$t('admin.motdPlaceholder')"
|
||||||
maxlength="500"
|
maxlength="500"
|
||||||
class="min-h-16"
|
class="min-h-16"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" class="shrink-0" :disabled="motdSaving">
|
<Button type="submit" class="shrink-0" :disabled="motdSaving">
|
||||||
{{ motdSaving ? 'Speichern…' : 'Speichern' }}
|
{{ motdSaving ? $t('common.saving') : $t('common.save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
+8
-7
@@ -38,6 +38,7 @@ interface UploadList {
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { t } = useI18n()
|
||||||
const FILTER_KEYS = ['tag', 'type', 'uploader', 'days'] as const
|
const FILTER_KEYS = ['tag', 'type', 'uploader', 'days'] as const
|
||||||
const hasFilters = computed(() => FILTER_KEYS.some(k => typeof route.query[k] === 'string' && route.query[k]))
|
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) {
|
onDrop(files) {
|
||||||
const valid = (files ?? []).filter(isMediaFile)
|
const valid = (files ?? []).filter(isMediaFile)
|
||||||
if (valid.length && !uploading.value) uploadFiles(valid)
|
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[]) {
|
async function uploadFiles(files: File[]) {
|
||||||
if (!user.value) {
|
if (!user.value) {
|
||||||
toast.error('Melde dich an, um hochzuladen.')
|
toast.error(t('upload.loginToUploadToast'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
@@ -166,7 +167,7 @@ async function uploadFiles(files: File[]) {
|
|||||||
}))
|
}))
|
||||||
const failed = results.filter(r => r.status === 'rejected').length
|
const failed = results.filter(r => r.status === 'rejected').length
|
||||||
if (failed) {
|
if (failed) {
|
||||||
toast.error(`${failed} von ${files.length} Dateien fehlgeschlagen`)
|
toast.error(t('upload.failedCount', { failed, total: files.length }))
|
||||||
}
|
}
|
||||||
await refreshNuxtData('uploads')
|
await refreshNuxtData('uploads')
|
||||||
await navigateTo('/')
|
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">
|
<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" />
|
<Icon name="ph:cloud-arrow-up-fill" class="size-12 text-primary" />
|
||||||
<p class="text-lg font-medium">
|
<p class="text-lg font-medium">
|
||||||
{{ uploading ? 'Lädt hoch…' : 'Zum Hochladen loslassen' }}
|
{{ uploading ? $t('upload.uploading') : $t('upload.dropToUpload') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref="filterRowEl" class="mb-2 flex items-center gap-2">
|
<div ref="filterRowEl" class="mb-2 flex items-center gap-2">
|
||||||
<UploadFilters />
|
<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>
|
</div>
|
||||||
<p v-if="pending" class="text-muted-foreground">
|
<p v-if="pending" class="text-muted-foreground">
|
||||||
Lade Uploads…
|
{{ $t('grid.loading') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="!uploads.length" class="text-muted-foreground">
|
<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>
|
</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]">
|
<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'
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
|
const { t } = useI18n()
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
@@ -20,7 +21,7 @@ async function onSubmit() {
|
|||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const e = err as { data?: { statusMessage?: string, message?: string } }
|
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 {
|
finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -32,29 +33,29 @@ async function onSubmit() {
|
|||||||
<div class="mx-auto max-w-sm px-4 lg:px-6">
|
<div class="mx-auto max-w-sm px-4 lg:px-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Login</CardTitle>
|
<CardTitle>{{ $t('auth.loginTitle') }}</CardTitle>
|
||||||
<CardDescription>Melde dich mit Name und Passwort an.</CardDescription>
|
<CardDescription>{{ $t('auth.loginDesc') }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
<div class="space-y-1.5">
|
<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 />
|
<Input id="name" v-model="name" autocomplete="username" required />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<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 />
|
<Input id="password" v-model="password" type="password" autocomplete="current-password" required />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="errorMessage" class="text-sm text-destructive">
|
<p v-if="errorMessage" class="text-sm text-destructive">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
<Button type="submit" class="w-full" :disabled="loading">
|
<Button type="submit" class="w-full" :disabled="loading">
|
||||||
Anmelden
|
{{ $t('auth.login') }}
|
||||||
</Button>
|
</Button>
|
||||||
<p class="text-center text-sm text-muted-foreground">
|
<p class="text-center text-sm text-muted-foreground">
|
||||||
Noch kein Konto?
|
{{ $t('auth.noAccount') }}
|
||||||
<NuxtLink to="/register" class="text-primary underline">
|
<NuxtLink to="/register" class="text-primary underline">
|
||||||
Registrieren
|
{{ $t('auth.register') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+10
-9
@@ -5,6 +5,7 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
|
|
||||||
const { register } = useAuth()
|
const { register } = useAuth()
|
||||||
|
const { t } = useI18n()
|
||||||
const name = ref('')
|
const name = ref('')
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const email = ref('')
|
const email = ref('')
|
||||||
@@ -21,7 +22,7 @@ async function onSubmit() {
|
|||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const e = err as { data?: { statusMessage?: string, message?: string } }
|
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 {
|
finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -33,33 +34,33 @@ async function onSubmit() {
|
|||||||
<div class="mx-auto max-w-sm px-4 lg:px-6">
|
<div class="mx-auto max-w-sm px-4 lg:px-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Registrieren</CardTitle>
|
<CardTitle>{{ $t('auth.registerTitle') }}</CardTitle>
|
||||||
<CardDescription>Name und Passwort reichen — E-Mail ist optional.</CardDescription>
|
<CardDescription>{{ $t('auth.registerDesc') }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
<div class="space-y-1.5">
|
<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" />
|
<Input id="name" v-model="name" autocomplete="username" required minlength="3" maxlength="50" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<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" />
|
<Input id="password" v-model="password" type="password" autocomplete="new-password" required minlength="6" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<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" />
|
<Input id="email" v-model="email" type="email" autocomplete="email" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="errorMessage" class="text-sm text-destructive">
|
<p v-if="errorMessage" class="text-sm text-destructive">
|
||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
<Button type="submit" class="w-full" :disabled="loading">
|
<Button type="submit" class="w-full" :disabled="loading">
|
||||||
Konto erstellen
|
{{ $t('auth.createAccount') }}
|
||||||
</Button>
|
</Button>
|
||||||
<p class="text-center text-sm text-muted-foreground">
|
<p class="text-center text-sm text-muted-foreground">
|
||||||
Schon registriert?
|
{{ $t('auth.alreadyRegistered') }}
|
||||||
<NuxtLink to="/login" class="text-primary underline">
|
<NuxtLink to="/login" class="text-primary underline">
|
||||||
Login
|
{{ $t('auth.loginTitle') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+46
-29
@@ -36,6 +36,7 @@ import {
|
|||||||
|
|
||||||
const { user, refresh } = useAuth()
|
const { user, refresh } = useAuth()
|
||||||
const { invalidate } = useUserSummaries()
|
const { invalidate } = useUserSummaries()
|
||||||
|
const { t, locale, setLocale } = useI18n()
|
||||||
|
|
||||||
const color = ref(user.value?.color ?? '#6366f1')
|
const color = ref(user.value?.color ?? '#6366f1')
|
||||||
const cardEffect = ref<string>(user.value?.cardEffect ?? 'none')
|
const cardEffect = ref<string>(user.value?.cardEffect ?? 'none')
|
||||||
@@ -121,11 +122,11 @@ async function save() {
|
|||||||
if (user.value) invalidate(user.value.id)
|
if (user.value) invalidate(user.value.id)
|
||||||
await refreshNuxtData()
|
await refreshNuxtData()
|
||||||
avatar.value = null
|
avatar.value = null
|
||||||
toast.success('Einstellungen gespeichert')
|
toast.success(t('settings.saved'))
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const e = err as { data?: { statusMessage?: string } }
|
const e = err as { data?: { statusMessage?: string } }
|
||||||
toast.error(e.data?.statusMessage ?? 'Speichern fehlgeschlagen')
|
toast.error(e.data?.statusMessage ?? t('settings.saveFailed'))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
@@ -137,34 +138,50 @@ async function save() {
|
|||||||
<div class="mx-auto w-full max-w-2xl px-4 lg:px-6">
|
<div class="mx-auto w-full max-w-2xl px-4 lg:px-6">
|
||||||
<Card v-if="user">
|
<Card v-if="user">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Einstellungen</CardTitle>
|
<CardTitle>{{ $t('settings.title') }}</CardTitle>
|
||||||
<CardDescription>Name-Farbe und Avatar anpassen.</CardDescription>
|
<CardDescription>{{ $t('settings.desc') }}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form class="space-y-4" @submit.prevent="save">
|
<form class="space-y-4" @submit.prevent="save">
|
||||||
<div class="space-y-1.5">
|
<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">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="h-9 w-14 cursor-pointer rounded-md border border-input shadow-sm"
|
class="h-9 w-14 cursor-pointer rounded-md border border-input shadow-sm"
|
||||||
:style="{ backgroundColor: color }"
|
:style="{ backgroundColor: color }"
|
||||||
aria-label="Farbe auswählen"
|
:aria-label="$t('settings.pickColor')"
|
||||||
/>
|
/>
|
||||||
</ColorPicker>
|
</ColorPicker>
|
||||||
<span class="text-sm font-medium" :style="{ color }">{{ user.name }}</span>
|
<span class="text-sm font-medium" :style="{ color }">{{ user.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>Hover-Card-Effekt</Label>
|
<Label>{{ $t('settings.cardEffect') }}</Label>
|
||||||
<Select v-model="cardEffect">
|
<Select v-model="cardEffect">
|
||||||
<SelectTrigger class="w-full">
|
<SelectTrigger class="w-full">
|
||||||
<SelectValue placeholder="Kein Effekt" />
|
<SelectValue :placeholder="$t('settings.noEffectPlaceholder')" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">
|
<SelectItem value="none">
|
||||||
Keiner
|
{{ $t('settings.noEffect') }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem v-for="e in CARD_EFFECTS" :key="e.key" :value="e.key">
|
<SelectItem v-for="e in CARD_EFFECTS" :key="e.key" :value="e.key">
|
||||||
{{ e.label }}
|
{{ 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">
|
<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">
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Checkbox v-model="cardEffectColorEnabled" />
|
<Checkbox v-model="cardEffectColorEnabled" />
|
||||||
Eigene Effekt-Farbe
|
{{ $t('settings.ownEffectColor') }}
|
||||||
</label>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
|
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
|
||||||
:style="{ backgroundColor: cardEffectColor }"
|
:style="{ backgroundColor: cardEffectColor }"
|
||||||
aria-label="Effekt-Farbe auswählen"
|
:aria-label="$t('settings.pickEffectColor')"
|
||||||
/>
|
/>
|
||||||
</ColorPicker>
|
</ColorPicker>
|
||||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Checkbox v-model="cardVignette" />
|
<Checkbox v-model="cardVignette" />
|
||||||
Vignette (Text lesbar halten)
|
{{ $t('settings.vignette') }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<Label>Hover-Card-Border</Label>
|
<Label>{{ $t('settings.cardBorder') }}</Label>
|
||||||
<Select v-model="cardBorderEffect">
|
<Select v-model="cardBorderEffect">
|
||||||
<SelectTrigger class="w-full">
|
<SelectTrigger class="w-full">
|
||||||
<SelectValue placeholder="Kein Effekt" />
|
<SelectValue :placeholder="$t('settings.noEffectPlaceholder')" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">
|
<SelectItem value="none">
|
||||||
Keiner
|
{{ $t('settings.noEffect') }}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem v-for="e in CARD_BORDER_EFFECTS" :key="e.key" :value="e.key">
|
<SelectItem v-for="e in CARD_BORDER_EFFECTS" :key="e.key" :value="e.key">
|
||||||
{{ e.label }}
|
{{ e.label }}
|
||||||
@@ -208,20 +225,20 @@ async function save() {
|
|||||||
<div v-if="cardBorderEffect !== 'none'" class="flex items-center gap-3 pt-1">
|
<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">
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground">
|
||||||
<Checkbox v-model="cardBorderEffectColorEnabled" />
|
<Checkbox v-model="cardBorderEffectColorEnabled" />
|
||||||
Eigene Border-Farbe
|
{{ $t('settings.ownBorderColor') }}
|
||||||
</label>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
|
class="h-7 w-12 cursor-pointer rounded-md border border-input shadow-sm"
|
||||||
:style="{ backgroundColor: cardBorderEffectColor }"
|
:style="{ backgroundColor: cardBorderEffectColor }"
|
||||||
aria-label="Border-Farbe auswählen"
|
:aria-label="$t('settings.pickBorderColor')"
|
||||||
/>
|
/>
|
||||||
</ColorPicker>
|
</ColorPicker>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showPreview" class="space-y-1.5">
|
<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">
|
<div class="relative h-24 overflow-hidden rounded-md border bg-popover">
|
||||||
<component :is="previewEffect" v-if="previewEffect" :color="previewEffectColor" />
|
<component :is="previewEffect" v-if="previewEffect" :color="previewEffectColor" />
|
||||||
<div v-if="previewEffect" class="absolute inset-0 bg-popover/60" />
|
<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" />
|
<CardBorderEffect v-if="previewBorder" :effect="previewBorder" :color="previewBorderColor" />
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">
|
<p class="text-xs text-muted-foreground">
|
||||||
So sieht deine Hover-Card für andere aus.
|
{{ $t('settings.previewHint') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<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">
|
<div v-if="user.avatarUrl && !avatar" class="mb-2 flex items-center gap-2">
|
||||||
<NuxtImg
|
<NuxtImg
|
||||||
:src="user.avatarUrl"
|
:src="user.avatarUrl"
|
||||||
alt="Aktueller Avatar"
|
:alt="$t('settings.currentAvatar')"
|
||||||
width="48"
|
width="48"
|
||||||
height="48"
|
height="48"
|
||||||
fit="cover"
|
fit="cover"
|
||||||
class="size-12 rounded-lg"
|
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>
|
</div>
|
||||||
<FileDropzone v-model="avatar" accept="image/*" hint="Bilder, max. 5 MB" />
|
<FileDropzone v-model="avatar" accept="image/*" :hint="t('upload.avatarHint')" />
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" class="w-full" :disabled="saving">
|
<Button type="submit" class="w-full" :disabled="saving">
|
||||||
{{ saving ? 'Speichert…' : 'Speichern' }}
|
{{ saving ? $t('common.saving') : $t('common.save') }}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<p v-else class="text-center text-muted-foreground">
|
<p v-else class="text-center text-muted-foreground">
|
||||||
<NuxtLink to="/login" class="text-primary underline">
|
<NuxtLink to="/login" class="text-primary underline">
|
||||||
Melde dich an
|
{{ $t('common.loginLink') }}
|
||||||
</NuxtLink>, um deine Einstellungen zu ändern.
|
</NuxtLink>{{ $t('settings.loginToChange') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+45
-44
@@ -65,6 +65,7 @@ const id = computed(() => route.params.id as string)
|
|||||||
// Twitch-Embeds brauchen den Hostname als parent-Parameter
|
// Twitch-Embeds brauchen den Hostname als parent-Parameter
|
||||||
const requestHost = useRequestURL().hostname
|
const requestHost = useRequestURL().hostname
|
||||||
const { user, can } = useAuth()
|
const { user, can } = useAuth()
|
||||||
|
const { t, locale } = useI18n()
|
||||||
|
|
||||||
// Beim Client-Side-Nav nicht awaiten, damit die Seite sofort rendert und das
|
// 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
|
// benannte Medium-Element für die View-Transition im Snapshot vorhanden ist
|
||||||
@@ -171,7 +172,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
async function reactToUpload(emoji: string) {
|
async function reactToUpload(emoji: string) {
|
||||||
if (!user.value) {
|
if (!user.value) {
|
||||||
toast.error('Melde dich an, um zu reagieren.')
|
toast.error(t('comments.loginToReact'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (reactionBusy.value) return
|
if (reactionBusy.value) return
|
||||||
@@ -183,7 +184,7 @@ async function reactToUpload(emoji: string) {
|
|||||||
reactionPickerOpen.value = false
|
reactionPickerOpen.value = false
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
toast.error('Reaktion fehlgeschlagen')
|
toast.error(t('comments.reactionFailed'))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
reactionBusy.value = false
|
reactionBusy.value = false
|
||||||
@@ -305,11 +306,11 @@ function pendingPreview(file: File): string | null {
|
|||||||
function addPendingFiles(files: File[]) {
|
function addPendingFiles(files: File[]) {
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
if (pendingFiles.value.length >= MAX_COMMENT_ATTACHMENTS) {
|
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
|
break
|
||||||
}
|
}
|
||||||
if (file.size > MAX_ATTACHMENT_SIZE) {
|
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
|
continue
|
||||||
}
|
}
|
||||||
pendingFiles.value.push(file)
|
pendingFiles.value.push(file)
|
||||||
@@ -362,7 +363,7 @@ async function startRecording() {
|
|||||||
recordStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
recordStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
toast.error('Kein Zugriff aufs Mikrofon')
|
toast.error(t('comments.noMic'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const mimeType = recorderMimeType()
|
const mimeType = recorderMimeType()
|
||||||
@@ -376,7 +377,7 @@ async function startRecording() {
|
|||||||
if (!recordDiscarded && recordChunks.length && pendingFiles.value.length < MAX_COMMENT_ATTACHMENTS) {
|
if (!recordDiscarded && recordChunks.length && pendingFiles.value.length < MAX_COMMENT_ATTACHMENTS) {
|
||||||
const type = recorder?.mimeType || 'audio/webm'
|
const type = recorder?.mimeType || 'audio/webm'
|
||||||
const ext = type.includes('ogg') ? 'ogg' : type.includes('mp4') ? 'm4a' : '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)
|
pendingFiles.value.push(file)
|
||||||
voicePending.value.add(file)
|
voicePending.value.add(file)
|
||||||
}
|
}
|
||||||
@@ -466,7 +467,7 @@ async function saveTags() {
|
|||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const e = err as { data?: { statusMessage?: string } }
|
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 {
|
finally {
|
||||||
tagSaving.value = false
|
tagSaving.value = false
|
||||||
@@ -476,7 +477,7 @@ async function saveTags() {
|
|||||||
async function toggleFavorite() {
|
async function toggleFavorite() {
|
||||||
if (!data.value || favBusy.value) return
|
if (!data.value || favBusy.value) return
|
||||||
if (!user.value) {
|
if (!user.value) {
|
||||||
toast.error('Melde dich an, um zu favorisieren.')
|
toast.error(t('thread.loginToFavorite'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
favBusy.value = true
|
favBusy.value = true
|
||||||
@@ -501,7 +502,7 @@ watch(id, () => {
|
|||||||
const timeAgo = useTimeAgo()
|
const timeAgo = useTimeAgo()
|
||||||
|
|
||||||
function formatDate(value: string) {
|
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() {
|
async function submitComment() {
|
||||||
@@ -533,7 +534,7 @@ async function submitComment() {
|
|||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
const e = err as { data?: { statusMessage?: string, message?: string } }
|
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 {
|
finally {
|
||||||
posting.value = false
|
posting.value = false
|
||||||
@@ -551,7 +552,7 @@ async function deleteUpload() {
|
|||||||
await navigateTo('/')
|
await navigateTo('/')
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
toast.error('Löschen fehlgeschlagen')
|
toast.error(t('thread.deleteFailed'))
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
deleting.value = false
|
deleting.value = false
|
||||||
@@ -569,7 +570,7 @@ async function deleteComment(commentId: number) {
|
|||||||
<template>
|
<template>
|
||||||
<div class="px-4 lg:px-6">
|
<div class="px-4 lg:px-6">
|
||||||
<div v-if="error" class="text-muted-foreground">
|
<div v-if="error" class="text-muted-foreground">
|
||||||
Upload nicht gefunden.
|
{{ $t('thread.notFound') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<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"
|
class="absolute left-2 top-1/2 z-10 -translate-y-1/2 rounded-full shadow opacity-80 hover:opacity-100"
|
||||||
as-child
|
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" />
|
<Icon name="ph:caret-left-fill" class="size-5" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</Button>
|
</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"
|
class="absolute right-2 top-1/2 z-10 -translate-y-1/2 rounded-full shadow opacity-80 hover:opacity-100"
|
||||||
as-child
|
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" />
|
<Icon name="ph:caret-right-fill" class="size-5" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -647,7 +648,7 @@ async function deleteComment(commentId: number) {
|
|||||||
class="group relative flex cursor-pointer items-center justify-center"
|
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'"
|
:class="media.previewImage ? 'aspect-square max-h-[70vh] w-full' : 'h-56 sm:h-64'"
|
||||||
role="button"
|
role="button"
|
||||||
:aria-label="audioPlaying ? 'Pause' : 'Abspielen'"
|
:aria-label="audioPlaying ? $t('audio.pause') : $t('audio.play')"
|
||||||
@click="toggleAudio"
|
@click="toggleAudio"
|
||||||
>
|
>
|
||||||
<Icon name="ph:music-note-fill" v-if="!media.previewImage" class="size-16 text-muted-foreground" />
|
<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
|
<button
|
||||||
type="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"
|
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"
|
@click="toggleAudio"
|
||||||
>
|
>
|
||||||
<Icon name="ph:pause-fill" v-if="audioPlaying" class="size-4" />
|
<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"
|
:max="Number.isFinite(audioDuration) ? audioDuration : 0"
|
||||||
step="0.1"
|
step="0.1"
|
||||||
:value="audioTime"
|
:value="audioTime"
|
||||||
aria-label="Wiedergabeposition"
|
:aria-label="$t('audio.seek')"
|
||||||
class="min-w-0 flex-1 cursor-pointer accent-primary"
|
class="min-w-0 flex-1 cursor-pointer accent-primary"
|
||||||
@input="seekAudio"
|
@input="seekAudio"
|
||||||
>
|
>
|
||||||
@@ -702,7 +703,7 @@ async function deleteComment(commentId: number) {
|
|||||||
<button
|
<button
|
||||||
type="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"
|
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"
|
@click="audioMuted = !audioMuted"
|
||||||
>
|
>
|
||||||
<Icon name="ph:speaker-x-fill" v-if="audioMuted" class="size-4" />
|
<Icon name="ph:speaker-x-fill" v-if="audioMuted" class="size-4" />
|
||||||
@@ -714,7 +715,7 @@ async function deleteComment(commentId: number) {
|
|||||||
min="0"
|
min="0"
|
||||||
max="1"
|
max="1"
|
||||||
step="0.05"
|
step="0.05"
|
||||||
aria-label="Lautstärke"
|
:aria-label="$t('audio.volume')"
|
||||||
class="hidden w-20 cursor-pointer accent-primary sm:block"
|
class="hidden w-20 cursor-pointer accent-primary sm:block"
|
||||||
:class="audioMuted && 'opacity-40'"
|
:class="audioMuted && 'opacity-40'"
|
||||||
>
|
>
|
||||||
@@ -798,7 +799,7 @@ async function deleteComment(commentId: number) {
|
|||||||
type="button"
|
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"
|
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"
|
: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"
|
@click="toggleFavorite"
|
||||||
@pointerenter="startReactionHover"
|
@pointerenter="startReactionHover"
|
||||||
@pointerleave="scheduleReactionClose"
|
@pointerleave="scheduleReactionClose"
|
||||||
@@ -839,7 +840,7 @@ async function deleteComment(commentId: number) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
class="text-muted-foreground hover:text-destructive"
|
class="text-muted-foreground hover:text-destructive"
|
||||||
aria-label="Upload löschen"
|
:aria-label="$t('thread.deleteUpload')"
|
||||||
@click="deleteConfirmOpen = true"
|
@click="deleteConfirmOpen = true"
|
||||||
>
|
>
|
||||||
<Icon name="ph:trash-fill" class="size-4" />
|
<Icon name="ph:trash-fill" class="size-4" />
|
||||||
@@ -847,17 +848,17 @@ async function deleteComment(commentId: number) {
|
|||||||
</PopoverAnchor>
|
</PopoverAnchor>
|
||||||
<PopoverContent side="bottom" align="end" class="w-56">
|
<PopoverContent side="bottom" align="end" class="w-56">
|
||||||
<p class="text-sm font-medium">
|
<p class="text-sm font-medium">
|
||||||
Upload wirklich löschen?
|
{{ $t('thread.deleteTitle') }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1 text-xs text-muted-foreground">
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
Der Beitrag und alle Kommentare werden entfernt.
|
{{ $t('thread.deleteDesc') }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-3 flex justify-end gap-2">
|
<div class="mt-3 flex justify-end gap-2">
|
||||||
<Button variant="ghost" size="sm" :disabled="deleting" @click="deleteConfirmOpen = false">
|
<Button variant="ghost" size="sm" :disabled="deleting" @click="deleteConfirmOpen = false">
|
||||||
Abbrechen
|
{{ $t('common.cancel') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="sm" :disabled="deleting" @click="deleteUpload">
|
<Button variant="destructive" size="sm" :disabled="deleting" @click="deleteUpload">
|
||||||
{{ deleting ? 'Löscht…' : 'Löschen' }}
|
{{ deleting ? $t('common.deleting') : $t('common.delete') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
@@ -920,7 +921,7 @@ async function deleteComment(commentId: number) {
|
|||||||
v-if="canDeleteTags || !originalTags.has(t)"
|
v-if="canDeleteTags || !originalTags.has(t)"
|
||||||
type="button"
|
type="button"
|
||||||
class="text-muted-foreground hover:text-foreground"
|
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)"
|
@click="tagDraft = tagDraft.filter(x => x !== t)"
|
||||||
>
|
>
|
||||||
<Icon name="ph:x-fill" class="size-3.5" />
|
<Icon name="ph:x-fill" class="size-3.5" />
|
||||||
@@ -930,7 +931,7 @@ async function deleteComment(commentId: number) {
|
|||||||
v-model="tagInput"
|
v-model="tagInput"
|
||||||
list="tag-suggestions"
|
list="tag-suggestions"
|
||||||
type="text"
|
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"
|
class="min-w-32 flex-1 bg-transparent px-1 py-1 text-sm outline-none placeholder:text-muted-foreground"
|
||||||
@keydown="onTagKeydown"
|
@keydown="onTagKeydown"
|
||||||
>
|
>
|
||||||
@@ -940,10 +941,10 @@ async function deleteComment(commentId: number) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-2.5 flex items-center gap-2">
|
<div class="mt-2.5 flex items-center gap-2">
|
||||||
<Button size="sm" :disabled="tagSaving" @click="saveTags">
|
<Button size="sm" :disabled="tagSaving" @click="saveTags">
|
||||||
{{ tagSaving ? 'Speichern…' : 'Speichern' }}
|
{{ tagSaving ? $t('common.saving') : $t('common.save') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="ghost" :disabled="tagSaving" @click="tagEditing = false">
|
<Button size="sm" variant="ghost" :disabled="tagSaving" @click="tagEditing = false">
|
||||||
Abbrechen
|
{{ $t('common.cancel') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -959,7 +960,7 @@ async function deleteComment(commentId: number) {
|
|||||||
v-if="canEditTags"
|
v-if="canEditTags"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-md p-1.5 text-muted-foreground transition-colors hover:text-foreground"
|
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"
|
@click="startTagEdit"
|
||||||
>
|
>
|
||||||
<Icon name="ph:pencil-fill" class="size-4" />
|
<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">
|
<section class="mx-auto mt-6 w-full lg:w-2/5">
|
||||||
<h2 class="mb-3 font-semibold">
|
<h2 class="mb-3 font-semibold">
|
||||||
Kommentare ({{ allComments.length }})
|
{{ $t('comments.title') }} ({{ allComments.length }})
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ul v-if="allComments.length" class="space-y-3">
|
<ul v-if="allComments.length" class="space-y-3">
|
||||||
@@ -1033,7 +1034,7 @@ async function deleteComment(commentId: number) {
|
|||||||
@click="deleteComment(comment.id)"
|
@click="deleteComment(comment.id)"
|
||||||
>
|
>
|
||||||
<Icon name="ph:trash-fill" class="size-4" />
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1045,7 +1046,7 @@ async function deleteComment(commentId: number) {
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p v-else class="text-sm text-muted-foreground">
|
<p v-else class="text-sm text-muted-foreground">
|
||||||
Noch keine Kommentare.
|
{{ $t('comments.none') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form
|
<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)]')">
|
<div :class="cn('rounded-md transition-shadow', isOverCommentDrop && 'shadow-[0_0_0_2px_var(--color-primary)]')">
|
||||||
<MentionTextarea
|
<MentionTextarea
|
||||||
v-model="newComment"
|
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">
|
<div class="absolute right-2 bottom-2 flex items-center gap-0.5">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
@@ -1066,13 +1067,13 @@ async function deleteComment(commentId: number) {
|
|||||||
type="button"
|
type="button"
|
||||||
class="flex size-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent"
|
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"
|
:disabled="posting || pendingFiles.length >= MAX_COMMENT_ATTACHMENTS"
|
||||||
aria-label="Dateien anhängen"
|
:aria-label="$t('comments.attachFiles')"
|
||||||
@click="fileInputRef?.click()"
|
@click="fileInputRef?.click()"
|
||||||
>
|
>
|
||||||
<Icon name="ph:folder-plus-fill" class="size-5" />
|
<Icon name="ph:folder-plus-fill" class="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>Dateien anhängen</TooltipContent>
|
<TooltipContent>{{ $t('comments.attachFiles') }}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip v-if="canRecord">
|
<Tooltip v-if="canRecord">
|
||||||
<TooltipTrigger as-child>
|
<TooltipTrigger as-child>
|
||||||
@@ -1083,13 +1084,13 @@ async function deleteComment(commentId: number) {
|
|||||||
recording && 'text-destructive hover:text-destructive',
|
recording && 'text-destructive hover:text-destructive',
|
||||||
)"
|
)"
|
||||||
:disabled="posting || pendingFiles.length >= MAX_COMMENT_ATTACHMENTS"
|
: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()"
|
@click="recording ? stopRecording() : startRecording()"
|
||||||
>
|
>
|
||||||
<Icon :name="recording ? 'ph:stop-fill' : 'ph:microphone-fill'" class="size-5" />
|
<Icon :name="recording ? 'ph:stop-fill' : 'ph:microphone-fill'" class="size-5" />
|
||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>{{ recording ? 'Aufnahme beenden' : 'Sprachnachricht aufnehmen' }}</TooltipContent>
|
<TooltipContent>{{ recording ? $t('comments.recordStop') : $t('comments.recordStart') }}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</MentionTextarea>
|
</MentionTextarea>
|
||||||
@@ -1115,7 +1116,7 @@ async function deleteComment(commentId: number) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="shrink-0 rounded-sm p-0.5 text-muted-foreground transition-colors hover:text-destructive"
|
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)"
|
@click="removePendingFile(i)"
|
||||||
>
|
>
|
||||||
<Icon name="ph:x" class="size-3.5" />
|
<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 class="relative inline-flex size-2.5 rounded-full bg-destructive" />
|
||||||
</span>
|
</span>
|
||||||
<span class="shrink-0 tabular-nums">{{ formatAudioTime(recordSeconds) }}</span>
|
<span class="shrink-0 tabular-nums">{{ formatAudioTime(recordSeconds) }}</span>
|
||||||
<span class="text-muted-foreground">Aufnahme läuft…</span>
|
<span class="text-muted-foreground">{{ $t('comments.recording') }}</span>
|
||||||
<span class="ml-auto text-muted-foreground">Zum Beenden Mikrofon klicken</span>
|
<span class="ml-auto text-muted-foreground">{{ $t('comments.recordHint') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<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>
|
<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)">
|
<Button type="submit" size="sm" class="ml-auto" :disabled="posting || (!newComment.trim() && !pendingFiles.length)">
|
||||||
Kommentieren
|
{{ $t('comments.submit') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
<p v-else class="mt-4 text-sm text-muted-foreground">
|
<p v-else class="mt-4 text-sm text-muted-foreground">
|
||||||
<NuxtLink to="/login" class="text-primary underline">
|
<NuxtLink to="/login" class="text-primary underline">
|
||||||
Melde dich an
|
{{ $t('common.loginLink') }}
|
||||||
</NuxtLink>, um zu kommentieren.
|
</NuxtLink>, {{ $t('comments.loginToComment') }}
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+11
-10
@@ -70,6 +70,7 @@ interface UserProfile {
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const { locale } = useI18n()
|
||||||
const userId = computed(() => route.params.id as string)
|
const userId = computed(() => route.params.id as string)
|
||||||
|
|
||||||
const { data, error } = await useFetch<UserProfile>(() => `/api/users/${userId.value}`)
|
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 profileEffectColor = computed(() => data.value?.user.cardEffectColor ?? profileColor.value)
|
||||||
const profileBorderColor = computed(() => data.value?.user.cardBorderEffectColor ?? profileColor.value)
|
const profileBorderColor = computed(() => data.value?.user.cardBorderEffectColor ?? profileColor.value)
|
||||||
const joinDate = computed(() => data.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()
|
const timeAgo = useTimeAgo()
|
||||||
@@ -194,7 +195,7 @@ const timeAgo = useTimeAgo()
|
|||||||
<template>
|
<template>
|
||||||
<div class="px-4 lg:px-6">
|
<div class="px-4 lg:px-6">
|
||||||
<p v-if="error" class="text-muted-foreground">
|
<p v-if="error" class="text-muted-foreground">
|
||||||
Nutzer nicht gefunden.
|
{{ $t('misc.userNotFound') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<template v-else-if="data">
|
<template v-else-if="data">
|
||||||
@@ -232,7 +233,7 @@ const timeAgo = useTimeAgo()
|
|||||||
</p>
|
</p>
|
||||||
<p class="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
|
<p class="mt-1 flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||||
<Icon name="ph:calendar-fill" class="size-3.5" />
|
<Icon name="ph:calendar-fill" class="size-3.5" />
|
||||||
Mitglied seit {{ joinDate }}
|
{{ $t('misc.memberSince', { date: joinDate }) }}
|
||||||
</p>
|
</p>
|
||||||
<div class="mt-3 flex items-center gap-4 text-sm">
|
<div class="mt-3 flex items-center gap-4 text-sm">
|
||||||
<button
|
<button
|
||||||
@@ -242,7 +243,7 @@ const timeAgo = useTimeAgo()
|
|||||||
@click="view = 'uploads'"
|
@click="view = 'uploads'"
|
||||||
>
|
>
|
||||||
<Icon name="ph:image-fill" class="size-4" />
|
<Icon name="ph:image-fill" class="size-4" />
|
||||||
{{ data.stats.uploads }} Uploads
|
{{ data.stats.uploads }} {{ $t('misc.uploadsCol') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -251,7 +252,7 @@ const timeAgo = useTimeAgo()
|
|||||||
@click="showComments"
|
@click="showComments"
|
||||||
>
|
>
|
||||||
<Icon name="ph:chat-fill" class="size-4" />
|
<Icon name="ph:chat-fill" class="size-4" />
|
||||||
{{ data.stats.comments }} Kommentare
|
{{ data.stats.comments }} {{ $t('misc.commentsCol') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,14 +261,14 @@ const timeAgo = useTimeAgo()
|
|||||||
|
|
||||||
<section class="mt-6">
|
<section class="mt-6">
|
||||||
<h2 ref="sectionHeadEl" class="mb-3 font-semibold">
|
<h2 ref="sectionHeadEl" class="mb-3 font-semibold">
|
||||||
{{ view === 'uploads' ? 'Uploads' : 'Kommentare' }}
|
{{ view === 'uploads' ? $t('misc.uploadsCol') : $t('misc.commentsCol') }}
|
||||||
</h2>
|
</h2>
|
||||||
<template v-if="view === 'uploads'">
|
<template v-if="view === 'uploads'">
|
||||||
<p v-if="uploadsPending" class="text-sm text-muted-foreground">
|
<p v-if="uploadsPending" class="text-sm text-muted-foreground">
|
||||||
Lade Uploads…
|
{{ $t('grid.loading') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="!uploads.length" class="text-sm text-muted-foreground">
|
<p v-else-if="!uploads.length" class="text-sm text-muted-foreground">
|
||||||
Noch keine Uploads.
|
{{ $t('misc.noUploads') }}
|
||||||
</p>
|
</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]">
|
<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" />
|
<UploadTile v-for="upload in uploads" :key="upload.id" :upload="upload" />
|
||||||
@@ -303,10 +304,10 @@ const timeAgo = useTimeAgo()
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p v-if="commentsPending" class="text-sm text-muted-foreground">
|
<p v-if="commentsPending" class="text-sm text-muted-foreground">
|
||||||
Lade Kommentare…
|
{{ $t('comments.loading') }}
|
||||||
</p>
|
</p>
|
||||||
<p v-else-if="!comments?.items.length" class="text-sm text-muted-foreground">
|
<p v-else-if="!comments?.items.length" class="text-sm text-muted-foreground">
|
||||||
Noch keine Kommentare.
|
{{ $t('comments.none') }}
|
||||||
</p>
|
</p>
|
||||||
<div v-else class="flex max-w-2xl flex-col divide-y rounded-md border">
|
<div v-else class="flex max-w-2xl flex-col divide-y rounded-md border">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
|
|||||||
+9
-7
@@ -25,6 +25,8 @@ interface ListedUser {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { locale } = useI18n()
|
||||||
|
|
||||||
const { data: users } = await useFetch<ListedUser[]>('/api/users', {
|
const { data: users } = await useFetch<ListedUser[]>('/api/users', {
|
||||||
key: 'users-list',
|
key: 'users-list',
|
||||||
default: () => [],
|
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]'
|
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) {
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="px-4 lg:px-6">
|
<div class="px-4 lg:px-6">
|
||||||
<h1 class="mb-4 text-xl font-semibold">
|
<h1 class="mb-4 text-xl font-semibold">
|
||||||
Benutzer
|
{{ $t('nav.users') }}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p v-if="!users.length" class="text-sm text-muted-foreground">
|
<p v-if="!users.length" class="text-sm text-muted-foreground">
|
||||||
Noch keine Benutzer.
|
{{ $t('misc.noUsers') }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-else class="max-w-4xl overflow-x-auto">
|
<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)">
|
<div :class="cn('grid items-center gap-3 px-3 py-2 text-xs text-muted-foreground', GRID_COLS)">
|
||||||
<span />
|
<span />
|
||||||
<span>Name</span>
|
<span>{{ $t('common.name') }}</span>
|
||||||
<span class="text-right">ID</span>
|
<span class="text-right">ID</span>
|
||||||
<span class="hidden text-right sm:block">Uploads</span>
|
<span class="hidden text-right sm:block">{{ $t('misc.uploadsCol') }}</span>
|
||||||
<span class="hidden text-right sm:block">Kommentare</span>
|
<span class="hidden text-right sm:block">{{ $t('misc.commentsCol') }}</span>
|
||||||
<span class="hidden text-right sm:block">Dabei seit</span>
|
<span class="hidden text-right sm:block">{{ $t('misc.joined') }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-1 flex flex-col gap-2">
|
<div class="mt-1 flex flex-col gap-2">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { LiveComment } from '@/composables/useLiveComments'
|
|||||||
import type { AppNotification } from '@/composables/useNotifications'
|
import type { AppNotification } from '@/composables/useNotifications'
|
||||||
|
|
||||||
export default defineNuxtPlugin(() => {
|
export default defineNuxtPlugin(() => {
|
||||||
|
const { $i18n } = useNuxtApp()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const { fetchAll, addLive, reset } = useNotifications()
|
const { fetchAll, addLive, reset } = useNotifications()
|
||||||
const { pushLive } = useLiveComments()
|
const { pushLive } = useLiveComments()
|
||||||
@@ -39,11 +40,11 @@ export default defineNuxtPlugin(() => {
|
|||||||
else if (message.type === 'notification') {
|
else if (message.type === 'notification') {
|
||||||
const n = message.notification as AppNotification
|
const n = message.notification as AppNotification
|
||||||
addLive(n, typeof message.unreadCount === 'number' ? message.unreadCount : undefined)
|
addLive(n, typeof message.unreadCount === 'number' ? message.unreadCount : undefined)
|
||||||
toast(n.isMention ? `${n.actorName} hat dich erwähnt` : `${n.actorName} hat in einem abonnierten Thread kommentiert`, {
|
toast($i18n.t(n.isMention ? 'notifications.toastMentioned' : 'notifications.toastCommented', { name: n.actorName }), {
|
||||||
id: `notification-${n.uploadId}`,
|
id: `notification-${n.uploadId}`,
|
||||||
description: n.content?.slice(0, 80) || undefined,
|
description: n.content?.slice(0, 80) || undefined,
|
||||||
action: {
|
action: {
|
||||||
label: 'Ansehen',
|
label: $i18n.t('notifications.view'),
|
||||||
onClick: () => navigateTo(`/thread/${n.uploadId}#comment-${n.commentId}`),
|
onClick: () => navigateTo(`/thread/${n.uploadId}#comment-${n.commentId}`),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Speichern",
|
||||||
|
"saving": "Speichern…",
|
||||||
|
"cancel": "Abbrechen",
|
||||||
|
"delete": "Löschen",
|
||||||
|
"deleting": "Löscht…",
|
||||||
|
"remove": "Entfernen",
|
||||||
|
"close": "Schließen",
|
||||||
|
"back": "Zurück",
|
||||||
|
"next": "Weiter",
|
||||||
|
"previous": "Zurück",
|
||||||
|
"reset": "Zurücksetzen",
|
||||||
|
"loginLink": "Melde dich an",
|
||||||
|
"name": "Name",
|
||||||
|
"password": "Passwort",
|
||||||
|
"email": "E-Mail"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": "Anmelden",
|
||||||
|
"logout": "Ausloggen",
|
||||||
|
"register": "Registrieren",
|
||||||
|
"loginTitle": "Login",
|
||||||
|
"loginDesc": "Melde dich mit Name und Passwort an.",
|
||||||
|
"registerTitle": "Registrieren",
|
||||||
|
"registerDesc": "Name und Passwort reichen — E-Mail ist optional.",
|
||||||
|
"emailOptional": "E-Mail (optional)",
|
||||||
|
"createAccount": "Konto erstellen",
|
||||||
|
"noAccount": "Noch kein Konto?",
|
||||||
|
"alreadyRegistered": "Schon registriert?",
|
||||||
|
"loginFailed": "Login fehlgeschlagen",
|
||||||
|
"registerFailed": "Registrierung fehlgeschlagen"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"users": "Benutzer",
|
||||||
|
"admin": "Admin",
|
||||||
|
"profile": "Profil",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"search": "Suchen…",
|
||||||
|
"searchAria": "Schnellsuche öffnen (Strg+K)",
|
||||||
|
"searchShortcut": "Strg K",
|
||||||
|
"gridBento": "Bento-Layout",
|
||||||
|
"gridUniform": "Einheitliche Größe"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"upload": "Hochladen",
|
||||||
|
"uploading": "Lädt hoch…",
|
||||||
|
"dropToUpload": "Zum Hochladen loslassen",
|
||||||
|
"desc": "Datei oder URL — Bilder, Videos, Audio (MP3/FLAC), YouTube- und Twitch-Links.",
|
||||||
|
"file": "Datei",
|
||||||
|
"orUrl": "oder URL",
|
||||||
|
"urlHint": "Direkte Bild-/Video-URLs werden gespeichert, YouTube- und Twitch-Links eingebettet.",
|
||||||
|
"loginToUpload": ", um Dateien hochzuladen.",
|
||||||
|
"loginToUploadToast": "Melde dich an, um hochzuladen.",
|
||||||
|
"onlyMedia": "Nur Bilder, Videos und Audiodateien sind erlaubt",
|
||||||
|
"failedCount": "{failed} von {total} Dateien fehlgeschlagen",
|
||||||
|
"failed": "Upload fehlgeschlagen",
|
||||||
|
"threadLoadFailed": "Thread konnte nicht geladen werden",
|
||||||
|
"dropzoneText": "Datei hierher ziehen oder klicken",
|
||||||
|
"dropzoneHint": "Bilder, Videos & Audio, max. 200 MB",
|
||||||
|
"avatarHint": "Bilder, max. 5 MB"
|
||||||
|
},
|
||||||
|
"grid": {
|
||||||
|
"loading": "Lade Uploads…",
|
||||||
|
"emptyFiltered": "Keine Uploads für die aktiven Filter.",
|
||||||
|
"empty": "Noch keine Uploads. Sei der Erste!",
|
||||||
|
"count": "{n} Uploads"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"filter": "Filter",
|
||||||
|
"type": "Typ",
|
||||||
|
"uploader": "Uploader",
|
||||||
|
"period": "Zeitraum",
|
||||||
|
"allUploaders": "Alle Uploader",
|
||||||
|
"searchUser": "Nutzer suchen…",
|
||||||
|
"noUser": "Kein Nutzer gefunden.",
|
||||||
|
"images": "Bilder",
|
||||||
|
"videos": "Videos",
|
||||||
|
"audio": "Audio",
|
||||||
|
"links": "Links",
|
||||||
|
"today": "Heute",
|
||||||
|
"days7": "7 Tage",
|
||||||
|
"days30": "30 Tage",
|
||||||
|
"lastYear": "Letztes Jahr",
|
||||||
|
"allTime": "Gesamter Zeitraum",
|
||||||
|
"removeTag": "Tag-Filter entfernen",
|
||||||
|
"removeType": "Typ-Filter {label} entfernen",
|
||||||
|
"removeUploader": "Uploader-Filter entfernen",
|
||||||
|
"removePeriod": "Zeitraum-Filter entfernen"
|
||||||
|
},
|
||||||
|
"comments": {
|
||||||
|
"title": "Kommentare",
|
||||||
|
"none": "Noch keine Kommentare.",
|
||||||
|
"loading": "Lade Kommentare…",
|
||||||
|
"placeholder": "Kommentar schreiben… {'@'} zum Erwähnen, Dateien hierher ziehen",
|
||||||
|
"submit": "Kommentieren",
|
||||||
|
"loginToComment": "um zu kommentieren.",
|
||||||
|
"attachFiles": "Dateien anhängen",
|
||||||
|
"removeAttachment": "Anhang entfernen",
|
||||||
|
"attachments": "Anhänge",
|
||||||
|
"deleteComment": "Kommentar löschen",
|
||||||
|
"commentAria": "Kommentar",
|
||||||
|
"mentionAria": "Benutzer erwähnen",
|
||||||
|
"recordStart": "Sprachnachricht aufnehmen",
|
||||||
|
"recordStop": "Aufnahme beenden",
|
||||||
|
"recording": "Aufnahme läuft…",
|
||||||
|
"recordHint": "Zum Beenden Mikrofon klicken",
|
||||||
|
"voiceMessage": "Sprachnachricht",
|
||||||
|
"maxAttachments": "Maximal {n} Anhänge pro Kommentar",
|
||||||
|
"fileTooLarge": "{name}: Datei ist zu groß (max. 200 MB)",
|
||||||
|
"noMic": "Kein Zugriff aufs Mikrofon",
|
||||||
|
"sendFailed": "Kommentar konnte nicht gesendet werden",
|
||||||
|
"addReaction": "Reaktion hinzufügen",
|
||||||
|
"reactionFailed": "Reaktion fehlgeschlagen",
|
||||||
|
"loginToReact": "Melde dich an, um zu reagieren.",
|
||||||
|
"playAttachment": "{name} abspielen"
|
||||||
|
},
|
||||||
|
"thread": {
|
||||||
|
"notFound": "Upload nicht gefunden.",
|
||||||
|
"newer": "Neuerer Upload",
|
||||||
|
"older": "Älterer Upload",
|
||||||
|
"favAdd": "Zu Favoriten hinzufügen",
|
||||||
|
"favRemove": "Aus Favoriten entfernen",
|
||||||
|
"loginToFavorite": "Melde dich an, um zu favorisieren.",
|
||||||
|
"deleteUpload": "Upload löschen",
|
||||||
|
"deleteTitle": "Upload wirklich löschen?",
|
||||||
|
"deleteDesc": "Der Beitrag und alle Kommentare werden entfernt.",
|
||||||
|
"deleteFailed": "Löschen fehlgeschlagen",
|
||||||
|
"editTags": "Tags bearbeiten",
|
||||||
|
"addTag": "Tag hinzufügen…",
|
||||||
|
"removeTag": "Tag {tag} entfernen",
|
||||||
|
"tagsSaveFailed": "Tags konnten nicht gespeichert werden",
|
||||||
|
"subscribeOn": "Benachrichtigungen für diesen Thread aktivieren",
|
||||||
|
"subscribeOff": "Benachrichtigungen für diesen Thread deaktivieren",
|
||||||
|
"subOn": "Thread-Benachrichtigungen aktiviert",
|
||||||
|
"subOff": "Thread-Benachrichtigungen deaktiviert",
|
||||||
|
"subFailed": "Abonnement konnte nicht geändert werden"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"play": "Abspielen",
|
||||||
|
"pause": "Pause",
|
||||||
|
"seek": "Wiedergabeposition",
|
||||||
|
"mute": "Stumm",
|
||||||
|
"unmute": "Ton an",
|
||||||
|
"volume": "Lautstärke",
|
||||||
|
"download": "Herunterladen"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"aria": "Benachrichtigungen",
|
||||||
|
"empty": "Keine Benachrichtigungen.",
|
||||||
|
"commented": "hat kommentiert",
|
||||||
|
"mentioned": "hat dich erwähnt",
|
||||||
|
"view": "Ansehen",
|
||||||
|
"toastCommented": "{name} hat in einem abonnierten Thread kommentiert",
|
||||||
|
"toastMentioned": "{name} hat dich erwähnt"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Einstellungen",
|
||||||
|
"desc": "Profil und Sprache anpassen.",
|
||||||
|
"nameColor": "Name-Farbe",
|
||||||
|
"pickColor": "Farbe auswählen",
|
||||||
|
"cardEffect": "Hover-Card-Effekt",
|
||||||
|
"noEffect": "Keiner",
|
||||||
|
"noEffectPlaceholder": "Kein Effekt",
|
||||||
|
"ownEffectColor": "Eigene Effekt-Farbe",
|
||||||
|
"pickEffectColor": "Effekt-Farbe auswählen",
|
||||||
|
"vignette": "Vignette (Text lesbar halten)",
|
||||||
|
"cardBorder": "Hover-Card-Border",
|
||||||
|
"ownBorderColor": "Eigene Border-Farbe",
|
||||||
|
"pickBorderColor": "Border-Farbe auswählen",
|
||||||
|
"preview": "Vorschau",
|
||||||
|
"previewHint": "So sieht deine Hover-Card für andere aus.",
|
||||||
|
"avatar": "Avatar",
|
||||||
|
"currentAvatar": "Aktueller Avatar",
|
||||||
|
"language": "Sprache",
|
||||||
|
"saved": "Einstellungen gespeichert",
|
||||||
|
"saveFailed": "Speichern fehlgeschlagen",
|
||||||
|
"loginToChange": ", um deine Einstellungen zu ändern."
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"roles": "Rollen",
|
||||||
|
"rolesDesc": "Bestehende Rollen und neue anlegen (z. B. moderator). Die admin-Rolle hat immer alle Berechtigungen.",
|
||||||
|
"memberCount": "{n} Nutzer",
|
||||||
|
"deleteRole": "Rolle löschen",
|
||||||
|
"newRole": "Neuer Rollenname",
|
||||||
|
"create": "Anlegen",
|
||||||
|
"users": "Benutzer",
|
||||||
|
"usersDesc": "Rollen zuweisen oder entfernen.",
|
||||||
|
"searchUsers": "Name oder E-Mail suchen…",
|
||||||
|
"noRoles": "keine Rollen",
|
||||||
|
"chooseRole": "Rolle wählen…",
|
||||||
|
"assign": "Zuweisen",
|
||||||
|
"removeRole": "Rolle {name} entfernen",
|
||||||
|
"rolesCol": "Rollen",
|
||||||
|
"actionCol": "Aktion",
|
||||||
|
"motd": "Ansage",
|
||||||
|
"motdDesc": "Wird allen Besuchern direkt unter der Kopfzeile angezeigt und bei Änderung sofort aktualisiert. Leer lassen, um sie zu entfernen.",
|
||||||
|
"motdPlaceholder": "Ansagetext…",
|
||||||
|
"actionFailed": "Aktion fehlgeschlagen",
|
||||||
|
"perms": {
|
||||||
|
"admin": { "access": "Admin-Bereich öffnen" },
|
||||||
|
"roles": { "manage": "Rollen & Berechtigungen verwalten" },
|
||||||
|
"users": { "manage": "Benutzer & Rollen verwalten" },
|
||||||
|
"site": { "manage": "Seiten-Einstellungen verwalten" },
|
||||||
|
"comment": { "delete": "Kommentare löschen" },
|
||||||
|
"upload": { "delete": "Uploads löschen" },
|
||||||
|
"tag": { "delete": "Tags löschen" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"palette": {
|
||||||
|
"title": "Schnellsuche",
|
||||||
|
"desc": "Seiten, Tags oder Uploader suchen",
|
||||||
|
"placeholder": "Seiten, Tags oder Uploader suchen…",
|
||||||
|
"empty": "Keine Ergebnisse.",
|
||||||
|
"pages": "Seiten",
|
||||||
|
"home": "Startseite",
|
||||||
|
"users": "Benutzer",
|
||||||
|
"myProfile": "Mein Profil",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"adminArea": "Admin-Bereich",
|
||||||
|
"login": "Login",
|
||||||
|
"register": "Registrieren",
|
||||||
|
"tags": "Tags",
|
||||||
|
"uploaders": "Uploader"
|
||||||
|
},
|
||||||
|
"timeAgo": {
|
||||||
|
"justNow": "gerade eben",
|
||||||
|
"min": "vor {n} Min",
|
||||||
|
"hour": "vor {n} Std",
|
||||||
|
"day": "vor {n} T",
|
||||||
|
"month": "vor {n} Mon",
|
||||||
|
"year": "vor {n} J"
|
||||||
|
},
|
||||||
|
"misc": {
|
||||||
|
"userNotFound": "Nutzer nicht gefunden.",
|
||||||
|
"memberSince": "Mitglied seit {date}",
|
||||||
|
"since": "seit {date}",
|
||||||
|
"noUsers": "Noch keine Benutzer.",
|
||||||
|
"noUploads": "Noch keine Uploads.",
|
||||||
|
"joined": "Dabei seit",
|
||||||
|
"commentsCol": "Kommentare",
|
||||||
|
"uploadsCol": "Uploads",
|
||||||
|
"noEntries": "Keine Einträge.",
|
||||||
|
"pageOf": "Seite {page} von {total}",
|
||||||
|
"theme": "Theme",
|
||||||
|
"toggleSidebar": "Seitenleiste umschalten",
|
||||||
|
"sidebar": "Seitenleiste",
|
||||||
|
"sidebarDesc": "Zeigt die mobile Seitenleiste.",
|
||||||
|
"more": "Mehr",
|
||||||
|
"setColor": "Farbe auf {color} setzen",
|
||||||
|
"breadcrumb": "Brotkrumennavigation"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"prev": "Vorherige Seite",
|
||||||
|
"next": "Nächste Seite",
|
||||||
|
"first": "Erste Seite",
|
||||||
|
"firstShort": "Erste",
|
||||||
|
"last": "Letzte Seite",
|
||||||
|
"lastShort": "Letzte",
|
||||||
|
"page": "Seite {page}",
|
||||||
|
"more": "Weitere Seiten"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,263 @@
|
|||||||
|
{
|
||||||
|
"common": {
|
||||||
|
"save": "Save",
|
||||||
|
"saving": "Saving…",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleting": "Deleting…",
|
||||||
|
"remove": "Remove",
|
||||||
|
"close": "Close",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"reset": "Reset",
|
||||||
|
"loginLink": "Log in",
|
||||||
|
"name": "Name",
|
||||||
|
"password": "Password",
|
||||||
|
"email": "Email"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"login": "Log in",
|
||||||
|
"logout": "Log out",
|
||||||
|
"register": "Sign up",
|
||||||
|
"loginTitle": "Log in",
|
||||||
|
"loginDesc": "Log in with your name and password.",
|
||||||
|
"registerTitle": "Sign up",
|
||||||
|
"registerDesc": "Name and password are enough — email is optional.",
|
||||||
|
"emailOptional": "Email (optional)",
|
||||||
|
"createAccount": "Create account",
|
||||||
|
"noAccount": "No account yet?",
|
||||||
|
"alreadyRegistered": "Already registered?",
|
||||||
|
"loginFailed": "Login failed",
|
||||||
|
"registerFailed": "Registration failed"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"users": "Users",
|
||||||
|
"admin": "Admin",
|
||||||
|
"profile": "Profile",
|
||||||
|
"settings": "Settings",
|
||||||
|
"search": "Search…",
|
||||||
|
"searchAria": "Open quick search (Ctrl+K)",
|
||||||
|
"searchShortcut": "Ctrl K",
|
||||||
|
"gridBento": "Bento layout",
|
||||||
|
"gridUniform": "Uniform size"
|
||||||
|
},
|
||||||
|
"upload": {
|
||||||
|
"upload": "Upload",
|
||||||
|
"uploading": "Uploading…",
|
||||||
|
"dropToUpload": "Drop to upload",
|
||||||
|
"desc": "File or URL — images, videos, audio (MP3/FLAC), YouTube and Twitch links.",
|
||||||
|
"file": "File",
|
||||||
|
"orUrl": "or URL",
|
||||||
|
"urlHint": "Direct image/video URLs are saved, YouTube and Twitch links are embedded.",
|
||||||
|
"loginToUpload": " to upload files.",
|
||||||
|
"loginToUploadToast": "Log in to upload.",
|
||||||
|
"onlyMedia": "Only images, videos, and audio files are allowed",
|
||||||
|
"failedCount": "{failed} of {total} files failed",
|
||||||
|
"failed": "Upload failed",
|
||||||
|
"threadLoadFailed": "Thread could not be loaded",
|
||||||
|
"dropzoneText": "Drag file here or click",
|
||||||
|
"dropzoneHint": "Images, videos & audio, max. 200 MB",
|
||||||
|
"avatarHint": "Images, max. 5 MB"
|
||||||
|
},
|
||||||
|
"grid": {
|
||||||
|
"loading": "Loading uploads…",
|
||||||
|
"emptyFiltered": "No uploads match the active filters.",
|
||||||
|
"empty": "No uploads yet. Be the first!",
|
||||||
|
"count": "{n} uploads"
|
||||||
|
},
|
||||||
|
"filters": {
|
||||||
|
"filter": "Filter",
|
||||||
|
"type": "Type",
|
||||||
|
"uploader": "Uploader",
|
||||||
|
"period": "Time range",
|
||||||
|
"allUploaders": "All uploaders",
|
||||||
|
"searchUser": "Search users…",
|
||||||
|
"noUser": "No user found.",
|
||||||
|
"images": "Images",
|
||||||
|
"videos": "Videos",
|
||||||
|
"audio": "Audio",
|
||||||
|
"links": "Links",
|
||||||
|
"today": "Today",
|
||||||
|
"days7": "7 days",
|
||||||
|
"days30": "30 days",
|
||||||
|
"lastYear": "Last year",
|
||||||
|
"allTime": "All time",
|
||||||
|
"removeTag": "Remove tag filter",
|
||||||
|
"removeType": "Remove {label} type filter",
|
||||||
|
"removeUploader": "Remove uploader filter",
|
||||||
|
"removePeriod": "Remove time range filter"
|
||||||
|
},
|
||||||
|
"comments": {
|
||||||
|
"title": "Comments",
|
||||||
|
"none": "No comments yet.",
|
||||||
|
"loading": "Loading comments…",
|
||||||
|
"placeholder": "Write a comment… {'@'} to mention, drop files here",
|
||||||
|
"submit": "Comment",
|
||||||
|
"loginToComment": "to comment.",
|
||||||
|
"attachFiles": "Attach files",
|
||||||
|
"removeAttachment": "Remove attachment",
|
||||||
|
"attachments": "Attachments",
|
||||||
|
"deleteComment": "Delete comment",
|
||||||
|
"commentAria": "Comment",
|
||||||
|
"mentionAria": "Mention user",
|
||||||
|
"recordStart": "Record voice message",
|
||||||
|
"recordStop": "Stop recording",
|
||||||
|
"recording": "Recording…",
|
||||||
|
"recordHint": "Click the microphone to stop",
|
||||||
|
"voiceMessage": "Voice message",
|
||||||
|
"maxAttachments": "Max {n} attachments per comment",
|
||||||
|
"fileTooLarge": "{name}: File is too large (max. 200 MB)",
|
||||||
|
"noMic": "No microphone access",
|
||||||
|
"sendFailed": "Comment could not be sent",
|
||||||
|
"addReaction": "Add reaction",
|
||||||
|
"reactionFailed": "Reaction failed",
|
||||||
|
"loginToReact": "Log in to react.",
|
||||||
|
"playAttachment": "Play {name}"
|
||||||
|
},
|
||||||
|
"thread": {
|
||||||
|
"notFound": "Upload not found.",
|
||||||
|
"newer": "Newer upload",
|
||||||
|
"older": "Older upload",
|
||||||
|
"favAdd": "Add to favorites",
|
||||||
|
"favRemove": "Remove from favorites",
|
||||||
|
"loginToFavorite": "Log in to favorite.",
|
||||||
|
"deleteUpload": "Delete upload",
|
||||||
|
"deleteTitle": "Really delete this upload?",
|
||||||
|
"deleteDesc": "The post and all comments will be removed.",
|
||||||
|
"deleteFailed": "Delete failed",
|
||||||
|
"editTags": "Edit tags",
|
||||||
|
"addTag": "Add tag…",
|
||||||
|
"removeTag": "Remove tag {tag}",
|
||||||
|
"tagsSaveFailed": "Tags could not be saved",
|
||||||
|
"subscribeOn": "Enable notifications for this thread",
|
||||||
|
"subscribeOff": "Disable notifications for this thread",
|
||||||
|
"subOn": "Thread notifications enabled",
|
||||||
|
"subOff": "Thread notifications disabled",
|
||||||
|
"subFailed": "Subscription could not be changed"
|
||||||
|
},
|
||||||
|
"audio": {
|
||||||
|
"play": "Play",
|
||||||
|
"pause": "Pause",
|
||||||
|
"seek": "Playback position",
|
||||||
|
"mute": "Mute",
|
||||||
|
"unmute": "Unmute",
|
||||||
|
"volume": "Volume",
|
||||||
|
"download": "Download"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"aria": "Notifications",
|
||||||
|
"empty": "No notifications.",
|
||||||
|
"commented": "commented",
|
||||||
|
"mentioned": "mentioned you",
|
||||||
|
"view": "View",
|
||||||
|
"toastCommented": "{name} commented in a subscribed thread",
|
||||||
|
"toastMentioned": "{name} mentioned you"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"title": "Settings",
|
||||||
|
"desc": "Adjust your profile and language.",
|
||||||
|
"nameColor": "Name color",
|
||||||
|
"pickColor": "Pick color",
|
||||||
|
"cardEffect": "Hover card effect",
|
||||||
|
"noEffect": "None",
|
||||||
|
"noEffectPlaceholder": "No effect",
|
||||||
|
"ownEffectColor": "Custom effect color",
|
||||||
|
"pickEffectColor": "Pick effect color",
|
||||||
|
"vignette": "Vignette (keeps text readable)",
|
||||||
|
"cardBorder": "Hover card border",
|
||||||
|
"ownBorderColor": "Custom border color",
|
||||||
|
"pickBorderColor": "Pick border color",
|
||||||
|
"preview": "Preview",
|
||||||
|
"previewHint": "This is how your hover card looks to others.",
|
||||||
|
"avatar": "Avatar",
|
||||||
|
"currentAvatar": "Current avatar",
|
||||||
|
"language": "Language",
|
||||||
|
"saved": "Settings saved",
|
||||||
|
"saveFailed": "Save failed",
|
||||||
|
"loginToChange": " to change your settings."
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"roles": "Roles",
|
||||||
|
"rolesDesc": "Manage existing roles and create new ones (e.g. moderator). The admin role always has all permissions.",
|
||||||
|
"memberCount": "{n} users",
|
||||||
|
"deleteRole": "Delete role",
|
||||||
|
"newRole": "New role name",
|
||||||
|
"create": "Create",
|
||||||
|
"users": "Users",
|
||||||
|
"usersDesc": "Assign or remove roles.",
|
||||||
|
"searchUsers": "Search name or email…",
|
||||||
|
"noRoles": "no roles",
|
||||||
|
"chooseRole": "Choose role…",
|
||||||
|
"assign": "Assign",
|
||||||
|
"removeRole": "Remove role {name}",
|
||||||
|
"rolesCol": "Roles",
|
||||||
|
"actionCol": "Action",
|
||||||
|
"motd": "Announcement",
|
||||||
|
"motdDesc": "Shown to all visitors directly below the header and updated instantly on change. Leave empty to remove it.",
|
||||||
|
"motdPlaceholder": "Announcement text…",
|
||||||
|
"actionFailed": "Action failed",
|
||||||
|
"perms": {
|
||||||
|
"admin": { "access": "Open admin area" },
|
||||||
|
"roles": { "manage": "Manage roles & permissions" },
|
||||||
|
"users": { "manage": "Manage users & roles" },
|
||||||
|
"site": { "manage": "Manage site settings" },
|
||||||
|
"comment": { "delete": "Delete comments" },
|
||||||
|
"upload": { "delete": "Delete uploads" },
|
||||||
|
"tag": { "delete": "Delete tags" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"palette": {
|
||||||
|
"title": "Quick search",
|
||||||
|
"desc": "Search pages, tags or uploaders",
|
||||||
|
"placeholder": "Search pages, tags or uploaders…",
|
||||||
|
"empty": "No results.",
|
||||||
|
"pages": "Pages",
|
||||||
|
"home": "Home",
|
||||||
|
"users": "Users",
|
||||||
|
"myProfile": "My profile",
|
||||||
|
"settings": "Settings",
|
||||||
|
"adminArea": "Admin area",
|
||||||
|
"login": "Log in",
|
||||||
|
"register": "Sign up",
|
||||||
|
"tags": "Tags",
|
||||||
|
"uploaders": "Uploaders"
|
||||||
|
},
|
||||||
|
"timeAgo": {
|
||||||
|
"justNow": "just now",
|
||||||
|
"min": "{n}m ago",
|
||||||
|
"hour": "{n}h ago",
|
||||||
|
"day": "{n}d ago",
|
||||||
|
"month": "{n}mo ago",
|
||||||
|
"year": "{n}y ago"
|
||||||
|
},
|
||||||
|
"misc": {
|
||||||
|
"userNotFound": "User not found.",
|
||||||
|
"memberSince": "Member since {date}",
|
||||||
|
"since": "since {date}",
|
||||||
|
"noUsers": "No users yet.",
|
||||||
|
"noUploads": "No uploads yet.",
|
||||||
|
"joined": "Joined",
|
||||||
|
"commentsCol": "Comments",
|
||||||
|
"uploadsCol": "Uploads",
|
||||||
|
"noEntries": "No entries.",
|
||||||
|
"pageOf": "Page {page} of {total}",
|
||||||
|
"theme": "Theme",
|
||||||
|
"toggleSidebar": "Toggle sidebar",
|
||||||
|
"sidebar": "Sidebar",
|
||||||
|
"sidebarDesc": "Displays the mobile sidebar.",
|
||||||
|
"more": "More",
|
||||||
|
"setColor": "Set color to {color}",
|
||||||
|
"breadcrumb": "Breadcrumb"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"prev": "Previous page",
|
||||||
|
"next": "Next page",
|
||||||
|
"first": "First page",
|
||||||
|
"firstShort": "First",
|
||||||
|
"last": "Last page",
|
||||||
|
"lastShort": "Last",
|
||||||
|
"page": "Page {page}",
|
||||||
|
"more": "More pages"
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-1
@@ -33,7 +33,21 @@ export default defineNuxtConfig({
|
|||||||
vite: {
|
vite: {
|
||||||
plugins: [tailwindcss()],
|
plugins: [tailwindcss()],
|
||||||
},
|
},
|
||||||
modules: ['shadcn-nuxt', '@nuxt/image', '@nuxt/icon'],
|
modules: ['shadcn-nuxt', '@nuxt/image', '@nuxt/icon', '@nuxtjs/i18n'],
|
||||||
|
i18n: {
|
||||||
|
locales: [
|
||||||
|
{ code: 'de', language: 'de-DE', name: 'Deutsch', file: 'de.json' },
|
||||||
|
{ code: 'en', language: 'en-US', name: 'English', file: 'en.json' },
|
||||||
|
],
|
||||||
|
defaultLocale: 'de',
|
||||||
|
strategy: 'no_prefix',
|
||||||
|
detectBrowserLanguage: {
|
||||||
|
useCookie: true,
|
||||||
|
cookieKey: 'i18n_locale',
|
||||||
|
redirectOn: 'root',
|
||||||
|
fallbackLocale: 'de',
|
||||||
|
},
|
||||||
|
},
|
||||||
icon: {
|
icon: {
|
||||||
mode: 'svg',
|
mode: 'svg',
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+2072
-7
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@
|
|||||||
"@iconify-json/ph": "^1.2.2",
|
"@iconify-json/ph": "^1.2.2",
|
||||||
"@iconify-json/simple-icons": "^1.2.98",
|
"@iconify-json/simple-icons": "^1.2.98",
|
||||||
"@nuxt/image": "^2.1.0",
|
"@nuxt/image": "^2.1.0",
|
||||||
|
"@nuxtjs/i18n": "^10.6.0",
|
||||||
"@tanstack/vue-table": "^9.2.4",
|
"@tanstack/vue-table": "^9.2.4",
|
||||||
"@uiw/color-convert": "^2.10.3",
|
"@uiw/color-convert": "^2.10.3",
|
||||||
"@unovis/ts": "^1.7.0",
|
"@unovis/ts": "^1.7.0",
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 876 KiB |
Reference in New Issue
Block a user