push
This commit is contained in:
181
innungsapp/apps/admin/components/ai-generator.tsx
Normal file
181
innungsapp/apps/admin/components/ai-generator.tsx
Normal file
@@ -0,0 +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>
|
||||
)
|
||||
}
|
||||
118
innungsapp/apps/admin/components/auth/LoginForm.tsx
Normal file
118
innungsapp/apps/admin/components/auth/LoginForm.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'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:3032'),
|
||||
})
|
||||
|
||||
interface LoginFormProps {
|
||||
primaryColor?: string
|
||||
}
|
||||
|
||||
export function LoginForm({ primaryColor = '#E63946' }: 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')
|
||||
window.location.href = callbackUrl || '/dashboard'
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,12 @@ import { createAuthClient } from 'better-auth/react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { LogOut } from 'lucide-react'
|
||||
|
||||
const authClient = createAuthClient()
|
||||
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:3032'),
|
||||
})
|
||||
|
||||
const PAGE_TITLES: Record<string, string> = {
|
||||
'/dashboard': 'Übersicht',
|
||||
|
||||
@@ -14,20 +14,24 @@ const navItems = [
|
||||
{ href: '/dashboard/einstellungen', label: 'Einstellungen', icon: Settings },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
export function Sidebar({ orgName, logoUrl }: { orgName?: string; logoUrl?: string | null }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<aside className="w-64 bg-white border-r flex flex-col flex-shrink-0">
|
||||
{/* Logo */}
|
||||
<div className="px-6 py-5 border-b">
|
||||
<Link href="/dashboard">
|
||||
<span
|
||||
className="text-xl font-bold text-gray-900 tracking-tight"
|
||||
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||
>
|
||||
Innungs<span className="text-brand-500">App</span>
|
||||
</span>
|
||||
<div className="px-6 py-5 border-b flex items-center gap-3">
|
||||
<Link href="/dashboard" className="flex items-center gap-3 w-full">
|
||||
{logoUrl ? (
|
||||
<img src={logoUrl} alt={orgName || 'Logo'} className="h-8 max-w-[120px] object-contain" />
|
||||
) : (
|
||||
<span
|
||||
className="text-xl font-bold text-gray-900 tracking-tight leading-tight line-clamp-2"
|
||||
style={{ fontFamily: "'Syne', system-ui, sans-serif" }}
|
||||
>
|
||||
{orgName || <span>Innungs<span className="text-brand-500">App</span></span>}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user