This commit is contained in:
2026-03-12 14:23:32 +01:00
parent d93f43bf01
commit 0084c5f05b
60 changed files with 7526 additions and 7522 deletions

View File

@@ -1,181 +1,181 @@
'use client'
import { useState, useEffect } from 'react'
import { Sparkles, Copy, Check } from 'lucide-react'
import { trpc } from '@/lib/trpc-client'
interface AIGeneratorProps {
type: 'news' | 'stelle'
onApply?: (text: string) => void
}
const THINKING_STEPS = [
'KI denkt nach…',
'Thema wird analysiert…',
'Recherchiere Inhalte…',
'Struktur wird geplant…',
'Einleitung wird formuliert…',
'Hauptteil wird ausgearbeitet…',
'Formulierungen werden verfeinert…',
'Fachbegriffe werden geprüft…',
'Absätze werden aufgeteilt…',
'Zwischenüberschriften werden gesetzt…',
'Stil wird angepasst…',
'Rechtschreibung wird kontrolliert…',
'Markdown wird formatiert…',
'Überschrift wird optimiert…',
'Fazit wird formuliert…',
'Länge wird angepasst…',
'Ton wird auf Zielgruppe abgestimmt…',
'Aufzählungen werden erstellt…',
'Fettungen werden gesetzt…',
'Satzfluss wird geprüft…',
'Grammatik wird überprüft…',
'Keywords werden eingebaut…',
'Einleitung wird überarbeitet…',
'Abschnitte werden umstrukturiert…',
'Wiederholungen werden entfernt…',
'Zeichensetzung wird geprüft…',
'Leerzeilen werden optimiert…',
'Fachlich wird validiert…',
'Lesbarkeit wird verbessert…',
'Zusammenfassung wird erstellt…',
'Text wird poliert…',
'Letzte Korrekturen…',
'Fast fertig…',
]
export function AIGenerator({ type, onApply }: AIGeneratorProps) {
const { data: org } = trpc.organizations.me.useQuery()
const [prompt, setPrompt] = useState('')
const [format, setFormat] = useState('markdown')
const [loading, setLoading] = useState(false)
const [generatedText, setGeneratedText] = useState('')
const [copied, setCopied] = useState(false)
const [stepIndex, setStepIndex] = useState(0)
useEffect(() => {
if (!loading) { setStepIndex(0); return }
const interval = setInterval(() => {
setStepIndex((i) => (i + 1) % THINKING_STEPS.length)
}, 5000)
return () => clearInterval(interval)
}, [loading])
async function handleGenerate() {
if (!prompt.trim()) return
setLoading(true)
setGeneratedText('')
try {
const res = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, type, format }),
})
if (!res.ok) {
throw new Error('Fehler bei der Generierung')
}
const data = await res.json()
setGeneratedText(data.text)
} catch (err) {
alert((err as Error).message)
} finally {
setLoading(false)
}
}
function handleCopy() {
navigator.clipboard.writeText(generatedText)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (org && !org.aiEnabled) return null
return (
<div className="bg-white rounded-xl border border-brand-100 shadow-sm p-6 space-y-4 flex flex-col h-full bg-gradient-to-br from-white to-brand-50/20">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-bold text-gray-900">KI-Assistent</h2>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{type === 'news' ? 'Worum geht es in dem News-Beitrag?' : 'Beschreiben Sie die Stelle für die Lehrlingsbörse'}
</label>
<textarea
rows={3}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={type === 'news' ? "Schreibe einen Artikel über..." : "Eine kurze Zusammenfassung der Aufgaben..."}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div className="flex items-center justify-between">
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white"
>
<option value="markdown">Markdown Format</option>
<option value="text">Einfacher Text</option>
</select>
<button
type="button"
onClick={handleGenerate}
disabled={loading || !prompt.trim()}
className="flex items-center gap-2 bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
>
{loading ? 'Generiere...' : 'Generieren'}
<Sparkles className="w-4 h-4" />
</button>
</div>
{loading && (
<div className="flex items-center gap-3 px-4 py-3 bg-brand-50 border border-brand-100 rounded-lg">
<div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:0ms]" />
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:150ms]" />
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:300ms]" />
</div>
<span className="text-sm text-brand-700 font-medium transition-all">{THINKING_STEPS[stepIndex]}</span>
</div>
)}
{generatedText && (
<div className="mt-4 flex-1 flex flex-col min-h-[300px] space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Ergebnis:</span>
<div className="flex gap-4">
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 font-medium transition-colors"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? 'Kopiert!' : 'Kopieren'}
</button>
{onApply && (
<button
type="button"
onClick={() => onApply(generatedText)}
className="flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 font-medium transition-colors"
>
<Check className="w-4 h-4" />
Übernehmen
</button>
)}
</div>
</div>
<textarea
readOnly
value={generatedText}
className="w-full flex-1 p-3 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500/50"
/>
</div>
)}
</div>
)
}
'use client'
import { useState, useEffect } from 'react'
import { Sparkles, Copy, Check } from 'lucide-react'
import { trpc } from '@/lib/trpc-client'
interface AIGeneratorProps {
type: 'news' | 'stelle'
onApply?: (text: string) => void
}
const THINKING_STEPS = [
'KI denkt nach…',
'Thema wird analysiert…',
'Recherchiere Inhalte…',
'Struktur wird geplant…',
'Einleitung wird formuliert…',
'Hauptteil wird ausgearbeitet…',
'Formulierungen werden verfeinert…',
'Fachbegriffe werden geprüft…',
'Absätze werden aufgeteilt…',
'Zwischenüberschriften werden gesetzt…',
'Stil wird angepasst…',
'Rechtschreibung wird kontrolliert…',
'Markdown wird formatiert…',
'Überschrift wird optimiert…',
'Fazit wird formuliert…',
'Länge wird angepasst…',
'Ton wird auf Zielgruppe abgestimmt…',
'Aufzählungen werden erstellt…',
'Fettungen werden gesetzt…',
'Satzfluss wird geprüft…',
'Grammatik wird überprüft…',
'Keywords werden eingebaut…',
'Einleitung wird überarbeitet…',
'Abschnitte werden umstrukturiert…',
'Wiederholungen werden entfernt…',
'Zeichensetzung wird geprüft…',
'Leerzeilen werden optimiert…',
'Fachlich wird validiert…',
'Lesbarkeit wird verbessert…',
'Zusammenfassung wird erstellt…',
'Text wird poliert…',
'Letzte Korrekturen…',
'Fast fertig…',
]
export function AIGenerator({ type, onApply }: AIGeneratorProps) {
const { data: org } = trpc.organizations.me.useQuery()
const [prompt, setPrompt] = useState('')
const [format, setFormat] = useState('markdown')
const [loading, setLoading] = useState(false)
const [generatedText, setGeneratedText] = useState('')
const [copied, setCopied] = useState(false)
const [stepIndex, setStepIndex] = useState(0)
useEffect(() => {
if (!loading) { setStepIndex(0); return }
const interval = setInterval(() => {
setStepIndex((i) => (i + 1) % THINKING_STEPS.length)
}, 5000)
return () => clearInterval(interval)
}, [loading])
async function handleGenerate() {
if (!prompt.trim()) return
setLoading(true)
setGeneratedText('')
try {
const res = await fetch('/api/ai/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, type, format }),
})
if (!res.ok) {
throw new Error('Fehler bei der Generierung')
}
const data = await res.json()
setGeneratedText(data.text)
} catch (err) {
alert((err as Error).message)
} finally {
setLoading(false)
}
}
function handleCopy() {
navigator.clipboard.writeText(generatedText)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
if (org && !org.aiEnabled) return null
return (
<div className="bg-white rounded-xl border border-brand-100 shadow-sm p-6 space-y-4 flex flex-col h-full bg-gradient-to-br from-white to-brand-50/20">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="w-5 h-5 text-brand-500" />
<h2 className="text-lg font-bold text-gray-900">KI-Assistent</h2>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{type === 'news' ? 'Worum geht es in dem News-Beitrag?' : 'Beschreiben Sie die Stelle für die Lehrlingsbörse'}
</label>
<textarea
rows={3}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={type === 'news' ? "Schreibe einen Artikel über..." : "Eine kurze Zusammenfassung der Aufgaben..."}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
/>
</div>
<div className="flex items-center justify-between">
<select
value={format}
onChange={(e) => setFormat(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-brand-500 bg-white"
>
<option value="markdown">Markdown Format</option>
<option value="text">Einfacher Text</option>
</select>
<button
type="button"
onClick={handleGenerate}
disabled={loading || !prompt.trim()}
className="flex items-center gap-2 bg-brand-500 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-brand-600 disabled:opacity-60 transition-colors"
>
{loading ? 'Generiere...' : 'Generieren'}
<Sparkles className="w-4 h-4" />
</button>
</div>
{loading && (
<div className="flex items-center gap-3 px-4 py-3 bg-brand-50 border border-brand-100 rounded-lg">
<div className="flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:0ms]" />
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:150ms]" />
<span className="w-2 h-2 rounded-full bg-brand-400 animate-bounce [animation-delay:300ms]" />
</div>
<span className="text-sm text-brand-700 font-medium transition-all">{THINKING_STEPS[stepIndex]}</span>
</div>
)}
{generatedText && (
<div className="mt-4 flex-1 flex flex-col min-h-[300px] space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">Ergebnis:</span>
<div className="flex gap-4">
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 font-medium transition-colors"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? 'Kopiert!' : 'Kopieren'}
</button>
{onApply && (
<button
type="button"
onClick={() => onApply(generatedText)}
className="flex items-center gap-1 text-xs text-brand-600 hover:text-brand-700 font-medium transition-colors"
>
<Check className="w-4 h-4" />
Übernehmen
</button>
)}
</div>
</div>
<textarea
readOnly
value={generatedText}
className="w-full flex-1 p-3 border border-gray-200 rounded-lg text-sm bg-gray-50 focus:outline-none focus:ring-2 focus:ring-brand-500/50"
/>
</div>
)}
</div>
)
}

View File

@@ -1,136 +1,136 @@
'use client'
import { useEffect, useState } from 'react'
import { createAuthClient } from 'better-auth/react'
const authClient = createAuthClient({
// Keep auth requests on the current origin (important for tenant subdomains).
baseURL: typeof window !== 'undefined'
? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
})
interface LoginFormProps {
primaryColor?: string
}
export function LoginForm({ primaryColor = '#C99738' }: LoginFormProps) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [successMessage, setSuccessMessage] = useState('')
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const emailParam = params.get('email')
if (emailParam) setEmail(emailParam)
const messageParam = params.get('message')
if (messageParam === 'password_changed') {
setSuccessMessage('Passwort erfolgreich geändert. Bitte melden Sie sich mit Ihrem neuen Passwort an.')
}
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const result = await authClient.signIn.email({
email,
password,
callbackURL: '/dashboard',
})
setLoading(false)
if (result.error) {
setError(result.error.message ?? 'E-Mail oder Passwort falsch.')
return
}
// Use callbackUrl if present, otherwise go to dashboard
// mustChangePassword is handled by the dashboard ForcePasswordChange component
const params = new URLSearchParams(window.location.search)
const callbackUrl = params.get('callbackUrl')
let target = '/dashboard'
if (callbackUrl?.startsWith('/')) {
target = callbackUrl
// Normalize stale tenant-prefixed callback URLs like /test/dashboard
// when already on the tenant subdomain test.localhost.
const hostname = window.location.hostname
const parts = hostname.split('.')
const isTenantSubdomain =
parts.length > 2 || (parts.length === 2 && parts[1] === 'localhost')
const tenantSlug = isTenantSubdomain ? parts[0] : null
if (tenantSlug && target.startsWith(`/${tenantSlug}/`)) {
target = target.slice(tenantSlug.length + 1) || '/dashboard'
}
}
window.location.href = target
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{successMessage && (
<p className="text-sm text-green-700 bg-green-50 border border-green-200 px-3 py-2 rounded-lg">
{successMessage}
</p>
)}
<div>
<label
htmlFor="email"
className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"
>
E-Mail-Adresse
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@ihre-innung.de"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:border-transparent"
style={{ '--tw-ring-color': primaryColor } as any}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"
>
Passwort
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="********"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:border-transparent"
style={{ '--tw-ring-color': primaryColor } as any}
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full text-white py-2.5 px-4 rounded-lg text-sm font-medium disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
style={{ backgroundColor: primaryColor }}
>
{loading ? 'Bitte warten...' : 'Anmelden'}
</button>
</form>
)
}
'use client'
import { useEffect, useState } from 'react'
import { createAuthClient } from 'better-auth/react'
const authClient = createAuthClient({
// Keep auth requests on the current origin (important for tenant subdomains).
baseURL: typeof window !== 'undefined'
? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
})
interface LoginFormProps {
primaryColor?: string
}
export function LoginForm({ primaryColor = '#C99738' }: LoginFormProps) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [successMessage, setSuccessMessage] = useState('')
useEffect(() => {
const params = new URLSearchParams(window.location.search)
const emailParam = params.get('email')
if (emailParam) setEmail(emailParam)
const messageParam = params.get('message')
if (messageParam === 'password_changed') {
setSuccessMessage('Passwort erfolgreich geändert. Bitte melden Sie sich mit Ihrem neuen Passwort an.')
}
}, [])
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
const result = await authClient.signIn.email({
email,
password,
callbackURL: '/dashboard',
})
setLoading(false)
if (result.error) {
setError(result.error.message ?? 'E-Mail oder Passwort falsch.')
return
}
// Use callbackUrl if present, otherwise go to dashboard
// mustChangePassword is handled by the dashboard ForcePasswordChange component
const params = new URLSearchParams(window.location.search)
const callbackUrl = params.get('callbackUrl')
let target = '/dashboard'
if (callbackUrl?.startsWith('/')) {
target = callbackUrl
// Normalize stale tenant-prefixed callback URLs like /test/dashboard
// when already on the tenant subdomain test.localhost.
const hostname = window.location.hostname
const parts = hostname.split('.')
const isTenantSubdomain =
parts.length > 2 || (parts.length === 2 && parts[1] === 'localhost')
const tenantSlug = isTenantSubdomain ? parts[0] : null
if (tenantSlug && target.startsWith(`/${tenantSlug}/`)) {
target = target.slice(tenantSlug.length + 1) || '/dashboard'
}
}
window.location.href = target
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
{successMessage && (
<p className="text-sm text-green-700 bg-green-50 border border-green-200 px-3 py-2 rounded-lg">
{successMessage}
</p>
)}
<div>
<label
htmlFor="email"
className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"
>
E-Mail-Adresse
</label>
<input
id="email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="admin@ihre-innung.de"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:border-transparent"
style={{ '--tw-ring-color': primaryColor } as any}
/>
</div>
<div>
<label
htmlFor="password"
className="block text-xs font-medium text-gray-600 mb-1 uppercase tracking-wide"
>
Passwort
</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="********"
className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:border-transparent"
style={{ '--tw-ring-color': primaryColor } as any}
/>
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 px-3 py-2 rounded-lg">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full text-white py-2.5 px-4 rounded-lg text-sm font-medium disabled:opacity-60 disabled:cursor-not-allowed transition-colors"
style={{ backgroundColor: primaryColor }}
>
{loading ? 'Bitte warten...' : 'Anmelden'}
</button>
</form>
)
}

View File

@@ -1,55 +1,55 @@
'use client'
import { createAuthClient } from 'better-auth/react'
import { useRouter, usePathname } from 'next/navigation'
import { LogOut } from 'lucide-react'
const authClient = createAuthClient({
// Keep auth requests on the current origin (important for tenant subdomains).
baseURL: typeof window !== 'undefined'
? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
})
const PAGE_TITLES: Record<string, string> = {
'/dashboard': 'Übersicht',
'/dashboard/mitglieder': 'Mitglieder',
'/dashboard/news': 'News',
'/dashboard/termine': 'Termine',
'/dashboard/stellen': 'Lehrlingsbörse',
'/dashboard/einstellungen': 'Einstellungen',
}
export function Header() {
const router = useRouter()
const pathname = usePathname()
const title = Object.entries(PAGE_TITLES)
.sort((a, b) => b[0].length - a[0].length)
.find(([path]) => pathname === path || pathname.startsWith(path + '/'))?.[1] ?? 'Dashboard'
async function handleSignOut() {
await authClient.signOut()
router.push('/login')
}
return (
<header className="h-14 bg-white border-b flex items-center justify-between px-6 flex-shrink-0">
<h2
className="text-sm font-semibold text-gray-700 tracking-tight"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
{title}
</h2>
<div className="flex items-center gap-3">
<button
onClick={handleSignOut}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors"
>
<LogOut size={14} />
Abmelden
</button>
</div>
</header>
)
}
'use client'
import { createAuthClient } from 'better-auth/react'
import { useRouter, usePathname } from 'next/navigation'
import { LogOut } from 'lucide-react'
const authClient = createAuthClient({
// Keep auth requests on the current origin (important for tenant subdomains).
baseURL: typeof window !== 'undefined'
? window.location.origin
: (process.env.NEXT_PUBLIC_APP_URL ?? 'http://localhost:3010'),
})
const PAGE_TITLES: Record<string, string> = {
'/dashboard': 'Übersicht',
'/dashboard/mitglieder': 'Mitglieder',
'/dashboard/news': 'News',
'/dashboard/termine': 'Termine',
'/dashboard/stellen': 'Lehrlingsbörse',
'/dashboard/einstellungen': 'Einstellungen',
}
export function Header() {
const router = useRouter()
const pathname = usePathname()
const title = Object.entries(PAGE_TITLES)
.sort((a, b) => b[0].length - a[0].length)
.find(([path]) => pathname === path || pathname.startsWith(path + '/'))?.[1] ?? 'Dashboard'
async function handleSignOut() {
await authClient.signOut()
router.push('/login')
}
return (
<header className="h-14 bg-white border-b flex items-center justify-between px-6 flex-shrink-0">
<h2
className="text-sm font-semibold text-gray-700 tracking-tight"
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
>
{title}
</h2>
<div className="flex items-center gap-3">
<button
onClick={handleSignOut}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors"
>
<LogOut size={14} />
Abmelden
</button>
</div>
</header>
)
}