Files
breakpilot-lehrer/admin-lehrer/app/(admin)/website/manager/page.tsx
Benjamin Admin b4613e26f3 [split-required] Split 500-850 LOC files (batch 2)
backend-lehrer (10 files):
- game/database.py (785 → 5), correction_api.py (683 → 4)
- classroom_engine/antizipation.py (676 → 5)
- llm_gateway schools/edu_search already done in prior batch

klausur-service (12 files):
- orientation_crop_api.py (694 → 5), pdf_export.py (677 → 4)
- zeugnis_crawler.py (676 → 5), grid_editor_api.py (671 → 5)
- eh_templates.py (658 → 5), mail/api.py (651 → 5)
- qdrant_service.py (638 → 5), training_api.py (625 → 4)

website (6 pages):
- middleware (696 → 8), mail (733 → 6), consent (628 → 8)
- compliance/risks (622 → 5), export (502 → 5), brandbook (629 → 7)

studio-v2 (3 components):
- B2BMigrationWizard (848 → 3), CleanupPanel (765 → 2)
- dashboard-experimental (739 → 2)

admin-lehrer (4 files):
- uebersetzungen (769 → 4), manager (670 → 2)
- ChunkBrowserQA (675 → 6), dsfa/page (674 → 5)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-25 08:24:01 +02:00

247 lines
15 KiB
TypeScript

'use client'
/**
* Website Manager - CMS Dashboard
*
* Visual CMS dashboard for the BreakPilot website (macmini:3000).
* 60/40 split: Section cards with inline editors | Live iframe preview.
* Status bar, content stats, reset, save.
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import type { WebsiteContent } from '@/lib/content-types'
import {
HeroEditor, FeaturesEditor, FAQEditor, PricingEditor,
TrustEditor, TestimonialEditor,
} from './_components/SectionEditors'
const ADMIN_KEY = 'breakpilot-admin-2024'
const SECTIONS = [
{ key: 'hero', name: 'Hero Section', icon: '🎯', scrollTo: 'hero' },
{ key: 'features', name: 'Features', icon: '⚡', scrollTo: 'features' },
{ key: 'faq', name: 'FAQ', icon: '❓', scrollTo: 'faq' },
{ key: 'pricing', name: 'Pricing', icon: '💰', scrollTo: 'pricing' },
{ key: 'trust', name: 'Trust Indicators', icon: '🛡️', scrollTo: 'trust' },
{ key: 'testimonial', name: 'Testimonial', icon: '💬', scrollTo: 'trust' },
] as const
type SectionKey = (typeof SECTIONS)[number]['key']
// ─── Helpers ───────────────────────────────────────────────────────────────
function countWords(content: WebsiteContent): number {
const texts: string[] = []
texts.push(content.hero.badge, content.hero.title, content.hero.titleHighlight1, content.hero.titleHighlight2, content.hero.subtitle, content.hero.ctaPrimary, content.hero.ctaSecondary, content.hero.ctaHint)
content.features.forEach(f => { texts.push(f.title, f.description) })
content.faq.forEach(f => { texts.push(f.question, ...f.answer) })
content.pricing.forEach(p => { texts.push(p.name, p.description, p.features.tasks, p.features.taskDescription, ...p.features.included) })
texts.push(content.trust.item1.value, content.trust.item1.label, content.trust.item2.value, content.trust.item2.label, content.trust.item3.value, content.trust.item3.label)
texts.push(content.testimonial.quote, content.testimonial.author, content.testimonial.role)
return texts.filter(Boolean).join(' ').split(/\s+/).filter(Boolean).length
}
function sectionComplete(content: WebsiteContent, section: SectionKey): boolean {
switch (section) {
case 'hero': return !!(content.hero.title && content.hero.subtitle && content.hero.ctaPrimary)
case 'features': return content.features.length > 0 && content.features.every(f => f.title && f.description)
case 'faq': return content.faq.length > 0 && content.faq.every(f => f.question && f.answer.length > 0)
case 'pricing': return content.pricing.length > 0 && content.pricing.every(p => p.name && p.price > 0)
case 'trust': return !!(content.trust.item1.value && content.trust.item2.value && content.trust.item3.value)
case 'testimonial': return !!(content.testimonial.quote && content.testimonial.author)
}
}
function sectionSummary(content: WebsiteContent, section: SectionKey): string {
switch (section) {
case 'hero': return `"${content.hero.title} ${content.hero.titleHighlight1}"`.slice(0, 50)
case 'features': return `${content.features.length} Features`
case 'faq': return `${content.faq.length} Fragen`
case 'pricing': return `${content.pricing.length} Plaene`
case 'trust': return `${content.trust.item1.value}, ${content.trust.item2.value}, ${content.trust.item3.value}`
case 'testimonial': return `"${content.testimonial.quote.slice(0, 40)}..."`
}
}
// ─── Main Component ────────────────────────────────────────────────────────
export default function WebsiteManagerPage() {
const [content, setContent] = useState<WebsiteContent | null>(null)
const [originalContent, setOriginalContent] = useState<WebsiteContent | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null)
const [expandedSection, setExpandedSection] = useState<SectionKey | null>(null)
const [websiteStatus, setWebsiteStatus] = useState<{ online: boolean; responseTime: number } | null>(null)
const iframeRef = useRef<HTMLIFrameElement>(null)
useEffect(() => { loadContent(); checkWebsiteStatus() }, [])
useEffect(() => { if (message) { const t = setTimeout(() => setMessage(null), 4000); return () => clearTimeout(t) } }, [message])
async function loadContent() {
try {
const res = await fetch('/api/website/content')
if (res.ok) { const data = await res.json(); setContent(data); setOriginalContent(JSON.parse(JSON.stringify(data))) }
else { setMessage({ type: 'error', text: 'Fehler beim Laden des Contents' }) }
} catch { setMessage({ type: 'error', text: 'Verbindungsfehler beim Laden' }) }
finally { setLoading(false) }
}
async function checkWebsiteStatus() {
try { const res = await fetch('/api/website/status'); if (res.ok) { setWebsiteStatus(await res.json()) } }
catch { setWebsiteStatus({ online: false, responseTime: 0 }) }
}
async function saveChanges() {
if (!content) return
setSaving(true); setMessage(null)
try {
const res = await fetch('/api/website/content', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-admin-key': ADMIN_KEY }, body: JSON.stringify(content) })
if (res.ok) { setMessage({ type: 'success', text: 'Erfolgreich gespeichert!' }); setOriginalContent(JSON.parse(JSON.stringify(content))); if (iframeRef.current) { iframeRef.current.src = iframeRef.current.src } }
else { const err = await res.json(); setMessage({ type: 'error', text: err.error || 'Fehler beim Speichern' }) }
} catch { setMessage({ type: 'error', text: 'Verbindungsfehler beim Speichern' }) }
finally { setSaving(false) }
}
function resetContent() {
if (originalContent) { setContent(JSON.parse(JSON.stringify(originalContent))); setMessage({ type: 'success', text: 'Zurueckgesetzt auf letzten gespeicherten Stand' }) }
}
const scrollPreview = useCallback((scrollTo: string) => {
if (!iframeRef.current?.contentWindow) return
try { iframeRef.current.contentWindow.postMessage({ type: 'scrollTo', section: scrollTo }, '*') } catch { /* cross-origin fallback */ }
}, [])
function toggleSection(key: SectionKey) {
const newExpanded = expandedSection === key ? null : key
setExpandedSection(newExpanded)
if (newExpanded) { const section = SECTIONS.find(s => s.key === newExpanded); if (section) scrollPreview(section.scrollTo) }
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<div className="flex items-center gap-3 text-slate-500">
<svg className="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Lade Website-Content...
</div>
</div>
)
}
if (!content) {
return <div className="flex items-center justify-center py-20"><div className="text-red-600">Content konnte nicht geladen werden.</div></div>
}
const wordCount = countWords(content)
const completeSections = SECTIONS.filter(s => sectionComplete(content, s.key)).length
const completionPct = Math.round((completeSections / SECTIONS.length) * 100)
const hasChanges = JSON.stringify(content) !== JSON.stringify(originalContent)
return (
<div className="space-y-4">
{/* Status Bar */}
<div className="bg-white rounded-xl border border-slate-200 px-5 py-3 flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${websiteStatus?.online ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-sm text-slate-700">
Website {websiteStatus?.online ? 'online' : 'offline'}
{websiteStatus?.online && websiteStatus.responseTime > 0 && <span className="text-slate-400 ml-1">({websiteStatus.responseTime}ms)</span>}
</span>
</div>
<a href="https://macmini:3000" target="_blank" rel="noopener noreferrer" className="text-sm text-sky-600 hover:text-sky-700 flex items-center gap-1">
Zur Website
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /></svg>
</a>
</div>
<div className="flex items-center gap-3">
{message && <span className={`px-3 py-1 rounded-lg text-sm font-medium ${message.type === 'success' ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>{message.text}</span>}
<button onClick={resetContent} disabled={!hasChanges} className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 rounded-lg hover:bg-slate-200 disabled:opacity-40 disabled:cursor-not-allowed transition-colors">Reset</button>
<button onClick={saveChanges} disabled={saving || !hasChanges} className="px-5 py-2 text-sm font-medium text-white bg-sky-600 rounded-lg hover:bg-sky-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">{saving ? 'Speichern...' : 'Speichern'}</button>
</div>
</div>
{/* Stats Bar */}
<div className="grid grid-cols-4 gap-3">
{[
{ label: 'Sektionen', value: `${SECTIONS.length}`, icon: '📄' },
{ label: 'Woerter', value: wordCount.toLocaleString('de-DE'), icon: '📝' },
{ label: 'Vollstaendig', value: `${completionPct}%`, icon: completionPct === 100 ? '✅' : '🔧' },
{ label: 'Aenderungen', value: hasChanges ? 'Ungespeichert' : 'Aktuell', icon: hasChanges ? '🟡' : '🟢' },
].map((stat) => (
<div key={stat.label} className="bg-white rounded-xl border border-slate-200 px-4 py-3 flex items-center gap-3">
<span className="text-xl">{stat.icon}</span>
<div><div className="text-sm font-semibold text-slate-900">{stat.value}</div><div className="text-xs text-slate-500">{stat.label}</div></div>
</div>
))}
</div>
{/* Main Layout: 60/40 */}
<div className="grid grid-cols-5 gap-4" style={{ height: 'calc(100vh - 300px)' }}>
{/* Left: Section Cards */}
<div className="col-span-3 overflow-y-auto pr-1 space-y-3">
{SECTIONS.map((section) => {
const isExpanded = expandedSection === section.key
const isComplete = sectionComplete(content, section.key)
return (
<div key={section.key} className={`bg-white rounded-xl border transition-all ${isExpanded ? 'border-sky-300 shadow-md' : 'border-slate-200 hover:border-slate-300'}`}>
<button onClick={() => toggleSection(section.key)} className="w-full px-5 py-4 flex items-center justify-between text-left">
<div className="flex items-center gap-3">
<span className="text-xl">{section.icon}</span>
<div>
<div className="font-medium text-slate-900">{section.name}</div>
<div className="text-xs text-slate-500 mt-0.5">{sectionSummary(content, section.key)}</div>
</div>
</div>
<div className="flex items-center gap-3">
{isComplete ? (
<span className="w-6 h-6 rounded-full bg-green-100 text-green-600 flex items-center justify-center text-xs">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
</span>
) : (
<span className="w-6 h-6 rounded-full bg-amber-100 text-amber-600 flex items-center justify-center text-xs">!</span>
)}
<svg className={`w-5 h-5 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{isExpanded && (
<div className="px-5 pb-5 border-t border-slate-100 pt-4">
{section.key === 'hero' && <HeroEditor content={content} setContent={setContent} />}
{section.key === 'features' && <FeaturesEditor content={content} setContent={setContent} />}
{section.key === 'faq' && <FAQEditor content={content} setContent={setContent} />}
{section.key === 'pricing' && <PricingEditor content={content} setContent={setContent} />}
{section.key === 'trust' && <TrustEditor content={content} setContent={setContent} />}
{section.key === 'testimonial' && <TestimonialEditor content={content} setContent={setContent} />}
</div>
)}
</div>
)
})}
</div>
{/* Right: Live Preview */}
<div className="col-span-2 bg-white rounded-xl border border-slate-200 overflow-hidden flex flex-col">
<div className="bg-slate-50 border-b border-slate-200 px-4 py-2.5 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-2">
<div className="flex gap-1.5"><div className="w-2.5 h-2.5 rounded-full bg-red-400" /><div className="w-2.5 h-2.5 rounded-full bg-yellow-400" /><div className="w-2.5 h-2.5 rounded-full bg-green-400" /></div>
<span className="text-xs text-slate-500 ml-2">macmini:3000</span>
</div>
<button onClick={() => { if (iframeRef.current) iframeRef.current.src = iframeRef.current.src }} className="p-1 text-slate-400 hover:text-slate-600 rounded transition-colors" title="Preview neu laden">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</button>
</div>
<div className="flex-1 relative bg-slate-100">
<iframe ref={iframeRef} src="https://macmini:3000/?preview=true" className="absolute inset-0 w-full h-full border-0" style={{ width: '166.67%', height: '166.67%', transform: 'scale(0.6)', transformOrigin: 'top left' }} title="Website Preview" sandbox="allow-same-origin allow-scripts" />
</div>
</div>
</div>
</div>
)
}