forked from BRETT/BRETT
Initial Commit
This commit is contained in:
@@ -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`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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] : [])))
|
||||
}
|
||||
@@ -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'`,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
Reference in New Issue
Block a user