Initial Commit

This commit is contained in:
2026-10-03 20:07:49 +02:00
commit a1346c98b1
427 changed files with 59320 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
export interface AttachmentAudioMeta {
title: string | null
artist: string | null
album: string | null
voice?: boolean
}
export interface CommentAttachment {
id: number
name: string
url: string
mimeType: string | null
size: number | null
cover?: string | null
meta?: AttachmentAudioMeta | null
}
export const MAX_COMMENT_ATTACHMENTS = 8
export const MAX_ATTACHMENT_SIZE = 200 * 1024 * 1024 // 200 MB
export function isImageAttachment(a: { mimeType?: string | null, name?: string }): boolean {
return !!a.mimeType?.startsWith('image/')
|| (!!a.name && /\.(avif|bmp|gif|jpe?g|png|svg|webp)$/i.test(a.name))
}
export function isAudioAttachment(a: { mimeType?: string | null, name?: string }): boolean {
return !!a.mimeType?.startsWith('audio/')
|| (!!a.name && /\.(mp3|flac|ogg|wav|m4a|aac|opus)$/i.test(a.name))
}
export function isVoiceAttachment(a: { meta?: { voice?: boolean } | null }): boolean {
return a.meta?.voice === true
}
export function formatFileSize(bytes: number | null | undefined): string {
if (!bytes || bytes <= 0) return ''
return bytes >= 1024 * 1024
? `${(bytes / 1024 / 1024).toFixed(1)} MB`
: `${Math.max(1, Math.round(bytes / 1024))} KB`
}
+15
View File
@@ -0,0 +1,15 @@
// Katalog der verfügbaren Hover-Card-Border-Animationen
export const CARD_BORDER_EFFECTS = [
{ key: 'electric', label: 'Electric' },
{ key: 'beam', label: 'Beam' },
{ key: 'pulse', label: 'Pulse' },
{ key: 'rainbow', label: 'Rainbow' },
] as const
export type CardBorderEffectKey = typeof CARD_BORDER_EFFECTS[number]['key']
const KEYS = new Set<string>(CARD_BORDER_EFFECTS.map(e => e.key))
export function isCardBorderEffect(value: string): value is CardBorderEffectKey {
return KEYS.has(value)
}
+25
View File
@@ -0,0 +1,25 @@
// Katalog der verfügbaren Hover-Card-Hintergrundeffekte (Vue-Bits-Ports)
export const CARD_EFFECTS = [
{ key: 'gradient-waves', label: 'Gradient Waves' },
{ key: 'aurora', label: 'Aurora' },
{ key: 'particles', label: 'Particles' },
{ key: 'letter-glitch', label: 'Letter Glitch' },
{ key: 'waves', label: 'Waves' },
{ key: 'scanner', label: 'Scanner' },
{ key: 'acid-squares', label: 'Acid Squares' },
{ key: 'crt-warp', label: 'CRT Warp' },
{ key: 'dither', label: 'Dither' },
{ key: 'soft-aurora', label: 'Soft Aurora' },
{ key: 'silk', label: 'Silk' },
{ key: 'shape-grid', label: 'Shape Grid' },
{ key: 'plasma', label: 'Plasma' },
{ key: 'line-waves', label: 'Line Waves' },
] as const
export type CardEffectKey = typeof CARD_EFFECTS[number]['key']
const KEYS = new Set<string>(CARD_EFFECTS.map(e => e.key))
export function isCardEffect(value: string): value is CardEffectKey {
return KEYS.has(value)
}
+19
View File
@@ -0,0 +1,19 @@
// Windows/Browser liefern für manche Formate (z. B. .flac, .mp3) keinen
// MIME-Type — beim Drag&Drop kommt dann '' oder 'application/octet-stream'.
// Daher wird der Dateityp über MIME *oder* Endung geprüft.
export const MEDIA_FILE_EXT = /\.(avif|bmp|gif|jpe?g|png|svg|webp|m4v|mov|mp4|ogg|ogv|webm|flac|mp3)$/i
// Für den dataTypes-Check während dragover: dort sind nur die Item-Typen
// bekannt, keine Dateinamen — unbekannte Typen durchlassen und beim
// eigentlichen Drop per isMediaFile filtern
export function isMediaItemType(type: string): boolean {
return type === '' || type === 'application/octet-stream'
|| type.startsWith('image/') || type.startsWith('video/') || type.startsWith('audio/')
}
export function isMediaFile(file: File): boolean {
const t = file.type
if (t.startsWith('image/') || t.startsWith('video/') || t.startsWith('audio/')) return true
return (!t || t === 'application/octet-stream') && MEDIA_FILE_EXT.test(file.name)
}
+37
View File
@@ -0,0 +1,37 @@
export interface MentionUser {
id: number
name: string
color: string | null
}
export function activeMention(text: string, caret: number) {
const before = text.slice(0, caret)
const start = before.lastIndexOf('@')
if (start < 0 || (start > 0 && !/[\s([{]/.test(before[start - 1]!))) return null
const query = before.slice(start + 1)
if (query.length > 50 || /[\r\n@]/.test(query)) return null
return { start, end: caret, query }
}
export function mentionParts(text: string, users: MentionUser[]) {
const names = [...users].sort((a, b) => b.name.length - a.name.length)
const parts: { text: string, user?: MentionUser }[] = []
let last = 0
for (let i = 0; i < text.length; i++) {
if (text[i] !== '@' || (i > 0 && !/[\s([{]/.test(text[i - 1]!))) continue
const user = names.find(u => text.slice(i + 1, i + 1 + u.name.length).toLowerCase() === u.name.toLowerCase()
&& !/[\p{L}\p{N}_-]/u.test(text[i + 1 + u.name.length] ?? ''))
if (!user) continue
if (i > last) parts.push({ text: text.slice(last, i) })
last = i + 1 + user.name.length
parts.push({ text: text.slice(i, last), user })
i = last - 1
}
if (last < text.length) parts.push({ text: text.slice(last) })
return parts
}
export function mentionedUserIds(content: string, users: MentionUser[]) {
return new Set(content.split(/https?:\/\/[^\s<>"']+/g)
.flatMap(text => mentionParts(text, users).flatMap(part => part.user ? [part.user.id] : [])))
}
+41
View File
@@ -0,0 +1,41 @@
export const PERMISSIONS = [
{ key: 'admin.access', label: 'Admin-Bereich öffnen' },
{ key: 'roles.manage', label: 'Rollen & Berechtigungen verwalten' },
{ key: 'users.manage', label: 'Benutzer & Rollen verwalten' },
{ key: 'site.manage', label: 'Seiten-Einstellungen verwalten' },
{ key: 'comment.delete', label: 'Kommentare löschen' },
{ key: 'upload.delete', label: 'Uploads löschen' },
{ key: 'tag.delete', label: 'Tags löschen' },
] as const
export type PermissionKey = (typeof PERMISSIONS)[number]['key']
export const PERMISSION_KEYS: string[] = PERMISSIONS.map(p => p.key)
// System-Rollen: immer vorhanden, nicht löschbar
export const SYSTEM_ROLES = ['admin', 'moderator'] as const
interface Queryable {
query: (sql: string, params?: unknown[]) => Promise<unknown>
}
// Permission-Katalog idempotent einpflegen. Läuft sowohl im Nitro-Plugin
// (Dev-Start) als auch im manuellen migrate-Script. Die bestehende
// moderator-Rolle erhält dabei die bisherigen Moderationsrechte.
export async function seedPermissions(db: Queryable) {
for (const role of SYSTEM_ROLES) {
await db.query('INSERT IGNORE INTO roles (name) VALUES (?)', [role])
}
for (const p of PERMISSIONS) {
await db.query(
'INSERT INTO permissions (`key`, `label`) VALUES (?, ?) ON DUPLICATE KEY UPDATE `label` = VALUES(`label`)',
[p.key, p.label],
)
}
await db.query(
`INSERT IGNORE INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id FROM roles r
JOIN permissions p ON p.\`key\` IN ('comment.delete', 'upload.delete', 'tag.delete')
WHERE r.name = 'moderator'`,
)
}
+20
View File
@@ -0,0 +1,20 @@
// Wörter aus Buchstaben/Ziffern/Unterstrich/Bindestrich, einzelne Leerzeichen dazwischen
const TAG_RE = /^[\p{L}\p{N}_-]+(?: [\p{L}\p{N}_-]+)*$/u
export const MAX_TAGS_PER_UPLOAD = 20
export const MAX_TAG_LENGTH = 64
export function normalizeTagName(value: string): string | null {
const name = value.trim().toLowerCase().replace(/\s+/g, ' ')
if (!name || name.length > MAX_TAG_LENGTH || !TAG_RE.test(name)) return null
return name
}
export function normalizeTagList(values: string[]): string[] {
const seen = new Set<string>()
for (const v of values) {
const name = normalizeTagName(v)
if (name) seen.add(name)
}
return [...seen].slice(0, MAX_TAGS_PER_UPLOAD)
}
+68
View File
@@ -0,0 +1,68 @@
export interface TwitchTarget {
kind: 'video' | 'channel' | 'clip'
id: string
}
// Twitch-Pfade, die keine Kanalnamen sind
const RESERVED_PATHS = new Set([
'directory', 'downloads', 'drops', 'friends', 'inventory', 'jobs', 'messages',
'moderator', 'notifications', 'p', 'payments', 'popout', 'products', 'settings',
'subscriptions', 'turbo', 'videos', 'wallet', 'broadcast', 'search', 'activate',
])
export function getTwitchTarget(href: string): TwitchTarget | null {
try {
const url = new URL(href)
const host = url.hostname.replace(/^(www\.|m\.)/, '')
// clips.twitch.tv/{slug}
if (host === 'clips.twitch.tv') {
const slug = url.pathname.split('/').filter(Boolean)[0]
return slug ? { kind: 'clip', id: slug } : null
}
if (host !== 'twitch.tv') return null
const seg = url.pathname.split('/').filter(Boolean)
// twitch.tv/videos/{id}
if (seg[0] === 'videos' && /^\d+$/.test(seg[1] ?? '')) {
return { kind: 'video', id: seg[1]! }
}
// twitch.tv/{channel}/clip/{slug}
if (seg.length === 3 && seg[1] === 'clip') {
return { kind: 'clip', id: seg[2]! }
}
// twitch.tv/{channel}
if (seg.length === 1 && /^[\w]{2,25}$/.test(seg[0]!) && !RESERVED_PATHS.has(seg[0]!.toLowerCase())) {
return { kind: 'channel', id: seg[0]! }
}
return null
}
catch {
return null
}
}
export function serializeTwitchTarget(t: TwitchTarget | null): string | null {
return t ? `${t.kind}:${t.id}` : null
}
export function parseTwitchTarget(value: string): TwitchTarget | null {
const i = value.indexOf(':')
if (i < 0) return null
const kind = value.slice(0, i)
const id = value.slice(i + 1)
if ((kind !== 'video' && kind !== 'channel' && kind !== 'clip') || !id) return null
return { kind, id }
}
// Twitch-Embeds brauchen zwingend ?parent=<Hostname der einbettenden Seite>
export function twitchEmbedUrl(target: TwitchTarget | string | null, parent: string): string | null {
const t = typeof target === 'string' ? parseTwitchTarget(target) : target
if (!t) return null
const p = `parent=${encodeURIComponent(parent)}`
const id = encodeURIComponent(t.id)
if (t.kind === 'clip') return `https://clips.twitch.tv/embed?clip=${id}&${p}&autoplay=false`
if (t.kind === 'channel') return `https://player.twitch.tv/?channel=${id}&${p}`
return `https://player.twitch.tv/?video=${id}&${p}&autoplay=false`
}
+25
View File
@@ -0,0 +1,25 @@
export function getYoutubeId(href: string): string | null {
try {
const url = new URL(href)
if (url.hostname === 'youtu.be') {
return url.pathname.slice(1).split('/')[0] || null
}
if (url.hostname === 'youtube.com' || url.hostname.endsWith('.youtube.com')) {
if (url.pathname === '/watch') return url.searchParams.get('v')
const m = url.pathname.match(/^\/(shorts|embed|live)\/([\w-]+)/)
if (m?.[2]) return m[2]
}
return null
}
catch {
return null
}
}
export function youtubeEmbedUrl(videoId: string) {
return `https://www.youtube.com/embed/${videoId}`
}
export function youtubeThumbUrl(videoId: string) {
return `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`
}