Compare commits

...
7 Commits
Author SHA1 Message Date
FliesentischKingandDevin 9059af71f0 Widen the login and registration forms
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-17 22:52:54 +02:00
FliesentischKingandDevin 1a3b19da6a Show full actor names in the notification popover and add a mark-all-as-read button
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-17 22:52:54 +02:00
FliesentischKingandDevin d8d7583d08 Keep bottom pinning stable during smooth-scroll animations when new comments grow the page
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-17 22:52:54 +02:00
FliesentischKingandDevin 8e822e031d Suppress notifications for threads the user is currently viewing
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-17 22:52:53 +02:00
FliesentischKingandDevin a421007af6 Add admin-configurable guest visibility lock and registration toggle
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-17 22:52:45 +02:00
FliesentischKingandDevin c5e5bc4837 Add !cas bot command for content-aware image scaling via seam carving
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-17 22:18:42 +02:00
FliesentischKingandDevin e639c97589 Embed relative /uploads/ image URLs in comments and open them in the lightbox
Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-17 22:18:40 +02:00
24 changed files with 687 additions and 76 deletions
+22 -8
View File
@@ -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>
+23 -5
View File
@@ -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>
+41 -38
View File
@@ -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>
+1 -1
View File
@@ -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' })
}
+32
View File
@@ -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 }
}
+20
View File
@@ -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 })
}
+6 -5
View File
@@ -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>
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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') }}
+1 -1
View File
@@ -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
View File
@@ -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') },
]"
+8
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+35
View File
@@ -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 }
})
+5 -1
View File
@@ -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')
+5
View File
@@ -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() }
})
+11
View File
@@ -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
View File
@@ -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'),
)
+134
View File
@@ -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()
}
+19
View File
@@ -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
+28
View File
@@ -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
}
+15
View File
@@ -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[]