Compare commits
7
Commits
e2095dea91
...
9059af71f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9059af71f0 | ||
|
|
1a3b19da6a | ||
|
|
d8d7583d08 | ||
|
|
8e822e031d | ||
|
|
a421007af6 | ||
|
|
c5e5bc4837 | ||
|
|
e639c97589 |
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import ImageLightbox from '@/components/ImageLightbox.vue'
|
||||
import { mentionParts } from '~~/shared/utils/mentions'
|
||||
import { quoteParts, type QuoteInfo } from '~~/shared/utils/quotes'
|
||||
import { stickerParts } from '~~/shared/utils/stickers'
|
||||
@@ -27,14 +28,21 @@ const props = defineProps<{
|
||||
// Twitch-Embeds brauchen den Hostname als parent-Parameter
|
||||
const requestHost = useRequestURL().hostname
|
||||
|
||||
const URL_RE = /https?:\/\/[^\s<>"']+/g
|
||||
// Eingebettete Bilder öffnen die Lightbox statt eines neuen Tabs
|
||||
const lightboxSrc = ref<string | null>(null)
|
||||
const lightboxAlt = ref('')
|
||||
|
||||
// Absolute URLs plus seitenrelative /uploads/-Pfade mit Bild-Endung
|
||||
// (z. B. vom Bot generierte Ergebnisbilder)
|
||||
const URL_RE = /(?:https?:\/\/[^\s<>"']+|\/uploads\/[^\s<>"']+?\.(?:avif|bmp|gif|jpe?g|png|svg|webp))/gi
|
||||
const TRAILING_PUNCT = /[.,;:!?)\]]+$/
|
||||
|
||||
const IMAGE_EXT = /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i
|
||||
|
||||
function isImageUrl(href: string): boolean {
|
||||
try {
|
||||
return IMAGE_EXT.test(new URL(href).pathname)
|
||||
const pathname = href.startsWith('/') ? href.split('?')[0]! : new URL(href).pathname
|
||||
return IMAGE_EXT.test(pathname)
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
@@ -232,12 +240,12 @@ const parts = computed<ParsedPart[]>(() => {
|
||||
class="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<a
|
||||
<button
|
||||
v-else-if="part.type === 'image'"
|
||||
:href="part.href"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="mt-2 block"
|
||||
type="button"
|
||||
class="mt-2 block cursor-pointer"
|
||||
:aria-label="part.text"
|
||||
@click.stop="lightboxSrc = part.href ?? null; lightboxAlt = part.text"
|
||||
>
|
||||
<NuxtImg
|
||||
:src="part.href"
|
||||
@@ -245,10 +253,16 @@ const parts = computed<ParsedPart[]>(() => {
|
||||
loading="lazy"
|
||||
class="max-h-60 rounded-md"
|
||||
/>
|
||||
</a>
|
||||
</button>
|
||||
<template v-else>{{ part.text }}</template>
|
||||
</template>
|
||||
<template v-else>{{ part.text }}</template>
|
||||
</template>
|
||||
<ImageLightbox
|
||||
v-if="lightboxSrc"
|
||||
:src="lightboxSrc"
|
||||
:alt="lightboxAlt"
|
||||
@close="lightboxSrc = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import type { AppNotification } from '@/composables/useNotifications'
|
||||
|
||||
const { notifications, unreadCount, markRead } = useNotifications()
|
||||
const { notifications, unreadCount, markRead, markAllRead } = useNotifications()
|
||||
const open = ref(false)
|
||||
const timeAgo = useTimeAgo()
|
||||
|
||||
@@ -30,6 +31,23 @@ async function goTo(n: AppNotification) {
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent v-if="open" side="bottom" align="end" class="w-80 gap-0 p-0">
|
||||
<div class="flex items-center justify-between border-b px-4 py-2">
|
||||
<span class="text-sm font-medium">{{ $t('notifications.aria') }}</span>
|
||||
<Tooltip v-if="unreadCount">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:aria-label="$t('notifications.markAllRead')"
|
||||
class="cursor-pointer text-muted-foreground hover:text-foreground"
|
||||
@click="markAllRead"
|
||||
>
|
||||
<Icon name="ph:checks-fill" class="size-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ $t('notifications.markAllRead') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<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">
|
||||
{{ $t('notifications.empty') }}
|
||||
@@ -38,7 +56,7 @@ async function goTo(n: AppNotification) {
|
||||
v-for="n in notifications"
|
||||
:key="n.id"
|
||||
type="button"
|
||||
class="flex w-full items-center gap-3 border-b px-4 py-3 text-left text-sm transition-colors last:border-b-0 hover:bg-accent/10"
|
||||
class="flex w-full cursor-pointer items-center gap-3 border-b px-4 py-3 text-left text-sm transition-colors last:border-b-0 hover:bg-accent/10"
|
||||
@click="goTo(n)"
|
||||
>
|
||||
<div class="size-12 shrink-0 overflow-hidden rounded-md bg-muted">
|
||||
@@ -66,13 +84,13 @@ async function goTo(n: AppNotification) {
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-baseline gap-1.5">
|
||||
<span
|
||||
class="truncate font-medium"
|
||||
class="min-w-0 flex-1 truncate font-medium"
|
||||
:style="n.actorColor ? { color: n.actorColor } : undefined"
|
||||
>{{ n.actorName }}</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="shrink-0 text-xs text-muted-foreground">{{ timeAgo(n.createdAt) }}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 line-clamp-2 break-words text-muted-foreground">
|
||||
<span class="text-foreground/70">{{ n.isMention ? $t('notifications.mentioned') : $t('notifications.commented') }}:</span>
|
||||
{{ n.content }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,7 @@ const { uniform, toggle: toggleGridMode } = useGridMode()
|
||||
const { open: paletteOpen } = useCommandPalette()
|
||||
const route = useRoute()
|
||||
const siteName = useRuntimeConfig().public.siteName
|
||||
const { site, locked } = useSiteSettings()
|
||||
|
||||
async function onLogoClick(e: MouseEvent) {
|
||||
if (route.path !== '/') return
|
||||
@@ -48,44 +49,47 @@ async function onLogout() {
|
||||
>
|
||||
{{ siteName }}
|
||||
</NuxtLink>
|
||||
<UploadPopover />
|
||||
<Button variant="ghost" as-child>
|
||||
<NuxtLink to="/memes" class="flex items-center gap-1.5">
|
||||
<Icon name="ph:smiley-sticker-fill" class="size-4" />
|
||||
{{ $t('nav.memes') }}
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
<Button variant="ghost" as-child>
|
||||
<NuxtLink to="/users" class="flex items-center gap-1.5">
|
||||
<Icon name="ph:users-fill" class="size-4" />
|
||||
{{ $t('nav.users') }}
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
<Button variant="ghost" as-child>
|
||||
<NuxtLink to="/stats" class="flex items-center gap-1.5">
|
||||
<Icon name="ph:chart-bar-fill" class="size-4" />
|
||||
{{ $t('nav.stats') }}
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
<Button v-if="can('admin.access')" variant="ghost" as-child>
|
||||
<NuxtLink to="/admin" class="flex items-center gap-1.5">
|
||||
<Icon name="ph:shield-fill" class="size-4" />
|
||||
Admin
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
<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"
|
||||
:aria-label="$t('nav.searchAria')"
|
||||
@click="paletteOpen = true"
|
||||
>
|
||||
<Icon name="ph:magnifying-glass-fill" class="size-4" />
|
||||
<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">{{ $t('nav.searchShortcut') }}</kbd>
|
||||
</button>
|
||||
<template v-if="!locked">
|
||||
<UploadPopover />
|
||||
<Button variant="ghost" as-child>
|
||||
<NuxtLink to="/memes" class="flex items-center gap-1.5">
|
||||
<Icon name="ph:smiley-sticker-fill" class="size-4" />
|
||||
{{ $t('nav.memes') }}
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
<Button variant="ghost" as-child>
|
||||
<NuxtLink to="/users" class="flex items-center gap-1.5">
|
||||
<Icon name="ph:users-fill" class="size-4" />
|
||||
{{ $t('nav.users') }}
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
<Button variant="ghost" as-child>
|
||||
<NuxtLink to="/stats" class="flex items-center gap-1.5">
|
||||
<Icon name="ph:chart-bar-fill" class="size-4" />
|
||||
{{ $t('nav.stats') }}
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
<Button v-if="can('admin.access')" variant="ghost" as-child>
|
||||
<NuxtLink to="/admin" class="flex items-center gap-1.5">
|
||||
<Icon name="ph:shield-fill" class="size-4" />
|
||||
Admin
|
||||
</NuxtLink>
|
||||
</Button>
|
||||
<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"
|
||||
:aria-label="$t('nav.searchAria')"
|
||||
@click="paletteOpen = true"
|
||||
>
|
||||
<Icon name="ph:magnifying-glass-fill" class="size-4" />
|
||||
<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">{{ $t('nav.searchShortcut') }}</kbd>
|
||||
</button>
|
||||
</template>
|
||||
<div v-else class="ml-auto" aria-hidden="true" />
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="!user">
|
||||
<Tooltip>
|
||||
<Tooltip v-if="!locked">
|
||||
<TooltipTrigger as-child>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -98,7 +102,6 @@ async function onLogout() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{{ uniform ? $t('nav.gridBento') : $t('nav.gridUniform') }}</TooltipContent>
|
||||
</Tooltip>
|
||||
<ThemeSelector />
|
||||
</template>
|
||||
<template v-if="user">
|
||||
<Button variant="ghost" size="icon" class="relative" as-child :aria-label="$t('nav.messages')">
|
||||
@@ -174,7 +177,7 @@ async function onLogout() {
|
||||
<Button variant="ghost" as-child>
|
||||
<NuxtLink to="/login">{{ $t('auth.login') }}</NuxtLink>
|
||||
</Button>
|
||||
<Button as-child>
|
||||
<Button v-if="site.registrationOpen" as-child>
|
||||
<NuxtLink to="/register">{{ $t('auth.register') }}</NuxtLink>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
@@ -36,7 +36,7 @@ export function useNotifications() {
|
||||
async function markAllRead() {
|
||||
if (!unreadCount.value) return
|
||||
unreadCount.value = 0
|
||||
notifications.value = notifications.value.map(n => ({ ...n, isRead: true }))
|
||||
notifications.value = []
|
||||
await $fetch('/api/notifications/read', { method: 'POST' })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { SITE_DEFAULTS, type SiteSettings } from '~~/shared/utils/site'
|
||||
|
||||
// Site-Flags (Guest-Access, Registrierung) - globaler State, wird von der
|
||||
// globalen Middleware beim ersten Route-Wechsel geladen und per WS-Event
|
||||
// `site` live aktualisiert (notifications.client.ts)
|
||||
export function useSiteSettings() {
|
||||
const site = useState<SiteSettings>('site-flags', () => ({ ...SITE_DEFAULTS }))
|
||||
const loaded = useState<boolean>('site-flags-loaded', () => false)
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (loaded.value) return
|
||||
try {
|
||||
const res = await useRequestFetch()<{ site: SiteSettings }>('/api/site')
|
||||
site.value = { ...SITE_DEFAULTS, ...res.site }
|
||||
loaded.value = true
|
||||
}
|
||||
catch {
|
||||
// Defaults bleiben aktiv (Gaeste werden auf /login geleitet)
|
||||
}
|
||||
}
|
||||
|
||||
function applyRemote(remote: Partial<SiteSettings>) {
|
||||
site.value = { ...site.value, ...remote }
|
||||
loaded.value = true
|
||||
}
|
||||
|
||||
// true = Gast ohne Guest-Access: Navigation, Kommentarleiste usw. ausblenden
|
||||
const { user } = useAuth()
|
||||
const locked = computed(() => !user.value && !site.value.guestAccess)
|
||||
|
||||
return { site, loaded, locked, ensureLoaded, applyRemote }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Modul-Singleton: welcher Thread gerade offen ist. Wird dem Server per WS
|
||||
// mitgeteilt, damit fuer den sichtbaren Thread keine Benachrichtigung
|
||||
// erzeugt wird (der Kommentar erscheint ja live im Thread).
|
||||
let sender: ((payload: unknown) => void) | null = null
|
||||
let current: string | null = null
|
||||
|
||||
export function useViewingThread() {
|
||||
function setViewing(uploadId: string | null) {
|
||||
current = uploadId
|
||||
sender?.({ type: 'viewing', uploadId })
|
||||
}
|
||||
return { setViewing }
|
||||
}
|
||||
|
||||
// Vom WS-Plugin aufgerufen, sobald der Socket offen ist - sendet dabei den
|
||||
// zuletzt bekannten Thread nochmal (Reconnect-Sicherheit)
|
||||
export function bindViewingSocket(send: (payload: unknown) => void) {
|
||||
sender = send
|
||||
if (current) send({ type: 'viewing', uploadId: current })
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/components/ui/sidebar'
|
||||
|
||||
const route = useRoute()
|
||||
const { locked } = useSiteSettings()
|
||||
// Der Seiteninhalt scrollt im SidebarInset (links von der Kommentar-Sidebar),
|
||||
// nicht mehr im Dokument - bei Seitenwechsel deshalb dessen Scrolltop zurücksetzen.
|
||||
// Hash-Navigation (#comment-N) übernimmt weiterhin die Browser-Anchorsprünge.
|
||||
@@ -31,8 +32,8 @@ watch(() => route.path, (to, from) => {
|
||||
>
|
||||
<SidebarInset id="main-scroll" class="min-h-0 overflow-x-clip overflow-y-auto">
|
||||
<SiteHeader />
|
||||
<MotdBanner />
|
||||
<SiteBreadcrumb />
|
||||
<MotdBanner v-if="!locked" />
|
||||
<SiteBreadcrumb v-if="!locked" />
|
||||
<div class="flex min-h-0 flex-1 flex-col">
|
||||
<div class="@container/main flex min-h-0 flex-1 flex-col gap-2">
|
||||
<div class="flex min-h-0 flex-1 flex-col pt-4 md:pt-6">
|
||||
@@ -42,8 +43,8 @@ watch(() => route.path, (to, from) => {
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
<CommentsSidebar />
|
||||
<CommandPalette />
|
||||
<GlobalDropZone />
|
||||
<CommentsSidebar v-if="!locked" />
|
||||
<CommandPalette v-if="!locked" />
|
||||
<GlobalDropZone v-if="!locked" />
|
||||
</SidebarProvider>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// Globale Sichtbarkeits-Sperre: Ist guestAccess aus, werden Gaeste von jeder
|
||||
// Seite auf /login umgeleitet. /register bleibt nur erreichbar, solange die
|
||||
// Registrierung geoeffnet ist.
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const { user } = useAuth()
|
||||
if (user.value) return
|
||||
|
||||
const { site, ensureLoaded } = useSiteSettings()
|
||||
await ensureLoaded()
|
||||
|
||||
if (site.value.guestAccess) return
|
||||
if (to.path === '/login') return
|
||||
if (to.path === '/register' && site.value.registrationOpen) return
|
||||
|
||||
return navigateTo('/login')
|
||||
})
|
||||
+75
-1
@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import {
|
||||
Select,
|
||||
@@ -19,6 +20,7 @@ import {
|
||||
} from '@/components/ui/select'
|
||||
import { Slider } from '@/components/ui/slider'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
|
||||
@@ -47,6 +49,31 @@ const { data: usersList, refresh: refreshUsers } = await useFetch<AdminUser[]>('
|
||||
const newRoleName = ref('')
|
||||
const motdDraft = ref('')
|
||||
const motdSaving = ref(false)
|
||||
|
||||
// Site-Flags (Guest-Access, Registrierung) - sofort speichern beim Umschalten
|
||||
const { site, ensureLoaded: ensureSiteLoaded, applyRemote: applySiteRemote } = useSiteSettings()
|
||||
const siteFlagSaving = ref<string | null>(null)
|
||||
const siteFlagError = ref('')
|
||||
if (can('site.manage')) await ensureSiteLoaded()
|
||||
async function setSiteFlag(key: 'guestAccess' | 'registrationOpen', value: boolean) {
|
||||
if (siteFlagSaving.value) return
|
||||
siteFlagSaving.value = key
|
||||
siteFlagError.value = ''
|
||||
try {
|
||||
const res = await $fetch<{ site: typeof site.value }>('/api/admin/site', {
|
||||
method: 'PUT',
|
||||
body: { [key]: value },
|
||||
})
|
||||
applySiteRemote(res.site)
|
||||
}
|
||||
catch (err) {
|
||||
const e = err as { data?: { statusMessage?: string, message?: string } }
|
||||
siteFlagError.value = e.data?.statusMessage ?? e.data?.message ?? t('common.error')
|
||||
}
|
||||
finally {
|
||||
siteFlagSaving.value = null
|
||||
}
|
||||
}
|
||||
const { data: motdData } = await useFetch<{ message: string }>('/api/motd', { default: () => ({ message: '' }) })
|
||||
motdDraft.value = motdData.value.message
|
||||
|
||||
@@ -352,7 +379,7 @@ async function resetLimit(key: LimitKey) {
|
||||
{{ $t('admin.memes') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger v-if="can('site.manage')" value="site" class="cursor-pointer">
|
||||
{{ $t('admin.motd') }}
|
||||
{{ $t('admin.site') }}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger v-if="can('site.manage')" value="limits" class="cursor-pointer">
|
||||
{{ $t('admin.limits') }}
|
||||
@@ -690,6 +717,52 @@ async function resetLimit(key: LimitKey) {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="can('site.manage')" value="site">
|
||||
<div class="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ $t('admin.visibility') }}</CardTitle>
|
||||
<CardDescription>{{ $t('admin.visibilityDesc') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="site-guest-access" class="text-sm font-medium">
|
||||
{{ $t('admin.guestAccess') }}
|
||||
</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('admin.guestAccessDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="site-guest-access"
|
||||
:model-value="site.guestAccess"
|
||||
:disabled="siteFlagSaving === 'guestAccess'"
|
||||
class="mt-0.5 shrink-0 cursor-pointer"
|
||||
@update:model-value="setSiteFlag('guestAccess', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="space-y-0.5">
|
||||
<Label for="site-registration-open" class="text-sm font-medium">
|
||||
{{ $t('admin.registrationOpen') }}
|
||||
</Label>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{{ $t('admin.registrationOpenDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="site-registration-open"
|
||||
:model-value="site.registrationOpen"
|
||||
:disabled="siteFlagSaving === 'registrationOpen'"
|
||||
class="mt-0.5 shrink-0 cursor-pointer"
|
||||
@update:model-value="setSiteFlag('registrationOpen', $event)"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="siteFlagError" class="text-sm text-destructive">
|
||||
{{ siteFlagError }}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ $t('admin.motd') }}</CardTitle>
|
||||
@@ -710,6 +783,7 @@ async function resetLimit(key: LimitKey) {
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent v-if="can('site.manage')" value="limits">
|
||||
|
||||
+3
-2
@@ -6,6 +6,7 @@ import { Label } from '@/components/ui/label'
|
||||
import { Spinner } from '@/components/ui/spinner'
|
||||
|
||||
const { login } = useAuth()
|
||||
const { site } = useSiteSettings()
|
||||
const { t } = useI18n()
|
||||
|
||||
useHead({ title: computed(() => t('auth.loginTitle')) })
|
||||
@@ -33,7 +34,7 @@ async function onSubmit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-sm px-4 lg:px-6">
|
||||
<div class="mx-auto w-full max-w-3xl px-4 lg:px-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ $t('auth.loginTitle') }}</CardTitle>
|
||||
@@ -56,7 +57,7 @@ async function onSubmit() {
|
||||
<Spinner v-if="loading" />
|
||||
{{ $t('auth.login') }}
|
||||
</Button>
|
||||
<p class="text-center text-sm text-muted-foreground">
|
||||
<p v-if="site.registrationOpen" class="text-center text-sm text-muted-foreground">
|
||||
{{ $t('auth.noAccount') }}
|
||||
<NuxtLink to="/register" class="text-primary underline">
|
||||
{{ $t('auth.register') }}
|
||||
|
||||
@@ -34,7 +34,7 @@ async function onSubmit() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-sm px-4 lg:px-6">
|
||||
<div class="mx-auto w-full max-w-3xl px-4 lg:px-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ $t('auth.registerTitle') }}</CardTitle>
|
||||
|
||||
+53
-7
@@ -82,6 +82,12 @@ interface FavUser {
|
||||
const route = useRoute()
|
||||
const id = computed(() => route.params.id as string)
|
||||
|
||||
// Dem Server melden, dass dieser Thread gerade betrachtet wird - fuer den
|
||||
// sichtbaren Thread wird dann keine Benachrichtigung erzeugt
|
||||
const { setViewing } = useViewingThread()
|
||||
watch(id, v => setViewing(v), { immediate: true })
|
||||
onUnmounted(() => setViewing(null))
|
||||
|
||||
// Thread-Layout: 4 Zonen (top, left, right, bottom) mit frei per Drag&Drop
|
||||
// anordenbaren Blöcken (media, uploader, tags, reactions, favorites, comments)
|
||||
const {
|
||||
@@ -432,15 +438,50 @@ const activeCommentFormEl = computed<HTMLElement | null>(() => {
|
||||
})
|
||||
let pinnedToBottom = false
|
||||
let lastKnownScrollHeight = 0
|
||||
// Waerend einer programmatischen Smooth-Animation laufen Scroll-Events weiter
|
||||
// und wuerden pinnedToBottom faelschlich zuruecksetzen - deshalb sperren wir
|
||||
// die Auswertung, bis sich die Animation beruhigt hat
|
||||
let scrollingToBottom = false
|
||||
let scrollSettleTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function isNearBottom(el: HTMLElement) {
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 120
|
||||
}
|
||||
|
||||
function scheduleScrollSettle(el: HTMLElement) {
|
||||
clearTimeout(scrollSettleTimer)
|
||||
scrollSettleTimer = setTimeout(() => {
|
||||
scrollingToBottom = false
|
||||
if (!el.isConnected) return
|
||||
// Ist waehrend der Animation Inhalt nachgewachsen (z. B. Bot-Kommentar
|
||||
// mit nachgeladenem Bild), auf das neue Ende nachziehen
|
||||
if (!isNearBottom(el)) el.scrollTo({ top: el.scrollHeight })
|
||||
pinnedToBottom = isNearBottom(el)
|
||||
lastKnownScrollHeight = el.scrollHeight
|
||||
}, 160)
|
||||
}
|
||||
|
||||
function scrollToBottomEl(el: HTMLElement, smooth = true) {
|
||||
scrollingToBottom = true
|
||||
pinnedToBottom = true
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
|
||||
scheduleScrollSettle(el)
|
||||
}
|
||||
|
||||
function cancelProgrammaticScroll() {
|
||||
scrollingToBottom = false
|
||||
clearTimeout(scrollSettleTimer)
|
||||
}
|
||||
|
||||
function updatePinnedState() {
|
||||
const el = mainScrollEl()
|
||||
if (!el) return
|
||||
lastKnownScrollHeight = el.scrollHeight
|
||||
if (scrollingToBottom) {
|
||||
// Weitere Events = Animation laeuft noch -> Settle-Timer verlaengern
|
||||
scheduleScrollSettle(el)
|
||||
return
|
||||
}
|
||||
pinnedToBottom = isNearBottom(el)
|
||||
}
|
||||
|
||||
@@ -451,8 +492,7 @@ function stickToBottomIfNeeded(smooth = false) {
|
||||
const composerFocused = !!formEl && formEl.contains(document.activeElement)
|
||||
const wasNearBottom = pinnedToBottom || (lastKnownScrollHeight > 0 && lastKnownScrollHeight - el.scrollTop - el.clientHeight < 120)
|
||||
if (wasNearBottom || (composerFocused && isNearBottom(el))) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: smooth ? 'smooth' : 'auto' })
|
||||
pinnedToBottom = true
|
||||
scrollToBottomEl(el, smooth)
|
||||
}
|
||||
lastKnownScrollHeight = el.scrollHeight
|
||||
}
|
||||
@@ -470,9 +510,8 @@ watch(() => allComments.value.length, (next, prev) => {
|
||||
nextTick(() => {
|
||||
const scroller = mainScrollEl()
|
||||
if (scroller) {
|
||||
scroller.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' })
|
||||
scrollToBottomEl(scroller, true)
|
||||
lastKnownScrollHeight = scroller.scrollHeight
|
||||
pinnedToBottom = true
|
||||
}
|
||||
})
|
||||
}, { flush: 'pre' })
|
||||
@@ -481,6 +520,8 @@ let commentListRo: ResizeObserver | null = null
|
||||
onMounted(() => {
|
||||
const scroller = mainScrollEl()
|
||||
scroller?.addEventListener('scroll', updatePinnedState, { passive: true })
|
||||
scroller?.addEventListener('wheel', cancelProgrammaticScroll, { passive: true })
|
||||
scroller?.addEventListener('touchstart', cancelProgrammaticScroll, { passive: true })
|
||||
updatePinnedState()
|
||||
commentListRo = new ResizeObserver(() => {
|
||||
if (pinnedToBottom) stickToBottomIfNeeded(true)
|
||||
@@ -492,7 +533,11 @@ onMounted(() => {
|
||||
}, { immediate: true })
|
||||
})
|
||||
onUnmounted(() => {
|
||||
mainScrollEl()?.removeEventListener('scroll', updatePinnedState)
|
||||
const scroller = mainScrollEl()
|
||||
scroller?.removeEventListener('scroll', updatePinnedState)
|
||||
scroller?.removeEventListener('wheel', cancelProgrammaticScroll)
|
||||
scroller?.removeEventListener('touchstart', cancelProgrammaticScroll)
|
||||
cancelProgrammaticScroll()
|
||||
commentListRo?.disconnect()
|
||||
commentListRo = null
|
||||
})
|
||||
@@ -605,7 +650,7 @@ function insertBotCommand(snippet: string) {
|
||||
// Gescrollt wird in #main-scroll (SidebarInset), nicht im window.
|
||||
function scrollToBottom() {
|
||||
const scroller = document.getElementById('main-scroll')
|
||||
scroller?.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' })
|
||||
if (scroller) scrollToBottomEl(scroller, true)
|
||||
}
|
||||
|
||||
function insertQuote(commentId: number) {
|
||||
@@ -916,7 +961,7 @@ async function submitComment() {
|
||||
await refreshNuxtData('latest-comments')
|
||||
await nextTick()
|
||||
const scroller = mainScrollEl()
|
||||
scroller?.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' })
|
||||
if (scroller) scrollToBottomEl(scroller, true)
|
||||
}
|
||||
catch (err) {
|
||||
const e = err as { data?: { statusMessage?: string, message?: string } }
|
||||
@@ -1976,6 +2021,7 @@ async function deleteComment(commentId: number) {
|
||||
{ cmd: '!flip', label: $t('comments.botCmd.flip'), desc: $t('comments.botCmd.flipDesc') },
|
||||
{ cmd: '!choose A / B', label: $t('comments.botCmd.choose'), desc: $t('comments.botCmd.chooseDesc') },
|
||||
{ cmd: '!8ball ', label: $t('comments.botCmd.ball'), desc: $t('comments.botCmd.ballDesc') },
|
||||
{ cmd: '!cas ', label: $t('comments.botCmd.cas'), desc: $t('comments.botCmd.casDesc') },
|
||||
{ cmd: '!stats', label: $t('comments.botCmd.stats'), desc: $t('comments.botCmd.statsDesc') },
|
||||
{ cmd: '!help', label: $t('comments.botCmd.help'), desc: $t('comments.botCmd.helpDesc') },
|
||||
]"
|
||||
|
||||
@@ -12,6 +12,7 @@ export default defineNuxtPlugin(() => {
|
||||
const { pushLive } = useLiveComments()
|
||||
const motd = useMotd()
|
||||
const { applyRemote: applyLimits } = useLimits()
|
||||
const { applyRemote: applySite } = useSiteSettings()
|
||||
const { applyEvent } = useCommentReactions()
|
||||
const { applyEvent: applyUploadEvent } = useUploadReactions()
|
||||
|
||||
@@ -24,6 +25,10 @@ export default defineNuxtPlugin(() => {
|
||||
const socket = new WebSocket(`${protocol}://${location.host}/_ws`)
|
||||
ws = socket
|
||||
|
||||
socket.onopen = () => {
|
||||
bindViewingSocket(payload => socket.send(JSON.stringify(payload)))
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const message = JSON.parse(event.data as string)
|
||||
@@ -66,6 +71,9 @@ export default defineNuxtPlugin(() => {
|
||||
else if (message.type === 'limits') {
|
||||
applyLimits(message.limits ?? {})
|
||||
}
|
||||
else if (message.type === 'site') {
|
||||
applySite(message.site ?? {})
|
||||
}
|
||||
else if (message.type === 'notification') {
|
||||
const n = message.notification as AppNotification
|
||||
addLive(n, typeof message.unreadCount === 'number' ? message.unreadCount : undefined)
|
||||
|
||||
+12
-2
@@ -142,6 +142,8 @@
|
||||
"chooseDesc": "Wählt zufällig eine der Optionen aus",
|
||||
"ball": "!8ball [Frage]",
|
||||
"ballDesc": "Befragt die magische 8-Ball-Kugel",
|
||||
"cas": "!cas [Bild-URL] [%]",
|
||||
"casDesc": "Content-Aware Scale auf das Thread-Bild, den Kommentar-Anhang oder eine Bild-URL (ohne % zufaellig)",
|
||||
"stats": "!stats",
|
||||
"statsDesc": "Zeigt Statistiken zu diesem Thread",
|
||||
"help": "!help",
|
||||
@@ -272,7 +274,8 @@
|
||||
"mentioned": "hat dich erwähnt",
|
||||
"view": "Ansehen",
|
||||
"toastCommented": "{name} hat in einem abonnierten Thread kommentiert",
|
||||
"toastMentioned": "{name} hat dich erwähnt"
|
||||
"toastMentioned": "{name} hat dich erwähnt",
|
||||
"markAllRead": "Alle als gelesen markieren"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Einstellungen",
|
||||
@@ -377,7 +380,14 @@
|
||||
"meme": {
|
||||
"standard": "Standard-Meme-Vorlagen verwalten"
|
||||
}
|
||||
}
|
||||
},
|
||||
"site": "Seite",
|
||||
"visibility": "Sichtbarkeit",
|
||||
"visibilityDesc": "Wer darf die Seite ohne Login sehen und ob sich neue Nutzer registrieren koennen.",
|
||||
"guestAccess": "Gast-Zugriff",
|
||||
"guestAccessDesc": "Gaeste duerfen die Seite ohne Login durchstoebern. Aus = jede Seite leitet auf den Login weiter.",
|
||||
"registrationOpen": "Registrierung offen",
|
||||
"registrationOpenDesc": "Neue Nutzer duerfen sich auf der Registrierungsseite ein Konto erstellen."
|
||||
},
|
||||
"palette": {
|
||||
"title": "Schnellsuche",
|
||||
|
||||
+12
-2
@@ -142,6 +142,8 @@
|
||||
"chooseDesc": "Randomly picks one of the given options",
|
||||
"ball": "!8ball [question]",
|
||||
"ballDesc": "Asks the magic 8-ball",
|
||||
"cas": "!cas [image URL] [%]",
|
||||
"casDesc": "Applies Content-Aware Scale to the thread image, a comment attachment, or an image URL (random % if omitted)",
|
||||
"stats": "!stats",
|
||||
"statsDesc": "Shows statistics for this thread",
|
||||
"help": "!help",
|
||||
@@ -272,7 +274,8 @@
|
||||
"mentioned": "mentioned you",
|
||||
"view": "View",
|
||||
"toastCommented": "{name} commented in a subscribed thread",
|
||||
"toastMentioned": "{name} mentioned you"
|
||||
"toastMentioned": "{name} mentioned you",
|
||||
"markAllRead": "Mark all as read"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Settings",
|
||||
@@ -377,7 +380,14 @@
|
||||
"meme": {
|
||||
"standard": "Manage standard meme templates"
|
||||
}
|
||||
}
|
||||
},
|
||||
"site": "Site",
|
||||
"visibility": "Visibility",
|
||||
"visibilityDesc": "Who may view the site without logging in and whether new users can register.",
|
||||
"guestAccess": "Guest access",
|
||||
"guestAccessDesc": "Guests may browse the site without logging in. Off = every page redirects to the login.",
|
||||
"registrationOpen": "Registration open",
|
||||
"registrationOpenDesc": "New users may create an account on the registration page."
|
||||
},
|
||||
"palette": {
|
||||
"title": "Quick search",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { settings } from '../../db/schema'
|
||||
import { SITE_FLAG_KEYS, type SiteFlagKey, type SiteSettings } from '../../../shared/utils/site'
|
||||
|
||||
const bodySchema = z.record(z.string(), z.boolean().nullable())
|
||||
|
||||
// Site-Flags setzen/zuruecksetzen - null loescht den Override (Default greift
|
||||
// wieder). Danach gehen die neuen Werte per WS an alle Clients.
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requirePermission(event, 'site.manage')
|
||||
const body = await readValidatedBody(event, bodySchema.parse)
|
||||
const db = useDb()
|
||||
|
||||
for (const [key, value] of Object.entries(body)) {
|
||||
if (!(SITE_FLAG_KEYS as string[]).includes(key)) {
|
||||
throw createError({ statusCode: 400, statusMessage: `Unbekanntes Site-Flag: ${key}` })
|
||||
}
|
||||
const settingsKey = `site.${key}`
|
||||
if (value === null) {
|
||||
await db.delete(settings).where(eq(settings.key, settingsKey))
|
||||
}
|
||||
else {
|
||||
await db
|
||||
.insert(settings)
|
||||
.values({ key: settingsKey, value: value ? '1' : '0' })
|
||||
.onDuplicateKeyUpdate({ set: { value: value ? '1' : '0' } })
|
||||
}
|
||||
}
|
||||
|
||||
invalidateSiteSettings()
|
||||
const site: SiteSettings = await getSiteSettings()
|
||||
broadcastToAll({ type: 'site', site })
|
||||
return { site }
|
||||
})
|
||||
@@ -12,6 +12,10 @@ export default defineEventHandler(async (event) => {
|
||||
const body = await readValidatedBody(event, bodySchema.parse)
|
||||
const db = useDb()
|
||||
|
||||
if (!(await getSiteSettings()).registrationOpen) {
|
||||
throw createError({ statusCode: 403, statusMessage: 'Registrierung ist derzeit geschlossen' })
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
@@ -31,7 +35,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
const userId = Number(result.insertId)
|
||||
|
||||
const [{ value: userCount }] = await db.select({ value: count() }).from(users)
|
||||
const [{ value: userCount } = { value: 0 }] = await db.select({ value: count() }).from(users)
|
||||
let roleNames: string[] = []
|
||||
if (userCount === 1) {
|
||||
const adminRoleId = await ensureRole('admin')
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Site-Flags fuer den Client (Guest-Redirect, Register-Link, ...) - bewusst
|
||||
// ohne Auth, damit auch Gaeste vor dem Login wissen, ob sie duerfen
|
||||
export default defineEventHandler(async () => {
|
||||
return { site: await getSiteSettings() }
|
||||
})
|
||||
@@ -13,6 +13,17 @@ export default defineWebSocketHandler({
|
||||
// Peer kann während des async-Lookups schon weg sein - nicht als Crash propagieren
|
||||
}
|
||||
},
|
||||
message(peer, message) {
|
||||
try {
|
||||
const data = JSON.parse(message.text()) as { type?: string, uploadId?: unknown }
|
||||
if (data.type === 'viewing') {
|
||||
setPeerViewing(peer, typeof data.uploadId === 'string' ? data.uploadId : null)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Ungueltige Client-Nachrichten ignorieren
|
||||
}
|
||||
},
|
||||
close(peer) {
|
||||
unregisterGlobalPeer(peer)
|
||||
unregisterPeer((peer.context as { userId?: number }).userId, peer)
|
||||
|
||||
+110
-3
@@ -1,6 +1,8 @@
|
||||
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { and, count, eq, isNull, lte, sql } from 'drizzle-orm'
|
||||
import { BOT_COLOR, BOT_NAME } from '../../shared/utils/bot'
|
||||
import { botReminders, comments, favorites, notifications, uploads, uploadReactions, users } from '../db/schema'
|
||||
import { botReminders, commentAttachments, comments, favorites, notifications, uploads, uploadReactions, users } from '../db/schema'
|
||||
|
||||
const EIGHT_BALL_ANSWERS = [
|
||||
'Ja, auf jeden Fall!',
|
||||
@@ -97,6 +99,105 @@ export async function postBotComment(uploadId: string, content: string, notifyRe
|
||||
return botCommentId
|
||||
}
|
||||
|
||||
// --- Content-Aware Scale (!cas) ---
|
||||
|
||||
const CAS_FETCH_TIMEOUT = 15_000
|
||||
const CAS_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
async function fetchRemoteImage(url: string): Promise<Buffer | null> {
|
||||
try {
|
||||
const res = await fetch(url, { signal: AbortSignal.timeout(CAS_FETCH_TIMEOUT), redirect: 'follow' })
|
||||
if (!res.ok) return null
|
||||
const len = Number(res.headers.get('content-length') ?? 0)
|
||||
if (len > CAS_MAX_BYTES) return null
|
||||
const buf = Buffer.from(await res.arrayBuffer())
|
||||
if (!buf.length || buf.length > CAS_MAX_BYTES) return null
|
||||
const ct = (res.headers.get('content-type') ?? '').toLowerCase()
|
||||
if (ct && !ct.startsWith('image/')) return null
|
||||
return buf
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Bildquelle für !cas: 1) URL im Befehl, 2) Bild-Anhang des auslösenden
|
||||
// Kommentars, 3) das Thread-Upload selbst, wenn es ein Bild ist
|
||||
async function resolveCasImageSource(uploadId: string, commentId: number, arg: string): Promise<Buffer | null> {
|
||||
const urlMatch = /https?:\/\/\S+/i.exec(arg)
|
||||
if (urlMatch) {
|
||||
return fetchRemoteImage(urlMatch[0]!.replace(/[),.;!"'\]]+$/, ''))
|
||||
}
|
||||
|
||||
// Erste Bild-Datei des auslösenden Kommentars
|
||||
const db = useDb()
|
||||
const atts = await db
|
||||
.select({ fileName: commentAttachments.fileName, mimeType: commentAttachments.mimeType })
|
||||
.from(commentAttachments)
|
||||
.where(eq(commentAttachments.commentId, commentId))
|
||||
const imgAtt = atts.find(a => (a.mimeType ?? '').startsWith('image/'))
|
||||
if (imgAtt) {
|
||||
try {
|
||||
return await readFile(join(uploadDir(), 'attachments', imgAtt.fileName))
|
||||
}
|
||||
catch { /* weiter mit Thread-Bild */ }
|
||||
}
|
||||
|
||||
const [upload] = await db
|
||||
.select({ type: uploads.type, fileName: uploads.fileName, externalUrl: uploads.externalUrl })
|
||||
.from(uploads)
|
||||
.where(eq(uploads.id, uploadId))
|
||||
.limit(1)
|
||||
if (upload?.type !== 'image') return null
|
||||
if (upload.externalUrl) return fetchRemoteImage(upload.externalUrl)
|
||||
if (!upload.fileName) return null
|
||||
try {
|
||||
return await readFile(join(uploadDir(), upload.fileName))
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCasCommand(opts: { uploadId: string, commentId: number, userName: string, arg: string }) {
|
||||
const { uploadId, commentId, userName, arg } = opts
|
||||
const pctMatch = /(?:^|\s)(\d{1,3})\s*%?(?=\s|$)/.exec(arg)
|
||||
// Ohne Prozentangabe zufaellig zwischen 25% und 65% wuerfeln, damit
|
||||
// wiederholte Aufrufe unterschiedliche Ergebnisse liefern
|
||||
const pct = pctMatch
|
||||
? Math.min(95, Math.max(10, parseInt(pctMatch[1]!, 10)))
|
||||
: 25 + Math.floor(Math.random() * 41)
|
||||
|
||||
const source = await resolveCasImageSource(uploadId, commentId, arg)
|
||||
if (!source) {
|
||||
await postBotComment(
|
||||
uploadId,
|
||||
`>>${commentId}\n@${userName} Ich finde kein Bild zum CAS'en - häng ein Bild an, nutze den Befehl in einem Bild-Thread oder gib eine Bild-URL an: \`!cas <URL> [%]\`.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const out = await contentAwareScale(source, pct)
|
||||
// Ergebnis direkt im Kommentar einbetten: relative /uploads/-Pfade mit
|
||||
// Bild-Endung rendert CommentContent als Inline-Bild mit Lightbox
|
||||
const dir = join(uploadDir(), 'bot')
|
||||
await mkdir(dir, { recursive: true })
|
||||
const name = `cas-${commentId}.webp`
|
||||
await writeFile(join(dir, name), out)
|
||||
await postBotComment(
|
||||
uploadId,
|
||||
`>>${commentId}\n@${userName} Hier ist dein CAS'd Bild (auf **${pct}%** der Breite zusammengedrückt):\n/uploads/bot/${name}`,
|
||||
)
|
||||
}
|
||||
catch {
|
||||
await postBotComment(
|
||||
uploadId,
|
||||
`>>${commentId}\n@${userName} Das Bild konnte nicht verarbeitet werden (kein unterstütztes Format oder zu groß).`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyUserDirectlyFromBot(userId: number, commentId: number, uploadId: string, content: string) {
|
||||
const db = useDb()
|
||||
const [upload] = await db
|
||||
@@ -157,7 +258,7 @@ export async function handleBotCommands(opts: {
|
||||
|
||||
// 1) Natürlicher Aufruf: "@BRETTBot erinnere mich in 1 Stunde [Notiz]" oder "RemindMeBot ..."
|
||||
const naturalRemind = /(?:^|\s)(?:@?brettbot|@?remindmebot|!remindme|\/remindme)[,:]?\s*(?:erinnere\s+mich\s+|remind\s+me\s+)?(.+)$/i.exec(trimmed)
|
||||
if (naturalRemind && !/^!(roll|würfel|flip|münze|coin|choose|wahl|8ball|orakel|stats|bot|help)\b/i.test(trimmed)) {
|
||||
if (naturalRemind && !/^!(roll|würfel|flip|münze|coin|choose|wahl|8ball|orakel|stats|bot|help|cas|carve|seamcarve)\b/i.test(trimmed)) {
|
||||
const parsed = parseDurationMs(naturalRemind[1]!)
|
||||
if (parsed) {
|
||||
await scheduleBotReminder({
|
||||
@@ -174,7 +275,7 @@ export async function handleBotCommands(opts: {
|
||||
}
|
||||
|
||||
// 2) Explizite Befehle am Zeilenanfang oder im Text: !<cmd> oder /<cmd>
|
||||
const cmdMatch = /(?:^|\n)\s*[!/](remindme|erinnermich|roll|würfel|wuerfel|flip|münze|muenze|coin|choose|wahl|8ball|orakel|stats|bot|help|hilfe)\b[ \t]*(.*)$/im.exec(trimmed)
|
||||
const cmdMatch = /(?:^|\n)\s*[!/](remindme|erinnermich|roll|würfel|wuerfel|flip|münze|muenze|coin|choose|wahl|8ball|orakel|stats|bot|help|hilfe|cas|carve|seamcarve)\b[ \t]*(.*)$/im.exec(trimmed)
|
||||
if (!cmdMatch) return
|
||||
|
||||
const cmd = cmdMatch[1]!.toLowerCase()
|
||||
@@ -243,6 +344,11 @@ export async function handleBotCommands(opts: {
|
||||
return
|
||||
}
|
||||
|
||||
if (cmd === 'cas' || cmd === 'carve' || cmd === 'seamcarve') {
|
||||
await handleCasCommand({ uploadId, commentId, userName, arg })
|
||||
return
|
||||
}
|
||||
|
||||
if (cmd === 'stats') {
|
||||
const db = useDb()
|
||||
const [[uCount], [cCount], [fCount], [rCount]] = await Promise.all([
|
||||
@@ -269,6 +375,7 @@ export async function handleBotCommands(opts: {
|
||||
`• \`!flip\` - wirft eine Münze (Kopf oder Zahl)`,
|
||||
`• \`!choose A | B | C\` - wählt zufällig eine Option aus`,
|
||||
`• \`!8ball <Frage>\` - befragt die magische Kugel`,
|
||||
`• \`!cas [URL] [%]\` - Content-Aware Scale (Seam Carving) auf das Thread-Bild, einen Kommentar-Anhang oder eine Bild-URL (ohne % zufaellig)`,
|
||||
`• \`!stats\` - zeigt Statistiken zu diesem Thread`,
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import sharp from 'sharp'
|
||||
|
||||
// Vor dem Carven auf max. diese Kantenlänge herunterrechnen - die Naht-Suche
|
||||
// ist O(Nahtzahl * W * H), so bleibt der Befehl auch bei großen Bildern flott
|
||||
const CARVE_MAX_EDGE = 640
|
||||
const MIN_WIDTH = 24
|
||||
// Pro Naht wird zufaellig eine der N besten Endpositionen entfernt -
|
||||
// sorgt dafuer, dass jeder !cas-Lauf ein anderes Ergebnis liefert
|
||||
const SEAM_CANDIDATES = 6
|
||||
// Das Ergebnis wird per Lanczos auf das Original-Seitenverhaeltnis
|
||||
// zurueckgestreckt; kleine Quellen werden zusaetzlich uniform vergroessert
|
||||
const UPSCALE_MIN_EDGE = 400
|
||||
const UPSCALE_MAX_FACTOR = 4
|
||||
// Ausgabe wird auf die Quell-Aufloesung zurueckskaliert, max. diese Kante
|
||||
const OUTPUT_MAX_EDGE = 1920
|
||||
|
||||
// Einfache Gradienten-Energie über die Luminanz: horizontaler +
|
||||
// vertikaler Gradient (Sobel-artig, 2-Nachbarn)
|
||||
function luminanceAt(data: Uint8Array, x: number, y: number, w: number, h: number): number {
|
||||
const cx = Math.min(w - 1, Math.max(0, x))
|
||||
const cy = Math.min(h - 1, Math.max(0, y))
|
||||
const i = (cy * w + cx) * 3
|
||||
return data[i]! * 0.299 + data[i + 1]! * 0.587 + data[i + 2]! * 0.114
|
||||
}
|
||||
|
||||
// Entfernt vertikale Naht für Naht die energiearmste vertikale Naht,
|
||||
// bis das Bild targetW breit ist (klassisches Seam Carving)
|
||||
function carveToWidth(src: Uint8Array, w: number, h: number, targetW: number): Uint8Array {
|
||||
let cur = src
|
||||
let cw = w
|
||||
|
||||
const energy = new Float32Array(cw * h)
|
||||
const cum = new Float32Array(cw * h)
|
||||
const back = new Int16Array(cw * h)
|
||||
const gray = new Float32Array(cw * h)
|
||||
const seam = new Int32Array(h)
|
||||
|
||||
while (cw > targetW) {
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < cw; x++) {
|
||||
const i = (y * cw + x) * 3
|
||||
gray[y * cw + x] = cur[i]! * 0.299 + cur[i + 1]! * 0.587 + cur[i + 2]! * 0.114
|
||||
}
|
||||
}
|
||||
for (let y = 0; y < h; y++) {
|
||||
for (let x = 0; x < cw; x++) {
|
||||
const xl = Math.max(0, x - 1)
|
||||
const xr = Math.min(cw - 1, x + 1)
|
||||
const yu = Math.max(0, y - 1)
|
||||
const yd = Math.min(h - 1, y + 1)
|
||||
energy[y * cw + x] = Math.abs(gray[y * cw + xr]! - gray[y * cw + xl]!)
|
||||
+ Math.abs(gray[yd * cw + x]! - gray[yu * cw + x]!)
|
||||
}
|
||||
}
|
||||
|
||||
// Kumulative Energie (dynamische Programmierung) + Rückverfolgung
|
||||
for (let x = 0; x < cw; x++) cum[x] = energy[x]!
|
||||
for (let y = 1; y < h; y++) {
|
||||
for (let x = 0; x < cw; x++) {
|
||||
const row = y * cw
|
||||
let best = cum[row - cw + x]!
|
||||
let dir = 0
|
||||
if (x > 0 && cum[row - cw + x - 1]! < best) { best = cum[row - cw + x - 1]!; dir = -1 }
|
||||
if (x < cw - 1 && cum[row - cw + x + 1]! < best) { best = cum[row - cw + x + 1]!; dir = 1 }
|
||||
cum[row + x] = energy[row + x]! + best
|
||||
back[row + x] = dir
|
||||
}
|
||||
}
|
||||
|
||||
// Zufaellig eine der SEAM_CANDIDATES energiearmsten Naht-Endpositionen
|
||||
// waehlen, damit jeder Lauf ein etwas anderes Ergebnis liefert
|
||||
let minX = 0
|
||||
{
|
||||
const row = (h - 1) * cw
|
||||
const cand: number[] = []
|
||||
for (let x = 0; x < cw; x++) cand.push(x)
|
||||
cand.sort((a, b) => cum[row + a]! - cum[row + b]!)
|
||||
minX = cand[Math.floor(Math.random() * Math.min(SEAM_CANDIDATES, cw))]!
|
||||
}
|
||||
for (let y = h - 1; y >= 0; y--) {
|
||||
seam[y] = minX
|
||||
minX += back[y * cw + minX]!
|
||||
}
|
||||
|
||||
const next = new Uint8Array((cw - 1) * h * 3)
|
||||
for (let y = 0; y < h; y++) {
|
||||
const sx = seam[y]!
|
||||
const srcRow = y * cw * 3
|
||||
const dstRow = y * (cw - 1) * 3
|
||||
next.set(cur.subarray(srcRow, srcRow + sx * 3), dstRow)
|
||||
next.set(cur.subarray(srcRow + (sx + 1) * 3, srcRow + cw * 3), dstRow + sx * 3)
|
||||
}
|
||||
cur = next
|
||||
cw--
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
export async function contentAwareScale(input: Buffer, percent = 45): Promise<Buffer> {
|
||||
const pct = Math.min(95, Math.max(10, percent))
|
||||
const srcMeta = await sharp(input, { failOn: 'none' }).metadata()
|
||||
const raw = await sharp(input, { failOn: 'none' })
|
||||
.rotate()
|
||||
.resize(CARVE_MAX_EDGE, CARVE_MAX_EDGE, { fit: 'inside', withoutEnlargement: true })
|
||||
.removeAlpha()
|
||||
.raw()
|
||||
.toBuffer({ resolveWithObject: true })
|
||||
|
||||
const { width, height } = raw.info
|
||||
const targetW = Math.max(MIN_WIDTH, Math.round(width * pct / 100))
|
||||
const carved = targetW >= width ? new Uint8Array(raw.data) : carveToWidth(new Uint8Array(raw.data), width, height, targetW)
|
||||
|
||||
const outW = Math.min(width, targetW)
|
||||
let result = sharp(Buffer.from(carved), {
|
||||
raw: { width: outW, height, channels: 3 },
|
||||
})
|
||||
|
||||
// Auf die Quell-Aufloesung und -Seitenverhaeltnis zurueckskalieren
|
||||
// (Lanczos glaettet den Stretch). Begrenzt auf OUTPUT_MAX_EDGE; sehr
|
||||
// kleine Quellen werden bis x4 vergroessert, damit 400px Mindestkante
|
||||
const srcW = srcMeta.width ?? width
|
||||
const srcH = srcMeta.height ?? height
|
||||
let s = Math.min(1, OUTPUT_MAX_EDGE / Math.max(srcW, srcH))
|
||||
if (Math.min(srcW, srcH) * s < UPSCALE_MIN_EDGE) {
|
||||
s = Math.min(UPSCALE_MAX_FACTOR, UPSCALE_MIN_EDGE / Math.min(srcW, srcH))
|
||||
}
|
||||
const finalW = Math.round(srcW * s)
|
||||
const finalH = Math.round(srcH * s)
|
||||
if (finalW !== outW || finalH !== height) {
|
||||
result = result.resize(finalW, finalH, { kernel: 'lanczos3' })
|
||||
}
|
||||
|
||||
return result.webp({ quality: 92 }).toBuffer()
|
||||
}
|
||||
@@ -65,6 +65,21 @@ export function pushToUser(userId: number, payload: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
// Client meldet per WS `viewing`, welchen Thread er offen hat - damit keine
|
||||
// Benachrichtigung fuer Kommentare erzeugt wird, die man live mitliest
|
||||
export function setPeerViewing(peer: Peer, uploadId: string | null) {
|
||||
;(peer.context as { viewing?: string | null }).viewing = uploadId
|
||||
}
|
||||
|
||||
export function isViewingUpload(userId: number, uploadId: string): boolean {
|
||||
const set = peersByUser.get(userId)
|
||||
if (!set) return false
|
||||
for (const peer of set) {
|
||||
if ((peer.context as { viewing?: string | null }).viewing === uploadId) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function getUserIdFromCookieHeader(cookieHeader: string | undefined) {
|
||||
const token = /(?:^|;\s*)session=([^;]+)/.exec(cookieHeader ?? '')?.[1]
|
||||
if (!token) return null
|
||||
@@ -98,6 +113,10 @@ export async function notifyComment(actorId: number | null, commentId: number, u
|
||||
if (!subscription.enabled) recipients.delete(subscription.userId)
|
||||
}
|
||||
if (actorId !== null) recipients.delete(actorId)
|
||||
// Nutzer, die den Thread gerade offen haben, bekommen keine Benachrichtigung
|
||||
for (const userId of [...recipients]) {
|
||||
if (isViewingUpload(userId, uploadId)) recipients.delete(userId)
|
||||
}
|
||||
if (!recipients.size) return
|
||||
|
||||
const [actor] = actorId !== null
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { like } from 'drizzle-orm'
|
||||
import { settings } from '../db/schema'
|
||||
import { SITE_DEFAULTS, SITE_FLAG_KEYS, type SiteSettings } from '../../shared/utils/site'
|
||||
|
||||
// Site-Flags aus der settings-Tabelle (site.<key>) mit Fallback auf die
|
||||
// Defaults. Kurzer In-Process-Cache; beim Admin-Save wird er invalidiert.
|
||||
let cache: { at: number, values: SiteSettings } | null = null
|
||||
|
||||
export async function getSiteSettings(): Promise<SiteSettings> {
|
||||
if (cache && Date.now() - cache.at < 10_000) return cache.values
|
||||
const rows = await useDb()
|
||||
.select({ key: settings.key, value: settings.value })
|
||||
.from(settings)
|
||||
.where(like(settings.key, 'site.%'))
|
||||
const values = { ...SITE_DEFAULTS } as SiteSettings
|
||||
for (const row of rows) {
|
||||
const key = row.key.slice('site.'.length)
|
||||
if ((SITE_FLAG_KEYS as string[]).includes(key)) {
|
||||
values[key as SiteFlagKey] = row.value === '1'
|
||||
}
|
||||
}
|
||||
cache = { at: Date.now(), values }
|
||||
return values
|
||||
}
|
||||
|
||||
export function invalidateSiteSettings() {
|
||||
cache = null
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Admin-einstellbare Sichtbarkeits-/Zugangs-Flags - Defaults gelten, solange
|
||||
// kein Override in der settings-Tabelle liegt (Key `site.<key>`). Server
|
||||
// liest ueber getSiteSettings() (server/utils/site.ts), Client ueber
|
||||
// /api/site + useSiteSettings().
|
||||
export const SITE_DEFAULTS = {
|
||||
// Gaeste duerfen die Seite ohne Login sehen (aus = Redirect auf /login)
|
||||
guestAccess: false,
|
||||
// Registrierung ist fuer neue Nutzer offen
|
||||
registrationOpen: true,
|
||||
} as const
|
||||
|
||||
export type SiteFlagKey = keyof typeof SITE_DEFAULTS
|
||||
export type SiteSettings = Record<SiteFlagKey, boolean>
|
||||
|
||||
export const SITE_FLAG_KEYS = Object.keys(SITE_DEFAULTS) as SiteFlagKey[]
|
||||
Reference in New Issue
Block a user