Initial commit: breakpilot-lehrer - Lehrer KI Platform
Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
*.md
|
||||
.env*.local
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Git pre-push hook for studio-v2
|
||||
# Runs Playwright E2E tests before pushing changes
|
||||
#
|
||||
# To install, run:
|
||||
# chmod +x .git-hooks/pre-push
|
||||
# git config core.hooksPath .git-hooks
|
||||
#
|
||||
# To skip tests (emergency only):
|
||||
# git push --no-verify
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${YELLOW}Running E2E tests before push...${NC}"
|
||||
echo ""
|
||||
|
||||
# Navigate to studio-v2 directory
|
||||
cd "$(dirname "$0")/../.." || exit 1
|
||||
|
||||
# Check if dev server is running on port 3001
|
||||
if ! curl -s http://localhost:3001 > /dev/null 2>&1; then
|
||||
echo -e "${YELLOW}Dev server not running. Checking remote...${NC}"
|
||||
|
||||
# Check if macmini server is available
|
||||
if curl -s https://macmini/korrektur > /dev/null 2>&1; then
|
||||
echo -e "${GREEN}Using remote server at https://macmini${NC}"
|
||||
export PLAYWRIGHT_BASE_URL="https://macmini"
|
||||
else
|
||||
echo -e "${RED}No server available. Start dev server with 'npm run dev' or deploy to macmini.${NC}"
|
||||
echo -e "${YELLOW}Skipping tests...${NC}"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run tests
|
||||
echo -e "${YELLOW}Running Playwright tests...${NC}"
|
||||
npm run test:e2e -- --reporter=list 2>&1
|
||||
|
||||
# Check test result
|
||||
if [ $? -eq 0 ]; then
|
||||
echo ""
|
||||
echo -e "${GREEN}All tests passed! Pushing...${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo ""
|
||||
echo -e "${RED}Tests failed! Push aborted.${NC}"
|
||||
echo -e "${YELLOW}Fix the failing tests and try again.${NC}"
|
||||
echo -e "${YELLOW}To push anyway (not recommended): git push --no-verify${NC}"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+21
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Setup git hooks for studio-v2
|
||||
#
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "Setting up git hooks for studio-v2..."
|
||||
|
||||
# Make hooks executable
|
||||
chmod +x "$SCRIPT_DIR/pre-push"
|
||||
|
||||
# Configure git to use custom hooks directory
|
||||
git config core.hooksPath .git-hooks
|
||||
|
||||
echo "Git hooks installed successfully!"
|
||||
echo ""
|
||||
echo "Hooks enabled:"
|
||||
echo " - pre-push: Runs E2E tests before pushing"
|
||||
echo ""
|
||||
echo "To disable hooks temporarily: git push --no-verify"
|
||||
@@ -0,0 +1,49 @@
|
||||
# Build stage
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json* ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build arguments for environment variables (needed at build time for Next.js)
|
||||
ARG NEXT_PUBLIC_VOICE_SERVICE_URL
|
||||
ARG NEXT_PUBLIC_KLAUSUR_SERVICE_URL
|
||||
|
||||
# Set environment variables for build
|
||||
ENV NEXT_PUBLIC_VOICE_SERVICE_URL=$NEXT_PUBLIC_VOICE_SERVICE_URL
|
||||
ENV NEXT_PUBLIC_KLAUSUR_SERVICE_URL=$NEXT_PUBLIC_KLAUSUR_SERVICE_URL
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy built application
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3001
|
||||
|
||||
ENV PORT=3001
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Footer } from '@/components/Footer'
|
||||
|
||||
export default function AGBPage() {
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
<div className="flex-1 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/"
|
||||
className={`inline-flex items-center gap-2 mb-8 transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t('back_to_selection')}
|
||||
</Link>
|
||||
|
||||
{/* Content Card */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-8 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h1 className={`text-3xl font-bold mb-8 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{t('legal')}
|
||||
</h1>
|
||||
|
||||
<div className={`space-y-6 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
§ 1 Geltungsbereich
|
||||
</h2>
|
||||
<p>
|
||||
Diese Allgemeinen Geschäftsbedingungen gelten für alle Verträge zwischen BreakPilot GmbH
|
||||
und dem Kunden über die Nutzung der BreakPilot Studio Plattform.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
§ 2 Vertragsgegenstand
|
||||
</h2>
|
||||
<p>
|
||||
Gegenstand des Vertrages ist die Bereitstellung der BreakPilot Studio Software als
|
||||
webbasierte Anwendung (Software as a Service) zur Unterstützung von Lehrkräften
|
||||
bei der Korrektur von Klausuren und Prüfungsarbeiten.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
§ 3 Nutzungsrechte
|
||||
</h2>
|
||||
<p>
|
||||
Der Kunde erhält das nicht-exklusive, nicht übertragbare Recht, die Software
|
||||
während der Vertragslaufzeit bestimmungsgemäß zu nutzen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
§ 4 Datenschutz
|
||||
</h2>
|
||||
<p>
|
||||
Die Verarbeitung personenbezogener Daten erfolgt gemäß unserer Datenschutzerklärung
|
||||
und den Vorgaben der DSGVO. Für die Verarbeitung von Schülerdaten wird ein
|
||||
Auftragsverarbeitungsvertrag (AVV) geschlossen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
§ 5 Haftung
|
||||
</h2>
|
||||
<p>
|
||||
Die Haftung richtet sich nach den gesetzlichen Bestimmungen mit den in diesen AGB
|
||||
enthaltenen Einschränkungen und Ergänzungen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className={`mt-8 p-4 rounded-2xl ${
|
||||
isDark ? 'bg-yellow-500/20 border border-yellow-500/30' : 'bg-yellow-50 border border-yellow-200'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-yellow-200' : 'text-yellow-800'}`}>
|
||||
Hinweis: Dies ist ein Platzhalter. Bitte ersetzen Sie diesen Text durch Ihre vollständigen
|
||||
Allgemeinen Geschäftsbedingungen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,466 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useAlerts, Alert, getImportanceColor, getRelativeTime } from '@/lib/AlertsContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { AlertsWizard } from '@/components/AlertsWizard'
|
||||
import { InfoBox, TipBox } from '@/components/InfoBox'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
|
||||
// Alert Detail Modal
|
||||
function AlertDetailModal({
|
||||
alert,
|
||||
onClose,
|
||||
onMarkRead
|
||||
}: {
|
||||
alert: Alert
|
||||
onClose: () => void
|
||||
onMarkRead: () => void
|
||||
}) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
useEffect(() => {
|
||||
if (!alert.isRead) {
|
||||
onMarkRead()
|
||||
}
|
||||
}, [alert, onMarkRead])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className={`relative w-full max-w-2xl rounded-3xl border p-8 max-h-[90vh] overflow-y-auto ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200 shadow-2xl'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium border ${getImportanceColor(alert.importance, isDark)}`}>
|
||||
{alert.importance}
|
||||
</span>
|
||||
<span className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{getRelativeTime(alert.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h2 className={`text-xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{alert.title}
|
||||
</h2>
|
||||
|
||||
{/* LLM Summary */}
|
||||
<div className={`rounded-xl p-4 mb-6 ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<h4 className={`text-sm font-medium mb-2 flex items-center gap-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
<span>🤖</span> KI-Zusammenfassung
|
||||
</h4>
|
||||
<p className={isDark ? 'text-white/80' : 'text-slate-600'}>
|
||||
{alert.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sources */}
|
||||
<div className="space-y-3">
|
||||
<h4 className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Quellen:
|
||||
</h4>
|
||||
{alert.sources.map((source, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`block p-3 rounded-lg transition-all ${
|
||||
isDark
|
||||
? 'bg-white/5 hover:bg-white/10'
|
||||
: 'bg-slate-50 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-blue-400' : 'text-blue-600'}`}>
|
||||
{source.title}
|
||||
</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{source.domain}
|
||||
</p>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Original Source */}
|
||||
<div className={`mt-6 pt-4 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Quelle: {alert.source}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Alert Headline Component
|
||||
function AlertHeadline({
|
||||
alert,
|
||||
onClick
|
||||
}: {
|
||||
alert: Alert
|
||||
onClick: () => void
|
||||
}) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left p-4 rounded-xl transition-all group ${
|
||||
isDark
|
||||
? `bg-white/5 hover:bg-white/10 ${!alert.isRead ? 'border-l-4 border-amber-500' : ''}`
|
||||
: `bg-slate-50 hover:bg-slate-100 ${!alert.isRead ? 'border-l-4 border-amber-500' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium border ${getImportanceColor(alert.importance, isDark)}`}>
|
||||
{alert.importance}
|
||||
</span>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{getRelativeTime(alert.timestamp)}
|
||||
</span>
|
||||
{!alert.isRead && (
|
||||
<span className="w-2 h-2 rounded-full bg-amber-500" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{alert.title}
|
||||
</h3>
|
||||
<p className={`text-sm truncate ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{alert.summary}
|
||||
</p>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 flex-shrink-0 transition-colors ${
|
||||
isDark ? 'text-white/30 group-hover:text-white/60' : 'text-slate-300 group-hover:text-slate-600'
|
||||
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AlertsPage() {
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const {
|
||||
alerts,
|
||||
unreadCount,
|
||||
isLoading,
|
||||
topics,
|
||||
settings,
|
||||
markAsRead,
|
||||
markAllAsRead,
|
||||
updateSettings
|
||||
} = useAlerts()
|
||||
|
||||
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null)
|
||||
const [showWizard, setShowWizard] = useState(false)
|
||||
const [filterImportance, setFilterImportance] = useState<string>('all')
|
||||
const [viewMode, setViewMode] = useState<'simple' | 'expert'>('simple')
|
||||
|
||||
// Zeige Wizard wenn noch nicht abgeschlossen
|
||||
useEffect(() => {
|
||||
if (!settings.wizardCompleted && topics.length === 0) {
|
||||
setShowWizard(true)
|
||||
}
|
||||
}, [settings.wizardCompleted, topics.length])
|
||||
|
||||
// Gefilterte Alerts
|
||||
const filteredAlerts = alerts.filter(alert => {
|
||||
if (filterImportance === 'all') return true
|
||||
if (filterImportance === 'unread') return !alert.isRead
|
||||
return alert.importance === filterImportance
|
||||
})
|
||||
|
||||
// Wizard-Modus
|
||||
if (showWizard) {
|
||||
return (
|
||||
<AlertsWizard
|
||||
onComplete={() => setShowWizard(false)}
|
||||
onSkip={() => {
|
||||
updateSettings({ wizardCompleted: true })
|
||||
setShowWizard(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen relative overflow-hidden flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
|
||||
isDark ? 'bg-amber-500 opacity-70' : 'bg-amber-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
|
||||
isDark ? 'bg-orange-500 opacity-70' : 'bg-orange-300 opacity-50'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex min-h-screen gap-6 p-4">
|
||||
{/* Sidebar */}
|
||||
<Sidebar selectedTab="alerts" />
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className={`text-4xl font-bold mb-2 flex items-center gap-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>🔔</span> Alerts
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-3 py-1 text-sm font-medium rounded-full bg-amber-500/20 text-amber-400 border border-amber-500/30">
|
||||
{unreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className={isDark ? 'text-white/60' : 'text-slate-600'}>
|
||||
Aktuelle Nachrichten zu Ihren Bildungsthemen
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Mode Toggle */}
|
||||
<div className={`flex rounded-xl overflow-hidden border ${isDark ? 'border-white/20' : 'border-slate-200'}`}>
|
||||
<button
|
||||
onClick={() => setViewMode('simple')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-all ${
|
||||
viewMode === 'simple'
|
||||
? 'bg-amber-500 text-white'
|
||||
: isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
Einfach
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('expert')}
|
||||
className={`px-4 py-2 text-sm font-medium transition-all ${
|
||||
viewMode === 'expert'
|
||||
? 'bg-amber-500 text-white'
|
||||
: isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
Experte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<LanguageDropdown />
|
||||
<ThemeToggle />
|
||||
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className={`p-3 backdrop-blur-xl border rounded-2xl hover:bg-white/20 transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white'
|
||||
: 'bg-black/5 border-black/10 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Alerts Liste */}
|
||||
<div className={`col-span-2 backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
{/* Filter Bar */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
{['all', 'unread', 'KRITISCH', 'DRINGEND', 'WICHTIG'].map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
onClick={() => setFilterImportance(filter)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
|
||||
filterImportance === filter
|
||||
? 'bg-amber-500/20 text-amber-400 border border-amber-500/30'
|
||||
: isDark
|
||||
? 'text-white/60 hover:text-white hover:bg-white/10'
|
||||
: 'text-slate-500 hover:text-slate-900 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{filter === 'all' ? 'Alle' : filter === 'unread' ? 'Ungelesen' : filter}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={markAllAsRead}
|
||||
className={`text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}
|
||||
>
|
||||
Alle als gelesen markieren
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-amber-500 border-t-transparent" />
|
||||
</div>
|
||||
) : filteredAlerts.length === 0 ? (
|
||||
<div className={`text-center py-12 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<span className="text-4xl block mb-4">📭</span>
|
||||
<p>Keine Alerts gefunden</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredAlerts.map(alert => (
|
||||
<AlertHeadline
|
||||
key={alert.id}
|
||||
alert={alert}
|
||||
onClick={() => setSelectedAlert(alert)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Topics */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>📋</span> Meine Themen
|
||||
</h2>
|
||||
{topics.length === 0 ? (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Noch keine Themen konfiguriert.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{topics.map(topic => (
|
||||
<div
|
||||
key={topic.id}
|
||||
className={`flex items-center gap-3 p-3 rounded-lg ${
|
||||
isDark ? 'bg-white/5' : 'bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">{topic.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium text-sm ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{topic.name}
|
||||
</p>
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{topic.keywords.slice(0, 3).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={`w-2 h-2 rounded-full ${topic.isActive ? 'bg-green-500' : 'bg-slate-400'}`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowWizard(true)}
|
||||
className={`w-full mt-4 p-3 rounded-xl text-sm font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
+ Thema hinzufuegen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>📊</span> Statistik
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Gesamt</span>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{alerts.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Ungelesen</span>
|
||||
<span className="font-medium text-amber-500">{unreadCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Themen</span>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{topics.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<TipBox title="LLM-Zusammenfassungen" icon="🤖">
|
||||
<p className="text-sm">
|
||||
Alle Alerts werden automatisch mit KI zusammengefasst,
|
||||
um Ihnen Zeit zu sparen.
|
||||
</p>
|
||||
</TipBox>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Alert Detail Modal */}
|
||||
{selectedAlert && (
|
||||
<AlertDetailModal
|
||||
alert={selectedAlert}
|
||||
onClose={() => setSelectedAlert(null)}
|
||||
onMarkRead={() => markAsRead(selectedAlert.id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
|
||||
{/* Blob Animation Styles */}
|
||||
<style jsx>{`
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* POST /api/companion/feedback
|
||||
* Submit feedback (bug report, feature request, general feedback)
|
||||
* Proxy to backend /api/feedback
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.type || !body.title || !body.description) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing required fields: type, title, description',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate feedback type
|
||||
const validTypes = ['bug', 'feature', 'feedback']
|
||||
if (!validTypes.includes(body.type)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid feedback type. Must be: bug, feature, or feedback',
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const response = await fetch(`${backendUrl}/api/feedback`, {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// // Add auth headers
|
||||
// },
|
||||
// body: JSON.stringify({
|
||||
// type: body.type,
|
||||
// title: body.title,
|
||||
// description: body.description,
|
||||
// screenshot: body.screenshot,
|
||||
// sessionId: body.sessionId,
|
||||
// metadata: {
|
||||
// ...body.metadata,
|
||||
// source: 'companion',
|
||||
// timestamp: new Date().toISOString(),
|
||||
// userAgent: request.headers.get('user-agent'),
|
||||
// },
|
||||
// }),
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - just acknowledge the submission
|
||||
const feedbackId = `fb-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
console.log('Feedback received:', {
|
||||
id: feedbackId,
|
||||
type: body.type,
|
||||
title: body.title,
|
||||
description: body.description.substring(0, 100) + '...',
|
||||
hasScreenshot: !!body.screenshot,
|
||||
sessionId: body.sessionId,
|
||||
})
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Feedback submitted successfully',
|
||||
data: {
|
||||
feedbackId,
|
||||
submittedAt: new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Submit feedback error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/companion/feedback
|
||||
* Get feedback history (admin only)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const type = searchParams.get('type')
|
||||
const limit = parseInt(searchParams.get('limit') || '10')
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
|
||||
// Mock response - empty list for now
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
feedback: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get feedback error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* POST /api/companion/lesson
|
||||
* Start a new lesson session
|
||||
* Proxy to backend /api/classroom/sessions
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const response = await fetch(`${backendUrl}/api/classroom/sessions`, {
|
||||
// method: 'POST',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// },
|
||||
// body: JSON.stringify(body),
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - create a new session
|
||||
const sessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
|
||||
const mockSession = {
|
||||
success: true,
|
||||
data: {
|
||||
sessionId,
|
||||
classId: body.classId,
|
||||
className: body.className || body.classId,
|
||||
subject: body.subject,
|
||||
topic: body.topic,
|
||||
startTime: new Date().toISOString(),
|
||||
phases: [
|
||||
{ phase: 'einstieg', duration: 8, status: 'active', actualTime: 0 },
|
||||
{ phase: 'erarbeitung', duration: 20, status: 'planned', actualTime: 0 },
|
||||
{ phase: 'sicherung', duration: 10, status: 'planned', actualTime: 0 },
|
||||
{ phase: 'transfer', duration: 7, status: 'planned', actualTime: 0 },
|
||||
{ phase: 'reflexion', duration: 5, status: 'planned', actualTime: 0 },
|
||||
],
|
||||
totalPlannedDuration: 50,
|
||||
currentPhaseIndex: 0,
|
||||
elapsedTime: 0,
|
||||
isPaused: false,
|
||||
pauseDuration: 0,
|
||||
overtimeMinutes: 0,
|
||||
status: 'in_progress',
|
||||
homeworkList: [],
|
||||
materials: [],
|
||||
},
|
||||
}
|
||||
|
||||
return NextResponse.json(mockSession)
|
||||
} catch (error) {
|
||||
console.error('Start lesson error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/companion/lesson
|
||||
* Get current lesson session or list of recent sessions
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const url = sessionId
|
||||
// ? `${backendUrl}/api/classroom/sessions/${sessionId}`
|
||||
// : `${backendUrl}/api/classroom/sessions`
|
||||
//
|
||||
// const response = await fetch(url)
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response
|
||||
if (sessionId) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: null, // No active session stored on server in mock
|
||||
})
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
sessions: [], // Empty list for now
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get lesson error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/companion/lesson
|
||||
* Update lesson session (timer state, phase changes, etc.)
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { sessionId, ...updates } = body
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Session ID required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const response = await fetch(`${backendUrl}/api/classroom/sessions/${sessionId}`, {
|
||||
// method: 'PATCH',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify(updates),
|
||||
// })
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - just acknowledge the update
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Session updated',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update lesson error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /api/companion/lesson
|
||||
* End/delete a lesson session
|
||||
*/
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
const sessionId = searchParams.get('sessionId')
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Session ID required' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Session ended',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('End lesson error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
defaultPhaseDurations: {
|
||||
einstieg: 8,
|
||||
erarbeitung: 20,
|
||||
sicherung: 10,
|
||||
transfer: 7,
|
||||
reflexion: 5,
|
||||
},
|
||||
preferredLessonLength: 45,
|
||||
autoAdvancePhases: true,
|
||||
soundNotifications: true,
|
||||
showKeyboardShortcuts: true,
|
||||
highContrastMode: false,
|
||||
onboardingCompleted: false,
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/companion/settings
|
||||
* Get teacher settings
|
||||
* Proxy to backend /api/teacher/settings
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
|
||||
// method: 'GET',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// // Add auth headers
|
||||
// },
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - return default settings
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: DEFAULT_SETTINGS,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Get settings error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/companion/settings
|
||||
* Update teacher settings
|
||||
*/
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate the settings structure
|
||||
if (!body || typeof body !== 'object') {
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Invalid settings data' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
// const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
// const response = await fetch(`${backendUrl}/api/teacher/settings`, {
|
||||
// method: 'PUT',
|
||||
// headers: {
|
||||
// 'Content-Type': 'application/json',
|
||||
// // Add auth headers
|
||||
// },
|
||||
// body: JSON.stringify(body),
|
||||
// })
|
||||
//
|
||||
// if (!response.ok) {
|
||||
// throw new Error(`Backend responded with ${response.status}`)
|
||||
// }
|
||||
//
|
||||
// const data = await response.json()
|
||||
// return NextResponse.json(data)
|
||||
|
||||
// Mock response - just acknowledge the save
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Settings saved',
|
||||
data: body,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Save settings error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/companion/settings
|
||||
* Partially update teacher settings
|
||||
*/
|
||||
export async function PATCH(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// TODO: Replace with actual backend call
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: 'Settings updated',
|
||||
data: body,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Update settings error:', error)
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Proxy for meetings API endpoints
|
||||
* Routes requests to the backend service to avoid mixed-content/CORS issues
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
params: { path: string[] }
|
||||
): Promise<NextResponse> {
|
||||
const path = params.path.join('/')
|
||||
const url = `${BACKEND_URL}/api/meetings/${path}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
// Forward authorization header if present
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
}
|
||||
|
||||
// Add body for POST/PUT/PATCH requests
|
||||
if (['POST', 'PUT', 'PATCH'].includes(request.method)) {
|
||||
fetchOptions.body = await request.text()
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
// Get response data
|
||||
const contentType = response.headers.get('content-type')
|
||||
let data: string | ArrayBuffer
|
||||
|
||||
if (contentType?.includes('application/json')) {
|
||||
data = await response.text()
|
||||
} else {
|
||||
data = await response.arrayBuffer()
|
||||
}
|
||||
|
||||
// Return proxied response
|
||||
return new NextResponse(data, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': contentType || 'application/json',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to proxy ${request.method} ${url}:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend nicht erreichbar', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const params = await context.params
|
||||
return proxyRequest(request, params)
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const params = await context.params
|
||||
return proxyRequest(request, params)
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const params = await context.params
|
||||
return proxyRequest(request, params)
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const params = await context.params
|
||||
return proxyRequest(request, params)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Proxy for recordings API endpoints
|
||||
* Routes requests to the backend service to avoid mixed-content/CORS issues
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
params: { path: string[] }
|
||||
): Promise<NextResponse> {
|
||||
const path = params.path.join('/')
|
||||
const url = `${BACKEND_URL}/api/recordings/${path}`
|
||||
|
||||
try {
|
||||
const headers: HeadersInit = {}
|
||||
|
||||
// Forward content-type if present
|
||||
const contentType = request.headers.get('content-type')
|
||||
if (contentType) {
|
||||
headers['Content-Type'] = contentType
|
||||
}
|
||||
|
||||
// Forward authorization header if present
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader) {
|
||||
headers['Authorization'] = authHeader
|
||||
}
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method: request.method,
|
||||
headers,
|
||||
}
|
||||
|
||||
// Add body for POST/PUT/PATCH requests
|
||||
if (['POST', 'PUT', 'PATCH'].includes(request.method)) {
|
||||
fetchOptions.body = await request.text()
|
||||
}
|
||||
|
||||
const response = await fetch(url, fetchOptions)
|
||||
|
||||
// Get response data
|
||||
const responseContentType = response.headers.get('content-type')
|
||||
|
||||
// Handle binary data (like video files)
|
||||
if (responseContentType?.includes('video') || responseContentType?.includes('octet-stream')) {
|
||||
const data = await response.arrayBuffer()
|
||||
return new NextResponse(data, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType,
|
||||
'Content-Disposition': response.headers.get('content-disposition') || '',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Handle JSON and text
|
||||
const data = await response.text()
|
||||
return new NextResponse(data, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': responseContentType || 'application/json',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to proxy ${request.method} ${url}:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend nicht erreichbar', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const params = await context.params
|
||||
return proxyRequest(request, params)
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const params = await context.params
|
||||
return proxyRequest(request, params)
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const params = await context.params
|
||||
return proxyRequest(request, params)
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
context: { params: Promise<{ path: string[] }> }
|
||||
) {
|
||||
const params = await context.params
|
||||
return proxyRequest(request, params)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
/**
|
||||
* Proxy for /api/recordings base endpoint
|
||||
*/
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const url = `${BACKEND_URL}/api/recordings`
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
const data = await response.text()
|
||||
return new NextResponse(data, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': response.headers.get('content-type') || 'application/json',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(`Failed to proxy GET ${url}:`, error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Backend nicht erreichbar', details: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import { writeFile, readFile, mkdir } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
// Speicherort fuer Uploads
|
||||
const UPLOADS_DIR = '/tmp/breakpilot-uploads'
|
||||
const METADATA_FILE = path.join(UPLOADS_DIR, 'metadata.json')
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
sessionId: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
uploadedAt: string
|
||||
dataUrl: string // Base64 data URL
|
||||
}
|
||||
|
||||
// Stelle sicher, dass das Upload-Verzeichnis existiert
|
||||
async function ensureUploadsDir() {
|
||||
if (!existsSync(UPLOADS_DIR)) {
|
||||
await mkdir(UPLOADS_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
// Lade Metadaten
|
||||
async function loadMetadata(): Promise<UploadedFile[]> {
|
||||
try {
|
||||
await ensureUploadsDir()
|
||||
if (existsSync(METADATA_FILE)) {
|
||||
const data = await readFile(METADATA_FILE, 'utf-8')
|
||||
return JSON.parse(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading metadata:', error)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// Speichere Metadaten
|
||||
async function saveMetadata(uploads: UploadedFile[]) {
|
||||
await ensureUploadsDir()
|
||||
await writeFile(METADATA_FILE, JSON.stringify(uploads, null, 2))
|
||||
}
|
||||
|
||||
// GET: Liste alle Uploads fuer eine Session
|
||||
export async function GET(request: NextRequest) {
|
||||
const sessionId = request.nextUrl.searchParams.get('sessionId')
|
||||
|
||||
const uploads = await loadMetadata()
|
||||
|
||||
if (sessionId) {
|
||||
const filtered = uploads.filter(u => u.sessionId === sessionId)
|
||||
return NextResponse.json({ uploads: filtered })
|
||||
}
|
||||
|
||||
// Alle Uploads (fuer Dashboard)
|
||||
return NextResponse.json({ uploads })
|
||||
}
|
||||
|
||||
// POST: Neuen Upload hinzufuegen
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { sessionId, name, type, size, dataUrl } = body
|
||||
|
||||
if (!sessionId || !name || !dataUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const upload: UploadedFile = {
|
||||
id: `upload-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
sessionId,
|
||||
name,
|
||||
type: type || 'application/octet-stream',
|
||||
size: size || 0,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
dataUrl
|
||||
}
|
||||
|
||||
const uploads = await loadMetadata()
|
||||
uploads.push(upload)
|
||||
await saveMetadata(uploads)
|
||||
|
||||
return NextResponse.json({ success: true, upload })
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
return NextResponse.json(
|
||||
{ error: 'Upload failed' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE: Upload loeschen
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const id = request.nextUrl.searchParams.get('id')
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing upload id' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
const uploads = await loadMetadata()
|
||||
const filtered = uploads.filter(u => u.id !== id)
|
||||
await saveMetadata(filtered)
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { CompanionDashboard } from '@/components/companion/CompanionDashboard'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
|
||||
export default function CompanionPage() {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
|
||||
isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'
|
||||
}`} />
|
||||
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
|
||||
isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'
|
||||
}`} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Companion
|
||||
</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Companion Dashboard */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<CompanionDashboard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// Spatial UI System
|
||||
import { PerformanceProvider, usePerformance } from '@/lib/spatial-ui/PerformanceContext'
|
||||
import { FocusProvider } from '@/lib/spatial-ui/FocusContext'
|
||||
import { FloatingMessage } from '@/components/spatial-ui/FloatingMessage'
|
||||
|
||||
/**
|
||||
* Apple Weather Style Dashboard - Refined Version
|
||||
*
|
||||
* Design principles:
|
||||
* - Photo/gradient background that sets the mood
|
||||
* - Ultra-translucent cards (~8% opacity)
|
||||
* - Cards blend INTO the background
|
||||
* - White text, monochrome palette
|
||||
* - Subtle blur, minimal shadows
|
||||
* - Useful info: time, weather, compass
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// GLASS CARD - Ultra Transparent
|
||||
// =============================================================================
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
delay?: number
|
||||
}
|
||||
|
||||
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0 }: GlassCardProps) {
|
||||
const { settings } = usePerformance()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'p-4',
|
||||
md: 'p-5',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
const blur = settings.enableBlur ? 24 * settings.blurIntensity : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-3xl
|
||||
${sizeClasses[size]}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
background: isHovered
|
||||
? 'rgba(255, 255, 255, 0.12)'
|
||||
: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: blur > 0 ? `blur(${blur}px) saturate(180%)` : 'none',
|
||||
WebkitBackdropFilter: blur > 0 ? `blur(${blur}px) saturate(180%)` : 'none',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible
|
||||
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
|
||||
: 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ANALOG CLOCK - Apple Style
|
||||
// =============================================================================
|
||||
|
||||
function AnalogClock() {
|
||||
const [time, setTime] = useState(new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTime(new Date()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const hours = time.getHours() % 12
|
||||
const minutes = time.getMinutes()
|
||||
const seconds = time.getSeconds()
|
||||
|
||||
const hourDeg = (hours * 30) + (minutes * 0.5)
|
||||
const minuteDeg = minutes * 6
|
||||
const secondDeg = seconds * 6
|
||||
|
||||
return (
|
||||
<div className="relative w-32 h-32">
|
||||
{/* Clock face */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.15)',
|
||||
}}
|
||||
>
|
||||
{/* Hour markers */}
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-1 h-3 bg-white/40 rounded-full"
|
||||
style={{
|
||||
left: '50%',
|
||||
top: '8px',
|
||||
transform: `translateX(-50%) rotate(${i * 30}deg)`,
|
||||
transformOrigin: '50% 56px',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hour hand */}
|
||||
<div
|
||||
className="absolute w-1.5 h-10 bg-white rounded-full"
|
||||
style={{
|
||||
left: '50%',
|
||||
bottom: '50%',
|
||||
transform: `translateX(-50%) rotate(${hourDeg}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Minute hand */}
|
||||
<div
|
||||
className="absolute w-1 h-14 bg-white/80 rounded-full"
|
||||
style={{
|
||||
left: '50%',
|
||||
bottom: '50%',
|
||||
transform: `translateX(-50%) rotate(${minuteDeg}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Second hand */}
|
||||
<div
|
||||
className="absolute w-0.5 h-14 bg-orange-400 rounded-full"
|
||||
style={{
|
||||
left: '50%',
|
||||
bottom: '50%',
|
||||
transform: `translateX(-50%) rotate(${secondDeg}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
transition: 'transform 0.1s ease-out',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Center dot */}
|
||||
<div className="absolute w-3 h-3 bg-white rounded-full left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPASS - Apple Weather Style
|
||||
// =============================================================================
|
||||
|
||||
function Compass({ direction = 225 }: { direction?: number }) {
|
||||
return (
|
||||
<div className="relative w-24 h-24">
|
||||
{/* Compass face */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.15)',
|
||||
}}
|
||||
>
|
||||
{/* Cardinal directions */}
|
||||
<span className="absolute top-2 left-1/2 -translate-x-1/2 text-xs font-bold text-red-400">N</span>
|
||||
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 text-xs font-medium text-white/50">S</span>
|
||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs font-medium text-white/50">W</span>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-medium text-white/50">O</span>
|
||||
|
||||
{/* Needle */}
|
||||
<div
|
||||
className="absolute inset-4"
|
||||
style={{
|
||||
transform: `rotate(${direction}deg)`,
|
||||
transition: 'transform 0.5s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* North (red) */}
|
||||
<div
|
||||
className="absolute w-1.5 h-8 bg-gradient-to-t from-red-500 to-red-400 rounded-full"
|
||||
style={{
|
||||
left: '50%',
|
||||
bottom: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
/>
|
||||
{/* South (white) */}
|
||||
<div
|
||||
className="absolute w-1.5 h-8 bg-gradient-to-b from-white/80 to-white/40 rounded-full"
|
||||
style={{
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
transformOrigin: 'top center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center */}
|
||||
<div className="absolute w-3 h-3 bg-white rounded-full left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BAR CHART - Apple Weather Hourly Style
|
||||
// =============================================================================
|
||||
|
||||
interface BarChartProps {
|
||||
data: { label: string; value: number; highlight?: boolean }[]
|
||||
maxValue?: number
|
||||
}
|
||||
|
||||
function BarChart({ data, maxValue }: BarChartProps) {
|
||||
const max = maxValue || Math.max(...data.map((d) => d.value))
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-between gap-2 h-32">
|
||||
{data.map((item, index) => {
|
||||
const height = (item.value / max) * 100
|
||||
return (
|
||||
<div key={index} className="flex flex-col items-center gap-2 flex-1">
|
||||
<span className="text-xs text-white/60 font-medium">{item.value}</span>
|
||||
<div
|
||||
className="w-full rounded-lg transition-all duration-500"
|
||||
style={{
|
||||
height: `${height}%`,
|
||||
minHeight: 8,
|
||||
background: item.highlight
|
||||
? 'linear-gradient(to top, rgba(96, 165, 250, 0.6), rgba(167, 139, 250, 0.6))'
|
||||
: 'rgba(255, 255, 255, 0.2)',
|
||||
boxShadow: item.highlight ? '0 0 20px rgba(139, 92, 246, 0.3)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-white/40">{item.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TEMPERATURE DISPLAY
|
||||
// =============================================================================
|
||||
|
||||
function TemperatureDisplay({ temp, condition }: { temp: number; condition: string }) {
|
||||
const conditionIcons: Record<string, string> = {
|
||||
sunny: '☀️',
|
||||
cloudy: '☁️',
|
||||
rainy: '🌧️',
|
||||
snowy: '🌨️',
|
||||
partly_cloudy: '⛅',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-2">{conditionIcons[condition] || '☀️'}</div>
|
||||
<div className="flex items-start justify-center">
|
||||
<span className="text-6xl font-extralight text-white">{temp}</span>
|
||||
<span className="text-2xl text-white/60 mt-2">°C</span>
|
||||
</div>
|
||||
<p className="text-white/50 text-sm mt-1 capitalize">
|
||||
{condition.replace('_', ' ')}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROGRESS RING
|
||||
// =============================================================================
|
||||
|
||||
interface ProgressRingProps {
|
||||
progress: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
label: string
|
||||
value: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
function ProgressRing({ progress, size = 80, strokeWidth = 6, label, value, color = '#a78bfa' }: ProgressRingProps) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="transform -rotate-90">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
style={{ transition: 'stroke-dashoffset 1s ease-out' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-light text-white">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white/40 text-xs mt-2 font-medium uppercase tracking-wide">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT DISPLAY
|
||||
// =============================================================================
|
||||
|
||||
function StatDisplay({ value, unit, label, icon }: { value: string; unit?: string; label: string; icon?: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
{icon && <div className="text-2xl mb-2 opacity-80">{icon}</div>}
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span className="text-4xl font-light text-white">{value}</span>
|
||||
{unit && <span className="text-lg text-white/50 font-light">{unit}</span>}
|
||||
</div>
|
||||
<p className="text-white/40 text-xs mt-1 font-medium uppercase tracking-wide">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LIST ITEM
|
||||
// =============================================================================
|
||||
|
||||
function ListItem({ icon, title, subtitle, value, delay = 0 }: {
|
||||
icon: string; title: string; subtitle?: string; value?: string; delay?: number
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-4 p-3 rounded-2xl cursor-pointer transition-all"
|
||||
style={{
|
||||
background: isHovered ? 'rgba(255, 255, 255, 0.06)' : 'transparent',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateX(0)' : 'translateX(-10px)',
|
||||
transition: 'all 0.3s ease-out',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-white/8 flex items-center justify-center text-xl"
|
||||
style={{ background: 'rgba(255,255,255,0.08)' }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium">{title}</p>
|
||||
{subtitle && <p className="text-white/40 text-sm">{subtitle}</p>}
|
||||
</div>
|
||||
{value && <span className="text-white/50 font-medium">{value}</span>}
|
||||
<svg className="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
style={{ transform: isHovered ? 'translateX(2px)' : 'translateX(0)', transition: 'transform 0.2s' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ACTION BUTTON
|
||||
// =============================================================================
|
||||
|
||||
function ActionButton({ icon, label, primary = false, onClick, delay = 0 }: {
|
||||
icon: string; label: string; primary?: boolean; onClick?: () => void; delay?: number
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isPressed, setIsPressed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
return (
|
||||
<button
|
||||
className="w-full flex items-center justify-center gap-3 p-4 rounded-2xl font-medium transition-all"
|
||||
style={{
|
||||
background: primary
|
||||
? 'linear-gradient(135deg, rgba(96, 165, 250, 0.3), rgba(167, 139, 250, 0.3))'
|
||||
: 'rgba(255, 255, 255, 0.06)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
color: 'white',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? `translateY(0) scale(${isPressed ? 0.97 : 1})` : 'translateY(10px)',
|
||||
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseDown={() => setIsPressed(true)}
|
||||
onMouseUp={() => setIsPressed(false)}
|
||||
onMouseLeave={() => setIsPressed(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text-xl">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// QUALITY INDICATOR
|
||||
// =============================================================================
|
||||
|
||||
function QualityIndicator() {
|
||||
const { metrics, settings, forceQuality } = usePerformance()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-6 left-6 z-50"
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 16,
|
||||
padding: isExpanded ? 16 : 12,
|
||||
minWidth: isExpanded ? 200 : 'auto',
|
||||
transition: 'all 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-3 text-white/70 text-sm"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
metrics.qualityLevel === 'high' ? 'bg-green-400' :
|
||||
metrics.qualityLevel === 'medium' ? 'bg-yellow-400' : 'bg-red-400'
|
||||
}`} />
|
||||
<span className="font-mono">{metrics.fps} FPS</span>
|
||||
<span className="text-white/30">|</span>
|
||||
<span className="uppercase text-xs tracking-wide">{metrics.qualityLevel}</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-white/10 space-y-2">
|
||||
<div className="flex gap-1">
|
||||
{(['high', 'medium', 'low'] as const).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => forceQuality(level)}
|
||||
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
metrics.qualityLevel === level
|
||||
? 'bg-white/15 text-white'
|
||||
: 'bg-white/5 text-white/40 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{level[0].toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN DASHBOARD
|
||||
// =============================================================================
|
||||
|
||||
function DashboardContent() {
|
||||
const router = useRouter()
|
||||
const { settings } = usePerformance()
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 })
|
||||
const [time, setTime] = useState(new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTime(new Date()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.enableParallax) return
|
||||
const handleMouseMove = (e: MouseEvent) => setMousePos({ x: e.clientX, y: e.clientY })
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
return () => window.removeEventListener('mousemove', handleMouseMove)
|
||||
}, [settings.enableParallax])
|
||||
|
||||
const windowWidth = typeof window !== 'undefined' ? window.innerWidth : 1920
|
||||
const windowHeight = typeof window !== 'undefined' ? window.innerHeight : 1080
|
||||
const parallax = settings.enableParallax
|
||||
? { x: (mousePos.x / windowWidth - 0.5) * 15, y: (mousePos.y / windowHeight - 0.5) * 15 }
|
||||
: { x: 0, y: 0 }
|
||||
|
||||
const greeting = time.getHours() < 12 ? 'Guten Morgen' : time.getHours() < 18 ? 'Guten Tag' : 'Guten Abend'
|
||||
|
||||
// Weekly correction data
|
||||
const weeklyData = [
|
||||
{ label: 'Mo', value: 4, highlight: false },
|
||||
{ label: 'Di', value: 7, highlight: false },
|
||||
{ label: 'Mi', value: 3, highlight: false },
|
||||
{ label: 'Do', value: 8, highlight: false },
|
||||
{ label: 'Fr', value: 6, highlight: true },
|
||||
{ label: 'Sa', value: 2, highlight: false },
|
||||
{ label: 'So', value: 0, highlight: false },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative overflow-hidden">
|
||||
{/* Background */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-br from-slate-900 via-indigo-950 to-slate-900"
|
||||
style={{
|
||||
transform: `translate(${parallax.x * 0.5}px, ${parallax.y * 0.5}px) scale(1.05)`,
|
||||
transition: 'transform 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Stars */}
|
||||
<div className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(2px 2px at 20px 30px, white, transparent),
|
||||
radial-gradient(2px 2px at 40px 70px, rgba(255,255,255,0.8), transparent),
|
||||
radial-gradient(1px 1px at 90px 40px, white, transparent),
|
||||
radial-gradient(2px 2px at 160px 120px, rgba(255,255,255,0.9), transparent),
|
||||
radial-gradient(1px 1px at 230px 80px, white, transparent),
|
||||
radial-gradient(2px 2px at 300px 150px, rgba(255,255,255,0.7), transparent)`,
|
||||
backgroundSize: '400px 200px',
|
||||
}}
|
||||
/>
|
||||
{/* Ambient glows */}
|
||||
<div className="absolute w-[500px] h-[500px] rounded-full opacity-20"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.5) 0%, transparent 70%)',
|
||||
left: '10%', top: '20%',
|
||||
transform: `translate(${parallax.x}px, ${parallax.y}px)`,
|
||||
transition: 'transform 0.5s ease-out',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute w-[400px] h-[400px] rounded-full opacity-15"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(167, 139, 250, 0.5) 0%, transparent 70%)',
|
||||
right: '5%', bottom: '10%',
|
||||
transform: `translate(${-parallax.x * 0.8}px, ${-parallax.y * 0.8}px)`,
|
||||
transition: 'transform 0.5s ease-out',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 min-h-screen p-6">
|
||||
{/* Header */}
|
||||
<header className="flex items-start justify-between mb-8">
|
||||
<div>
|
||||
<p className="text-white/40 text-sm font-medium tracking-wide uppercase mb-1">
|
||||
{time.toLocaleDateString('de-DE', { weekday: 'long', day: 'numeric', month: 'long' })}
|
||||
</p>
|
||||
<h1 className="text-4xl font-light text-white tracking-tight">{greeting}</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<GlassCard size="sm" className="!p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">🔔</span>
|
||||
<span className="text-white font-medium text-sm">3</span>
|
||||
</div>
|
||||
</GlassCard>
|
||||
<GlassCard size="sm" className="!p-3">
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-400 to-purple-500" />
|
||||
</GlassCard>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Grid */}
|
||||
<div className="grid grid-cols-12 gap-4 max-w-7xl mx-auto">
|
||||
|
||||
{/* Clock & Weather Row */}
|
||||
<div className="col-span-3">
|
||||
<GlassCard size="lg" delay={50}>
|
||||
<div className="flex flex-col items-center">
|
||||
<AnalogClock />
|
||||
<p className="text-white text-2xl font-light mt-4">
|
||||
{time.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<GlassCard size="lg" delay={100}>
|
||||
<TemperatureDisplay temp={8} condition="partly_cloudy" />
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<GlassCard size="lg" delay={150}>
|
||||
<div className="flex flex-col items-center">
|
||||
<Compass direction={225} />
|
||||
<p className="text-white/50 text-sm mt-3">SW Wind</p>
|
||||
<p className="text-white text-lg font-light">12 km/h</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<GlassCard size="lg" delay={200}>
|
||||
<StatDisplay icon="📋" value="12" label="Offene Korrekturen" />
|
||||
<div className="mt-4 pt-4 border-t border-white/10 flex justify-around">
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-light text-white">28</p>
|
||||
<p className="text-white/40 text-xs">Diese Woche</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-light text-white">156</p>
|
||||
<p className="text-white/40 text-xs">Gesamt</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Bar Chart */}
|
||||
<div className="col-span-6">
|
||||
<GlassCard size="lg" delay={250}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-white text-sm font-medium uppercase tracking-wide opacity-60">Korrekturen diese Woche</h2>
|
||||
<span className="text-white/40 text-sm">30 gesamt</span>
|
||||
</div>
|
||||
<BarChart data={weeklyData} maxValue={10} />
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Progress Rings */}
|
||||
<div className="col-span-3">
|
||||
<GlassCard size="lg" delay={300}>
|
||||
<div className="flex justify-around">
|
||||
<ProgressRing progress={75} label="Fortschritt" value="75%" color="#60a5fa" />
|
||||
<ProgressRing progress={92} label="Qualitaet" value="92%" color="#a78bfa" />
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Time Saved */}
|
||||
<div className="col-span-3">
|
||||
<GlassCard size="lg" delay={350}>
|
||||
<StatDisplay icon="⏱" value="4.2" unit="h" label="Zeit gespart" />
|
||||
<p className="text-center text-white/30 text-xs mt-3">durch KI-Unterstuetzung</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Klausuren List */}
|
||||
<div className="col-span-8">
|
||||
<GlassCard size="lg" delay={400}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-white text-sm font-medium uppercase tracking-wide opacity-60">Aktuelle Klausuren</h2>
|
||||
<button className="text-white/40 text-xs hover:text-white transition-colors">Alle anzeigen</button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<ListItem icon="📝" title="Deutsch LK - Textanalyse" subtitle="24 Schueler" value="18/24" delay={450} />
|
||||
<ListItem icon="✅" title="Deutsch GK - Eroerterung" subtitle="Abgeschlossen" value="28/28" delay={500} />
|
||||
<ListItem icon="📝" title="Vorabitur - Gedichtanalyse" subtitle="22 Schueler" value="10/22" delay={550} />
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="col-span-4">
|
||||
<GlassCard size="lg" delay={450}>
|
||||
<h2 className="text-white text-sm font-medium uppercase tracking-wide opacity-60 mb-4">Schnellaktionen</h2>
|
||||
<div className="space-y-2">
|
||||
<ActionButton icon="➕" label="Neue Klausur" primary delay={500} />
|
||||
<ActionButton icon="📤" label="Arbeiten hochladen" delay={550} />
|
||||
<ActionButton icon="🎨" label="Worksheet Editor" onClick={() => router.push('/worksheet-editor')} delay={600} />
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Messages */}
|
||||
<FloatingMessage
|
||||
autoDismissMs={12000}
|
||||
maxQueue={3}
|
||||
position="top-right"
|
||||
offset={{ x: 24, y: 24 }}
|
||||
/>
|
||||
|
||||
{/* Quality Indicator */}
|
||||
<QualityIndicator />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function ExperimentalDashboard() {
|
||||
return (
|
||||
<PerformanceProvider>
|
||||
<FocusProvider>
|
||||
<DashboardContent />
|
||||
</FocusProvider>
|
||||
</PerformanceProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Footer } from '@/components/Footer'
|
||||
|
||||
export default function DatenschutzPage() {
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
<div className="flex-1 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/"
|
||||
className={`inline-flex items-center gap-2 mb-8 transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t('back_to_selection')}
|
||||
</Link>
|
||||
|
||||
{/* Content Card */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-8 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h1 className={`text-3xl font-bold mb-8 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{t('privacy')}
|
||||
</h1>
|
||||
|
||||
<div className={`space-y-6 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
1. Datenschutz auf einen Blick
|
||||
</h2>
|
||||
<h3 className={`text-lg font-medium mb-2 ${isDark ? 'text-white/90' : 'text-slate-800'}`}>
|
||||
Allgemeine Hinweise
|
||||
</h3>
|
||||
<p>
|
||||
Die folgenden Hinweise geben einen einfachen Überblick darüber, was mit Ihren personenbezogenen
|
||||
Daten passiert, wenn Sie diese Website besuchen. Personenbezogene Daten sind alle Daten, mit denen
|
||||
Sie persönlich identifiziert werden können.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
2. Datenerfassung auf dieser Website
|
||||
</h2>
|
||||
<h3 className={`text-lg font-medium mb-2 ${isDark ? 'text-white/90' : 'text-slate-800'}`}>
|
||||
Wer ist verantwortlich für die Datenerfassung?
|
||||
</h3>
|
||||
<p className="mb-4">
|
||||
Die Datenverarbeitung auf dieser Website erfolgt durch den Websitebetreiber.
|
||||
Dessen Kontaktdaten können Sie dem Impressum dieser Website entnehmen.
|
||||
</p>
|
||||
|
||||
<h3 className={`text-lg font-medium mb-2 ${isDark ? 'text-white/90' : 'text-slate-800'}`}>
|
||||
Wie erfassen wir Ihre Daten?
|
||||
</h3>
|
||||
<p>
|
||||
Ihre Daten werden zum einen dadurch erhoben, dass Sie uns diese mitteilen.
|
||||
Andere Daten werden automatisch beim Besuch der Website durch unsere IT-Systeme erfasst.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
3. Ihre Rechte
|
||||
</h2>
|
||||
<p>
|
||||
Sie haben jederzeit das Recht auf unentgeltliche Auskunft über Herkunft, Empfänger und Zweck
|
||||
Ihrer gespeicherten personenbezogenen Daten. Sie haben außerdem ein Recht, die Berichtigung
|
||||
oder Löschung dieser Daten zu verlangen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
4. Cookies
|
||||
</h2>
|
||||
<p>
|
||||
Diese Website verwendet Cookies. Sie können Ihre Cookie-Einstellungen jederzeit über den
|
||||
Link "Cookie-Einstellungen" im Footer dieser Seite anpassen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className={`mt-8 p-4 rounded-2xl ${
|
||||
isDark ? 'bg-yellow-500/20 border border-yellow-500/30' : 'bg-yellow-50 border border-yellow-200'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-yellow-200' : 'text-yellow-800'}`}>
|
||||
Hinweis: Dies ist ein Platzhalter. Bitte ersetzen Sie diesen Text durch Ihre vollständige
|
||||
Datenschutzerklärung gemäß DSGVO.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import {
|
||||
AOIResponse,
|
||||
AOITheme,
|
||||
AOIQuality,
|
||||
Difficulty,
|
||||
GeoJSONPolygon,
|
||||
LearningNode,
|
||||
GeoServiceHealth,
|
||||
DemoTemplate,
|
||||
} from './types'
|
||||
|
||||
// Dynamic imports for map components (no SSR)
|
||||
const AOISelector = dynamic(
|
||||
() => import('@/components/geo-lernwelt/AOISelector'),
|
||||
{ ssr: false, loading: () => <MapLoadingPlaceholder /> }
|
||||
)
|
||||
|
||||
const UnityViewer = dynamic(
|
||||
() => import('@/components/geo-lernwelt/UnityViewer'),
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
// API base URL
|
||||
const GEO_SERVICE_URL = process.env.NEXT_PUBLIC_GEO_SERVICE_URL || 'http://localhost:8088'
|
||||
|
||||
// Loading placeholder for map
|
||||
function MapLoadingPlaceholder() {
|
||||
return (
|
||||
<div className="w-full h-[400px] bg-slate-800 rounded-xl flex items-center justify-center">
|
||||
<div className="text-white/60 flex flex-col items-center gap-2">
|
||||
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
<span>Karte wird geladen...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Theme icons and colors
|
||||
const THEME_CONFIG: Record<AOITheme, { icon: string; color: string; label: string }> = {
|
||||
topographie: { icon: '🏔️', color: 'bg-amber-500', label: 'Topographie' },
|
||||
landnutzung: { icon: '🏘️', color: 'bg-green-500', label: 'Landnutzung' },
|
||||
orientierung: { icon: '🧭', color: 'bg-blue-500', label: 'Orientierung' },
|
||||
geologie: { icon: '🪨', color: 'bg-stone-500', label: 'Geologie' },
|
||||
hydrologie: { icon: '💧', color: 'bg-cyan-500', label: 'Hydrologie' },
|
||||
vegetation: { icon: '🌲', color: 'bg-emerald-500', label: 'Vegetation' },
|
||||
}
|
||||
|
||||
export default function GeoLernweltPage() {
|
||||
// State
|
||||
const [serviceHealth, setServiceHealth] = useState<GeoServiceHealth | null>(null)
|
||||
const [currentAOI, setCurrentAOI] = useState<AOIResponse | null>(null)
|
||||
const [drawnPolygon, setDrawnPolygon] = useState<GeoJSONPolygon | null>(null)
|
||||
const [selectedTheme, setSelectedTheme] = useState<AOITheme>('topographie')
|
||||
const [quality, setQuality] = useState<AOIQuality>('medium')
|
||||
const [difficulty, setDifficulty] = useState<Difficulty>('mittel')
|
||||
const [learningNodes, setLearningNodes] = useState<LearningNode[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'map' | 'unity'>('map')
|
||||
const [demoTemplate, setDemoTemplate] = useState<DemoTemplate | null>(null)
|
||||
|
||||
// Check service health on mount
|
||||
useEffect(() => {
|
||||
checkServiceHealth()
|
||||
loadMainauTemplate()
|
||||
}, [])
|
||||
|
||||
const checkServiceHealth = async () => {
|
||||
try {
|
||||
const res = await fetch(`${GEO_SERVICE_URL}/health`)
|
||||
if (res.ok) {
|
||||
const health = await res.json()
|
||||
setServiceHealth(health)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Service health check failed:', e)
|
||||
setServiceHealth(null)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMainauTemplate = async () => {
|
||||
try {
|
||||
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/aoi/templates/mainau`)
|
||||
if (res.ok) {
|
||||
const template = await res.json()
|
||||
setDemoTemplate(template)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load Mainau template:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePolygonDrawn = useCallback((polygon: GeoJSONPolygon) => {
|
||||
setDrawnPolygon(polygon)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const handleCreateAOI = async () => {
|
||||
if (!drawnPolygon) {
|
||||
setError('Bitte zeichne zuerst ein Gebiet auf der Karte.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/aoi`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
polygon: drawnPolygon,
|
||||
theme: selectedTheme,
|
||||
quality,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json()
|
||||
throw new Error(errorData.detail || 'Fehler beim Erstellen des Gebiets')
|
||||
}
|
||||
|
||||
const aoi = await res.json()
|
||||
setCurrentAOI(aoi)
|
||||
|
||||
// Poll for completion
|
||||
pollAOIStatus(aoi.aoi_id)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const pollAOIStatus = async (aoiId: string) => {
|
||||
const poll = async () => {
|
||||
try {
|
||||
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/aoi/${aoiId}`)
|
||||
if (res.ok) {
|
||||
const aoi = await res.json()
|
||||
setCurrentAOI(aoi)
|
||||
|
||||
if (aoi.status === 'completed') {
|
||||
// Load learning nodes
|
||||
generateLearningNodes(aoiId)
|
||||
} else if (aoi.status === 'failed') {
|
||||
setError('Verarbeitung fehlgeschlagen')
|
||||
} else {
|
||||
// Continue polling
|
||||
setTimeout(poll, 2000)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Polling error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
poll()
|
||||
}
|
||||
|
||||
const generateLearningNodes = async (aoiId: string) => {
|
||||
try {
|
||||
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/learning/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
aoi_id: aoiId,
|
||||
theme: selectedTheme,
|
||||
difficulty,
|
||||
node_count: 5,
|
||||
language: 'de',
|
||||
}),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setLearningNodes(data.nodes)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to generate learning nodes:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleLoadDemo = () => {
|
||||
if (demoTemplate) {
|
||||
setDrawnPolygon(demoTemplate.polygon)
|
||||
setSelectedTheme(demoTemplate.suggested_themes[0] || 'topographie')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-indigo-950 to-slate-900">
|
||||
{/* Header */}
|
||||
<header className="border-b border-white/10 bg-black/20 backdrop-blur-lg">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">🌍</span>
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-white">Geo-Lernwelt</h1>
|
||||
<p className="text-sm text-white/60">Interaktive Erdkunde-Lernplattform</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Service Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
serviceHealth?.status === 'healthy'
|
||||
? 'bg-green-500'
|
||||
: 'bg-yellow-500 animate-pulse'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-white/60">
|
||||
{serviceHealth?.status === 'healthy' ? 'Verbunden' : 'Verbinde...'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('map')}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
activeTab === 'map'
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
🗺️ Gebiet waehlen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('unity')}
|
||||
disabled={!currentAOI || currentAOI.status !== 'completed'}
|
||||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||||
activeTab === 'unity'
|
||||
? 'bg-white/10 text-white'
|
||||
: 'text-white/60 hover:text-white hover:bg-white/5'
|
||||
} disabled:opacity-40 disabled:cursor-not-allowed`}
|
||||
>
|
||||
🎮 3D-Lernwelt
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'map' ? (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Map Section (2/3) */}
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
{/* Map Card */}
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 overflow-hidden">
|
||||
<div className="p-4 border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-medium text-white">Gebiet auf der Karte waehlen</h2>
|
||||
{demoTemplate && (
|
||||
<button
|
||||
onClick={handleLoadDemo}
|
||||
className="px-3 py-1.5 text-sm bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 rounded-lg transition-colors"
|
||||
>
|
||||
📍 Demo: Insel Mainau
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-white/60 mt-1">
|
||||
Zeichne ein Polygon (max. 4 km²) um das gewuenschte Lerngebiet
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="h-[500px]">
|
||||
<AOISelector
|
||||
onPolygonDrawn={handlePolygonDrawn}
|
||||
initialPolygon={drawnPolygon}
|
||||
maxAreaKm2={4}
|
||||
geoServiceUrl={GEO_SERVICE_URL}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attribution */}
|
||||
<div className="text-xs text-white/40 text-center">
|
||||
Kartendaten: © OpenStreetMap contributors (ODbL) | Hoehenmodell: © Copernicus DEM
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Panel (1/3) */}
|
||||
<div className="space-y-4">
|
||||
{/* Theme Selection */}
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-3">Lernthema</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{(Object.keys(THEME_CONFIG) as AOITheme[]).map((theme) => {
|
||||
const config = THEME_CONFIG[theme]
|
||||
return (
|
||||
<button
|
||||
key={theme}
|
||||
onClick={() => setSelectedTheme(theme)}
|
||||
className={`p-3 rounded-xl text-left transition-all ${
|
||||
selectedTheme === theme
|
||||
? 'bg-white/15 border border-white/30'
|
||||
: 'bg-white/5 border border-transparent hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl">{config.icon}</span>
|
||||
<div className="text-sm text-white mt-1">{config.label}</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quality Selection */}
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-3">Qualitaet</h3>
|
||||
<div className="flex gap-2">
|
||||
{(['low', 'medium', 'high'] as AOIQuality[]).map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onClick={() => setQuality(q)}
|
||||
className={`flex-1 py-2 rounded-lg text-sm transition-all ${
|
||||
quality === q
|
||||
? 'bg-white/15 text-white border border-white/30'
|
||||
: 'bg-white/5 text-white/60 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{q === 'low' ? 'Schnell' : q === 'medium' ? 'Standard' : 'Hoch'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Difficulty Selection */}
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-3">Schwierigkeitsgrad</h3>
|
||||
<div className="flex gap-2">
|
||||
{(['leicht', 'mittel', 'schwer'] as Difficulty[]).map((d) => (
|
||||
<button
|
||||
key={d}
|
||||
onClick={() => setDifficulty(d)}
|
||||
className={`flex-1 py-2 rounded-lg text-sm capitalize transition-all ${
|
||||
difficulty === d
|
||||
? 'bg-white/15 text-white border border-white/30'
|
||||
: 'bg-white/5 text-white/60 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{d}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Area Info */}
|
||||
{drawnPolygon && (
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-2">Ausgewaehltes Gebiet</h3>
|
||||
<div className="text-sm text-white/60">
|
||||
<p>Polygon gezeichnet ✓</p>
|
||||
<p className="text-white/40 text-xs mt-1">
|
||||
Klicke "Lernwelt erstellen" um fortzufahren
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Button */}
|
||||
<button
|
||||
onClick={handleCreateAOI}
|
||||
disabled={!drawnPolygon || isLoading}
|
||||
className={`w-full py-4 rounded-xl font-medium text-lg transition-all ${
|
||||
drawnPolygon && !isLoading
|
||||
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:from-blue-600 hover:to-purple-600'
|
||||
: 'bg-white/10 text-white/40 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Wird erstellt...
|
||||
</span>
|
||||
) : (
|
||||
'🚀 Lernwelt erstellen'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* AOI Status */}
|
||||
{currentAOI && (
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-2">Status</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
currentAOI.status === 'completed'
|
||||
? 'bg-green-500'
|
||||
: currentAOI.status === 'failed'
|
||||
? 'bg-red-500'
|
||||
: 'bg-yellow-500 animate-pulse'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-white/80 capitalize">
|
||||
{currentAOI.status === 'queued'
|
||||
? 'In Warteschlange...'
|
||||
: currentAOI.status === 'processing'
|
||||
? 'Wird verarbeitet...'
|
||||
: currentAOI.status === 'completed'
|
||||
? 'Fertig!'
|
||||
: 'Fehlgeschlagen'}
|
||||
</span>
|
||||
</div>
|
||||
{currentAOI.area_km2 > 0 && (
|
||||
<p className="text-xs text-white/50">
|
||||
Flaeche: {currentAOI.area_km2.toFixed(2)} km²
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Unity 3D Viewer Tab */
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 overflow-hidden">
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-white">3D-Lernwelt</h2>
|
||||
<p className="text-sm text-white/60">
|
||||
Erkunde das Gebiet und bearbeite die Lernstationen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-white/60">
|
||||
{learningNodes.length} Lernstationen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-[600px]">
|
||||
{currentAOI && currentAOI.status === 'completed' ? (
|
||||
<UnityViewer
|
||||
aoiId={currentAOI.aoi_id}
|
||||
manifestUrl={currentAOI.manifest_url}
|
||||
learningNodes={learningNodes}
|
||||
geoServiceUrl={GEO_SERVICE_URL}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-white/60">
|
||||
Erstelle zuerst ein Lerngebiet im Tab "Gebiet waehlen"
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Learning Nodes List */}
|
||||
{learningNodes.length > 0 && (
|
||||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||||
<h3 className="text-white font-medium mb-3">Lernstationen</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{learningNodes.map((node, idx) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className="p-3 bg-white/5 rounded-xl border border-white/10"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="w-6 h-6 bg-blue-500/30 rounded-full flex items-center justify-center text-xs text-white">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="text-white font-medium text-sm">{node.title}</span>
|
||||
</div>
|
||||
<p className="text-xs text-white/60 line-clamp-2">{node.question}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-xs bg-white/10 px-2 py-0.5 rounded text-white/60">
|
||||
{node.points} Punkte
|
||||
</span>
|
||||
{node.approved && (
|
||||
<span className="text-xs text-green-400">✓ Freigegeben</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* GeoEdu Service - TypeScript Types
|
||||
* Types for the geography learning platform
|
||||
*/
|
||||
|
||||
// Geographic types
|
||||
export interface Position {
|
||||
latitude: number
|
||||
longitude: number
|
||||
altitude?: number
|
||||
}
|
||||
|
||||
export interface Bounds {
|
||||
west: number
|
||||
south: number
|
||||
east: number
|
||||
north: number
|
||||
}
|
||||
|
||||
export interface GeoJSONPolygon {
|
||||
type: 'Polygon'
|
||||
coordinates: number[][][]
|
||||
}
|
||||
|
||||
// AOI (Area of Interest) types
|
||||
export type AOIStatus = 'queued' | 'processing' | 'completed' | 'failed'
|
||||
export type AOIQuality = 'low' | 'medium' | 'high'
|
||||
export type AOITheme =
|
||||
| 'topographie'
|
||||
| 'landnutzung'
|
||||
| 'orientierung'
|
||||
| 'geologie'
|
||||
| 'hydrologie'
|
||||
| 'vegetation'
|
||||
|
||||
export interface AOIRequest {
|
||||
polygon: GeoJSONPolygon
|
||||
theme: AOITheme
|
||||
quality: AOIQuality
|
||||
}
|
||||
|
||||
export interface AOIResponse {
|
||||
aoi_id: string
|
||||
status: AOIStatus
|
||||
area_km2: number
|
||||
estimated_size_mb: number
|
||||
message?: string
|
||||
download_url?: string
|
||||
manifest_url?: string
|
||||
created_at?: string
|
||||
completed_at?: string
|
||||
}
|
||||
|
||||
export interface AOIManifest {
|
||||
version: string
|
||||
aoi_id: string
|
||||
created_at: string
|
||||
bounds: Bounds
|
||||
center: Position
|
||||
area_km2: number
|
||||
theme: AOITheme
|
||||
quality: AOIQuality
|
||||
assets: {
|
||||
terrain: { file: string; config: string }
|
||||
osm_features: { file: string }
|
||||
learning_positions: { file: string }
|
||||
attribution: { file: string }
|
||||
}
|
||||
unity: {
|
||||
coordinate_system: string
|
||||
scale: number
|
||||
terrain_resolution: number
|
||||
}
|
||||
}
|
||||
|
||||
// Learning Node types
|
||||
export type NodeType = 'question' | 'observation' | 'exploration'
|
||||
export type Difficulty = 'leicht' | 'mittel' | 'schwer'
|
||||
|
||||
export interface LearningNode {
|
||||
id: string
|
||||
aoi_id: string
|
||||
title: string
|
||||
theme: AOITheme
|
||||
position: Position
|
||||
question: string
|
||||
hints: string[]
|
||||
answer: string
|
||||
explanation: string
|
||||
node_type: NodeType
|
||||
points: number
|
||||
approved: boolean
|
||||
media?: {
|
||||
type: 'image' | 'audio' | 'video'
|
||||
url: string
|
||||
}[]
|
||||
tags?: string[]
|
||||
difficulty?: Difficulty
|
||||
grade_level?: string
|
||||
}
|
||||
|
||||
export interface LearningNodeRequest {
|
||||
aoi_id: string
|
||||
theme: AOITheme
|
||||
difficulty: Difficulty
|
||||
node_count: number
|
||||
grade_level?: string
|
||||
language?: string
|
||||
}
|
||||
|
||||
export interface LearningNodeResponse {
|
||||
aoi_id: string
|
||||
theme: string
|
||||
nodes: LearningNode[]
|
||||
total_count: number
|
||||
generation_model: string
|
||||
}
|
||||
|
||||
// Theme template types
|
||||
export interface ThemeTemplate {
|
||||
id: AOITheme
|
||||
name: string
|
||||
description: string
|
||||
icon: string
|
||||
grade_levels: string[]
|
||||
example_questions: string[]
|
||||
keywords: string[]
|
||||
}
|
||||
|
||||
export interface LearningTemplates {
|
||||
themes: ThemeTemplate[]
|
||||
difficulties: {
|
||||
id: Difficulty
|
||||
name: string
|
||||
description: string
|
||||
}[]
|
||||
supported_languages: string[]
|
||||
}
|
||||
|
||||
// Attribution types
|
||||
export interface AttributionSource {
|
||||
name: string
|
||||
license: string
|
||||
url: string
|
||||
attribution: string
|
||||
required: boolean
|
||||
logo_url?: string
|
||||
}
|
||||
|
||||
export interface Attribution {
|
||||
sources: AttributionSource[]
|
||||
generated_at: string
|
||||
notice: string
|
||||
}
|
||||
|
||||
// Tile metadata types
|
||||
export interface TileMetadata {
|
||||
name: string
|
||||
description: string
|
||||
format: string
|
||||
scheme: string
|
||||
minzoom: number
|
||||
maxzoom: number
|
||||
bounds: [number, number, number, number]
|
||||
center: [number, number, number]
|
||||
attribution: string
|
||||
data_available: boolean
|
||||
last_updated?: string
|
||||
}
|
||||
|
||||
export interface DEMMetadata {
|
||||
name: string
|
||||
description: string
|
||||
resolution_m: number
|
||||
coverage: string
|
||||
bounds: [number, number, number, number]
|
||||
vertical_datum: string
|
||||
horizontal_datum: string
|
||||
license: string
|
||||
attribution: string
|
||||
data_available: boolean
|
||||
tiles_generated: number
|
||||
}
|
||||
|
||||
// Service health status
|
||||
export interface GeoServiceHealth {
|
||||
status: 'healthy' | 'degraded' | 'unhealthy'
|
||||
service: string
|
||||
version: string
|
||||
environment: string
|
||||
data_status: {
|
||||
pmtiles_available: boolean
|
||||
dem_available: boolean
|
||||
tile_cache_dir: boolean
|
||||
bundle_dir: boolean
|
||||
}
|
||||
config: {
|
||||
max_aoi_size_km2: number
|
||||
supported_themes: AOITheme[]
|
||||
}
|
||||
}
|
||||
|
||||
// Map style types (for MapLibre)
|
||||
export interface MapStyle {
|
||||
version: number
|
||||
name: string
|
||||
metadata: {
|
||||
description: string
|
||||
attribution: string
|
||||
}
|
||||
sources: {
|
||||
[key: string]: {
|
||||
type: string
|
||||
tiles?: string[]
|
||||
url?: string
|
||||
minzoom?: number
|
||||
maxzoom?: number
|
||||
attribution?: string
|
||||
tileSize?: number
|
||||
}
|
||||
}
|
||||
layers: MapLayer[]
|
||||
terrain?: {
|
||||
source: string
|
||||
exaggeration: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface MapLayer {
|
||||
id: string
|
||||
type: string
|
||||
source?: string // Optional for background layers
|
||||
'source-layer'?: string
|
||||
minzoom?: number
|
||||
maxzoom?: number
|
||||
filter?: unknown[] // MapLibre filter expressions can have mixed types
|
||||
layout?: Record<string, unknown>
|
||||
paint?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// UI State types
|
||||
export interface GeoLernweltState {
|
||||
// Current AOI
|
||||
currentAOI: AOIResponse | null
|
||||
drawnPolygon: GeoJSONPolygon | null
|
||||
|
||||
// Selected theme and settings
|
||||
selectedTheme: AOITheme
|
||||
quality: AOIQuality
|
||||
difficulty: Difficulty
|
||||
|
||||
// Learning nodes
|
||||
learningNodes: LearningNode[]
|
||||
selectedNode: LearningNode | null
|
||||
|
||||
// UI state
|
||||
isDrawing: boolean
|
||||
isLoading: boolean
|
||||
error: string | null
|
||||
|
||||
// Unity viewer state
|
||||
unityReady: boolean
|
||||
unityProgress: number
|
||||
}
|
||||
|
||||
// API response wrapper
|
||||
export interface ApiResponse<T> {
|
||||
data?: T
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
// Demo templates
|
||||
export interface DemoTemplate {
|
||||
name: string
|
||||
description: string
|
||||
polygon: GeoJSONPolygon
|
||||
center: [number, number]
|
||||
area_km2: number
|
||||
suggested_themes: AOITheme[]
|
||||
features: string[]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* BreakPilot Studio v2 - Base Styles */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f8fafc;
|
||||
color: #1e293b;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Footer } from '@/components/Footer'
|
||||
|
||||
export default function ImpressumPage() {
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
<div className="flex-1 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/"
|
||||
className={`inline-flex items-center gap-2 mb-8 transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t('back_to_selection')}
|
||||
</Link>
|
||||
|
||||
{/* Content Card */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-8 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h1 className={`text-3xl font-bold mb-8 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{t('imprint')}
|
||||
</h1>
|
||||
|
||||
<div className={`space-y-6 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Angaben gemäß § 5 TMG
|
||||
</h2>
|
||||
<p>
|
||||
BreakPilot GmbH<br />
|
||||
Musterstraße 123<br />
|
||||
12345 Musterstadt<br />
|
||||
Deutschland
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Kontakt
|
||||
</h2>
|
||||
<p>
|
||||
Telefon: +49 (0) 123 456789<br />
|
||||
E-Mail: info@breakpilot.de
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Vertretungsberechtigte Geschäftsführer
|
||||
</h2>
|
||||
<p>Max Mustermann</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Registereintrag
|
||||
</h2>
|
||||
<p>
|
||||
Eintragung im Handelsregister<br />
|
||||
Registergericht: Amtsgericht Musterstadt<br />
|
||||
Registernummer: HRB 12345
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Umsatzsteuer-ID
|
||||
</h2>
|
||||
<p>
|
||||
Umsatzsteuer-Identifikationsnummer gemäß § 27 a Umsatzsteuergesetz:<br />
|
||||
DE 123456789
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div className={`mt-8 p-4 rounded-2xl ${
|
||||
isDark ? 'bg-yellow-500/20 border border-yellow-500/30' : 'bg-yellow-50 border border-yellow-200'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-yellow-200' : 'text-yellow-800'}`}>
|
||||
Hinweis: Dies ist ein Platzhalter. Bitte ersetzen Sie diese Angaben durch Ihre tatsächlichen Unternehmensdaten.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Footer } from '@/components/Footer'
|
||||
|
||||
export default function KontaktPage() {
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
<div className="flex-1 p-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Back Link */}
|
||||
<Link
|
||||
href="/"
|
||||
className={`inline-flex items-center gap-2 mb-8 transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
{t('back_to_selection')}
|
||||
</Link>
|
||||
|
||||
{/* Content Card */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-8 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h1 className={`text-3xl font-bold mb-8 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{t('contact')}
|
||||
</h1>
|
||||
|
||||
<div className={`space-y-8 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
{/* Contact Info */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Kontaktdaten
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span>info@breakpilot.de</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
|
||||
</svg>
|
||||
<span>+49 (0) 123 456789</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className={`w-5 h-5 mt-0.5 ${isDark ? 'text-white/60' : 'text-slate-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<span>
|
||||
BreakPilot GmbH<br />
|
||||
Musterstraße 123<br />
|
||||
12345 Musterstadt
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Support
|
||||
</h2>
|
||||
<p className="mb-4">
|
||||
Unser Support-Team ist für Sie da:
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<p>Mo - Fr: 9:00 - 17:00 Uhr</p>
|
||||
<p>E-Mail: support@breakpilot.de</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Contact Form Placeholder */}
|
||||
<section>
|
||||
<h2 className={`text-xl font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Nachricht senden
|
||||
</h2>
|
||||
<div className={`p-6 rounded-2xl border ${
|
||||
isDark ? 'bg-white/5 border-white/10' : 'bg-slate-50 border-slate-200'
|
||||
}`}>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className={`w-full px-4 py-3 rounded-xl border transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-white/40'
|
||||
: 'bg-white border-slate-300 text-slate-900 placeholder-slate-400 focus:border-indigo-500'
|
||||
} focus:outline-none`}
|
||||
placeholder="Ihr Name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
className={`w-full px-4 py-3 rounded-xl border transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-white/40'
|
||||
: 'bg-white border-slate-300 text-slate-900 placeholder-slate-400 focus:border-indigo-500'
|
||||
} focus:outline-none`}
|
||||
placeholder="ihre@email.de"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Nachricht
|
||||
</label>
|
||||
<textarea
|
||||
rows={4}
|
||||
className={`w-full px-4 py-3 rounded-xl border transition-colors resize-none ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-white/40'
|
||||
: 'bg-white border-slate-300 text-slate-900 placeholder-slate-400 focus:border-indigo-500'
|
||||
} focus:outline-none`}
|
||||
placeholder="Ihre Nachricht..."
|
||||
/>
|
||||
</div>
|
||||
<button className="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-500 text-white font-medium rounded-xl hover:shadow-lg hover:shadow-purple-500/30 transition-all">
|
||||
Nachricht senden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className={`mt-8 p-4 rounded-2xl ${
|
||||
isDark ? 'bg-yellow-500/20 border border-yellow-500/30' : 'bg-yellow-50 border border-yellow-200'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-yellow-200' : 'text-yellow-800'}`}>
|
||||
Hinweis: Das Kontaktformular ist noch nicht funktionsfähig. Bitte nutzen Sie vorerst
|
||||
die angegebene E-Mail-Adresse für Anfragen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import {
|
||||
DocumentViewer,
|
||||
AnnotationLayer,
|
||||
AnnotationToolbar,
|
||||
AnnotationLegend,
|
||||
CriteriaPanel,
|
||||
GutachtenEditor,
|
||||
EHSuggestionPanel,
|
||||
} from '@/components/korrektur'
|
||||
import { korrekturApi } from '@/lib/korrektur/api'
|
||||
import type {
|
||||
Klausur,
|
||||
StudentWork,
|
||||
Annotation,
|
||||
AnnotationType,
|
||||
AnnotationPosition,
|
||||
CriteriaScores,
|
||||
EHSuggestion,
|
||||
} from '../../types'
|
||||
|
||||
// =============================================================================
|
||||
// GLASS CARD
|
||||
// =============================================================================
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function GlassCard({ children, className = '' }: GlassCardProps) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-3xl p-4 ${className}`}
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function StudentWorkspacePage() {
|
||||
const { isDark } = useTheme()
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const klausurId = params.klausurId as string
|
||||
const studentId = params.studentId as string
|
||||
|
||||
// State
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [student, setStudent] = useState<StudentWork | null>(null)
|
||||
const [students, setStudents] = useState<StudentWork[]>([])
|
||||
const [annotations, setAnnotations] = useState<Annotation[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Editor state
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [selectedTool, setSelectedTool] = useState<AnnotationType | null>(null)
|
||||
const [selectedAnnotation, setSelectedAnnotation] = useState<string | null>(null)
|
||||
const [activeTab, setActiveTab] = useState<'kriterien' | 'gutachten' | 'eh'>('kriterien')
|
||||
|
||||
// Criteria and Gutachten state
|
||||
const [criteriaScores, setCriteriaScores] = useState<CriteriaScores>({})
|
||||
const [gutachten, setGutachten] = useState('')
|
||||
const [isGeneratingGutachten, setIsGeneratingGutachten] = useState(false)
|
||||
|
||||
// EH Suggestions state
|
||||
const [ehSuggestions, setEhSuggestions] = useState<EHSuggestion[]>([])
|
||||
const [isLoadingEH, setIsLoadingEH] = useState(false)
|
||||
|
||||
// Saving state
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||
|
||||
// Load data
|
||||
const loadData = useCallback(async () => {
|
||||
if (!klausurId || !studentId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [klausurData, studentData, studentsData, annotationsData] = await Promise.all([
|
||||
korrekturApi.getKlausur(klausurId),
|
||||
korrekturApi.getStudent(studentId),
|
||||
korrekturApi.getStudents(klausurId),
|
||||
korrekturApi.getAnnotations(studentId),
|
||||
])
|
||||
|
||||
setKlausur(klausurData)
|
||||
setStudent(studentData)
|
||||
setStudents(studentsData)
|
||||
setAnnotations(annotationsData)
|
||||
|
||||
// Initialize editor state from student data
|
||||
setCriteriaScores(studentData.criteria_scores || {})
|
||||
setGutachten(studentData.gutachten || '')
|
||||
|
||||
// Estimate total pages (for images, usually 1; for PDFs, would need backend info)
|
||||
setTotalPages(studentData.file_type === 'pdf' ? 5 : 1)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [klausurId, studentId])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
// Get current student index
|
||||
const currentIndex = students.findIndex((s) => s.id === studentId)
|
||||
const prevStudent = currentIndex > 0 ? students[currentIndex - 1] : null
|
||||
const nextStudent = currentIndex < students.length - 1 ? students[currentIndex + 1] : null
|
||||
|
||||
// Navigation
|
||||
const goToStudent = (id: string) => {
|
||||
if (hasUnsavedChanges) {
|
||||
if (!confirm('Sie haben ungespeicherte Aenderungen. Trotzdem wechseln?')) {
|
||||
return
|
||||
}
|
||||
}
|
||||
router.push(`/korrektur/${klausurId}/${id}`)
|
||||
}
|
||||
|
||||
// Handle criteria change
|
||||
const handleCriteriaChange = (criterion: string, value: number) => {
|
||||
setCriteriaScores((prev) => ({
|
||||
...prev,
|
||||
[criterion]: value,
|
||||
}))
|
||||
setHasUnsavedChanges(true)
|
||||
}
|
||||
|
||||
// Handle gutachten change
|
||||
const handleGutachtenChange = (value: string) => {
|
||||
setGutachten(value)
|
||||
setHasUnsavedChanges(true)
|
||||
}
|
||||
|
||||
// Generate gutachten
|
||||
const handleGenerateGutachten = async () => {
|
||||
setIsGeneratingGutachten(true)
|
||||
try {
|
||||
const result = await korrekturApi.generateGutachten(studentId)
|
||||
setGutachten(result.gutachten)
|
||||
setHasUnsavedChanges(true)
|
||||
} catch (err) {
|
||||
console.error('Failed to generate gutachten:', err)
|
||||
setError('Gutachten-Generierung fehlgeschlagen')
|
||||
} finally {
|
||||
setIsGeneratingGutachten(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Load EH suggestions
|
||||
const handleLoadEHSuggestions = async (criterion?: string) => {
|
||||
setIsLoadingEH(true)
|
||||
try {
|
||||
const suggestions = await korrekturApi.getEHSuggestions(studentId, criterion)
|
||||
setEhSuggestions(suggestions)
|
||||
} catch (err) {
|
||||
console.error('Failed to load EH suggestions:', err)
|
||||
setError('EH-Vorschlaege konnten nicht geladen werden')
|
||||
} finally {
|
||||
setIsLoadingEH(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Create annotation
|
||||
const handleAnnotationCreate = async (position: AnnotationPosition, type: AnnotationType) => {
|
||||
try {
|
||||
const newAnnotation = await korrekturApi.createAnnotation(studentId, {
|
||||
page: currentPage,
|
||||
position,
|
||||
type,
|
||||
text: '',
|
||||
severity: 'minor',
|
||||
})
|
||||
setAnnotations((prev) => [...prev, newAnnotation])
|
||||
setSelectedAnnotation(newAnnotation.id)
|
||||
setSelectedTool(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to create annotation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete annotation
|
||||
const handleAnnotationDelete = async (id: string) => {
|
||||
try {
|
||||
await korrekturApi.deleteAnnotation(id)
|
||||
setAnnotations((prev) => prev.filter((a) => a.id !== id))
|
||||
setSelectedAnnotation(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete annotation:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Save all changes
|
||||
const handleSave = async () => {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await Promise.all([
|
||||
korrekturApi.updateCriteria(studentId, criteriaScores),
|
||||
korrekturApi.updateGutachten(studentId, gutachten),
|
||||
])
|
||||
setHasUnsavedChanges(false)
|
||||
} catch (err) {
|
||||
console.error('Failed to save:', err)
|
||||
setError('Speichern fehlgeschlagen')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Insert EH suggestion into gutachten
|
||||
const handleInsertSuggestion = (text: string) => {
|
||||
setGutachten((prev) => prev + '\n\n' + text)
|
||||
setHasUnsavedChanges(true)
|
||||
setActiveTab('gutachten')
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
setSelectedTool(null)
|
||||
setSelectedAnnotation(null)
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
setSelectedTool('rechtschreibung')
|
||||
} else if (e.key === 'g' || e.key === 'G') {
|
||||
setSelectedTool('grammatik')
|
||||
} else if (e.key === 'i' || e.key === 'I') {
|
||||
setSelectedTool('inhalt')
|
||||
} else if (e.key === 's' && e.metaKey) {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [criteriaScores, gutachten])
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-900 via-purple-900/30 to-slate-900">
|
||||
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex relative overflow-hidden bg-gradient-to-br from-slate-900 via-purple-900/30 to-slate-900">
|
||||
{/* Animated Background Blobs */}
|
||||
<div className="absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob bg-purple-500 opacity-30" />
|
||||
<div className="absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 bg-blue-500 opacity-30" />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push(`/korrektur/${klausurId}`)}
|
||||
className="p-2 rounded-xl bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">
|
||||
{student?.anonym_id || 'Student'}
|
||||
</h1>
|
||||
<p className="text-white/50 text-sm">{klausur?.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => prevStudent && goToStudent(prevStudent.id)}
|
||||
disabled={!prevStudent}
|
||||
className="p-2 rounded-xl bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-30"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-white/60 text-sm px-3">
|
||||
{currentIndex + 1} / {students.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => nextStudent && goToStudent(nextStudent.id)}
|
||||
disabled={!nextStudent}
|
||||
className="p-2 rounded-xl bg-white/10 hover:bg-white/20 text-white transition-colors disabled:opacity-30"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{hasUnsavedChanges && (
|
||||
<span className="text-amber-400 text-sm">Ungespeicherte Aenderungen</span>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasUnsavedChanges}
|
||||
className="px-4 py-2 rounded-xl bg-gradient-to-r from-green-500 to-emerald-500 text-white font-medium hover:shadow-lg hover:shadow-green-500/30 transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<GlassCard className="mb-4">
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm">{error}</span>
|
||||
<button
|
||||
onClick={() => setError(null)}
|
||||
className="ml-auto text-white/60 hover:text-white"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Main Workspace - 2/3 - 1/3 Layout */}
|
||||
<div className="flex-1 flex gap-4 overflow-hidden">
|
||||
{/* Left: Document Viewer (2/3) */}
|
||||
<div className="w-2/3 flex flex-col">
|
||||
<GlassCard className="flex-1 flex flex-col overflow-hidden">
|
||||
<DocumentViewer
|
||||
fileUrl={korrekturApi.getStudentFileUrl(studentId)}
|
||||
fileType={student?.file_type || 'image'}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
>
|
||||
<AnnotationLayer
|
||||
annotations={annotations.filter((a) => a.page === currentPage)}
|
||||
selectedAnnotation={selectedAnnotation}
|
||||
currentTool={selectedTool}
|
||||
onAnnotationCreate={handleAnnotationCreate}
|
||||
onAnnotationSelect={setSelectedAnnotation}
|
||||
onAnnotationDelete={handleAnnotationDelete}
|
||||
/>
|
||||
</DocumentViewer>
|
||||
</GlassCard>
|
||||
|
||||
{/* Annotation Toolbar */}
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<AnnotationToolbar
|
||||
selectedTool={selectedTool}
|
||||
onToolSelect={setSelectedTool}
|
||||
/>
|
||||
<AnnotationLegend className="hidden lg:flex" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Criteria/Gutachten Panel (1/3) */}
|
||||
<div className="w-1/3 flex flex-col">
|
||||
<GlassCard className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-white/10 mb-4">
|
||||
<button
|
||||
onClick={() => setActiveTab('kriterien')}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'kriterien'
|
||||
? 'text-white border-b-2 border-purple-500'
|
||||
: 'text-white/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Kriterien
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('gutachten')}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'gutachten'
|
||||
? 'text-white border-b-2 border-purple-500'
|
||||
: 'text-white/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Gutachten
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('eh')}
|
||||
className={`flex-1 py-2 text-sm font-medium transition-colors ${
|
||||
activeTab === 'eh'
|
||||
? 'text-white border-b-2 border-purple-500'
|
||||
: 'text-white/50 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
EH
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{activeTab === 'kriterien' && (
|
||||
<CriteriaPanel
|
||||
scores={criteriaScores}
|
||||
annotations={annotations}
|
||||
onScoreChange={handleCriteriaChange}
|
||||
onLoadEHSuggestions={(criterion) => {
|
||||
handleLoadEHSuggestions(criterion)
|
||||
setActiveTab('eh')
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'gutachten' && (
|
||||
<GutachtenEditor
|
||||
value={gutachten}
|
||||
onChange={handleGutachtenChange}
|
||||
onGenerate={handleGenerateGutachten}
|
||||
isGenerating={isGeneratingGutachten}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'eh' && (
|
||||
<EHSuggestionPanel
|
||||
suggestions={ehSuggestions}
|
||||
isLoading={isLoadingEH}
|
||||
onLoadSuggestions={handleLoadEHSuggestions}
|
||||
onInsertSuggestion={handleInsertSuggestion}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { korrekturApi } from '@/lib/korrektur/api'
|
||||
import type { Klausur, StudentWork, FairnessAnalysis } from '../../types'
|
||||
import { DEFAULT_CRITERIA, ANNOTATION_COLORS, getGradeLabel } from '../../types'
|
||||
|
||||
// =============================================================================
|
||||
// GLASS CARD
|
||||
// =============================================================================
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
delay?: number
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
function GlassCard({ children, className = '', delay = 0, isDark = true }: GlassCardProps) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-3xl p-5 ${className}`}
|
||||
style={{
|
||||
background: isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)',
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateY(0)' : 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HISTOGRAM
|
||||
// =============================================================================
|
||||
|
||||
interface HistogramProps {
|
||||
students: StudentWork[]
|
||||
className?: string
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
function Histogram({ students, className = '', isDark = true }: HistogramProps) {
|
||||
// Group students by grade points (0-15)
|
||||
const distribution = useMemo(() => {
|
||||
const counts: Record<number, number> = {}
|
||||
for (let i = 0; i <= 15; i++) {
|
||||
counts[i] = 0
|
||||
}
|
||||
for (const student of students) {
|
||||
if (student.grade_points !== undefined) {
|
||||
counts[student.grade_points] = (counts[student.grade_points] || 0) + 1
|
||||
}
|
||||
}
|
||||
return counts
|
||||
}, [students])
|
||||
|
||||
const maxCount = Math.max(...Object.values(distribution), 1)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Notenverteilung</h3>
|
||||
<div className="flex items-end gap-1 h-40">
|
||||
{Array.from({ length: 16 }, (_, i) => 15 - i).map((grade) => {
|
||||
const count = distribution[grade] || 0
|
||||
const height = (count / maxCount) * 100
|
||||
|
||||
// Color based on grade
|
||||
let color = '#22c55e' // Green for good grades
|
||||
if (grade <= 4) color = '#ef4444' // Red for poor grades
|
||||
else if (grade <= 9) color = '#f97316' // Orange for medium grades
|
||||
|
||||
return (
|
||||
<div
|
||||
key={grade}
|
||||
className="flex-1 flex flex-col items-center gap-1"
|
||||
>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{count || ''}</span>
|
||||
<div
|
||||
className="w-full rounded-t transition-all hover:opacity-80"
|
||||
style={{
|
||||
height: `${height}%`,
|
||||
minHeight: count > 0 ? '8px' : '0',
|
||||
backgroundColor: color,
|
||||
}}
|
||||
title={`${grade} Punkte (${getGradeLabel(grade)}): ${count} Schueler`}
|
||||
/>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{grade}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<p className={`text-xs text-center mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Punkte</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CRITERIA HEATMAP
|
||||
// =============================================================================
|
||||
|
||||
interface CriteriaHeatmapProps {
|
||||
students: StudentWork[]
|
||||
className?: string
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
function CriteriaHeatmap({ students, className = '', isDark = true }: CriteriaHeatmapProps) {
|
||||
// Calculate average for each criterion
|
||||
const criteriaAverages = useMemo(() => {
|
||||
const sums: Record<string, { sum: number; count: number }> = {}
|
||||
|
||||
for (const criterion of Object.keys(DEFAULT_CRITERIA)) {
|
||||
sums[criterion] = { sum: 0, count: 0 }
|
||||
}
|
||||
|
||||
for (const student of students) {
|
||||
if (student.criteria_scores) {
|
||||
for (const [criterion, score] of Object.entries(student.criteria_scores)) {
|
||||
if (score !== undefined && sums[criterion]) {
|
||||
sums[criterion].sum += score
|
||||
sums[criterion].count += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const averages: Record<string, number> = {}
|
||||
for (const [criterion, data] of Object.entries(sums)) {
|
||||
averages[criterion] = data.count > 0 ? Math.round(data.sum / data.count) : 0
|
||||
}
|
||||
|
||||
return averages
|
||||
}, [students])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Kriterien-Durchschnitt</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
|
||||
const average = criteriaAverages[criterion] || 0
|
||||
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
|
||||
|
||||
return (
|
||||
<div key={criterion} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className={`text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>{config.name}</span>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{average}%</span>
|
||||
</div>
|
||||
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${average}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OUTLIER LIST
|
||||
// =============================================================================
|
||||
|
||||
interface OutlierListProps {
|
||||
fairness: FairnessAnalysis | null
|
||||
onStudentClick: (studentId: string) => void
|
||||
className?: string
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
function OutlierList({ fairness, onStudentClick, className = '', isDark = true }: OutlierListProps) {
|
||||
if (!fairness || fairness.outliers.length === 0) {
|
||||
return (
|
||||
<div className={`text-center py-8 ${className}`}>
|
||||
<div className="w-12 h-12 rounded-full bg-green-500/20 flex items-center justify-center mx-auto mb-3">
|
||||
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Keine Ausreisser erkannt</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Alle Bewertungen sind konsistent</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<h3 className={`font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Ausreisser ({fairness.outliers.length})
|
||||
</h3>
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{fairness.outliers.map((outlier) => (
|
||||
<button
|
||||
key={outlier.student_id}
|
||||
onClick={() => onStudentClick(outlier.student_id)}
|
||||
className={`w-full p-3 rounded-xl border transition-colors text-left ${isDark ? 'bg-white/5 border-white/10 hover:bg-white/10' : 'bg-slate-100 border-slate-200 hover:bg-slate-200'}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{outlier.anonym_id}</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
outlier.deviation > 0
|
||||
? 'bg-green-500/20 text-green-400'
|
||||
: 'bg-red-500/20 text-red-400'
|
||||
}`}
|
||||
>
|
||||
{outlier.deviation > 0 ? '+' : ''}{outlier.deviation.toFixed(1)} Punkte
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{outlier.grade_points} Punkte ({getGradeLabel(outlier.grade_points)})
|
||||
</span>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{outlier.reason}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FAIRNESS SCORE
|
||||
// =============================================================================
|
||||
|
||||
interface FairnessScoreProps {
|
||||
fairness: FairnessAnalysis | null
|
||||
className?: string
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
function FairnessScore({ fairness, className = '', isDark = true }: FairnessScoreProps) {
|
||||
const score = fairness?.fairness_score || 0
|
||||
const percentage = Math.round(score * 100)
|
||||
|
||||
let color = '#22c55e' // Green
|
||||
let label = 'Ausgezeichnet'
|
||||
if (percentage < 70) {
|
||||
color = '#ef4444'
|
||||
label = 'Ueberpruefung empfohlen'
|
||||
} else if (percentage < 85) {
|
||||
color = '#f97316'
|
||||
label = 'Akzeptabel'
|
||||
} else if (percentage < 95) {
|
||||
color = '#22c55e'
|
||||
label = 'Gut'
|
||||
}
|
||||
|
||||
// SVG ring
|
||||
const size = 120
|
||||
const strokeWidth = 10
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (percentage / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className={`text-center ${className}`}>
|
||||
<div className="relative inline-block" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 1s ease-out' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<span className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{percentage}</span>
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>%</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`font-medium mt-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>{label}</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Fairness-Score</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function FairnessPage() {
|
||||
const { isDark } = useTheme()
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const klausurId = params.klausurId as string
|
||||
|
||||
// State
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [students, setStudents] = useState<StudentWork[]>([])
|
||||
const [fairness, setFairness] = useState<FairnessAnalysis | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Load data
|
||||
const loadData = useCallback(async () => {
|
||||
if (!klausurId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [klausurData, studentsData, fairnessData] = await Promise.all([
|
||||
korrekturApi.getKlausur(klausurId),
|
||||
korrekturApi.getStudents(klausurId),
|
||||
korrekturApi.getFairnessAnalysis(klausurId),
|
||||
])
|
||||
|
||||
setKlausur(klausurData)
|
||||
setStudents(studentsData)
|
||||
setFairness(fairnessData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
// Calculated stats
|
||||
const stats = useMemo(() => {
|
||||
if (!fairness) return null
|
||||
|
||||
return {
|
||||
studentCount: fairness.student_count,
|
||||
average: fairness.average_grade,
|
||||
stdDev: fairness.std_deviation,
|
||||
spread: fairness.spread,
|
||||
outlierCount: fairness.outliers.length,
|
||||
warningCount: fairness.warnings.length,
|
||||
}
|
||||
}, [fairness])
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
||||
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
||||
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push(`/korrektur/${klausurId}`)}
|
||||
className={`p-2 rounded-xl transition-colors ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-200 hover:bg-slate-300 text-slate-700'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className={`text-3xl font-bold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Fairness-Analyse</h1>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>{klausur?.title}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<a
|
||||
href={korrekturApi.getOverviewExportUrl(klausurId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`px-4 py-2 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
PDF Export
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<GlassCard className="mb-6" isDark={isDark}>
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className={`ml-auto px-3 py-1 rounded-lg transition-colors text-sm ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-200 hover:bg-slate-300'}`}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!isLoading && fairness && (
|
||||
<>
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
|
||||
<GlassCard delay={100} isDark={isDark}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Arbeiten</p>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.studentCount}</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={150} isDark={isDark}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Durchschnitt</p>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{stats?.average.toFixed(1)} P
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={200} isDark={isDark}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Standardabw.</p>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{stats?.stdDev.toFixed(2)}
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={250} isDark={isDark}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Spannweite</p>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stats?.spread} P</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={300} isDark={isDark}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Ausreisser</p>
|
||||
<p className={`text-2xl font-bold ${stats?.outlierCount ? 'text-amber-400' : 'text-green-400'}`}>
|
||||
{stats?.outlierCount}
|
||||
</p>
|
||||
</GlassCard>
|
||||
<GlassCard delay={350} isDark={isDark}>
|
||||
<p className={`text-xs mb-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Warnungen</p>
|
||||
<p className={`text-2xl font-bold ${stats?.warningCount ? 'text-red-400' : 'text-green-400'}`}>
|
||||
{stats?.warningCount}
|
||||
</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Warnings */}
|
||||
{fairness.warnings.length > 0 && (
|
||||
<GlassCard className="mb-6" delay={400} isDark={isDark}>
|
||||
<h3 className={`font-semibold mb-3 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<svg className="w-5 h-5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
Warnungen
|
||||
</h3>
|
||||
<ul className="space-y-2">
|
||||
{fairness.warnings.map((warning, index) => (
|
||||
<li key={index} className={`text-sm flex items-start gap-2 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
<span className="text-amber-400 mt-1">-</span>
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Main Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Fairness Score */}
|
||||
<GlassCard delay={450} isDark={isDark}>
|
||||
<FairnessScore fairness={fairness} isDark={isDark} />
|
||||
</GlassCard>
|
||||
|
||||
{/* Histogram */}
|
||||
<GlassCard className="lg:col-span-2" delay={500} isDark={isDark}>
|
||||
<Histogram students={students} isDark={isDark} />
|
||||
</GlassCard>
|
||||
|
||||
{/* Criteria Heatmap */}
|
||||
<GlassCard delay={550} isDark={isDark}>
|
||||
<CriteriaHeatmap students={students} isDark={isDark} />
|
||||
</GlassCard>
|
||||
|
||||
{/* Outlier List */}
|
||||
<GlassCard className="lg:col-span-2" delay={600} isDark={isDark}>
|
||||
<OutlierList
|
||||
fairness={fairness}
|
||||
onStudentClick={(studentId) =>
|
||||
router.push(`/korrektur/${klausurId}/${studentId}`)
|
||||
}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* No Data */}
|
||||
{!isLoading && !fairness && !error && (
|
||||
<GlassCard className="text-center py-12" isDark={isDark}>
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-4 ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<svg className={`w-8 h-8 ${isDark ? 'text-white/30' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className={`text-xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Keine Daten verfuegbar</h3>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>
|
||||
Die Fairness-Analyse erfordert korrigierte Arbeiten.
|
||||
</p>
|
||||
</GlassCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,578 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useRouter, useParams } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
|
||||
import { korrekturApi } from '@/lib/korrektur/api'
|
||||
import type { Klausur, StudentWork, StudentStatus } from '../types'
|
||||
import { STATUS_COLORS, STATUS_LABELS, getGradeLabel } from '../types'
|
||||
|
||||
// LocalStorage Key for upload session
|
||||
const SESSION_ID_KEY = 'bp_korrektur_student_session'
|
||||
|
||||
// =============================================================================
|
||||
// GLASS CARD
|
||||
// =============================================================================
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
delay?: number
|
||||
}
|
||||
|
||||
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps & { isDark?: boolean }) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'p-4',
|
||||
md: 'p-5',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-3xl
|
||||
${sizeClasses[size]}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
background: isDark
|
||||
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
|
||||
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible
|
||||
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
|
||||
: 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STUDENT CARD
|
||||
// =============================================================================
|
||||
|
||||
interface StudentCardProps {
|
||||
student: StudentWork
|
||||
index: number
|
||||
onClick: () => void
|
||||
delay?: number
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
function StudentCard({ student, index, onClick, delay = 0, isDark = true }: StudentCardProps) {
|
||||
const statusColor = STATUS_COLORS[student.status] || '#6b7280'
|
||||
const statusLabel = STATUS_LABELS[student.status] || student.status
|
||||
|
||||
const hasGrade = student.status === 'COMPLETED' || student.status === 'FIRST_EXAMINER' || student.status === 'SECOND_EXAMINER'
|
||||
|
||||
return (
|
||||
<GlassCard onClick={onClick} delay={delay} size="sm" isDark={isDark}>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Index/Number */}
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center font-medium ${isDark ? 'bg-white/10 text-white/60' : 'bg-slate-200 text-slate-600'}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>{student.anonym_id}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{ backgroundColor: `${statusColor}20`, color: statusColor }}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
{hasGrade && student.grade_points > 0 && (
|
||||
<span className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{student.grade_points} P ({getGradeLabel(student.grade_points)})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// UPLOAD MODAL
|
||||
// =============================================================================
|
||||
|
||||
interface UploadModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onUpload: (files: File[], anonymIds: string[]) => void
|
||||
isUploading: boolean
|
||||
}
|
||||
|
||||
function UploadModal({ isOpen, onClose, onUpload, isUploading }: UploadModalProps) {
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const [anonymIds, setAnonymIds] = useState<string[]>([])
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleFileSelect = (selectedFiles: FileList | null) => {
|
||||
if (!selectedFiles) return
|
||||
const newFiles = Array.from(selectedFiles)
|
||||
setFiles((prev) => [...prev, ...newFiles])
|
||||
// Generate default anonym IDs
|
||||
setAnonymIds((prev) => [
|
||||
...prev,
|
||||
...newFiles.map((_, i) => `Arbeit-${prev.length + i + 1}`),
|
||||
])
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
handleFileSelect(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index))
|
||||
setAnonymIds((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const updateAnonymId = (index: number, value: string) => {
|
||||
setAnonymIds((prev) => {
|
||||
const updated = [...prev]
|
||||
updated[index] = value
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (files.length > 0) {
|
||||
onUpload(files, anonymIds)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<GlassCard className="relative w-full max-w-2xl max-h-[80vh] overflow-hidden" size="lg" delay={0}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-bold text-white">Arbeiten hochladen</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-white/60 transition-colors"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drop Zone */}
|
||||
<div
|
||||
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 mb-6"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*,.pdf"
|
||||
multiple
|
||||
onChange={(e) => handleFileSelect(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<svg className="w-12 h-12 mx-auto mb-3 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-white font-medium">Dateien hierher ziehen</p>
|
||||
<p className="text-white/50 text-sm mt-1">oder klicken zum Auswaehlen</p>
|
||||
</div>
|
||||
|
||||
{/* File List */}
|
||||
{files.length > 0 && (
|
||||
<div className="max-h-64 overflow-y-auto space-y-2 mb-6">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-3 p-3 rounded-xl bg-white/5"
|
||||
>
|
||||
<span className="text-lg">
|
||||
{file.type.startsWith('image/') ? '🖼️' : '📄'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm truncate">{file.name}</p>
|
||||
<input
|
||||
type="text"
|
||||
value={anonymIds[index] || ''}
|
||||
onChange={(e) => updateAnonymId(index, e.target.value)}
|
||||
placeholder="Anonym-ID"
|
||||
className="mt-1 w-full px-2 py-1 rounded bg-white/10 border border-white/10 text-white text-sm placeholder-white/40 focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeFile(index)}
|
||||
className="p-2 rounded-lg hover:bg-red-500/20 text-red-400 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isUploading || files.length === 0}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Hochladen...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
{files.length} Dateien hochladen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function KlausurDetailPage() {
|
||||
const { isDark } = useTheme()
|
||||
const router = useRouter()
|
||||
const params = useParams()
|
||||
const klausurId = params.klausurId as string
|
||||
|
||||
// State
|
||||
const [klausur, setKlausur] = useState<Klausur | null>(null)
|
||||
const [students, setStudents] = useState<StudentWork[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Modal states
|
||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||||
const [showQRModal, setShowQRModal] = useState(false)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
const [uploadSessionId, setUploadSessionId] = useState('')
|
||||
|
||||
// Initialize session ID
|
||||
useEffect(() => {
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `student-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
}
|
||||
setUploadSessionId(storedSessionId)
|
||||
}, [])
|
||||
|
||||
// Load data
|
||||
const loadData = useCallback(async () => {
|
||||
if (!klausurId) return
|
||||
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [klausurData, studentsData] = await Promise.all([
|
||||
korrekturApi.getKlausur(klausurId),
|
||||
korrekturApi.getStudents(klausurId),
|
||||
])
|
||||
setKlausur(klausurData)
|
||||
setStudents(studentsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [klausurId])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
// Handle upload
|
||||
const handleUpload = async (files: File[], anonymIds: string[]) => {
|
||||
setIsUploading(true)
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
await korrekturApi.uploadStudentWork(klausurId, files[i], anonymIds[i])
|
||||
}
|
||||
setShowUploadModal(false)
|
||||
loadData() // Refresh the list
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate progress
|
||||
const completedCount = students.filter(s => s.status === 'COMPLETED').length
|
||||
const progress = students.length > 0 ? Math.round((completedCount / students.length) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
||||
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
||||
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => router.push('/korrektur')}
|
||||
className={`p-2 rounded-xl transition-colors ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-200 hover:bg-slate-300 text-slate-700'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className={`text-3xl font-bold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{klausur?.title || 'Klausur'}
|
||||
</h1>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>
|
||||
{klausur ? `${klausur.subject} ${klausur.semester} ${klausur.year}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
{!isLoading && klausur && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-8">
|
||||
<GlassCard size="sm" delay={100} isDark={isDark}>
|
||||
<div className="text-center">
|
||||
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{students.length}</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Arbeiten</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
<GlassCard size="sm" delay={150} isDark={isDark}>
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-green-400">{completedCount}</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Abgeschlossen</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
<GlassCard size="sm" delay={200} isDark={isDark}>
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-orange-400">{students.length - completedCount}</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Offen</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
<GlassCard size="sm" delay={250} isDark={isDark}>
|
||||
<div className="text-center">
|
||||
<p className="text-3xl font-bold text-purple-400">{progress}%</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Fortschritt</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress Bar */}
|
||||
{!isLoading && students.length > 0 && (
|
||||
<GlassCard size="sm" className="mb-6" delay={300} isDark={isDark}>
|
||||
<div className="flex items-center justify-between text-sm mb-2">
|
||||
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>Gesamtfortschritt</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>{completedCount}/{students.length} korrigiert</span>
|
||||
</div>
|
||||
<div className={`h-3 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500 bg-gradient-to-r from-green-500 to-emerald-400"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<GlassCard className="mb-6" size="sm" isDark={isDark}>
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className={`ml-auto px-3 py-1 rounded-lg transition-colors text-sm ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-200 hover:bg-slate-300'}`}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
{!isLoading && (
|
||||
<div className="flex gap-3 mb-6">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Arbeiten hochladen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
className={`px-6 py-3 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
<span className="text-xl">📱</span>
|
||||
QR Upload
|
||||
</button>
|
||||
{students.length > 0 && (
|
||||
<button
|
||||
onClick={() => router.push(`/korrektur/${klausurId}/fairness`)}
|
||||
className={`px-6 py-3 rounded-xl transition-colors flex items-center gap-2 ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
Fairness-Analyse
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Students List */}
|
||||
{!isLoading && students.length === 0 && (
|
||||
<GlassCard className="text-center py-12" delay={350} isDark={isDark}>
|
||||
<div className={`w-20 h-20 mx-auto mb-4 rounded-2xl flex items-center justify-center ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<svg className={`w-10 h-10 ${isDark ? 'text-white/30' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className={`text-xl font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Keine Arbeiten vorhanden</h3>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Laden Sie Schuelerarbeiten hoch, um mit der Korrektur zu beginnen.</p>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all"
|
||||
>
|
||||
Arbeiten hochladen
|
||||
</button>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{!isLoading && students.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{students.map((student, index) => (
|
||||
<StudentCard
|
||||
key={student.id}
|
||||
student={student}
|
||||
index={index}
|
||||
onClick={() => router.push(`/korrektur/${klausurId}/${student.id}`)}
|
||||
delay={350 + index * 30}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
<UploadModal
|
||||
isOpen={showUploadModal}
|
||||
onClose={() => setShowUploadModal(false)}
|
||||
onUpload={handleUpload}
|
||||
isUploading={isUploading}
|
||||
/>
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
||||
<div className={`relative w-full max-w-md rounded-3xl ${isDark ? 'bg-slate-900' : 'bg-white'}`}>
|
||||
<QRCodeUpload
|
||||
sessionId={uploadSessionId}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
onFilesChanged={(files) => {
|
||||
// Handle mobile uploaded files
|
||||
if (files.length > 0) {
|
||||
// Could auto-process the files here
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,914 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
|
||||
import {
|
||||
korrekturApi,
|
||||
getKorrekturStats,
|
||||
type KorrekturStats,
|
||||
} from '@/lib/korrektur/api'
|
||||
import type { Klausur, CreateKlausurData } from './types'
|
||||
|
||||
// LocalStorage Key for upload session
|
||||
const SESSION_ID_KEY = 'bp_korrektur_session'
|
||||
|
||||
// =============================================================================
|
||||
// GLASS CARD - Ultra Transparent (Apple Weather Style)
|
||||
// =============================================================================
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
delay?: number
|
||||
}
|
||||
|
||||
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps & { isDark?: boolean }) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'p-4',
|
||||
md: 'p-5',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-3xl
|
||||
${sizeClasses[size]}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
background: isDark
|
||||
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
|
||||
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible
|
||||
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
|
||||
: 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT CARD
|
||||
// =============================================================================
|
||||
|
||||
interface StatCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon: React.ReactNode
|
||||
color?: string
|
||||
delay?: number
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
function StatCard({ label, value, icon, color = '#a78bfa', delay = 0, isDark = true }: StatCardProps) {
|
||||
return (
|
||||
<GlassCard size="sm" delay={delay} isDark={isDark}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className="w-12 h-12 rounded-2xl flex items-center justify-center"
|
||||
style={{ backgroundColor: `${color}20` }}
|
||||
>
|
||||
<span style={{ color }}>{icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{label}</p>
|
||||
<p className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// KLAUSUR CARD
|
||||
// =============================================================================
|
||||
|
||||
interface KlausurCardProps {
|
||||
klausur: Klausur
|
||||
onClick: () => void
|
||||
delay?: number
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
function KlausurCard({ klausur, onClick, delay = 0, isDark = true }: KlausurCardProps) {
|
||||
const progress = klausur.student_count
|
||||
? Math.round(((klausur.completed_count || 0) / klausur.student_count) * 100)
|
||||
: 0
|
||||
|
||||
const statusColor = klausur.status === 'completed'
|
||||
? '#22c55e'
|
||||
: klausur.status === 'in_progress'
|
||||
? '#f97316'
|
||||
: '#6b7280'
|
||||
|
||||
return (
|
||||
<GlassCard onClick={onClick} delay={delay} className="min-h-[180px]" isDark={isDark}>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur.title}</h3>
|
||||
<span
|
||||
className="px-2 py-1 rounded-full text-xs font-medium"
|
||||
style={{ backgroundColor: `${statusColor}20`, color: statusColor }}
|
||||
>
|
||||
{klausur.status === 'completed' ? 'Fertig' : klausur.status === 'in_progress' ? 'In Arbeit' : 'Entwurf'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className={`text-sm mb-4 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{klausur.subject} {klausur.semester} {klausur.year}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className={isDark ? 'text-white/50' : 'text-slate-500'}>{klausur.student_count || 0} Arbeiten</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>{progress}%</span>
|
||||
</div>
|
||||
|
||||
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${progress}%`,
|
||||
background: `linear-gradient(90deg, ${statusColor}, ${statusColor}80)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CREATE KLAUSUR MODAL
|
||||
// =============================================================================
|
||||
|
||||
interface CreateKlausurModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (data: CreateKlausurData) => void
|
||||
isLoading: boolean
|
||||
isDark?: boolean
|
||||
}
|
||||
|
||||
function CreateKlausurModal({ isOpen, onClose, onSubmit, isLoading, isDark = true }: CreateKlausurModalProps) {
|
||||
const [title, setTitle] = useState('')
|
||||
const [subject, setSubject] = useState('Deutsch')
|
||||
const [year, setYear] = useState(new Date().getFullYear())
|
||||
const [semester, setSemester] = useState('Abitur')
|
||||
const [modus, setModus] = useState<'landes_abitur' | 'vorabitur'>('landes_abitur')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSubmit({ title, subject, year, semester, modus })
|
||||
}
|
||||
|
||||
const inputClasses = isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-slate-100 border-slate-300 text-slate-900 placeholder-slate-400'
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
|
||||
<GlassCard className="relative w-full max-w-md" size="lg" delay={0} isDark={isDark}>
|
||||
<h2 className={`text-xl font-bold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>Neue Klausur erstellen</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="klausur-titel" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Titel</label>
|
||||
<input
|
||||
id="klausur-titel"
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="z.B. Deutsch LK Q4"
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 focus:border-transparent ${inputClasses}`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="klausur-fach" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Fach</label>
|
||||
<select
|
||||
id="klausur-fach"
|
||||
value={subject}
|
||||
onChange={(e) => setSubject(e.target.value)}
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}
|
||||
>
|
||||
<option value="Deutsch">Deutsch</option>
|
||||
<option value="Englisch">Englisch</option>
|
||||
<option value="Mathematik">Mathematik</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="klausur-jahr" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Jahr</label>
|
||||
<input
|
||||
id="klausur-jahr"
|
||||
type="number"
|
||||
value={year}
|
||||
onChange={(e) => setYear(Number(e.target.value))}
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="klausur-semester" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Semester</label>
|
||||
<select
|
||||
id="klausur-semester"
|
||||
value={semester}
|
||||
onChange={(e) => setSemester(e.target.value)}
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}
|
||||
>
|
||||
<option value="Abitur">Abitur</option>
|
||||
<option value="Q1">Q1</option>
|
||||
<option value="Q2">Q2</option>
|
||||
<option value="Q3">Q3</option>
|
||||
<option value="Q4">Q4</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="klausur-modus" className={`block text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>Modus</label>
|
||||
<select
|
||||
id="klausur-modus"
|
||||
value={modus}
|
||||
onChange={(e) => setModus(e.target.value as 'landes_abitur' | 'vorabitur')}
|
||||
className={`w-full p-3 rounded-xl border focus:ring-2 focus:ring-purple-500 ${inputClasses}`}
|
||||
>
|
||||
<option value="landes_abitur">Landes-Abitur (NiBiS EH)</option>
|
||||
<option value="vorabitur">Vorabitur (Eigener EH)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !title.trim()}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Erstelle...' : 'Erstellen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function KorrekturPage() {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const router = useRouter()
|
||||
|
||||
// State
|
||||
const [klausuren, setKlausuren] = useState<Klausur[]>([])
|
||||
const [stats, setStats] = useState<KorrekturStats | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Modal states
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
const [showQRModal, setShowQRModal] = useState(false)
|
||||
const [uploadSessionId, setUploadSessionId] = useState('')
|
||||
const [showDirectUpload, setShowDirectUpload] = useState(false)
|
||||
const [showEHUpload, setShowEHUpload] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
|
||||
const [ehFile, setEhFile] = useState<File | null>(null)
|
||||
const [isUploading, setIsUploading] = useState(false)
|
||||
|
||||
// Initialize session ID
|
||||
useEffect(() => {
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `korrektur-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
}
|
||||
setUploadSessionId(storedSessionId)
|
||||
}, [])
|
||||
|
||||
// Load data
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const [klausurenData, statsData] = await Promise.all([
|
||||
korrekturApi.getKlausuren(),
|
||||
getKorrekturStats(),
|
||||
])
|
||||
setKlausuren(klausurenData)
|
||||
setStats(statsData)
|
||||
} catch (err) {
|
||||
console.error('Failed to load data:', err)
|
||||
setError(err instanceof Error ? err.message : 'Laden fehlgeschlagen')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
// Create klausur
|
||||
const handleCreateKlausur = async (data: CreateKlausurData) => {
|
||||
setIsCreating(true)
|
||||
try {
|
||||
const newKlausur = await korrekturApi.createKlausur(data)
|
||||
setKlausuren((prev) => [newKlausur, ...prev])
|
||||
setShowCreateModal(false)
|
||||
// Navigate to the new klausur
|
||||
router.push(`/korrektur/${newKlausur.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to create klausur:', err)
|
||||
setError(err instanceof Error ? err.message : 'Erstellung fehlgeschlagen')
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle QR uploaded files
|
||||
const handleMobileFileSelect = async (uploadedFile: UploadedFile) => {
|
||||
// For now, just close the modal - in production this would create a quick-start klausur
|
||||
setShowQRModal(false)
|
||||
// Could auto-create a klausur and navigate
|
||||
}
|
||||
|
||||
// Handle direct file upload with drag & drop
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent, isEH = false) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const files = Array.from(e.dataTransfer.files).filter(
|
||||
f => f.type === 'application/pdf' || f.type.startsWith('image/')
|
||||
)
|
||||
if (isEH && files.length > 0) {
|
||||
setEhFile(files[0])
|
||||
} else {
|
||||
setUploadedFiles(prev => [...prev, ...files])
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>, isEH = false) => {
|
||||
if (!e.target.files) return
|
||||
const files = Array.from(e.target.files)
|
||||
if (isEH && files.length > 0) {
|
||||
setEhFile(files[0])
|
||||
} else {
|
||||
setUploadedFiles(prev => [...prev, ...files])
|
||||
}
|
||||
}
|
||||
|
||||
const handleDirectUpload = async () => {
|
||||
if (uploadedFiles.length === 0) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
// Create a quick-start klausur
|
||||
const newKlausur = await korrekturApi.createKlausur({
|
||||
title: `Schnellstart ${new Date().toLocaleDateString('de-DE')}`,
|
||||
subject: 'Deutsch',
|
||||
year: new Date().getFullYear(),
|
||||
semester: 'Abitur',
|
||||
modus: 'landes_abitur'
|
||||
})
|
||||
|
||||
// Upload each file
|
||||
for (let i = 0; i < uploadedFiles.length; i++) {
|
||||
await korrekturApi.uploadStudentWork(newKlausur.id, uploadedFiles[i], `Arbeit-${i + 1}`)
|
||||
}
|
||||
|
||||
setShowDirectUpload(false)
|
||||
setUploadedFiles([])
|
||||
router.push(`/korrektur/${newKlausur.id}`)
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Upload fehlgeschlagen')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEHUpload = async () => {
|
||||
if (!ehFile) return
|
||||
setIsUploading(true)
|
||||
try {
|
||||
// Upload EH to backend
|
||||
await korrekturApi.uploadEH(ehFile)
|
||||
setShowEHUpload(false)
|
||||
setEhFile(null)
|
||||
loadData() // Refresh to show new EH
|
||||
} catch (err) {
|
||||
console.error('EH Upload failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'EH Upload fehlgeschlagen')
|
||||
} finally {
|
||||
setIsUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
||||
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
||||
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className={`text-3xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Korrekturplattform</h1>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>KI-gestuetzte Abiturklausur-Korrektur</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<StatCard
|
||||
label="Offene Korrekturen"
|
||||
value={stats.openCorrections}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
}
|
||||
color="#f97316"
|
||||
delay={100}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<StatCard
|
||||
label="Erledigt (Woche)"
|
||||
value={stats.completedThisWeek}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
color="#22c55e"
|
||||
delay={200}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<StatCard
|
||||
label="Durchschnitt"
|
||||
value={stats.averageGrade > 0 ? `${stats.averageGrade} P` : '-'}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
}
|
||||
color="#3b82f6"
|
||||
delay={300}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<StatCard
|
||||
label="Zeit gespart"
|
||||
value={`${stats.timeSavedHours}h`}
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
}
|
||||
color="#a78bfa"
|
||||
delay={400}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<GlassCard className="mb-6" size="sm" isDark={isDark}>
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className={`ml-auto px-3 py-1 rounded-lg transition-colors text-sm ${isDark ? 'bg-white/10 hover:bg-white/20' : 'bg-slate-200 hover:bg-slate-300'}`}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Klausuren Grid */}
|
||||
{!isLoading && (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>Klausuren</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
|
||||
{klausuren.map((klausur, index) => (
|
||||
<KlausurCard
|
||||
key={klausur.id}
|
||||
klausur={klausur}
|
||||
onClick={() => router.push(`/korrektur/${klausur.id}`)}
|
||||
delay={500 + index * 50}
|
||||
isDark={isDark}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* New Klausur Card */}
|
||||
<GlassCard
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
delay={500 + klausuren.length * 50}
|
||||
className={`min-h-[180px] border-2 border-dashed ${isDark ? 'border-white/20 hover:border-purple-400/50' : 'border-slate-300 hover:border-purple-400'}`}
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center h-full text-center">
|
||||
<div className="w-16 h-16 rounded-2xl bg-purple-500/20 flex items-center justify-center mb-4">
|
||||
<svg className="w-8 h-8 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Neue Klausur</p>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Klausur erstellen</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<h2 className={`text-xl font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Schnellaktionen</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<GlassCard
|
||||
onClick={() => setShowQRModal(true)}
|
||||
delay={700}
|
||||
className="cursor-pointer"
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-blue-500/20 flex items-center justify-center">
|
||||
<span className="text-2xl">📱</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>QR Upload</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Mit Handy scannen</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard
|
||||
onClick={() => setShowDirectUpload(true)}
|
||||
delay={750}
|
||||
className="cursor-pointer"
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-green-500/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Direkt hochladen</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Drag & Drop</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
delay={800}
|
||||
className="cursor-pointer"
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-purple-500/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Schnellstart</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Direkt loslegen</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard
|
||||
onClick={() => setShowEHUpload(true)}
|
||||
delay={850}
|
||||
className="cursor-pointer"
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-orange-500/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>EH hochladen</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Erwartungshorizont</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard
|
||||
onClick={() => router.push('/korrektur/archiv')}
|
||||
delay={900}
|
||||
className="cursor-pointer"
|
||||
isDark={isDark}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 rounded-2xl bg-indigo-500/20 flex items-center justify-center">
|
||||
<svg className="w-6 h-6 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Abitur-Archiv</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>EH durchsuchen</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Klausur Modal */}
|
||||
<CreateKlausurModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreateKlausur}
|
||||
isLoading={isCreating}
|
||||
isDark={isDark}
|
||||
/>
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
||||
<div className={`relative w-full max-w-md rounded-3xl ${isDark ? 'bg-slate-900' : 'bg-white'}`}>
|
||||
<QRCodeUpload
|
||||
sessionId={uploadSessionId}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
onFileUploaded={handleMobileFileSelect}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Direct Upload Modal */}
|
||||
{showDirectUpload && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowDirectUpload(false)} />
|
||||
<GlassCard className="relative w-full max-w-lg" size="lg" delay={0} isDark={isDark}>
|
||||
<h2 className={`text-xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeiten hochladen</h2>
|
||||
<p className={`mb-4 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Ziehen Sie eingescannte Klausuren hierher oder klicken Sie zum Auswaehlen.
|
||||
</p>
|
||||
|
||||
{/* Error Display in Modal */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag & Drop Zone */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, false)}
|
||||
className={`relative p-8 rounded-2xl border-2 border-dashed transition-colors ${
|
||||
isDragging
|
||||
? 'border-purple-400 bg-purple-500/10'
|
||||
: isDark
|
||||
? 'border-white/20 hover:border-white/40'
|
||||
: 'border-slate-300 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,image/*"
|
||||
multiple
|
||||
onChange={(e) => handleFileSelect(e, false)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<svg className={`w-12 h-12 mx-auto mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{isDragging ? 'Dateien hier ablegen' : 'Dateien hierher ziehen'}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
PDF oder Bilder (JPG, PNG)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Uploaded Files List */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
{uploadedFiles.length} Datei(en) ausgewaehlt:
|
||||
</p>
|
||||
<div className="max-h-32 overflow-y-auto space-y-1">
|
||||
{uploadedFiles.map((file, idx) => (
|
||||
<div key={idx} className={`flex items-center justify-between p-2 rounded-lg ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
|
||||
<span className={`text-sm truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{file.name}</span>
|
||||
<button
|
||||
onClick={() => setUploadedFiles(prev => prev.filter((_, i) => i !== idx))}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => { setShowDirectUpload(false); setUploadedFiles([]) }}
|
||||
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDirectUpload}
|
||||
disabled={uploadedFiles.length === 0 || isUploading}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-500 text-white font-semibold hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? 'Hochladen...' : `${uploadedFiles.length} Arbeiten hochladen`}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EH Upload Modal */}
|
||||
{showEHUpload && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowEHUpload(false)} />
|
||||
<GlassCard className="relative w-full max-w-lg" size="lg" delay={0} isDark={isDark}>
|
||||
<h2 className={`text-xl font-bold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>Erwartungshorizont hochladen</h2>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Laden Sie einen eigenen Erwartungshorizont fuer Vorabitur-Klausuren hoch.
|
||||
</p>
|
||||
|
||||
{/* Drag & Drop Zone */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, true)}
|
||||
className={`relative p-8 rounded-2xl border-2 border-dashed transition-colors ${
|
||||
isDragging
|
||||
? 'border-orange-400 bg-orange-500/10'
|
||||
: isDark
|
||||
? 'border-white/20 hover:border-white/40'
|
||||
: 'border-slate-300 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.docx,.doc"
|
||||
onChange={(e) => handleFileSelect(e, true)}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<div className="text-center">
|
||||
<svg className={`w-12 h-12 mx-auto mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{ehFile ? ehFile.name : 'EH-Datei hierher ziehen'}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
PDF oder Word-Dokument
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected File */}
|
||||
{ehFile && (
|
||||
<div className={`mt-4 flex items-center justify-between p-3 rounded-lg ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span className={`text-sm truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{ehFile.name}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setEhFile(null)}
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => { setShowEHUpload(false); setEhFile(null) }}
|
||||
className={`flex-1 px-4 py-3 rounded-xl transition-colors ${isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEHUpload}
|
||||
disabled={!ehFile || isUploading}
|
||||
className="flex-1 px-4 py-3 rounded-xl bg-gradient-to-r from-orange-500 to-red-500 text-white font-semibold hover:shadow-lg hover:shadow-orange-500/30 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? 'Hochladen...' : 'EH hochladen'}
|
||||
</button>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
// TypeScript Interfaces fuer Korrekturplattform (Studio v2)
|
||||
|
||||
export interface Klausur {
|
||||
id: string
|
||||
title: string
|
||||
subject: string
|
||||
year: number
|
||||
semester: string
|
||||
modus: 'landes_abitur' | 'vorabitur'
|
||||
eh_id?: string
|
||||
created_at: string
|
||||
student_count?: number
|
||||
completed_count?: number
|
||||
status?: 'draft' | 'in_progress' | 'completed'
|
||||
}
|
||||
|
||||
export interface StudentWork {
|
||||
id: string
|
||||
klausur_id: string
|
||||
anonym_id: string
|
||||
file_path: string
|
||||
file_type: 'pdf' | 'image'
|
||||
ocr_text: string
|
||||
criteria_scores: CriteriaScores
|
||||
gutachten: string
|
||||
status: StudentStatus
|
||||
raw_points: number
|
||||
grade_points: number
|
||||
grade_label?: string
|
||||
created_at: string
|
||||
examiner_id?: string
|
||||
second_examiner_id?: string
|
||||
second_examiner_grade?: number
|
||||
}
|
||||
|
||||
export type StudentStatus =
|
||||
| 'UPLOADED'
|
||||
| 'OCR_PROCESSING'
|
||||
| 'OCR_COMPLETE'
|
||||
| 'ANALYZING'
|
||||
| 'FIRST_EXAMINER'
|
||||
| 'SECOND_EXAMINER'
|
||||
| 'COMPLETED'
|
||||
| 'ERROR'
|
||||
|
||||
export interface CriteriaScores {
|
||||
rechtschreibung?: number
|
||||
grammatik?: number
|
||||
inhalt?: number
|
||||
struktur?: number
|
||||
stil?: number
|
||||
[key: string]: number | undefined
|
||||
}
|
||||
|
||||
export interface Criterion {
|
||||
id: string
|
||||
name: string
|
||||
weight: number
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface GradeInfo {
|
||||
thresholds: Record<number, number>
|
||||
labels: Record<number, string>
|
||||
criteria: Record<string, Criterion>
|
||||
}
|
||||
|
||||
export interface Annotation {
|
||||
id: string
|
||||
student_work_id: string
|
||||
page: number
|
||||
position: AnnotationPosition
|
||||
type: AnnotationType
|
||||
text: string
|
||||
severity: 'minor' | 'major' | 'critical'
|
||||
suggestion?: string
|
||||
created_by: string
|
||||
created_at: string
|
||||
role: 'first_examiner' | 'second_examiner'
|
||||
linked_criterion?: string
|
||||
}
|
||||
|
||||
export interface AnnotationPosition {
|
||||
x: number // Prozent (0-100)
|
||||
y: number // Prozent (0-100)
|
||||
width: number // Prozent (0-100)
|
||||
height: number // Prozent (0-100)
|
||||
}
|
||||
|
||||
export type AnnotationType =
|
||||
| 'rechtschreibung'
|
||||
| 'grammatik'
|
||||
| 'inhalt'
|
||||
| 'struktur'
|
||||
| 'stil'
|
||||
| 'comment'
|
||||
| 'highlight'
|
||||
|
||||
export interface FairnessAnalysis {
|
||||
klausur_id: string
|
||||
student_count: number
|
||||
average_grade: number
|
||||
std_deviation: number
|
||||
spread: number
|
||||
outliers: OutlierInfo[]
|
||||
criteria_analysis: Record<string, CriteriaStats>
|
||||
fairness_score: number
|
||||
warnings: string[]
|
||||
}
|
||||
|
||||
export interface OutlierInfo {
|
||||
student_id: string
|
||||
anonym_id: string
|
||||
grade_points: number
|
||||
deviation: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
export interface CriteriaStats {
|
||||
min: number
|
||||
max: number
|
||||
average: number
|
||||
std_deviation: number
|
||||
}
|
||||
|
||||
export interface EHSuggestion {
|
||||
criterion: string
|
||||
excerpt: string
|
||||
relevance_score: number
|
||||
source_chunk_id: string
|
||||
// Attribution fields (CTRL-SRC-002)
|
||||
source_document?: string
|
||||
source_url?: string
|
||||
license?: string
|
||||
license_url?: string
|
||||
publisher?: string
|
||||
}
|
||||
|
||||
// Default Attribution for NiBiS documents (CTRL-SRC-002)
|
||||
export const NIBIS_ATTRIBUTION = {
|
||||
publisher: 'Niedersaechsischer Bildungsserver (NiBiS)',
|
||||
license: 'DL-DE-BY-2.0',
|
||||
license_url: 'https://www.govdata.de/dl-de/by-2-0',
|
||||
source_url: 'https://nibis.de',
|
||||
}
|
||||
|
||||
export interface GutachtenSection {
|
||||
title: string
|
||||
content: string
|
||||
evidence_links?: string[]
|
||||
}
|
||||
|
||||
export interface Gutachten {
|
||||
einleitung: string
|
||||
hauptteil: string
|
||||
fazit: string
|
||||
staerken: string[]
|
||||
schwaechen: string[]
|
||||
generated_at?: string
|
||||
}
|
||||
|
||||
// API Response Types
|
||||
export interface KlausurenResponse {
|
||||
klausuren: Klausur[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface StudentsResponse {
|
||||
students: StudentWork[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface AnnotationsResponse {
|
||||
annotations: Annotation[]
|
||||
}
|
||||
|
||||
// Create/Update Types
|
||||
export interface CreateKlausurData {
|
||||
title: string
|
||||
subject?: string
|
||||
year?: number
|
||||
semester?: string
|
||||
modus?: 'landes_abitur' | 'vorabitur'
|
||||
}
|
||||
|
||||
// Color mapping for annotation types
|
||||
export const ANNOTATION_COLORS: Record<AnnotationType, string> = {
|
||||
rechtschreibung: '#dc2626', // Red
|
||||
grammatik: '#2563eb', // Blue
|
||||
inhalt: '#16a34a', // Green
|
||||
struktur: '#9333ea', // Purple
|
||||
stil: '#ea580c', // Orange
|
||||
comment: '#6b7280', // Gray
|
||||
highlight: '#eab308', // Yellow
|
||||
}
|
||||
|
||||
// Status colors
|
||||
export const STATUS_COLORS: Record<StudentStatus, string> = {
|
||||
UPLOADED: '#6b7280',
|
||||
OCR_PROCESSING: '#eab308',
|
||||
OCR_COMPLETE: '#3b82f6',
|
||||
ANALYZING: '#8b5cf6',
|
||||
FIRST_EXAMINER: '#f97316',
|
||||
SECOND_EXAMINER: '#06b6d4',
|
||||
COMPLETED: '#22c55e',
|
||||
ERROR: '#ef4444',
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<StudentStatus, string> = {
|
||||
UPLOADED: 'Hochgeladen',
|
||||
OCR_PROCESSING: 'OCR laeuft',
|
||||
OCR_COMPLETE: 'OCR fertig',
|
||||
ANALYZING: 'Analyse laeuft',
|
||||
FIRST_EXAMINER: 'Erstkorrektur',
|
||||
SECOND_EXAMINER: 'Zweitkorrektur',
|
||||
COMPLETED: 'Abgeschlossen',
|
||||
ERROR: 'Fehler',
|
||||
}
|
||||
|
||||
// Default criteria with weights (NI standard)
|
||||
export const DEFAULT_CRITERIA: Record<string, { name: string; weight: number }> = {
|
||||
rechtschreibung: { name: 'Rechtschreibung', weight: 15 },
|
||||
grammatik: { name: 'Grammatik', weight: 15 },
|
||||
inhalt: { name: 'Inhalt', weight: 40 },
|
||||
struktur: { name: 'Struktur', weight: 15 },
|
||||
stil: { name: 'Stil', weight: 15 },
|
||||
}
|
||||
|
||||
// Grade thresholds (15-point system)
|
||||
export const GRADE_THRESHOLDS: Record<number, number> = {
|
||||
15: 95, 14: 90, 13: 85, 12: 80, 11: 75,
|
||||
10: 70, 9: 65, 8: 60, 7: 55, 6: 50,
|
||||
5: 45, 4: 40, 3: 33, 2: 27, 1: 20, 0: 0
|
||||
}
|
||||
|
||||
// Helper function to calculate grade from percentage
|
||||
export function calculateGrade(percentage: number): number {
|
||||
for (const [grade, threshold] of Object.entries(GRADE_THRESHOLDS).sort((a, b) => Number(b[0]) - Number(a[0]))) {
|
||||
if (percentage >= threshold) {
|
||||
return Number(grade)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Helper function to get grade label
|
||||
export function getGradeLabel(points: number): string {
|
||||
const labels: Record<number, string> = {
|
||||
15: '1+', 14: '1', 13: '1-',
|
||||
12: '2+', 11: '2', 10: '2-',
|
||||
9: '3+', 8: '3', 7: '3-',
|
||||
6: '4+', 5: '4', 4: '4-',
|
||||
3: '5+', 2: '5', 1: '5-',
|
||||
0: '6'
|
||||
}
|
||||
return labels[points] || String(points)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
import { LanguageProvider } from '@/lib/LanguageContext'
|
||||
import { ThemeProvider } from '@/lib/ThemeContext'
|
||||
import { AlertsProvider } from '@/lib/AlertsContext'
|
||||
import { AlertsB2BProvider } from '@/lib/AlertsB2BContext'
|
||||
import { MessagesProvider } from '@/lib/MessagesContext'
|
||||
import { ActivityProvider } from '@/lib/ActivityContext'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'BreakPilot Studio v2',
|
||||
description: 'Lehrer-Plattform für Korrektur, Arbeitsblaetter und mehr',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="de">
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<LanguageProvider>
|
||||
<AlertsProvider>
|
||||
<AlertsB2BProvider>
|
||||
<MessagesProvider>
|
||||
<ActivityProvider>
|
||||
{children}
|
||||
</ActivityProvider>
|
||||
</MessagesProvider>
|
||||
</AlertsB2BProvider>
|
||||
</AlertsProvider>
|
||||
</LanguageProvider>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Layout from '@/components/Layout'
|
||||
|
||||
export default function MagicHelpLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <Layout>{children}</Layout>
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Magic Help - Handschrift-OCR
|
||||
*
|
||||
* Ermöglicht das Erkennen von Handschrift in Bildern.
|
||||
* Backend: POST /api/klausur/trocr/recognize
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
|
||||
// Backend URL - dynamisch basierend auf Protokoll
|
||||
const getBackendUrl = () => {
|
||||
if (typeof window === 'undefined') return 'http://localhost:8000'
|
||||
const { hostname, protocol } = window.location
|
||||
return hostname === 'localhost' ? 'http://localhost:8000' : `${protocol}//${hostname}:8000`
|
||||
}
|
||||
|
||||
interface OCRResult {
|
||||
text: string
|
||||
confidence: number
|
||||
processing_time_ms: number
|
||||
model: string
|
||||
}
|
||||
|
||||
export default function MagicHelpPage() {
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [ocrResult, setOcrResult] = useState<OCRResult | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Datei auswählen
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
setSelectedFile(file)
|
||||
setPreviewUrl(URL.createObjectURL(file))
|
||||
setOcrResult(null)
|
||||
setError(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Drag & Drop
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
setSelectedFile(file)
|
||||
setPreviewUrl(URL.createObjectURL(file))
|
||||
setOcrResult(null)
|
||||
setError(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
}, [])
|
||||
|
||||
// OCR ausführen
|
||||
const runOCR = useCallback(async () => {
|
||||
if (!selectedFile) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('file', selectedFile)
|
||||
|
||||
const res = await fetch(`${getBackendUrl()}/api/klausur/trocr/recognize`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}: ${res.statusText}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
setOcrResult(data)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Fehler bei der OCR-Erkennung')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [selectedFile])
|
||||
|
||||
// Reset
|
||||
const handleReset = useCallback(() => {
|
||||
setSelectedFile(null)
|
||||
setPreviewUrl(null)
|
||||
setOcrResult(null)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-slate-800">Magic Help</h1>
|
||||
<p className="text-slate-500 mt-1">Handschrift-Erkennung mit TrOCR</p>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6">
|
||||
<h2 className="font-semibold text-slate-700 mb-4">Bild hochladen</h2>
|
||||
|
||||
{!previewUrl ? (
|
||||
<div
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
className="border-2 border-dashed border-slate-300 rounded-lg p-12 text-center hover:border-primary-500 transition-colors cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
/>
|
||||
<label htmlFor="file-upload" className="cursor-pointer">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto text-slate-400 mb-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-slate-600 font-medium">Bild hier ablegen oder klicken</p>
|
||||
<p className="text-sm text-slate-400 mt-1">PNG, JPG, JPEG bis 10MB</p>
|
||||
</label>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Preview */}
|
||||
<div className="relative bg-slate-100 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="max-h-96 mx-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={runOCR}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-primary-500 text-white px-6 py-3 rounded-lg font-medium hover:bg-primary-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<svg className="animate-spin w-5 h-5" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Wird erkannt...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
Text erkennen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-3 rounded-lg font-medium border border-slate-300 text-slate-600 hover:bg-slate-50 transition-colors"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-red-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-red-800">Fehler</p>
|
||||
<p className="text-sm text-red-600 mt-1">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result */}
|
||||
{ocrResult && (
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
|
||||
<h2 className="font-semibold text-slate-700 mb-4">Erkannter Text</h2>
|
||||
|
||||
{/* Text Output */}
|
||||
<div className="bg-slate-50 rounded-lg p-4 mb-4 font-mono text-slate-800 whitespace-pre-wrap">
|
||||
{ocrResult.text || '(Kein Text erkannt)'}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-slate-500">Konfidenz</div>
|
||||
<div className="font-semibold text-slate-800">
|
||||
{(ocrResult.confidence * 100).toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-slate-500">Dauer</div>
|
||||
<div className="font-semibold text-slate-800">
|
||||
{ocrResult.processing_time_ms}ms
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<div className="text-slate-500">Modell</div>
|
||||
<div className="font-semibold text-slate-800 truncate">
|
||||
{ocrResult.model}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Copy Button */}
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(ocrResult.text)}
|
||||
className="mt-4 px-4 py-2 rounded-lg font-medium border border-slate-300 text-slate-600 hover:bg-slate-50 transition-colors text-sm flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Text kopieren
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
{!previewUrl && !ocrResult && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-blue-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<p className="font-medium text-blue-800">Tipp</p>
|
||||
<p className="text-sm text-blue-600 mt-1">
|
||||
Laden Sie ein Bild mit handgeschriebenem Text hoch. Der TrOCR-Dienst erkennt
|
||||
deutsche Handschrift und gibt den Text zurück.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,934 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { BPIcon } from '@/components/Logo'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useAlerts, getImportanceColor, getRelativeTime } from '@/lib/AlertsContext'
|
||||
import { useMessages, formatMessageTime, getContactInitials } from '@/lib/MessagesContext'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { OnboardingWizard, OnboardingData } from '@/components/OnboardingWizard'
|
||||
import { DocumentUpload } from '@/components/DocumentUpload'
|
||||
import { QRCodeUpload } from '@/components/QRCodeUpload'
|
||||
import { DocumentSpace } from '@/components/DocumentSpace'
|
||||
import { ChatOverlay } from '@/components/ChatOverlay'
|
||||
|
||||
// LocalStorage Keys
|
||||
const ONBOARDING_KEY = 'bp_onboarding_complete'
|
||||
const USER_DATA_KEY = 'bp_user_data'
|
||||
const DOCUMENTS_KEY = 'bp_documents'
|
||||
const FIRST_VISIT_KEY = 'bp_first_dashboard_visit'
|
||||
const SESSION_ID_KEY = 'bp_session_id'
|
||||
|
||||
// BreakPilot Studio v2 - Glassmorphism Design
|
||||
|
||||
interface StoredDocument {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
uploadedAt: Date
|
||||
url?: string
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter()
|
||||
const [selectedTab, setSelectedTab] = useState('dashboard')
|
||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null)
|
||||
const [userData, setUserData] = useState<OnboardingData | null>(null)
|
||||
const [documents, setDocuments] = useState<StoredDocument[]>([])
|
||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||||
const [showQRModal, setShowQRModal] = useState(false)
|
||||
const [isFirstVisit, setIsFirstVisit] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<string>('')
|
||||
const [showAlertsDropdown, setShowAlertsDropdown] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
const { alerts, unreadCount, markAsRead } = useAlerts()
|
||||
const { conversations, unreadCount: messagesUnreadCount, contacts, markAsRead: markMessageAsRead } = useMessages()
|
||||
|
||||
// Funktion zum Laden von Uploads aus der API
|
||||
const fetchUploadsFromAPI = useCallback(async (sid: string) => {
|
||||
if (!sid) return
|
||||
try {
|
||||
const response = await fetch(`/api/uploads?sessionId=${encodeURIComponent(sid)}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.uploads && data.uploads.length > 0) {
|
||||
// Konvertiere API-Uploads zu StoredDocument Format
|
||||
const apiDocs: StoredDocument[] = data.uploads.map((u: any) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
type: u.type,
|
||||
size: u.size,
|
||||
uploadedAt: new Date(u.uploadedAt),
|
||||
url: u.dataUrl // Data URL direkt verwenden
|
||||
}))
|
||||
// Merge mit existierenden Dokumenten (ohne Duplikate)
|
||||
setDocuments(prev => {
|
||||
const existingIds = new Set(prev.map(d => d.id))
|
||||
const newDocs = apiDocs.filter(d => !existingIds.has(d.id))
|
||||
if (newDocs.length > 0) {
|
||||
return [...prev, ...newDocs]
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching uploads:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Prüfe beim Laden, ob Onboarding abgeschlossen ist
|
||||
useEffect(() => {
|
||||
const onboardingComplete = localStorage.getItem(ONBOARDING_KEY)
|
||||
const storedUserData = localStorage.getItem(USER_DATA_KEY)
|
||||
const storedDocs = localStorage.getItem(DOCUMENTS_KEY)
|
||||
const firstVisit = localStorage.getItem(FIRST_VISIT_KEY)
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
|
||||
// Session ID generieren falls nicht vorhanden
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
}
|
||||
setSessionId(storedSessionId)
|
||||
|
||||
if (onboardingComplete === 'true' && storedUserData) {
|
||||
setUserData(JSON.parse(storedUserData))
|
||||
setShowOnboarding(false)
|
||||
|
||||
// Dokumente laden
|
||||
if (storedDocs) {
|
||||
setDocuments(JSON.parse(storedDocs))
|
||||
}
|
||||
|
||||
// Erster Dashboard-Besuch nach Onboarding?
|
||||
if (!firstVisit) {
|
||||
setIsFirstVisit(true)
|
||||
localStorage.setItem(FIRST_VISIT_KEY, 'true')
|
||||
}
|
||||
|
||||
// Initialer Fetch von der API
|
||||
fetchUploadsFromAPI(storedSessionId)
|
||||
} else {
|
||||
setShowOnboarding(true)
|
||||
}
|
||||
}, [fetchUploadsFromAPI])
|
||||
|
||||
// Polling fuer neue Uploads von der API (alle 3 Sekunden)
|
||||
useEffect(() => {
|
||||
if (!sessionId || showOnboarding) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchUploadsFromAPI(sessionId)
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [sessionId, showOnboarding, fetchUploadsFromAPI])
|
||||
|
||||
// Dokumente in localStorage speichern
|
||||
useEffect(() => {
|
||||
if (documents.length > 0) {
|
||||
localStorage.setItem(DOCUMENTS_KEY, JSON.stringify(documents))
|
||||
}
|
||||
}, [documents])
|
||||
|
||||
// Handler fuer neue Uploads
|
||||
const handleUploadComplete = (uploadedDocs: any[]) => {
|
||||
const newDocs: StoredDocument[] = uploadedDocs.map(d => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
type: d.type,
|
||||
size: d.size,
|
||||
uploadedAt: d.uploadedAt,
|
||||
url: d.url
|
||||
}))
|
||||
setDocuments(prev => [...prev, ...newDocs])
|
||||
setIsFirstVisit(false)
|
||||
}
|
||||
|
||||
// Dokument loeschen (aus State und API)
|
||||
const handleDeleteDocument = async (id: string) => {
|
||||
setDocuments(prev => prev.filter(d => d.id !== id))
|
||||
// Auch aus API loeschen
|
||||
try {
|
||||
await fetch(`/api/uploads?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
} catch (error) {
|
||||
console.error('Error deleting from API:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Dokument umbenennen
|
||||
const handleRenameDocument = (id: string, newName: string) => {
|
||||
setDocuments(prev => prev.map(d => d.id === id ? { ...d, name: newName } : d))
|
||||
}
|
||||
|
||||
// Onboarding abschließen
|
||||
const handleOnboardingComplete = (data: OnboardingData) => {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true')
|
||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data))
|
||||
setUserData(data)
|
||||
setShowOnboarding(false)
|
||||
}
|
||||
|
||||
// Zeige Ladebildschirm während der Prüfung
|
||||
if (showOnboarding === null) {
|
||||
return (
|
||||
<div className={`min-h-screen flex items-center justify-center ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<BPIcon variant="cupertino" size={48} className="animate-pulse" />
|
||||
<span className={`text-xl font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Laden...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Zeige Onboarding falls noch nicht abgeschlossen
|
||||
if (showOnboarding) {
|
||||
return <OnboardingWizard onComplete={handleOnboardingComplete} />
|
||||
}
|
||||
|
||||
// Ab hier: Dashboard (bestehender Code)
|
||||
|
||||
const stats = [
|
||||
{ labelKey: 'stat_open_corrections', value: '12', icon: '📋', color: 'from-blue-400 to-blue-600' },
|
||||
{ labelKey: 'stat_completed_week', value: '28', icon: '✅', color: 'from-green-400 to-green-600' },
|
||||
{ labelKey: 'stat_average', value: '2.3', icon: '📈', color: 'from-purple-400 to-purple-600' },
|
||||
{ labelKey: 'stat_time_saved', value: '4.2h', icon: '⏱', color: 'from-orange-400 to-orange-600' },
|
||||
]
|
||||
|
||||
const recentKlausuren = [
|
||||
{ id: 1, title: 'Deutsch LK - Textanalyse', students: 24, completed: 18, statusKey: 'status_in_progress' },
|
||||
{ id: 2, title: 'Deutsch GK - Erörterung', students: 28, completed: 28, statusKey: 'status_completed' },
|
||||
{ id: 3, title: 'Vorabitur - Gedichtanalyse', students: 22, completed: 10, statusKey: 'status_in_progress' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen relative overflow-hidden flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
|
||||
isDark ? 'bg-purple-500 opacity-70' : 'bg-purple-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
|
||||
isDark ? 'bg-blue-500 opacity-70' : 'bg-blue-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${
|
||||
isDark ? 'bg-pink-500 opacity-70' : 'bg-pink-300 opacity-50'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex min-h-screen gap-6 p-4">
|
||||
{/* Sidebar */}
|
||||
<Sidebar selectedTab={selectedTab} onTabChange={setSelectedTab} />
|
||||
|
||||
{/* ============================================
|
||||
ARBEITSFLAECHE (Main Content)
|
||||
============================================ */}
|
||||
<main className="flex-1">
|
||||
{/* Kopfleiste (Header) */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className={`text-4xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('dashboard')}</h1>
|
||||
<p className={isDark ? 'text-white/60' : 'text-slate-600'}>{t('dashboard_subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Search, Language & Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search_placeholder')}
|
||||
className={`backdrop-blur-xl border rounded-2xl px-5 py-3 pl-12 focus:outline-none focus:ring-2 w-64 transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:ring-white/30'
|
||||
: 'bg-white/70 border-black/10 text-slate-900 placeholder-slate-400 focus:ring-indigo-300'
|
||||
}`}
|
||||
/>
|
||||
<svg className={`absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Language Dropdown */}
|
||||
<LanguageDropdown />
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Notifications Bell with Glow Effect */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowAlertsDropdown(!showAlertsDropdown)}
|
||||
className={`relative p-3 backdrop-blur-xl border rounded-2xl transition-all ${
|
||||
unreadCount > 0
|
||||
? 'animate-pulse bg-gradient-to-r from-amber-500/20 to-orange-500/20 border-amber-500/30 shadow-lg shadow-amber-500/30'
|
||||
: isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/20'
|
||||
: 'bg-black/5 border-black/10 hover:bg-black/10'
|
||||
} ${isDark ? 'text-white' : 'text-slate-700'}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full text-xs text-white flex items-center justify-center font-medium">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Alerts Dropdown */}
|
||||
{showAlertsDropdown && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAlertsDropdown(false)} />
|
||||
<div className={`absolute right-0 mt-2 w-80 rounded-2xl border shadow-xl z-50 ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Aktuelle Alerts
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-500/20 text-amber-500">
|
||||
{unreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{alerts.slice(0, 5).map(alert => (
|
||||
<button
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
markAsRead(alert.id)
|
||||
setShowAlertsDropdown(false)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full text-left p-4 transition-all ${
|
||||
isDark
|
||||
? `hover:bg-white/5 ${!alert.isRead ? 'bg-amber-500/5 border-l-2 border-amber-500' : ''}`
|
||||
: `hover:bg-slate-50 ${!alert.isRead ? 'bg-amber-50 border-l-2 border-amber-500' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${getImportanceColor(alert.importance, isDark)}`}>
|
||||
{alert.importance.slice(0, 4)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{getRelativeTime(alert.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<div className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<span className="text-2xl block mb-2">📭</span>
|
||||
<p className="text-sm">Keine Alerts</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 border-t ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAlertsDropdown(false)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
isDark
|
||||
? 'text-amber-400 hover:bg-amber-500/10'
|
||||
: 'text-amber-600 hover:bg-amber-50'
|
||||
}`}
|
||||
>
|
||||
Alle Alerts anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Willkommensnachricht fuer ersten Besuch */}
|
||||
{isFirstVisit && documents.length === 0 && (
|
||||
<div className={`mb-8 p-6 rounded-3xl border backdrop-blur-xl ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 border-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-50 to-pink-50 border-purple-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-6">
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center text-3xl ${
|
||||
isDark ? 'bg-white/20' : 'bg-white shadow-lg'
|
||||
}`}>
|
||||
🎉
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Willkommen bei BreakPilot Studio!
|
||||
</h2>
|
||||
<p className={`mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
Grossartig, dass Sie hier sind! Laden Sie jetzt Ihr erstes Dokument hoch,
|
||||
um die KI-gestuetzte Korrektur zu erleben. Sie koennen Dateien von Ihrem
|
||||
Computer oder Mobiltelefon hochladen.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105"
|
||||
>
|
||||
Dokument hochladen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
className={`px-6 py-3 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'bg-white text-slate-700 hover:bg-slate-50 shadow'
|
||||
}`}
|
||||
>
|
||||
Mit Mobiltelefon hochladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFirstVisit(false)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-white'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats Kacheln */}
|
||||
<div className="grid grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`backdrop-blur-xl border rounded-3xl p-6 transition-all hover:scale-105 hover:shadow-xl cursor-pointer ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-12 h-12 bg-gradient-to-br ${stat.color} rounded-2xl flex items-center justify-center text-2xl shadow-lg`}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className={`text-sm mb-1 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{t(stat.labelKey)}</p>
|
||||
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab-Content */}
|
||||
{selectedTab === 'dokumente' ? (
|
||||
/* Dokumente-Tab */
|
||||
<div className="space-y-6">
|
||||
{/* Upload-Optionen */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
|
||||
isDark ? 'bg-blue-500/20' : 'bg-blue-100'
|
||||
}`}>
|
||||
📤
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Direkt hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Ziehen Sie Dateien hierher oder klicken Sie zum Auswaehlen
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
|
||||
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
|
||||
}`}>
|
||||
📱
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Mit Mobiltelefon hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
QR-Code scannen (nur im lokalen Netzwerk)
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Document Space */}
|
||||
<div className={`rounded-3xl border backdrop-blur-xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Meine Dokumente
|
||||
</h2>
|
||||
<DocumentSpace
|
||||
documents={documents}
|
||||
onDelete={handleDeleteDocument}
|
||||
onRename={handleRenameDocument}
|
||||
onOpen={(doc) => doc.url && window.open(doc.url, '_blank')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Dashboard-Tab (Standard) */
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Aktuelle Klausuren Kachel */}
|
||||
<div className={`col-span-2 backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('recent_klausuren')}</h2>
|
||||
<button className={`text-sm transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'
|
||||
}`}>
|
||||
{t('show_all')} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{recentKlausuren.map((klausur) => (
|
||||
<div
|
||||
key={klausur.id}
|
||||
className={`flex items-center gap-4 p-4 rounded-2xl transition-all cursor-pointer group ${
|
||||
isDark
|
||||
? 'bg-white/5 hover:bg-white/10'
|
||||
: 'bg-slate-50 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-400 to-purple-500 rounded-2xl flex items-center justify-center">
|
||||
<span className="text-2xl">📝</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur.title}</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.students} {t('students')}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
klausur.statusKey === 'status_completed'
|
||||
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
||||
: isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{t(klausur.statusKey)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className={`w-24 h-1.5 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-cyan-400 to-blue-500 rounded-full"
|
||||
style={{ width: `${(klausur.completed / klausur.students) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.completed}/{klausur.students}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 transition-colors ${
|
||||
isDark ? 'text-white/30 group-hover:text-white/60' : 'text-slate-300 group-hover:text-slate-600'
|
||||
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schnellaktionen Kachel */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('quick_actions')}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button className="w-full flex items-center gap-4 p-4 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl text-white hover:shadow-xl hover:shadow-purple-500/30 transition-all hover:scale-105">
|
||||
<span className="text-2xl">➕</span>
|
||||
<span className="font-medium">{t('create_klausur')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">📤</span>
|
||||
<span className="font-medium">{t('upload_work')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedTab('dokumente')}
|
||||
className={`w-full flex items-center justify-between p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-2xl">📁</span>
|
||||
<span className="font-medium">{t('nav_dokumente')}</span>
|
||||
</div>
|
||||
{documents.length > 0 && (
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
isDark ? 'bg-white/20' : 'bg-slate-200'
|
||||
}`}>
|
||||
{documents.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/worksheet-editor')}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 text-white hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-50 to-pink-50 text-slate-800 hover:from-purple-100 hover:to-pink-100 border border-purple-200'
|
||||
}`}>
|
||||
<span className="text-2xl">🎨</span>
|
||||
<span className="font-medium">{t('nav_worksheet_editor')}</span>
|
||||
</button>
|
||||
|
||||
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">✨</span>
|
||||
<span className="font-medium">{t('magic_help')}</span>
|
||||
</button>
|
||||
|
||||
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">📊</span>
|
||||
<span className="font-medium">{t('fairness_check')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Insight mini */}
|
||||
<div className={`mt-6 p-4 rounded-2xl border ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-pink-500/20 to-purple-500/20 border-pink-500/30'
|
||||
: 'bg-gradient-to-r from-pink-50 to-purple-50 border-pink-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-lg">🤖</span>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{t('ai_tip')}</span>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
{t('ai_tip_text')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Alerts Kachel */}
|
||||
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-amber-500/10 to-orange-500/10 border-amber-500/30'
|
||||
: 'bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>🔔</span> Aktuelle Alerts
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-amber-500/20 text-amber-500 font-medium">
|
||||
{unreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Headlines Liste */}
|
||||
<div className="space-y-2">
|
||||
{alerts.slice(0, 3).map(alert => (
|
||||
<button
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
markAsRead(alert.id)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
|
||||
isDark
|
||||
? `hover:bg-white/10 ${!alert.isRead ? 'bg-white/5' : ''}`
|
||||
: `hover:bg-white ${!alert.isRead ? 'bg-white/50' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{!alert.isRead && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1.5 flex-shrink-0" />
|
||||
)}
|
||||
<p className={`truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Keine Alerts vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mehr anzeigen */}
|
||||
<button
|
||||
onClick={() => router.push('/alerts')}
|
||||
className={`w-full mt-3 text-sm font-medium ${
|
||||
isDark ? 'text-amber-400 hover:text-amber-300' : 'text-amber-600 hover:text-amber-700'
|
||||
}`}
|
||||
>
|
||||
Alle Alerts anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nachrichten Kachel */}
|
||||
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-500/30'
|
||||
: 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>💬</span> {t('nav_messages')}
|
||||
</h3>
|
||||
{messagesUnreadCount > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-green-500/20 text-green-500 font-medium">
|
||||
{messagesUnreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conversations Liste */}
|
||||
<div className="space-y-2">
|
||||
{conversations.slice(0, 3).map(conv => {
|
||||
const contact = contacts.find(c => conv.participant_ids.includes(c.id))
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => {
|
||||
if (conv.unread_count > 0) {
|
||||
markMessageAsRead(conv.id)
|
||||
}
|
||||
router.push('/messages')
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
|
||||
isDark
|
||||
? `hover:bg-white/10 ${conv.unread_count > 0 ? 'bg-white/5' : ''}`
|
||||
: `hover:bg-white ${conv.unread_count > 0 ? 'bg-white/50' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Avatar */}
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${
|
||||
contact?.online
|
||||
? isDark
|
||||
? 'bg-green-500/30 text-green-300'
|
||||
: 'bg-green-200 text-green-700'
|
||||
: isDark
|
||||
? 'bg-slate-600 text-slate-300'
|
||||
: 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{conv.title ? getContactInitials(conv.title) : '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{conv.title || 'Unbenannt'}
|
||||
</span>
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{conv.last_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{conv.last_message_time && (
|
||||
<span className={`text-xs flex-shrink-0 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{formatMessageTime(conv.last_message_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{conversations.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Keine Nachrichten vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mehr anzeigen */}
|
||||
<button
|
||||
onClick={() => router.push('/messages')}
|
||||
className={`w-full mt-3 text-sm font-medium ${
|
||||
isDark ? 'text-green-400 hover:text-green-300' : 'text-green-600 hover:text-green-700'
|
||||
}`}
|
||||
>
|
||||
Alle Nachrichten anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowUploadModal(false)} />
|
||||
<div className={`relative w-full max-w-2xl rounded-3xl border p-6 max-h-[90vh] overflow-y-auto ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200 shadow-2xl'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Dokumente hochladen
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<DocumentUpload
|
||||
onUploadComplete={(docs) => {
|
||||
handleUploadComplete(docs)
|
||||
}}
|
||||
/>
|
||||
{/* Aktions-Buttons */}
|
||||
<div className={`mt-6 pt-6 border-t flex justify-between items-center ${
|
||||
isDark ? 'border-white/10' : 'border-slate-200'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{documents.length > 0 ? `${documents.length} Dokument${documents.length !== 1 ? 'e' : ''} gespeichert` : 'Noch keine Dokumente'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUploadModal(false)
|
||||
setSelectedTab('dokumente')
|
||||
}}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl text-sm font-medium hover:shadow-lg transition-all"
|
||||
>
|
||||
Zu meinen Dokumenten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
||||
<div className={`relative w-full max-w-md rounded-3xl ${
|
||||
isDark ? 'bg-slate-900' : 'bg-white'
|
||||
}`}>
|
||||
<QRCodeUpload
|
||||
sessionId={sessionId}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diegetic Chat Overlay - Cinematic message notifications */}
|
||||
<ChatOverlay
|
||||
typewriterEnabled={true}
|
||||
typewriterSpeed={25}
|
||||
autoDismissMs={0}
|
||||
maxQueue={5}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
|
||||
{/* Blob Animation Styles */}
|
||||
<style jsx>{`
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { BPIcon } from '@/components/Logo'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useAlerts, getImportanceColor, getRelativeTime } from '@/lib/AlertsContext'
|
||||
import { useMessages, formatMessageTime, getContactInitials } from '@/lib/MessagesContext'
|
||||
import { useActivity, formatDurationCompact } from '@/lib/ActivityContext'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { Footer } from '@/components/Footer'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { OnboardingWizard, OnboardingData } from '@/components/OnboardingWizard'
|
||||
import { DocumentUpload } from '@/components/DocumentUpload'
|
||||
import { QRCodeUpload } from '@/components/QRCodeUpload'
|
||||
import { DocumentSpace } from '@/components/DocumentSpace'
|
||||
import { ChatOverlay } from '@/components/ChatOverlay'
|
||||
import { AiPrompt } from '@/components/AiPrompt'
|
||||
|
||||
// LocalStorage Keys
|
||||
const ONBOARDING_KEY = 'bp_onboarding_complete'
|
||||
const USER_DATA_KEY = 'bp_user_data'
|
||||
const DOCUMENTS_KEY = 'bp_documents'
|
||||
const FIRST_VISIT_KEY = 'bp_first_dashboard_visit'
|
||||
const SESSION_ID_KEY = 'bp_session_id'
|
||||
|
||||
// BreakPilot Studio v2 - Glassmorphism Design
|
||||
|
||||
interface StoredDocument {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
uploadedAt: Date
|
||||
url?: string
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter()
|
||||
const [selectedTab, setSelectedTab] = useState('dashboard')
|
||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null)
|
||||
const [userData, setUserData] = useState<OnboardingData | null>(null)
|
||||
const [documents, setDocuments] = useState<StoredDocument[]>([])
|
||||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||||
const [showQRModal, setShowQRModal] = useState(false)
|
||||
const [isFirstVisit, setIsFirstVisit] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<string>('')
|
||||
const [showAlertsDropdown, setShowAlertsDropdown] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
const { alerts, unreadCount, markAsRead } = useAlerts()
|
||||
const { conversations, unreadCount: messagesUnreadCount, contacts, markAsRead: markMessageAsRead } = useMessages()
|
||||
const { stats: activityStats } = useActivity()
|
||||
|
||||
// Funktion zum Laden von Uploads aus der API
|
||||
const fetchUploadsFromAPI = useCallback(async (sid: string) => {
|
||||
if (!sid) return
|
||||
try {
|
||||
const response = await fetch(`/api/uploads?sessionId=${encodeURIComponent(sid)}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
if (data.uploads && data.uploads.length > 0) {
|
||||
// Konvertiere API-Uploads zu StoredDocument Format
|
||||
const apiDocs: StoredDocument[] = data.uploads.map((u: any) => ({
|
||||
id: u.id,
|
||||
name: u.name,
|
||||
type: u.type,
|
||||
size: u.size,
|
||||
uploadedAt: new Date(u.uploadedAt),
|
||||
url: u.dataUrl // Data URL direkt verwenden
|
||||
}))
|
||||
// Merge mit existierenden Dokumenten (ohne Duplikate)
|
||||
setDocuments(prev => {
|
||||
const existingIds = new Set(prev.map(d => d.id))
|
||||
const newDocs = apiDocs.filter(d => !existingIds.has(d.id))
|
||||
if (newDocs.length > 0) {
|
||||
return [...prev, ...newDocs]
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching uploads:', error)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Prüfe beim Laden, ob Onboarding abgeschlossen ist
|
||||
useEffect(() => {
|
||||
const onboardingComplete = localStorage.getItem(ONBOARDING_KEY)
|
||||
const storedUserData = localStorage.getItem(USER_DATA_KEY)
|
||||
const storedDocs = localStorage.getItem(DOCUMENTS_KEY)
|
||||
const firstVisit = localStorage.getItem(FIRST_VISIT_KEY)
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
|
||||
// Session ID generieren falls nicht vorhanden
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
}
|
||||
setSessionId(storedSessionId)
|
||||
|
||||
if (onboardingComplete === 'true' && storedUserData) {
|
||||
setUserData(JSON.parse(storedUserData))
|
||||
setShowOnboarding(false)
|
||||
|
||||
// Dokumente laden
|
||||
if (storedDocs) {
|
||||
setDocuments(JSON.parse(storedDocs))
|
||||
}
|
||||
|
||||
// Erster Dashboard-Besuch nach Onboarding?
|
||||
if (!firstVisit) {
|
||||
setIsFirstVisit(true)
|
||||
localStorage.setItem(FIRST_VISIT_KEY, 'true')
|
||||
}
|
||||
|
||||
// Initialer Fetch von der API
|
||||
fetchUploadsFromAPI(storedSessionId)
|
||||
} else {
|
||||
setShowOnboarding(true)
|
||||
}
|
||||
}, [fetchUploadsFromAPI])
|
||||
|
||||
// Polling fuer neue Uploads von der API (alle 3 Sekunden)
|
||||
useEffect(() => {
|
||||
if (!sessionId || showOnboarding) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
fetchUploadsFromAPI(sessionId)
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [sessionId, showOnboarding, fetchUploadsFromAPI])
|
||||
|
||||
// Dokumente in localStorage speichern
|
||||
useEffect(() => {
|
||||
if (documents.length > 0) {
|
||||
localStorage.setItem(DOCUMENTS_KEY, JSON.stringify(documents))
|
||||
}
|
||||
}, [documents])
|
||||
|
||||
// Handler fuer neue Uploads
|
||||
const handleUploadComplete = (uploadedDocs: any[]) => {
|
||||
const newDocs: StoredDocument[] = uploadedDocs.map(d => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
type: d.type,
|
||||
size: d.size,
|
||||
uploadedAt: d.uploadedAt,
|
||||
url: d.url
|
||||
}))
|
||||
setDocuments(prev => [...prev, ...newDocs])
|
||||
setIsFirstVisit(false)
|
||||
}
|
||||
|
||||
// Dokument loeschen (aus State und API)
|
||||
const handleDeleteDocument = async (id: string) => {
|
||||
setDocuments(prev => prev.filter(d => d.id !== id))
|
||||
// Auch aus API loeschen
|
||||
try {
|
||||
await fetch(`/api/uploads?id=${encodeURIComponent(id)}`, { method: 'DELETE' })
|
||||
} catch (error) {
|
||||
console.error('Error deleting from API:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Dokument umbenennen
|
||||
const handleRenameDocument = (id: string, newName: string) => {
|
||||
setDocuments(prev => prev.map(d => d.id === id ? { ...d, name: newName } : d))
|
||||
}
|
||||
|
||||
// Onboarding abschließen
|
||||
const handleOnboardingComplete = (data: OnboardingData) => {
|
||||
localStorage.setItem(ONBOARDING_KEY, 'true')
|
||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(data))
|
||||
setUserData(data)
|
||||
setShowOnboarding(false)
|
||||
}
|
||||
|
||||
// Zeige Ladebildschirm während der Prüfung
|
||||
if (showOnboarding === null) {
|
||||
return (
|
||||
<div className={`min-h-screen flex items-center justify-center ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<BPIcon variant="cupertino" size={48} className="animate-pulse" />
|
||||
<span className={`text-xl font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Laden...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Zeige Onboarding falls noch nicht abgeschlossen
|
||||
if (showOnboarding) {
|
||||
return <OnboardingWizard onComplete={handleOnboardingComplete} />
|
||||
}
|
||||
|
||||
// Ab hier: Dashboard (bestehender Code)
|
||||
|
||||
// Calculate time saved from activity tracking
|
||||
const timeSaved = formatDurationCompact(activityStats.weekSavedSeconds)
|
||||
const timeSavedDisplay = activityStats.weekSavedSeconds > 0
|
||||
? `${timeSaved.value}${timeSaved.unit}`
|
||||
: '0min'
|
||||
|
||||
const stats = [
|
||||
{ labelKey: 'stat_open_corrections', value: '12', icon: '📋', color: 'from-blue-400 to-blue-600' },
|
||||
{ labelKey: 'stat_completed_week', value: String(activityStats.activityCount), icon: '✅', color: 'from-green-400 to-green-600' },
|
||||
{ labelKey: 'stat_average', value: '2.3', icon: '📈', color: 'from-purple-400 to-purple-600' },
|
||||
{ labelKey: 'stat_time_saved', value: timeSavedDisplay, icon: '⏱', color: 'from-orange-400 to-orange-600' },
|
||||
]
|
||||
|
||||
const recentKlausuren = [
|
||||
{ id: 1, title: 'Deutsch LK - Textanalyse', students: 24, completed: 18, statusKey: 'status_in_progress' },
|
||||
{ id: 2, title: 'Deutsch GK - Erörterung', students: 28, completed: 28, statusKey: 'status_completed' },
|
||||
{ id: 3, title: 'Vorabitur - Gedichtanalyse', students: 22, completed: 10, statusKey: 'status_in_progress' },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen relative overflow-hidden flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
|
||||
isDark ? 'bg-purple-500 opacity-70' : 'bg-purple-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
|
||||
isDark ? 'bg-blue-500 opacity-70' : 'bg-blue-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${
|
||||
isDark ? 'bg-pink-500 opacity-70' : 'bg-pink-300 opacity-50'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex min-h-screen gap-6 p-4">
|
||||
{/* Sidebar */}
|
||||
<Sidebar selectedTab={selectedTab} onTabChange={setSelectedTab} />
|
||||
|
||||
{/* ============================================
|
||||
ARBEITSFLAECHE (Main Content)
|
||||
============================================ */}
|
||||
<main className="flex-1">
|
||||
{/* Kopfleiste (Header) */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className={`text-4xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('dashboard')}</h1>
|
||||
<p className={isDark ? 'text-white/60' : 'text-slate-600'}>{t('dashboard_subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{/* Search, Language & Actions */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search_placeholder')}
|
||||
className={`backdrop-blur-xl border rounded-2xl px-5 py-3 pl-12 focus:outline-none focus:ring-2 w-64 transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:ring-white/30'
|
||||
: 'bg-white/70 border-black/10 text-slate-900 placeholder-slate-400 focus:ring-indigo-300'
|
||||
}`}
|
||||
/>
|
||||
<svg className={`absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Language Dropdown */}
|
||||
<LanguageDropdown />
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Notifications Bell with Glow Effect */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setShowAlertsDropdown(!showAlertsDropdown)}
|
||||
className={`relative p-3 backdrop-blur-xl border rounded-2xl transition-all ${
|
||||
unreadCount > 0
|
||||
? 'animate-pulse bg-gradient-to-r from-amber-500/20 to-orange-500/20 border-amber-500/30 shadow-lg shadow-amber-500/30'
|
||||
: isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/20'
|
||||
: 'bg-black/5 border-black/10 hover:bg-black/10'
|
||||
} ${isDark ? 'text-white' : 'text-slate-700'}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full text-xs text-white flex items-center justify-center font-medium">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Alerts Dropdown */}
|
||||
{showAlertsDropdown && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setShowAlertsDropdown(false)} />
|
||||
<div className={`absolute right-0 mt-2 w-80 rounded-2xl border shadow-xl z-50 ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div className={`p-4 border-b ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Aktuelle Alerts
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-amber-500/20 text-amber-500">
|
||||
{unreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto">
|
||||
{alerts.slice(0, 5).map(alert => (
|
||||
<button
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
markAsRead(alert.id)
|
||||
setShowAlertsDropdown(false)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full text-left p-4 transition-all ${
|
||||
isDark
|
||||
? `hover:bg-white/5 ${!alert.isRead ? 'bg-amber-500/5 border-l-2 border-amber-500' : ''}`
|
||||
: `hover:bg-slate-50 ${!alert.isRead ? 'bg-amber-50 border-l-2 border-amber-500' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${getImportanceColor(alert.importance, isDark)}`}>
|
||||
{alert.importance.slice(0, 4)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{getRelativeTime(alert.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<div className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<span className="text-2xl block mb-2">📭</span>
|
||||
<p className="text-sm">Keine Alerts</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={`p-3 border-t ${isDark ? 'border-white/10' : 'border-slate-100'}`}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAlertsDropdown(false)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full py-2 text-sm font-medium rounded-lg transition-all ${
|
||||
isDark
|
||||
? 'text-amber-400 hover:bg-amber-500/10'
|
||||
: 'text-amber-600 hover:bg-amber-50'
|
||||
}`}
|
||||
>
|
||||
Alle Alerts anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Willkommensnachricht fuer ersten Besuch */}
|
||||
{isFirstVisit && documents.length === 0 && (
|
||||
<div className={`mb-8 p-6 rounded-3xl border backdrop-blur-xl ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 border-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-50 to-pink-50 border-purple-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-6">
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center text-3xl ${
|
||||
isDark ? 'bg-white/20' : 'bg-white shadow-lg'
|
||||
}`}>
|
||||
🎉
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className={`text-xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Willkommen bei BreakPilot Studio!
|
||||
</h2>
|
||||
<p className={`mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
Grossartig, dass Sie hier sind! Laden Sie jetzt Ihr erstes Dokument hoch,
|
||||
um die KI-gestuetzte Korrektur zu erleben. Sie koennen Dateien von Ihrem
|
||||
Computer oder Mobiltelefon hochladen.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="px-6 py-3 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all hover:scale-105"
|
||||
>
|
||||
Dokument hochladen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
className={`px-6 py-3 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/20 text-white hover:bg-white/30'
|
||||
: 'bg-white text-slate-700 hover:bg-slate-50 shadow'
|
||||
}`}
|
||||
>
|
||||
Mit Mobiltelefon hochladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsFirstVisit(false)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-white'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* KI-Assistent */}
|
||||
<AiPrompt />
|
||||
|
||||
{/* Stats Kacheln */}
|
||||
<div className="grid grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`backdrop-blur-xl border rounded-3xl p-6 transition-all hover:scale-105 hover:shadow-xl cursor-pointer ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`w-12 h-12 bg-gradient-to-br ${stat.color} rounded-2xl flex items-center justify-center text-2xl shadow-lg`}>
|
||||
{stat.icon}
|
||||
</div>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className={`text-sm mb-1 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>{t(stat.labelKey)}</p>
|
||||
<p className={`text-3xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>{stat.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab-Content */}
|
||||
{selectedTab === 'dokumente' ? (
|
||||
/* Dokumente-Tab */
|
||||
<div className="space-y-6">
|
||||
{/* Upload-Optionen */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
|
||||
isDark ? 'bg-blue-500/20' : 'bg-blue-100'
|
||||
}`}>
|
||||
📤
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Direkt hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Ziehen Sie Dateien hierher oder klicken Sie zum Auswaehlen
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowQRModal(true)}
|
||||
className={`p-6 rounded-3xl border backdrop-blur-xl text-left transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 hover:bg-white/15'
|
||||
: 'bg-white/70 border-black/10 hover:bg-white/90 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-2xl flex items-center justify-center text-3xl mb-4 ${
|
||||
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
|
||||
}`}>
|
||||
📱
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Mit Mobiltelefon hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
QR-Code scannen (nur im lokalen Netzwerk)
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Document Space */}
|
||||
<div className={`rounded-3xl border backdrop-blur-xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Meine Dokumente
|
||||
</h2>
|
||||
<DocumentSpace
|
||||
documents={documents}
|
||||
onDelete={handleDeleteDocument}
|
||||
onRename={handleRenameDocument}
|
||||
onOpen={(doc) => doc.url && window.open(doc.url, '_blank')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Dashboard-Tab (Standard) */
|
||||
<div className="grid grid-cols-3 gap-6">
|
||||
{/* Aktuelle Klausuren Kachel */}
|
||||
<div className={`col-span-2 backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('recent_klausuren')}</h2>
|
||||
<button className={`text-sm transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'
|
||||
}`}>
|
||||
{t('show_all')} →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{recentKlausuren.map((klausur) => (
|
||||
<div
|
||||
key={klausur.id}
|
||||
className={`flex items-center gap-4 p-4 rounded-2xl transition-all cursor-pointer group ${
|
||||
isDark
|
||||
? 'bg-white/5 hover:bg-white/10'
|
||||
: 'bg-slate-50 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<div className="w-14 h-14 bg-gradient-to-br from-blue-400 to-purple-500 rounded-2xl flex items-center justify-center">
|
||||
<span className="text-2xl">📝</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{klausur.title}</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.students} {t('students')}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${
|
||||
klausur.statusKey === 'status_completed'
|
||||
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
||||
: isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{t(klausur.statusKey)}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className={`w-24 h-1.5 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-cyan-400 to-blue-500 rounded-full"
|
||||
style={{ width: `${(klausur.completed / klausur.students) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{klausur.completed}/{klausur.students}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg className={`w-5 h-5 transition-colors ${
|
||||
isDark ? 'text-white/30 group-hover:text-white/60' : 'text-slate-300 group-hover:text-slate-600'
|
||||
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Schnellaktionen Kachel */}
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-lg'
|
||||
}`}>
|
||||
<h2 className={`text-xl font-semibold mb-6 ${isDark ? 'text-white' : 'text-slate-900'}`}>{t('quick_actions')}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<button className="w-full flex items-center gap-4 p-4 bg-gradient-to-r from-blue-500 to-purple-500 rounded-2xl text-white hover:shadow-xl hover:shadow-purple-500/30 transition-all hover:scale-105">
|
||||
<span className="text-2xl">➕</span>
|
||||
<span className="font-medium">{t('create_klausur')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">📤</span>
|
||||
<span className="font-medium">{t('upload_work')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSelectedTab('dokumente')}
|
||||
className={`w-full flex items-center justify-between p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-2xl">📁</span>
|
||||
<span className="font-medium">{t('nav_dokumente')}</span>
|
||||
</div>
|
||||
{documents.length > 0 && (
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
|
||||
isDark ? 'bg-white/20' : 'bg-slate-200'
|
||||
}`}>
|
||||
{documents.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => router.push('/worksheet-editor')}
|
||||
className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-purple-500/20 to-pink-500/20 text-white hover:from-purple-500/30 hover:to-pink-500/30 border border-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-50 to-pink-50 text-slate-800 hover:from-purple-100 hover:to-pink-100 border border-purple-200'
|
||||
}`}>
|
||||
<span className="text-2xl">🎨</span>
|
||||
<span className="font-medium">{t('nav_worksheet_editor')}</span>
|
||||
</button>
|
||||
|
||||
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">✨</span>
|
||||
<span className="font-medium">{t('magic_help')}</span>
|
||||
</button>
|
||||
|
||||
<button className={`w-full flex items-center gap-4 p-4 rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-800 hover:bg-slate-200'
|
||||
}`}>
|
||||
<span className="text-2xl">📊</span>
|
||||
<span className="font-medium">{t('fairness_check')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* AI Insight mini */}
|
||||
<div className={`mt-6 p-4 rounded-2xl border ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-pink-500/20 to-purple-500/20 border-pink-500/30'
|
||||
: 'bg-gradient-to-r from-pink-50 to-purple-50 border-pink-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-lg">🤖</span>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>{t('ai_tip')}</span>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
{t('ai_tip_text')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Alerts Kachel */}
|
||||
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-amber-500/10 to-orange-500/10 border-amber-500/30'
|
||||
: 'bg-gradient-to-r from-amber-50 to-orange-50 border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>🔔</span> Aktuelle Alerts
|
||||
</h3>
|
||||
{unreadCount > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-amber-500/20 text-amber-500 font-medium">
|
||||
{unreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Headlines Liste */}
|
||||
<div className="space-y-2">
|
||||
{alerts.slice(0, 3).map(alert => (
|
||||
<button
|
||||
key={alert.id}
|
||||
onClick={() => {
|
||||
markAsRead(alert.id)
|
||||
router.push('/alerts')
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
|
||||
isDark
|
||||
? `hover:bg-white/10 ${!alert.isRead ? 'bg-white/5' : ''}`
|
||||
: `hover:bg-white ${!alert.isRead ? 'bg-white/50' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{!alert.isRead && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-amber-500 mt-1.5 flex-shrink-0" />
|
||||
)}
|
||||
<p className={`truncate ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
{alert.title}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{alerts.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Keine Alerts vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mehr anzeigen */}
|
||||
<button
|
||||
onClick={() => router.push('/alerts')}
|
||||
className={`w-full mt-3 text-sm font-medium ${
|
||||
isDark ? 'text-amber-400 hover:text-amber-300' : 'text-amber-600 hover:text-amber-700'
|
||||
}`}
|
||||
>
|
||||
Alle Alerts anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Nachrichten Kachel */}
|
||||
<div className={`mt-6 backdrop-blur-xl border rounded-2xl p-4 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-green-500/10 to-emerald-500/10 border-green-500/30'
|
||||
: 'bg-gradient-to-r from-green-50 to-emerald-50 border-green-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-semibold flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>💬</span> {t('nav_messages')}
|
||||
</h3>
|
||||
{messagesUnreadCount > 0 && (
|
||||
<span className="text-xs px-2 py-1 rounded-full bg-green-500/20 text-green-500 font-medium">
|
||||
{messagesUnreadCount} neu
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Conversations Liste */}
|
||||
<div className="space-y-2">
|
||||
{conversations.slice(0, 3).map(conv => {
|
||||
const contact = contacts.find(c => conv.participant_ids.includes(c.id))
|
||||
return (
|
||||
<button
|
||||
key={conv.id}
|
||||
onClick={() => {
|
||||
if (conv.unread_count > 0) {
|
||||
markMessageAsRead(conv.id)
|
||||
}
|
||||
router.push('/messages')
|
||||
}}
|
||||
className={`w-full text-left p-2 rounded-lg transition-all text-sm ${
|
||||
isDark
|
||||
? `hover:bg-white/10 ${conv.unread_count > 0 ? 'bg-white/5' : ''}`
|
||||
: `hover:bg-white ${conv.unread_count > 0 ? 'bg-white/50' : ''}`
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Avatar */}
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium flex-shrink-0 ${
|
||||
contact?.online
|
||||
? isDark
|
||||
? 'bg-green-500/30 text-green-300'
|
||||
: 'bg-green-200 text-green-700'
|
||||
: isDark
|
||||
? 'bg-slate-600 text-slate-300'
|
||||
: 'bg-slate-200 text-slate-600'
|
||||
}`}>
|
||||
{conv.title ? getContactInitials(conv.title) : '?'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{conv.unread_count > 0 && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 flex-shrink-0" />
|
||||
)}
|
||||
<span className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{conv.title || 'Unbenannt'}
|
||||
</span>
|
||||
</div>
|
||||
{conv.last_message && (
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{conv.last_message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{conv.last_message_time && (
|
||||
<span className={`text-xs flex-shrink-0 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{formatMessageTime(conv.last_message_time)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{conversations.length === 0 && (
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Keine Nachrichten vorhanden
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mehr anzeigen */}
|
||||
<button
|
||||
onClick={() => router.push('/messages')}
|
||||
className={`w-full mt-3 text-sm font-medium ${
|
||||
isDark ? 'text-green-400 hover:text-green-300' : 'text-green-600 hover:text-green-700'
|
||||
}`}
|
||||
>
|
||||
Alle Nachrichten anzeigen →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowUploadModal(false)} />
|
||||
<div className={`relative w-full max-w-2xl rounded-3xl border p-6 max-h-[90vh] overflow-y-auto ${
|
||||
isDark
|
||||
? 'bg-slate-900 border-white/20'
|
||||
: 'bg-white border-slate-200 shadow-2xl'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Dokumente hochladen
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<DocumentUpload
|
||||
onUploadComplete={(docs) => {
|
||||
handleUploadComplete(docs)
|
||||
}}
|
||||
/>
|
||||
{/* Aktions-Buttons */}
|
||||
<div className={`mt-6 pt-6 border-t flex justify-between items-center ${
|
||||
isDark ? 'border-white/10' : 'border-slate-200'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{documents.length > 0 ? `${documents.length} Dokument${documents.length !== 1 ? 'e' : ''} gespeichert` : 'Noch keine Dokumente'}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowUploadModal(false)}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUploadModal(false)
|
||||
setSelectedTab('dokumente')
|
||||
}}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl text-sm font-medium hover:shadow-lg transition-all"
|
||||
>
|
||||
Zu meinen Dokumenten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
||||
<div className={`relative w-full max-w-md rounded-3xl ${
|
||||
isDark ? 'bg-slate-900' : 'bg-white'
|
||||
}`}>
|
||||
<QRCodeUpload
|
||||
sessionId={sessionId}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Diegetic Chat Overlay - Cinematic message notifications */}
|
||||
<ChatOverlay
|
||||
typewriterEnabled={true}
|
||||
typewriterSpeed={25}
|
||||
autoDismissMs={0}
|
||||
maxQueue={5}
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<Footer />
|
||||
|
||||
{/* Blob Animation Styles */}
|
||||
<style jsx>{`
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { BPIcon } from '@/components/Logo'
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
name: string
|
||||
size: number
|
||||
status: 'uploading' | 'complete' | 'error'
|
||||
progress: number
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export default function MobileUploadPage() {
|
||||
const params = useParams()
|
||||
const sessionId = params.sessionId as string
|
||||
const [files, setFiles] = useState<UploadedFile[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Echten Upload durchfuehren
|
||||
const uploadFile = useCallback(async (file: File) => {
|
||||
const localId = `file-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
const uploadFileState: UploadedFile = {
|
||||
id: localId,
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
status: 'uploading',
|
||||
progress: 0
|
||||
}
|
||||
|
||||
setFiles(prev => [...prev, uploadFileState])
|
||||
|
||||
try {
|
||||
// Fortschritt auf 30% setzen (Datei wird gelesen)
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === localId ? { ...f, progress: 30 } : f
|
||||
))
|
||||
|
||||
// Datei als Base64 Data URL konvertieren
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
|
||||
// Fortschritt auf 60% setzen (Upload wird gesendet)
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === localId ? { ...f, progress: 60 } : f
|
||||
))
|
||||
|
||||
// An API senden
|
||||
const response = await fetch('/api/uploads', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sessionId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
dataUrl
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload fehlgeschlagen')
|
||||
}
|
||||
|
||||
// Upload erfolgreich
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === localId ? { ...f, status: 'complete', progress: 100 } : f
|
||||
))
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
setFiles(prev => prev.map(f =>
|
||||
f.id === localId ? { ...f, status: 'error', progress: 0 } : f
|
||||
))
|
||||
}
|
||||
}, [sessionId])
|
||||
|
||||
const handleFiles = useCallback((fileList: FileList | null) => {
|
||||
if (!fileList) return
|
||||
Array.from(fileList).forEach(file => {
|
||||
if (file.type === 'application/pdf' || file.type.startsWith('image/')) {
|
||||
uploadFile(file)
|
||||
}
|
||||
})
|
||||
}, [uploadFile])
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
const completedCount = files.filter(f => f.status === 'complete').length
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 relative overflow-hidden">
|
||||
{/* Animated Background Blobs */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-purple-500 opacity-70 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" />
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-blue-500 opacity-70 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" style={{ animationDelay: '2s' }} />
|
||||
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 bg-pink-500 opacity-70 rounded-full mix-blend-multiply filter blur-3xl animate-pulse" style={{ animationDelay: '4s' }} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 min-h-screen flex flex-col p-4 safe-area-inset">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-center gap-3 py-6">
|
||||
<BPIcon variant="cupertino" size={40} />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">BreakPilot</h1>
|
||||
<p className="text-xs text-white/60">Mobiler Upload</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div className="flex-1 flex flex-col gap-4">
|
||||
{/* Upload-Button */}
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full backdrop-blur-xl bg-gradient-to-r from-purple-500 to-pink-500 border border-white/20 rounded-3xl p-8 flex flex-col items-center justify-center text-center transition-all hover:shadow-xl hover:shadow-purple-500/30 active:scale-95"
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png,image/*,application/pdf"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="w-20 h-20 bg-white/20 rounded-2xl flex items-center justify-center mb-4">
|
||||
<svg className="w-10 h-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-white">Dokument hochladen</p>
|
||||
<p className="text-sm text-white/70 mt-2">Tippen um Foto oder Datei auszuwaehlen</p>
|
||||
<p className="text-xs text-white/50 mt-1">PDF, JPG, PNG</p>
|
||||
</button>
|
||||
|
||||
{/* Uploaded Files */}
|
||||
{files.length > 0 && (
|
||||
<div className="backdrop-blur-xl bg-white/10 border border-white/20 rounded-2xl overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white">
|
||||
Hochgeladene Dateien
|
||||
</span>
|
||||
<span className="text-xs text-white/60">
|
||||
{completedCount}/{files.length} fertig
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-white/10 max-h-[40vh] overflow-y-auto">
|
||||
{files.map((file) => (
|
||||
<div key={file.id} className="p-4 flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl ${
|
||||
file.status === 'complete' ? 'bg-green-500/20' : 'bg-blue-500/20'
|
||||
}`}>
|
||||
{file.status === 'complete' ? '✅' : '📄'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-xs text-white/50">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
{file.status === 'uploading' && (
|
||||
<span className="text-xs text-blue-300">
|
||||
{Math.round(file.progress)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{file.status === 'uploading' && (
|
||||
<div className="mt-2 h-1 bg-white/10 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all"
|
||||
style={{ width: `${file.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success Message */}
|
||||
{completedCount > 0 && (
|
||||
<div className="backdrop-blur-xl bg-green-500/20 border border-green-500/30 rounded-2xl p-4 text-center">
|
||||
<p className="text-green-300 font-medium">
|
||||
{completedCount} Datei{completedCount !== 1 ? 'en' : ''} erfolgreich hochgeladen!
|
||||
</p>
|
||||
<p className="text-green-300/70 text-sm mt-1">
|
||||
Sie koennen diese Seite jetzt schliessen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="py-4 text-center">
|
||||
<p className="text-xs text-white/40">
|
||||
Session: {sessionId}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { VoiceCapture, VoiceCommandBar } from '@/components/voice'
|
||||
import { VoiceTask } from '@/lib/voice/voice-api'
|
||||
|
||||
/**
|
||||
* Voice Test Page
|
||||
* For testing and demonstrating voice interface
|
||||
*/
|
||||
export default function VoiceTestPage() {
|
||||
const [activeTab, setActiveTab] = useState<'simple' | 'full'>('full')
|
||||
const [transcripts, setTranscripts] = useState<string[]>([])
|
||||
const [intents, setIntents] = useState<{ intent: string; params: Record<string, unknown> }[]>([])
|
||||
const [tasks, setTasks] = useState<VoiceTask[]>([])
|
||||
|
||||
const handleTranscript = (text: string, isFinal: boolean) => {
|
||||
if (isFinal) {
|
||||
setTranscripts((prev) => [...prev.slice(-9), text])
|
||||
}
|
||||
}
|
||||
|
||||
const handleIntent = (intent: string, params: Record<string, unknown>) => {
|
||||
setIntents((prev) => [...prev.slice(-9), { intent, params }])
|
||||
}
|
||||
|
||||
const handleTaskCreated = (task: VoiceTask) => {
|
||||
setTasks((prev) => [...prev.slice(-9), task])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-100 to-gray-200 p-8">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-2">
|
||||
Breakpilot Voice Test
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
Testen Sie die Sprachsteuerung fuer Breakpilot. Sprechen Sie Befehle wie:
|
||||
</p>
|
||||
<ul className="mt-2 text-sm text-gray-500 list-disc list-inside">
|
||||
<li>"Notiz zu Max: heute wiederholt gestoert"</li>
|
||||
<li>"Erinner mich morgen an Hausaufgabenkontrolle"</li>
|
||||
<li>"Erstelle Arbeitsblatt mit 3 Lueckentexten"</li>
|
||||
<li>"Elternbrief wegen wiederholter Stoerungen"</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Tab switcher */}
|
||||
<div className="flex gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('full')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
activeTab === 'full'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Volle Ansicht
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('simple')}
|
||||
className={`px-4 py-2 rounded-lg transition-colors ${
|
||||
activeTab === 'simple'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
Einfacher Modus
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Voice Component */}
|
||||
<div>
|
||||
{activeTab === 'full' ? (
|
||||
<VoiceCommandBar
|
||||
onTaskCreated={handleTaskCreated}
|
||||
className="h-[500px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Sprachaufnahme</h2>
|
||||
<VoiceCapture
|
||||
onTranscript={handleTranscript}
|
||||
onIntent={handleIntent}
|
||||
onTaskCreated={handleTaskCreated}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Debug panel */}
|
||||
<div className="space-y-6">
|
||||
{/* Transcripts */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Erkannte Texte</h2>
|
||||
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
||||
{transcripts.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">
|
||||
Noch keine Transkripte...
|
||||
</p>
|
||||
) : (
|
||||
transcripts.map((t, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-gray-50 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
{t}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Intents */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Erkannte Absichten</h2>
|
||||
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
||||
{intents.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">Noch keine Intents...</p>
|
||||
) : (
|
||||
intents.map((intent, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-blue-50 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="font-medium text-blue-700">
|
||||
{intent.intent}
|
||||
</span>
|
||||
{Object.keys(intent.params).length > 0 && (
|
||||
<pre className="mt-1 text-xs text-gray-500">
|
||||
{JSON.stringify(intent.params, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Erstellte Aufgaben</h2>
|
||||
<div className="space-y-2 max-h-[150px] overflow-y-auto">
|
||||
{tasks.length === 0 ? (
|
||||
<p className="text-gray-400 text-sm">
|
||||
Noch keine Aufgaben...
|
||||
</p>
|
||||
) : (
|
||||
tasks.map((task, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-green-50 rounded px-3 py-2 text-sm"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-medium text-green-700">
|
||||
{task.type}
|
||||
</span>
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs ${
|
||||
task.state === 'completed'
|
||||
? 'bg-green-200 text-green-800'
|
||||
: task.state === 'ready'
|
||||
? 'bg-yellow-200 text-yellow-800'
|
||||
: 'bg-gray-200 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{task.state}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
ID: {task.id.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mt-8 bg-white rounded-xl shadow-lg p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Anleitung</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 text-sm">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700 mb-2">
|
||||
1. Notizen & Beobachtungen
|
||||
</h3>
|
||||
<ul className="text-gray-500 space-y-1">
|
||||
<li>• "Notiz zu [Name]: [Beobachtung]"</li>
|
||||
<li>• "[Name] braucht extra Uebung"</li>
|
||||
<li>• "Hausaufgabe kontrollieren"</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700 mb-2">
|
||||
2. Materialerstellung
|
||||
</h3>
|
||||
<ul className="text-gray-500 space-y-1">
|
||||
<li>• "Arbeitsblatt erstellen"</li>
|
||||
<li>• "Quiz mit 10 Fragen"</li>
|
||||
<li>• "Elternbrief wegen..."</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-700 mb-2">
|
||||
3. Organisation
|
||||
</h3>
|
||||
<ul className="text-gray-500 space-y-1">
|
||||
<li>• "Erinner mich morgen..."</li>
|
||||
<li>• "Nachricht an Klasse 8a"</li>
|
||||
<li>• "Offene Aufgaben zeigen"</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy note */}
|
||||
<div className="mt-6 text-center text-sm text-gray-400">
|
||||
<p>
|
||||
DSGVO-konform: Audio wird nur im Arbeitsspeicher verarbeitet und
|
||||
nie gespeichert.
|
||||
</p>
|
||||
<p>
|
||||
Alle personenbezogenen Daten werden verschluesselt gespeichert -
|
||||
der Schluessel bleibt auf Ihrem Geraet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,899 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
|
||||
|
||||
// LocalStorage Key for upload session
|
||||
const SESSION_ID_KEY = 'bp_cleanup_session'
|
||||
|
||||
/**
|
||||
* Worksheet Cleanup Page - Apple Weather Dashboard Style
|
||||
*
|
||||
* Design principles:
|
||||
* - Dark gradient background
|
||||
* - Ultra-translucent glass cards (~8% opacity)
|
||||
* - White text, monochrome palette
|
||||
* - Step-by-step cleanup wizard
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// GLASS CARD - Ultra Transparent
|
||||
// =============================================================================
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
delay?: number
|
||||
}
|
||||
|
||||
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps & { isDark?: boolean }) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'p-4',
|
||||
md: 'p-5',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-3xl
|
||||
${sizeClasses[size]}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
background: isDark
|
||||
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
|
||||
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
|
||||
backdropFilter: 'blur(24px) saturate(180%)',
|
||||
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
||||
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
||||
boxShadow: isDark
|
||||
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
||||
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible
|
||||
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
|
||||
: 'translateY(20px)',
|
||||
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROGRESS RING
|
||||
// =============================================================================
|
||||
|
||||
interface ProgressRingProps {
|
||||
progress: number
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
label: string
|
||||
value: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
function ProgressRing({
|
||||
progress,
|
||||
size = 80,
|
||||
strokeWidth = 6,
|
||||
label,
|
||||
value,
|
||||
color = '#a78bfa'
|
||||
}: ProgressRingProps) {
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = radius * 2 * Math.PI
|
||||
const offset = circumference - (progress / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg className="transform -rotate-90" width={size} height={size}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="rgba(255, 255, 255, 0.1)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
strokeLinecap="round"
|
||||
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-bold text-white">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="mt-2 text-xs text-white/50">{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface PreviewResult {
|
||||
has_handwriting: boolean
|
||||
confidence: number
|
||||
handwriting_ratio: number
|
||||
image_width: number
|
||||
image_height: number
|
||||
estimated_times_ms: {
|
||||
detection: number
|
||||
inpainting: number
|
||||
reconstruction: number
|
||||
total: number
|
||||
}
|
||||
}
|
||||
|
||||
interface PipelineResult {
|
||||
success: boolean
|
||||
handwriting_detected: boolean
|
||||
handwriting_removed: boolean
|
||||
layout_reconstructed: boolean
|
||||
cleaned_image_base64?: string
|
||||
fabric_json?: any
|
||||
metadata: any
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// MAIN PAGE
|
||||
// =============================================================================
|
||||
|
||||
export default function WorksheetCleanupPage() {
|
||||
const { isDark } = useTheme()
|
||||
const router = useRouter()
|
||||
|
||||
// File state
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [cleanedUrl, setCleanedUrl] = useState<string | null>(null)
|
||||
const [maskUrl, setMaskUrl] = useState<string | null>(null)
|
||||
|
||||
// Loading states
|
||||
const [isPreviewing, setIsPreviewing] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Results
|
||||
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
|
||||
const [pipelineResult, setPipelineResult] = useState<PipelineResult | null>(null)
|
||||
|
||||
// Options
|
||||
const [removeHandwriting, setRemoveHandwriting] = useState(true)
|
||||
const [reconstructLayout, setReconstructLayout] = useState(true)
|
||||
const [inpaintingMethod, setInpaintingMethod] = useState<string>('auto')
|
||||
|
||||
// Step tracking
|
||||
const [currentStep, setCurrentStep] = useState<'upload' | 'preview' | 'processing' | 'result'>('upload')
|
||||
|
||||
// QR Code Upload
|
||||
const [showQRModal, setShowQRModal] = useState(false)
|
||||
const [uploadSessionId, setUploadSessionId] = useState('')
|
||||
const [mobileUploadedFiles, setMobileUploadedFiles] = useState<UploadedFile[]>([])
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Initialize upload session ID
|
||||
useEffect(() => {
|
||||
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
||||
if (!storedSessionId) {
|
||||
storedSessionId = `cleanup-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
||||
}
|
||||
setUploadSessionId(storedSessionId)
|
||||
}, [])
|
||||
|
||||
const getApiUrl = useCallback(() => {
|
||||
if (typeof window === 'undefined') return 'http://localhost:8086'
|
||||
const { hostname, protocol } = window.location
|
||||
return hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
||||
}, [])
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = useCallback((selectedFile: File) => {
|
||||
setFile(selectedFile)
|
||||
setError(null)
|
||||
setPreviewResult(null)
|
||||
setPipelineResult(null)
|
||||
setCleanedUrl(null)
|
||||
setMaskUrl(null)
|
||||
|
||||
const url = URL.createObjectURL(selectedFile)
|
||||
setPreviewUrl(url)
|
||||
setCurrentStep('upload')
|
||||
}, [])
|
||||
|
||||
// Handle mobile file selection - convert to File and trigger handleFileSelect
|
||||
const handleMobileFileSelect = useCallback(async (uploadedFile: UploadedFile) => {
|
||||
try {
|
||||
const base64Data = uploadedFile.dataUrl.split(',')[1]
|
||||
const byteCharacters = atob(base64Data)
|
||||
const byteNumbers = new Array(byteCharacters.length)
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers)
|
||||
const blob = new Blob([byteArray], { type: uploadedFile.type })
|
||||
const file = new File([blob], uploadedFile.name, { type: uploadedFile.type })
|
||||
handleFileSelect(file)
|
||||
setShowQRModal(false)
|
||||
} catch (error) {
|
||||
console.error('Failed to convert mobile file:', error)
|
||||
setError('Fehler beim Laden der Datei vom Handy')
|
||||
}
|
||||
}, [handleFileSelect])
|
||||
|
||||
// Handle drop
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
const droppedFile = e.dataTransfer.files[0]
|
||||
if (droppedFile && droppedFile.type.startsWith('image/')) {
|
||||
handleFileSelect(droppedFile)
|
||||
}
|
||||
}, [handleFileSelect])
|
||||
|
||||
// Preview cleanup
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
setIsPreviewing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/preview-cleanup`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
setPreviewResult(result)
|
||||
setCurrentStep('preview')
|
||||
} catch (err) {
|
||||
console.error('Preview failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen')
|
||||
} finally {
|
||||
setIsPreviewing(false)
|
||||
}
|
||||
}, [file, getApiUrl])
|
||||
|
||||
// Run full cleanup pipeline
|
||||
const handleCleanup = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
setIsProcessing(true)
|
||||
setCurrentStep('processing')
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
formData.append('remove_handwriting', String(removeHandwriting))
|
||||
formData.append('reconstruct', String(reconstructLayout))
|
||||
formData.append('inpainting_method', inpaintingMethod)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result: PipelineResult = await response.json()
|
||||
setPipelineResult(result)
|
||||
|
||||
// Create cleaned image URL
|
||||
if (result.cleaned_image_base64) {
|
||||
const cleanedBlob = await fetch(`data:image/png;base64,${result.cleaned_image_base64}`).then(r => r.blob())
|
||||
setCleanedUrl(URL.createObjectURL(cleanedBlob))
|
||||
}
|
||||
|
||||
setCurrentStep('result')
|
||||
} catch (err) {
|
||||
console.error('Cleanup failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Bereinigung fehlgeschlagen')
|
||||
setCurrentStep('preview')
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [file, removeHandwriting, reconstructLayout, inpaintingMethod, getApiUrl])
|
||||
|
||||
// Get detection mask
|
||||
const handleGetMask = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting/mask`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
setMaskUrl(URL.createObjectURL(blob))
|
||||
} catch (err) {
|
||||
console.error('Mask fetch failed:', err)
|
||||
}
|
||||
}, [file, getApiUrl])
|
||||
|
||||
// Open in worksheet editor
|
||||
const handleOpenInEditor = useCallback(() => {
|
||||
if (pipelineResult?.fabric_json) {
|
||||
// Store the fabric JSON in sessionStorage
|
||||
sessionStorage.setItem('worksheetCleanupResult', JSON.stringify(pipelineResult.fabric_json))
|
||||
router.push('/worksheet-editor')
|
||||
}
|
||||
}, [pipelineResult, router])
|
||||
|
||||
// Reset to start
|
||||
const handleReset = useCallback(() => {
|
||||
setFile(null)
|
||||
setPreviewUrl(null)
|
||||
setCleanedUrl(null)
|
||||
setMaskUrl(null)
|
||||
setPreviewResult(null)
|
||||
setPipelineResult(null)
|
||||
setError(null)
|
||||
setCurrentStep('upload')
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
||||
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
||||
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className={`text-3xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt bereinigen</h1>
|
||||
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Handschrift entfernen und Layout rekonstruieren</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<ThemeToggle />
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center gap-4 mb-8">
|
||||
{['upload', 'preview', 'processing', 'result'].map((step, idx) => (
|
||||
<div key={step} className="flex items-center">
|
||||
<div className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all
|
||||
${currentStep === step
|
||||
? 'bg-purple-500 text-white shadow-lg shadow-purple-500/50'
|
||||
: ['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx
|
||||
? 'bg-green-500 text-white'
|
||||
: isDark ? 'bg-white/10 text-white/40' : 'bg-slate-200 text-slate-400'
|
||||
}
|
||||
`}>
|
||||
{['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx ? (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
idx + 1
|
||||
)}
|
||||
</div>
|
||||
{idx < 3 && (
|
||||
<div className={`w-16 h-0.5 mx-2 ${
|
||||
['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx
|
||||
? 'bg-green-500'
|
||||
: isDark ? 'bg-white/20' : 'bg-slate-300'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<GlassCard className="mb-6" size="sm" isDark={isDark}>
|
||||
<div className="flex items-center gap-3 text-red-400">
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Content based on step */}
|
||||
<div className="flex-1">
|
||||
{/* Step 1: Upload */}
|
||||
{currentStep === 'upload' && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Upload Options - File and QR Code side by side */}
|
||||
<GlassCard className="col-span-1" delay={100}>
|
||||
<div
|
||||
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 min-h-[280px] flex flex-col items-center justify-center"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
|
||||
className="hidden"
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<div className="space-y-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="max-h-40 mx-auto rounded-xl shadow-2xl"
|
||||
/>
|
||||
<p className="text-white font-medium text-sm">{file?.name}</p>
|
||||
<p className="text-white/50 text-xs">Klicke zum Ändern</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-16 h-16 mx-auto mb-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className="text-xl font-semibold text-white mb-2">Datei auswählen</p>
|
||||
<p className="text-white/50 text-sm mb-2">Ziehe ein Bild hierher oder klicke</p>
|
||||
<p className="text-white/30 text-xs">PNG, JPG, JPEG</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* QR Code Upload */}
|
||||
<GlassCard className="col-span-1" delay={150}>
|
||||
<div
|
||||
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 min-h-[280px] flex flex-col items-center justify-center"
|
||||
onClick={() => setShowQRModal(true)}
|
||||
>
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
||||
<span className="text-3xl">📱</span>
|
||||
</div>
|
||||
<p className="text-xl font-semibold text-white mb-2">Mit Handy scannen</p>
|
||||
<p className="text-white/50 text-sm mb-2">QR-Code scannen um Foto hochzuladen</p>
|
||||
<p className="text-white/30 text-xs">Im lokalen Netzwerk</p>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Options */}
|
||||
{file && (
|
||||
<>
|
||||
<GlassCard delay={200}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Optionen</h3>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeHandwriting}
|
||||
onChange={(e) => setRemoveHandwriting(e.target.checked)}
|
||||
className="w-5 h-5 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-white font-medium group-hover:text-purple-300 transition-colors">
|
||||
Handschrift entfernen
|
||||
</span>
|
||||
<p className="text-white/40 text-sm">Erkennt und entfernt handgeschriebene Inhalte</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-4 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reconstructLayout}
|
||||
onChange={(e) => setReconstructLayout(e.target.checked)}
|
||||
className="w-5 h-5 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-white font-medium group-hover:text-purple-300 transition-colors">
|
||||
Layout rekonstruieren
|
||||
</span>
|
||||
<p className="text-white/40 text-sm">Erstellt bearbeitbare Textblöcke</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
<GlassCard delay={300}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Methode</h3>
|
||||
<select
|
||||
value={inpaintingMethod}
|
||||
onChange={(e) => setInpaintingMethod(e.target.value)}
|
||||
className="w-full p-3 rounded-xl bg-white/10 border border-white/20 text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
||||
>
|
||||
<option value="auto">Automatisch (empfohlen)</option>
|
||||
<option value="opencv_telea">OpenCV Telea (schnell)</option>
|
||||
<option value="opencv_ns">OpenCV NS (glatter)</option>
|
||||
</select>
|
||||
<p className="text-white/40 text-sm mt-3">
|
||||
Die automatische Methode wählt die beste Option basierend auf dem Bildinhalt.
|
||||
</p>
|
||||
</GlassCard>
|
||||
|
||||
{/* Action Button */}
|
||||
<div className="col-span-1 lg:col-span-2 flex justify-center">
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={isPreviewing}
|
||||
className="px-8 py-4 rounded-2xl font-semibold text-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center gap-3"
|
||||
>
|
||||
{isPreviewing ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Analysiere...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
Vorschau
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Preview */}
|
||||
{currentStep === 'preview' && previewResult && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Stats */}
|
||||
<GlassCard delay={100}>
|
||||
<h3 className="text-lg font-semibold text-white mb-6">Analyse</h3>
|
||||
<div className="flex justify-around">
|
||||
<ProgressRing
|
||||
progress={previewResult.confidence * 100}
|
||||
label="Konfidenz"
|
||||
value={`${Math.round(previewResult.confidence * 100)}%`}
|
||||
color={previewResult.has_handwriting ? '#f97316' : '#22c55e'}
|
||||
/>
|
||||
<ProgressRing
|
||||
progress={previewResult.handwriting_ratio * 100 * 10}
|
||||
label="Handschrift"
|
||||
value={`${(previewResult.handwriting_ratio * 100).toFixed(1)}%`}
|
||||
color="#a78bfa"
|
||||
/>
|
||||
</div>
|
||||
<div className={`mt-6 p-4 rounded-xl text-center ${
|
||||
previewResult.has_handwriting
|
||||
? 'bg-orange-500/20 text-orange-300'
|
||||
: 'bg-green-500/20 text-green-300'
|
||||
}`}>
|
||||
{previewResult.has_handwriting
|
||||
? 'Handschrift erkannt'
|
||||
: 'Keine Handschrift gefunden'}
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Time Estimates */}
|
||||
<GlassCard delay={200}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Geschätzte Zeit</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Erkennung</span>
|
||||
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.detection / 1000)}s</span>
|
||||
</div>
|
||||
{removeHandwriting && previewResult.has_handwriting && (
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Bereinigung</span>
|
||||
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.inpainting / 1000)}s</span>
|
||||
</div>
|
||||
)}
|
||||
{reconstructLayout && (
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Layout</span>
|
||||
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.reconstruction / 1000)}s</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between pt-3 border-t border-white/10 font-medium">
|
||||
<span className="text-white">Gesamt</span>
|
||||
<span className="text-purple-300">~{Math.round(previewResult.estimated_times_ms.total / 1000)}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Image Info */}
|
||||
<GlassCard delay={300}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Bild-Info</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Breite</span>
|
||||
<span className="text-white">{previewResult.image_width}px</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Höhe</span>
|
||||
<span className="text-white">{previewResult.image_height}px</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-white/70">
|
||||
<span>Pixel</span>
|
||||
<span className="text-white">{(previewResult.image_width * previewResult.image_height / 1000000).toFixed(1)}MP</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleGetMask}
|
||||
className="w-full mt-4 px-4 py-2 rounded-xl bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-all text-sm"
|
||||
>
|
||||
Maske anzeigen
|
||||
</button>
|
||||
</GlassCard>
|
||||
|
||||
{/* Preview Images */}
|
||||
<GlassCard className="col-span-1 lg:col-span-2" delay={400}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Original</h3>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Original"
|
||||
className="w-full max-h-96 object-contain rounded-xl"
|
||||
/>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{maskUrl && (
|
||||
<GlassCard delay={500}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Maske</h3>
|
||||
<img
|
||||
src={maskUrl}
|
||||
alt="Mask"
|
||||
className="w-full max-h-96 object-contain rounded-xl"
|
||||
/>
|
||||
</GlassCard>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="col-span-1 lg:col-span-3 flex justify-center gap-4">
|
||||
<button
|
||||
onClick={() => setCurrentStep('upload')}
|
||||
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCleanup}
|
||||
disabled={isProcessing}
|
||||
className="px-8 py-3 rounded-xl font-semibold bg-gradient-to-r from-orange-500 to-red-500 text-white hover:shadow-lg hover:shadow-orange-500/30 transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
Bereinigen starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Processing */}
|
||||
{currentStep === 'processing' && (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<GlassCard className="text-center max-w-md" delay={0}>
|
||||
<div className="w-20 h-20 mx-auto mb-6 relative">
|
||||
<div className="absolute inset-0 rounded-full border-4 border-white/10"></div>
|
||||
<div className="absolute inset-0 rounded-full border-4 border-purple-500 border-t-transparent animate-spin"></div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-white mb-2">Verarbeite Bild...</h3>
|
||||
<p className="text-white/50">
|
||||
{removeHandwriting ? 'Handschrift wird erkannt und entfernt' : 'Bild wird analysiert'}
|
||||
</p>
|
||||
</GlassCard>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Result */}
|
||||
{currentStep === 'result' && pipelineResult && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Status */}
|
||||
<GlassCard className="col-span-1 lg:col-span-2" delay={100}>
|
||||
<div className={`flex items-center gap-4 ${
|
||||
pipelineResult.success ? 'text-green-300' : 'text-red-300'
|
||||
}`}>
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
||||
pipelineResult.success ? 'bg-green-500/20' : 'bg-red-500/20'
|
||||
}`}>
|
||||
{pipelineResult.success ? (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold">
|
||||
{pipelineResult.success ? 'Erfolgreich bereinigt!' : 'Verarbeitung fehlgeschlagen'}
|
||||
</h3>
|
||||
<p className="text-white/50">
|
||||
{pipelineResult.handwriting_removed
|
||||
? `Handschrift wurde entfernt. ${pipelineResult.metadata?.layout?.element_count || 0} Elemente erkannt.`
|
||||
: pipelineResult.handwriting_detected
|
||||
? 'Handschrift erkannt, aber nicht entfernt'
|
||||
: 'Keine Handschrift im Bild gefunden'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
|
||||
{/* Original */}
|
||||
<GlassCard delay={200}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Original</h3>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Original"
|
||||
className="w-full rounded-xl"
|
||||
/>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* Cleaned */}
|
||||
<GlassCard delay={300}>
|
||||
<h3 className="text-lg font-semibold text-white mb-4">Bereinigt</h3>
|
||||
{cleanedUrl ? (
|
||||
<img
|
||||
src={cleanedUrl}
|
||||
alt="Cleaned"
|
||||
className="w-full rounded-xl"
|
||||
/>
|
||||
) : (
|
||||
<div className="aspect-video rounded-xl bg-white/5 flex items-center justify-center text-white/40">
|
||||
Kein Bild
|
||||
</div>
|
||||
)}
|
||||
</GlassCard>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="col-span-1 lg:col-span-2 flex justify-center gap-4">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
Neues Bild
|
||||
</button>
|
||||
{cleanedUrl && (
|
||||
<a
|
||||
href={cleanedUrl}
|
||||
download="bereinigt.png"
|
||||
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
{pipelineResult.layout_reconstructed && pipelineResult.fabric_json && (
|
||||
<button
|
||||
onClick={handleOpenInEditor}
|
||||
className="px-8 py-3 rounded-xl font-semibold bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/30 transition-all flex items-center gap-2"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
Im Editor öffnen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* QR Code Modal */}
|
||||
{showQRModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
||||
<div className="relative w-full max-w-md rounded-3xl bg-slate-900">
|
||||
<QRCodeUpload
|
||||
sessionId={uploadSessionId}
|
||||
onClose={() => setShowQRModal(false)}
|
||||
onFilesChanged={(files) => {
|
||||
setMobileUploadedFiles(files)
|
||||
}}
|
||||
/>
|
||||
{/* Select button for mobile files */}
|
||||
{mobileUploadedFiles.length > 0 && (
|
||||
<div className="p-4 border-t border-white/10">
|
||||
<p className="text-white/60 text-sm mb-3">Datei auswählen:</p>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{mobileUploadedFiles.map((file) => (
|
||||
<button
|
||||
key={file.id}
|
||||
onClick={() => handleMobileFileSelect(file)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all bg-white/5 hover:bg-white/10 border border-white/10"
|
||||
>
|
||||
<span className="text-xl">{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium truncate">{file.name}</p>
|
||||
<p className="text-white/50 text-xs">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
<span className="text-purple-400 text-sm">Verwenden →</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { WorksheetProvider, useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { EditorToolbar } from '@/components/worksheet-editor/EditorToolbar'
|
||||
import { PropertiesPanel } from '@/components/worksheet-editor/PropertiesPanel'
|
||||
import { CanvasControls } from '@/components/worksheet-editor/CanvasControls'
|
||||
import { PageNavigator } from '@/components/worksheet-editor/PageNavigator'
|
||||
import { AIImageGenerator } from '@/components/worksheet-editor/AIImageGenerator'
|
||||
import { ExportPanel } from '@/components/worksheet-editor/ExportPanel'
|
||||
import { AIPromptBar } from '@/components/worksheet-editor/AIPromptBar'
|
||||
import { DocumentImporter } from '@/components/worksheet-editor/DocumentImporter'
|
||||
import { CleanupPanel } from '@/components/worksheet-editor/CleanupPanel'
|
||||
import { OCRImportPanel } from '@/components/worksheet-editor/OCRImportPanel'
|
||||
import { ThemeToggle } from '@/components/ThemeToggle'
|
||||
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
||||
|
||||
// Dynamic import to prevent SSR issues with Fabric.js
|
||||
const FabricCanvas = dynamic(
|
||||
() => import('@/components/worksheet-editor/FabricCanvas').then(mod => mod.FabricCanvas),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// Storage key for saved worksheets
|
||||
const WORKSHEETS_KEY = 'bp_worksheets'
|
||||
|
||||
interface SavedWorksheet {
|
||||
id: string
|
||||
title: string
|
||||
updatedAt: string
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
function WorksheetEditorContent() {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const { document, setDocument, isDirty, setIsDirty, saveDocument, loadDocument, canvas } = useWorksheet()
|
||||
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [isAIGeneratorOpen, setIsAIGeneratorOpen] = useState(false)
|
||||
const [isExportPanelOpen, setIsExportPanelOpen] = useState(false)
|
||||
const [isDocumentImporterOpen, setIsDocumentImporterOpen] = useState(false)
|
||||
const [isCleanupPanelOpen, setIsCleanupPanelOpen] = useState(false)
|
||||
const [isOCRImportOpen, setIsOCRImportOpen] = useState(false)
|
||||
const [isDocumentListOpen, setIsDocumentListOpen] = useState(false)
|
||||
const [savedWorksheets, setSavedWorksheets] = useState<SavedWorksheet[]>([])
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [title, setTitle] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
loadSavedWorksheets()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (document) {
|
||||
setTitle(document.title)
|
||||
}
|
||||
}, [document])
|
||||
|
||||
// Load saved worksheets from localStorage
|
||||
const loadSavedWorksheets = useCallback(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(WORKSHEETS_KEY)
|
||||
if (stored) {
|
||||
setSavedWorksheets(JSON.parse(stored))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load worksheets:', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save current worksheet
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!document) return
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
// Save to context (which saves to API or localStorage)
|
||||
await saveDocument()
|
||||
|
||||
// Update worksheets list
|
||||
const worksheetEntry: SavedWorksheet = {
|
||||
id: document.id,
|
||||
title: document.title,
|
||||
updatedAt: new Date().toISOString(),
|
||||
thumbnail: canvas?.toDataURL({ format: 'png', multiplier: 0.1 })
|
||||
}
|
||||
|
||||
setSavedWorksheets(prev => {
|
||||
const filtered = prev.filter(w => w.id !== document.id)
|
||||
const updated = [worksheetEntry, ...filtered]
|
||||
localStorage.setItem(WORKSHEETS_KEY, JSON.stringify(updated))
|
||||
return updated
|
||||
})
|
||||
|
||||
setIsDirty(false)
|
||||
} catch (e) {
|
||||
console.error('Save failed:', e)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [document, saveDocument, canvas, setIsDirty])
|
||||
|
||||
// Load a saved worksheet
|
||||
const handleLoadWorksheet = useCallback(async (id: string) => {
|
||||
try {
|
||||
await loadDocument(id)
|
||||
setIsDocumentListOpen(false)
|
||||
} catch (e) {
|
||||
console.error('Failed to load worksheet:', e)
|
||||
}
|
||||
}, [loadDocument])
|
||||
|
||||
// Delete a saved worksheet
|
||||
const handleDeleteWorksheet = useCallback((id: string) => {
|
||||
setSavedWorksheets(prev => {
|
||||
const updated = prev.filter(w => w.id !== id)
|
||||
localStorage.setItem(WORKSHEETS_KEY, JSON.stringify(updated))
|
||||
localStorage.removeItem(`worksheet_${id}`)
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Create new worksheet
|
||||
const handleNewWorksheet = useCallback(() => {
|
||||
const newDoc = {
|
||||
id: `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
title: 'Neues Arbeitsblatt',
|
||||
pages: [{
|
||||
id: `page_${Date.now()}`,
|
||||
index: 0,
|
||||
canvasJSON: ''
|
||||
}],
|
||||
pageFormat: {
|
||||
width: 210,
|
||||
height: 297,
|
||||
orientation: 'portrait' as const,
|
||||
margins: { top: 15, right: 15, bottom: 15, left: 15 }
|
||||
},
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
}
|
||||
setDocument(newDoc)
|
||||
setIsDocumentListOpen(false)
|
||||
if (canvas) {
|
||||
canvas.clear()
|
||||
canvas.backgroundColor = '#ffffff'
|
||||
canvas.renderAll()
|
||||
}
|
||||
}, [setDocument, canvas])
|
||||
|
||||
const handleTitleChange = (newTitle: string) => {
|
||||
setTitle(newTitle)
|
||||
if (document) {
|
||||
setDocument({
|
||||
...document,
|
||||
title: newTitle,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className={`min-h-screen flex items-center justify-center ${
|
||||
isDark ? 'bg-slate-900' : 'bg-slate-100'
|
||||
}`}>
|
||||
<div className="animate-spin w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs */}
|
||||
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
|
||||
isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'
|
||||
}`} />
|
||||
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
|
||||
isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${
|
||||
isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'
|
||||
}`} />
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 p-4 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<h1 className={`text-2xl font-bold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt-Editor</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
placeholder="Arbeitsblatt-Titel..."
|
||||
className={`text-sm px-3 py-1.5 rounded-lg border transition-all w-56 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400'
|
||||
: 'bg-white/50 border-slate-300 text-slate-900 placeholder-slate-400 focus:border-purple-500'
|
||||
}`}
|
||||
/>
|
||||
{isDirty && (
|
||||
<span className={`px-2 py-1 rounded-lg text-xs font-medium ${
|
||||
isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
Ungespeichert
|
||||
</span>
|
||||
)}
|
||||
{/* Save Button */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !isDirty}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-all ${
|
||||
isDirty
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/40 cursor-not-allowed'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{isSaving ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
)}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Document List Button */}
|
||||
<button
|
||||
onClick={() => setIsDocumentListOpen(true)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-white text-slate-700 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Meine Arbeitsblätter
|
||||
</button>
|
||||
|
||||
{/* Export Button */}
|
||||
<button
|
||||
onClick={() => setIsExportPanelOpen(true)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Exportieren
|
||||
</button>
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Language Dropdown */}
|
||||
<LanguageDropdown />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor Area - New Layout */}
|
||||
<div className="flex-1 flex gap-4 overflow-hidden">
|
||||
{/* Left Toolbar */}
|
||||
<div className="flex-shrink-0">
|
||||
<EditorToolbar
|
||||
onOpenAIGenerator={() => setIsAIGeneratorOpen(true)}
|
||||
onOpenDocumentImporter={() => setIsDocumentImporterOpen(true)}
|
||||
onOpenCleanupPanel={() => setIsCleanupPanelOpen(true)}
|
||||
onOpenOCRImport={() => setIsOCRImportOpen(true)}
|
||||
className="h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Canvas Area - takes remaining space */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{/* Canvas with fixed aspect ratio container */}
|
||||
<div className={`flex-1 overflow-auto rounded-xl ${
|
||||
isDark ? 'bg-slate-800/50' : 'bg-slate-200/50'
|
||||
}`}>
|
||||
<FabricCanvas className="h-full" />
|
||||
</div>
|
||||
|
||||
{/* Bottom Controls */}
|
||||
<div className="flex items-center justify-between gap-4 py-2">
|
||||
<PageNavigator />
|
||||
<CanvasControls />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel - AI Prompt + Properties */}
|
||||
<div className="w-80 flex-shrink-0 flex flex-col gap-4 overflow-hidden">
|
||||
{/* AI Prompt Bar */}
|
||||
<div className="flex-shrink-0">
|
||||
<AIPromptBar />
|
||||
</div>
|
||||
|
||||
{/* Properties Panel */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PropertiesPanel className="h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document List Modal */}
|
||||
{isDocumentListOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setIsDocumentListOpen(false)} />
|
||||
<div className={`relative w-full max-w-2xl rounded-3xl p-6 ${
|
||||
isDark ? 'bg-slate-900/95' : 'bg-white/95'
|
||||
} backdrop-blur-xl border ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Meine Arbeitsblätter
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setIsDocumentListOpen(false)}
|
||||
className={`p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className={`w-5 h-5 ${isDark ? 'text-white' : 'text-slate-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New Worksheet Button */}
|
||||
<button
|
||||
onClick={handleNewWorksheet}
|
||||
className={`w-full mb-4 p-4 rounded-xl border-2 border-dashed transition-all flex items-center justify-center gap-2 ${
|
||||
isDark
|
||||
? 'border-white/20 text-white/60 hover:border-purple-400 hover:text-purple-300 hover:bg-purple-500/10'
|
||||
: 'border-slate-300 text-slate-500 hover:border-purple-500 hover:text-purple-600 hover:bg-purple-50'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Neues Arbeitsblatt erstellen
|
||||
</button>
|
||||
|
||||
{/* Worksheets List */}
|
||||
<div className="max-h-96 overflow-y-auto space-y-2">
|
||||
{savedWorksheets.length === 0 ? (
|
||||
<div className={`text-center py-8 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<svg className="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p>Noch keine Arbeitsblätter gespeichert</p>
|
||||
</div>
|
||||
) : (
|
||||
savedWorksheets.map((worksheet) => (
|
||||
<div
|
||||
key={worksheet.id}
|
||||
className={`flex items-center gap-4 p-4 rounded-xl transition-all cursor-pointer ${
|
||||
isDark
|
||||
? 'bg-white/5 hover:bg-white/10'
|
||||
: 'bg-slate-50 hover:bg-slate-100'
|
||||
} ${document?.id === worksheet.id ? (isDark ? 'ring-2 ring-purple-500' : 'ring-2 ring-purple-500') : ''}`}
|
||||
onClick={() => handleLoadWorksheet(worksheet.id)}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className={`w-16 h-20 rounded-lg flex-shrink-0 overflow-hidden ${
|
||||
isDark ? 'bg-white/10' : 'bg-slate-200'
|
||||
}`}>
|
||||
{worksheet.thumbnail ? (
|
||||
<img src={worksheet.thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-white/30' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{worksheet.title}
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{new Date(worksheet.updatedAt).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
{document?.id === worksheet.id && (
|
||||
<span className="inline-block mt-1 px-2 py-0.5 rounded text-xs bg-purple-500/20 text-purple-400">
|
||||
Aktuell geöffnet
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (confirm('Arbeitsblatt wirklich löschen?')) {
|
||||
handleDeleteWorksheet(worksheet.id)
|
||||
}
|
||||
}}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-red-500/20 text-white/50 hover:text-red-400' : 'hover:bg-red-50 text-slate-400 hover:text-red-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<AIImageGenerator
|
||||
isOpen={isAIGeneratorOpen}
|
||||
onClose={() => setIsAIGeneratorOpen(false)}
|
||||
/>
|
||||
|
||||
<ExportPanel
|
||||
isOpen={isExportPanelOpen}
|
||||
onClose={() => setIsExportPanelOpen(false)}
|
||||
/>
|
||||
|
||||
<DocumentImporter
|
||||
isOpen={isDocumentImporterOpen}
|
||||
onClose={() => setIsDocumentImporterOpen(false)}
|
||||
/>
|
||||
|
||||
<CleanupPanel
|
||||
isOpen={isCleanupPanelOpen}
|
||||
onClose={() => setIsCleanupPanelOpen(false)}
|
||||
/>
|
||||
|
||||
<OCRImportPanel
|
||||
isOpen={isOCRImportOpen}
|
||||
onClose={() => setIsOCRImportOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WorksheetEditorPage() {
|
||||
return (
|
||||
<WorksheetProvider>
|
||||
<WorksheetEditorContent />
|
||||
</WorksheetProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Worksheet Editor - TypeScript Interfaces
|
||||
*
|
||||
* Types for the visual worksheet editor using Fabric.js
|
||||
*/
|
||||
|
||||
import type { Canvas, Object as FabricObject } from 'fabric'
|
||||
|
||||
// Tool Types
|
||||
export type EditorTool =
|
||||
| 'select'
|
||||
| 'text'
|
||||
| 'rectangle'
|
||||
| 'circle'
|
||||
| 'line'
|
||||
| 'arrow'
|
||||
| 'image'
|
||||
| 'ai-image'
|
||||
| 'table'
|
||||
|
||||
// Text Alignment
|
||||
export type TextAlign = 'left' | 'center' | 'right' | 'justify'
|
||||
|
||||
// Font Weight
|
||||
export type FontWeight = 'normal' | 'bold'
|
||||
|
||||
// Font Style
|
||||
export type FontStyle = 'normal' | 'italic'
|
||||
|
||||
// Object Type
|
||||
export type WorksheetObjectType =
|
||||
| 'text'
|
||||
| 'image'
|
||||
| 'rectangle'
|
||||
| 'circle'
|
||||
| 'line'
|
||||
| 'arrow'
|
||||
| 'table'
|
||||
| 'ai-image'
|
||||
|
||||
// Base Object Properties
|
||||
export interface BaseObjectProps {
|
||||
id: string
|
||||
type: WorksheetObjectType
|
||||
left: number
|
||||
top: number
|
||||
width?: number
|
||||
height?: number
|
||||
angle: number
|
||||
opacity: number
|
||||
fill?: string
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
locked?: boolean
|
||||
}
|
||||
|
||||
// Text Object Properties
|
||||
export interface TextObjectProps extends BaseObjectProps {
|
||||
type: 'text'
|
||||
text: string
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
fontWeight: FontWeight
|
||||
fontStyle: FontStyle
|
||||
textAlign: TextAlign
|
||||
lineHeight: number
|
||||
charSpacing: number
|
||||
underline?: boolean
|
||||
linethrough?: boolean
|
||||
}
|
||||
|
||||
// Image Object Properties
|
||||
export interface ImageObjectProps extends BaseObjectProps {
|
||||
type: 'image' | 'ai-image'
|
||||
src: string
|
||||
originalWidth: number
|
||||
originalHeight: number
|
||||
cropX?: number
|
||||
cropY?: number
|
||||
cropWidth?: number
|
||||
cropHeight?: number
|
||||
}
|
||||
|
||||
// Shape Object Properties
|
||||
export interface ShapeObjectProps extends BaseObjectProps {
|
||||
type: 'rectangle' | 'circle' | 'line' | 'arrow'
|
||||
rx?: number // Corner radius for rectangles
|
||||
ry?: number
|
||||
}
|
||||
|
||||
// Table Object Properties
|
||||
export interface TableObjectProps extends BaseObjectProps {
|
||||
type: 'table'
|
||||
rows: number
|
||||
cols: number
|
||||
cellWidth: number
|
||||
cellHeight: number
|
||||
cellData: string[][]
|
||||
}
|
||||
|
||||
// Union type for all objects
|
||||
export type WorksheetObject =
|
||||
| TextObjectProps
|
||||
| ImageObjectProps
|
||||
| ShapeObjectProps
|
||||
| TableObjectProps
|
||||
|
||||
// Page
|
||||
export interface WorksheetPage {
|
||||
id: string
|
||||
index: number
|
||||
canvasJSON: string // Serialized Fabric.js canvas state
|
||||
thumbnail?: string
|
||||
}
|
||||
|
||||
// Worksheet Document
|
||||
export interface WorksheetDocument {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
pages: WorksheetPage[]
|
||||
pageFormat: PageFormat
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// Page Format
|
||||
export interface PageFormat {
|
||||
width: number // in mm
|
||||
height: number // in mm
|
||||
orientation: 'portrait' | 'landscape'
|
||||
margins: {
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
left: number
|
||||
}
|
||||
}
|
||||
|
||||
// Default A4 Format
|
||||
export const DEFAULT_PAGE_FORMAT: PageFormat = {
|
||||
width: 210,
|
||||
height: 297,
|
||||
orientation: 'portrait',
|
||||
margins: {
|
||||
top: 15,
|
||||
right: 15,
|
||||
bottom: 15,
|
||||
left: 15
|
||||
}
|
||||
}
|
||||
|
||||
// Canvas Scale (mm to pixels at 96 DPI)
|
||||
export const MM_TO_PX = 3.7795275591 // 1mm = 3.78px at 96 DPI
|
||||
|
||||
// AI Image Generation
|
||||
export interface AIImageRequest {
|
||||
prompt: string
|
||||
style: AIImageStyle
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
export type AIImageStyle =
|
||||
| 'realistic'
|
||||
| 'cartoon'
|
||||
| 'sketch'
|
||||
| 'clipart'
|
||||
| 'educational'
|
||||
|
||||
export interface AIImageResponse {
|
||||
image_base64: string
|
||||
prompt_used: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
// Editor State
|
||||
export interface EditorState {
|
||||
activeTool: EditorTool
|
||||
activeObject: FabricObject | null
|
||||
selectedObjects: FabricObject[]
|
||||
zoom: number
|
||||
showGrid: boolean
|
||||
snapToGrid: boolean
|
||||
gridSize: number
|
||||
currentPageIndex: number
|
||||
}
|
||||
|
||||
// History Entry for Undo/Redo
|
||||
export interface HistoryEntry {
|
||||
canvasJSON: string
|
||||
timestamp: number
|
||||
action: string
|
||||
}
|
||||
|
||||
// Typography Presets
|
||||
export interface TypographyPreset {
|
||||
id: string
|
||||
name: string
|
||||
fontFamily: string
|
||||
fontSize: number
|
||||
fontWeight: FontWeight
|
||||
lineHeight: number
|
||||
}
|
||||
|
||||
// Default Typography Presets
|
||||
export const DEFAULT_TYPOGRAPHY_PRESETS: TypographyPreset[] = [
|
||||
{ id: 'h1', name: 'Überschrift 1', fontFamily: 'Arial', fontSize: 32, fontWeight: 'bold', lineHeight: 1.2 },
|
||||
{ id: 'h2', name: 'Überschrift 2', fontFamily: 'Arial', fontSize: 24, fontWeight: 'bold', lineHeight: 1.3 },
|
||||
{ id: 'h3', name: 'Überschrift 3', fontFamily: 'Arial', fontSize: 18, fontWeight: 'bold', lineHeight: 1.4 },
|
||||
{ id: 'body', name: 'Fließtext', fontFamily: 'Arial', fontSize: 12, fontWeight: 'normal', lineHeight: 1.5 },
|
||||
{ id: 'small', name: 'Klein', fontFamily: 'Arial', fontSize: 10, fontWeight: 'normal', lineHeight: 1.4 },
|
||||
{ id: 'caption', name: 'Bildunterschrift', fontFamily: 'Arial', fontSize: 9, fontWeight: 'normal', lineHeight: 1.3 },
|
||||
]
|
||||
|
||||
// Available Fonts
|
||||
export const AVAILABLE_FONTS = [
|
||||
{ name: 'Arial', family: 'Arial, sans-serif' },
|
||||
{ name: 'Times New Roman', family: 'Times New Roman, serif' },
|
||||
{ name: 'Georgia', family: 'Georgia, serif' },
|
||||
{ name: 'Verdana', family: 'Verdana, sans-serif' },
|
||||
{ name: 'Comic Sans MS', family: 'Comic Sans MS, cursive' },
|
||||
{ name: 'OpenDyslexic', family: 'OpenDyslexic, sans-serif' },
|
||||
{ name: 'Schulschrift', family: 'Schulschrift, cursive' },
|
||||
{ name: 'Courier New', family: 'Courier New, monospace' },
|
||||
]
|
||||
|
||||
// Export Format
|
||||
export type ExportFormat = 'pdf' | 'png' | 'jpg' | 'json'
|
||||
|
||||
// Export Options
|
||||
export interface ExportOptions {
|
||||
format: ExportFormat
|
||||
quality?: number // 0-1 for images
|
||||
includeBackground?: boolean
|
||||
scale?: number
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* AI Prompt Component for Studio v2
|
||||
*
|
||||
* Eingabezeile für Fragen an den lokalen Ollama-Server.
|
||||
* Unterstützt Streaming-Antworten und automatische Modell-Erkennung.
|
||||
* Angepasst an das glassmorphism Design von Studio v2.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
|
||||
interface OllamaModel {
|
||||
name: string
|
||||
size: number
|
||||
digest: string
|
||||
}
|
||||
|
||||
export function AiPrompt() {
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [response, setResponse] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [models, setModels] = useState<OllamaModel[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState('llama3.2:latest')
|
||||
const [showResponse, setShowResponse] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// Lade verfügbare Modelle von Ollama
|
||||
useEffect(() => {
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const ollamaUrl = getOllamaBaseUrl()
|
||||
const res = await fetch(`${ollamaUrl}/api/tags`)
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
if (data.models && data.models.length > 0) {
|
||||
setModels(data.models)
|
||||
setSelectedModel(data.models[0].name)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Ollama nicht erreichbar:', error)
|
||||
}
|
||||
}
|
||||
loadModels()
|
||||
}, [])
|
||||
|
||||
const getOllamaBaseUrl = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (window.location.hostname === 'macmini') {
|
||||
return 'http://macmini:11434'
|
||||
}
|
||||
}
|
||||
return 'http://localhost:11434'
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
const autoResize = () => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 120) + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
const sendPrompt = async () => {
|
||||
if (!prompt.trim() || isLoading) return
|
||||
|
||||
// Vorherige Anfrage abbrechen
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
setIsLoading(true)
|
||||
setResponse('')
|
||||
setShowResponse(true)
|
||||
|
||||
try {
|
||||
const ollamaUrl = getOllamaBaseUrl()
|
||||
const res = await fetch(`${ollamaUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: selectedModel,
|
||||
prompt: prompt.trim(),
|
||||
stream: true,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Ollama Fehler: ${res.status}`)
|
||||
}
|
||||
|
||||
const reader = res.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let fullResponse = ''
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
|
||||
const chunk = decoder.decode(value)
|
||||
const lines = chunk.split('\n').filter(l => l.trim())
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const data = JSON.parse(line)
|
||||
if (data.response) {
|
||||
fullResponse += data.response
|
||||
setResponse(fullResponse)
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors for partial chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as Error).name === 'AbortError') {
|
||||
setResponse('Anfrage abgebrochen.')
|
||||
} else {
|
||||
console.error('AI Prompt Fehler:', error)
|
||||
setResponse(`Fehler: ${(error as Error).message}\n\nBitte prüfen Sie, ob Ollama läuft.`)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const formatResponse = (text: string) => {
|
||||
// Einfache Markdown-Formatierung
|
||||
return text
|
||||
.replace(/```(\w+)?\n([\s\S]*?)```/g, `<pre class="${isDark ? 'bg-white/10' : 'bg-slate-800'} text-slate-100 p-3 rounded-lg my-2 overflow-x-auto text-sm"><code>$2</code></pre>`)
|
||||
.replace(/`([^`]+)`/g, `<code class="${isDark ? 'bg-white/20' : 'bg-slate-200'} px-1.5 py-0.5 rounded text-sm">$1</code>`)
|
||||
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
|
||||
.replace(/\n/g, '<br>')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`backdrop-blur-xl border rounded-3xl p-6 mb-8 transition-all ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-purple-500/10 to-pink-500/10 border-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-50 to-pink-50 border-purple-200 shadow-lg'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center text-2xl shadow-lg ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-purple-500 to-pink-500'
|
||||
: 'bg-gradient-to-br from-purple-400 to-pink-400'
|
||||
}`}>
|
||||
🤖
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>KI-Assistent</h3>
|
||||
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
Fragen Sie Ihren lokalen Ollama-Assistenten
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="flex gap-3 items-end">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={prompt}
|
||||
onChange={(e) => {
|
||||
setPrompt(e.target.value)
|
||||
autoResize()
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Stellen Sie eine Frage... (z.B. 'Wie schreibe ich einen Elternbrief?' oder 'Erstelle mir einen Lückentext')"
|
||||
rows={1}
|
||||
className={`flex-1 min-h-[48px] max-h-[120px] px-5 py-3 rounded-2xl border resize-none transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:outline-none focus:ring-2 focus:ring-purple-500/50'
|
||||
: 'bg-white/80 border-slate-200 text-slate-900 placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-purple-300'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={sendPrompt}
|
||||
disabled={isLoading || !prompt.trim()}
|
||||
className={`w-12 h-12 rounded-2xl flex items-center justify-center text-white text-lg transition-all shadow-lg ${
|
||||
isLoading
|
||||
? 'bg-slate-500 cursor-wait animate-pulse'
|
||||
: 'bg-gradient-to-br from-purple-500 to-pink-500 hover:shadow-xl hover:shadow-purple-500/30 hover:scale-105 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100'
|
||||
}`}
|
||||
>
|
||||
{isLoading ? '⏳' : '➤'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Response */}
|
||||
{showResponse && (
|
||||
<div className={`mt-4 p-5 rounded-2xl border ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/10'
|
||||
: 'bg-white/80 border-slate-200 shadow-inner'
|
||||
}`}>
|
||||
<div className={`flex items-center gap-2 text-xs mb-3 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
<span>🤖</span>
|
||||
<span className="font-medium">{selectedModel}</span>
|
||||
{isLoading && <span className="animate-pulse">• Generiert...</span>}
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm leading-relaxed prose prose-sm max-w-none ${isDark ? 'text-white/80' : 'text-slate-700'}`}
|
||||
dangerouslySetInnerHTML={{ __html: formatResponse(response) || `<span class="${isDark ? 'text-white/40' : 'text-slate-400'} italic">Warte auf Antwort...</span>` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selector */}
|
||||
<div className={`flex items-center gap-2 mt-4 pt-4 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>Modell:</span>
|
||||
<select
|
||||
value={selectedModel}
|
||||
onChange={(e) => setSelectedModel(e.target.value)}
|
||||
className={`text-xs px-3 py-1.5 rounded-xl border cursor-pointer transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white focus:outline-none focus:ring-2 focus:ring-purple-500/50'
|
||||
: 'bg-white border-slate-200 text-slate-700 focus:outline-none focus:ring-2 focus:ring-purple-300'
|
||||
}`}
|
||||
>
|
||||
{models.length > 0 ? (
|
||||
models.map((model) => (
|
||||
<option key={model.name} value={model.name}>
|
||||
{model.name}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<option value="llama3.2:latest">Llama 3.2</option>
|
||||
<option value="mistral:latest">Mistral</option>
|
||||
<option value="qwen2.5:7b">Qwen 2.5</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
{models.length === 0 && (
|
||||
<span className={`text-xs ${isDark ? 'text-amber-400' : 'text-amber-600'}`}>
|
||||
⚠️ Ollama nicht verbunden
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AiPrompt
|
||||
@@ -0,0 +1,552 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useAlerts, lehrerThemen, Topic, AlertImportance } from '@/lib/AlertsContext'
|
||||
import { InfoBox, TipBox, StepBox } from './InfoBox'
|
||||
import { BPIcon } from './Logo'
|
||||
|
||||
interface AlertsWizardProps {
|
||||
onComplete: () => void
|
||||
onSkip?: () => void
|
||||
}
|
||||
|
||||
export function AlertsWizard({ onComplete, onSkip }: AlertsWizardProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { addTopic, updateSettings, settings } = useAlerts()
|
||||
|
||||
const [step, setStep] = useState(1)
|
||||
const [selectedTopics, setSelectedTopics] = useState<string[]>([])
|
||||
const [customTopic, setCustomTopic] = useState({ name: '', keywords: '' })
|
||||
const [rssFeedUrl, setRssFeedUrl] = useState('')
|
||||
const [notificationFrequency, setNotificationFrequency] = useState<'realtime' | 'hourly' | 'daily'>('daily')
|
||||
const [minImportance, setMinImportance] = useState<AlertImportance>('PRUEFEN')
|
||||
|
||||
const totalSteps = 4
|
||||
|
||||
const handleNext = () => {
|
||||
if (step < totalSteps) {
|
||||
setStep(step + 1)
|
||||
} else {
|
||||
// Wizard abschliessen
|
||||
completeWizard()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) {
|
||||
setStep(step - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const completeWizard = () => {
|
||||
// Ausgewaehlte vordefinierte Topics hinzufuegen
|
||||
selectedTopics.forEach(topicId => {
|
||||
const topic = lehrerThemen.find(t => t.name === topicId)
|
||||
if (topic) {
|
||||
addTopic({
|
||||
id: `topic-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: topic.name,
|
||||
keywords: topic.keywords,
|
||||
icon: topic.icon,
|
||||
isActive: true,
|
||||
rssFeedUrl: rssFeedUrl || undefined
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Custom Topic hinzufuegen falls vorhanden
|
||||
if (customTopic.name.trim()) {
|
||||
addTopic({
|
||||
id: `topic-${Date.now()}-custom`,
|
||||
name: customTopic.name,
|
||||
keywords: customTopic.keywords.split(',').map(k => k.trim()).filter(k => k),
|
||||
icon: '📌',
|
||||
isActive: true,
|
||||
rssFeedUrl: rssFeedUrl || undefined
|
||||
})
|
||||
}
|
||||
|
||||
// Settings speichern
|
||||
updateSettings({
|
||||
notificationFrequency,
|
||||
minImportance,
|
||||
wizardCompleted: true
|
||||
})
|
||||
|
||||
onComplete()
|
||||
}
|
||||
|
||||
const toggleTopic = (topicName: string) => {
|
||||
setSelectedTopics(prev =>
|
||||
prev.includes(topicName)
|
||||
? prev.filter(t => t !== topicName)
|
||||
: [...prev, topicName]
|
||||
)
|
||||
}
|
||||
|
||||
const canProceed = () => {
|
||||
switch (step) {
|
||||
case 1:
|
||||
return selectedTopics.length > 0 || customTopic.name.trim().length > 0
|
||||
case 2:
|
||||
return true // Info-Schritt, immer weiter
|
||||
case 3:
|
||||
return true // RSS optional
|
||||
case 4:
|
||||
return true // Einstellungen immer gueltig
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
|
||||
isDark ? 'bg-amber-500 opacity-50' : 'bg-amber-300 opacity-30'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
|
||||
isDark ? 'bg-orange-500 opacity-50' : 'bg-orange-300 opacity-30'
|
||||
}`} style={{ animationDelay: '1s' }} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col items-center justify-center p-8">
|
||||
{/* Logo & Titel */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-3xl shadow-lg">
|
||||
🔔
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Google Alerts einrichten
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Bleiben Sie informiert ueber Bildungsthemen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full max-w-2xl mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{[1, 2, 3, 4].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all ${
|
||||
s === step
|
||||
? 'bg-gradient-to-br from-amber-400 to-orange-500 text-white scale-110 shadow-lg'
|
||||
: s < step
|
||||
? isDark
|
||||
? 'bg-green-500/30 text-green-300'
|
||||
: 'bg-green-100 text-green-700'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/40'
|
||||
: 'bg-slate-200 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{s < step ? '✓' : s}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-amber-400 to-orange-500 transition-all duration-500"
|
||||
style={{ width: `${((step - 1) / (totalSteps - 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<div className={`w-full max-w-2xl backdrop-blur-xl border rounded-3xl p-8 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/80 border-black/10 shadow-xl'
|
||||
}`}>
|
||||
{/* Step 1: Themen waehlen */}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Welche Themen interessieren Sie?
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Waehlen Sie Themen, ueber die Sie informiert werden moechten
|
||||
</p>
|
||||
|
||||
{/* Vordefinierte Themen */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 mb-6">
|
||||
{lehrerThemen.map((topic) => {
|
||||
const isSelected = selectedTopics.includes(topic.name)
|
||||
return (
|
||||
<button
|
||||
key={topic.name}
|
||||
onClick={() => toggleTopic(topic.name)}
|
||||
className={`p-4 rounded-xl border-2 transition-all hover:scale-105 text-left ${
|
||||
isSelected
|
||||
? 'border-amber-500 bg-amber-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10 hover:border-white/20'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{topic.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium truncate ${
|
||||
isSelected
|
||||
? isDark ? 'text-amber-300' : 'text-amber-700'
|
||||
: isDark ? 'text-white' : 'text-slate-900'
|
||||
}`}>
|
||||
{topic.name}
|
||||
</p>
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{topic.keywords.slice(0, 2).join(', ')}
|
||||
</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="w-6 h-6 rounded-full bg-amber-500 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Custom Topic */}
|
||||
<div className={`p-4 rounded-xl border ${isDark ? 'bg-white/5 border-white/10' : 'bg-slate-50 border-slate-200'}`}>
|
||||
<h4 className={`font-medium mb-3 flex items-center gap-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
<span>📌</span> Eigenes Thema hinzufuegen
|
||||
</h4>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Themenname (z.B. 'Mathematik Didaktik')"
|
||||
value={customTopic.name}
|
||||
onChange={(e) => setCustomTopic({ ...customTopic, name: e.target.value })}
|
||||
className={`w-full px-4 py-2 rounded-lg border ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Stichwoerter (kommagetrennt)"
|
||||
value={customTopic.keywords}
|
||||
onChange={(e) => setCustomTopic({ ...customTopic, keywords: e.target.value })}
|
||||
className={`w-full px-4 py-2 rounded-lg border ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Google Alerts Anleitung */}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Google Alerts einrichten
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Google sendet Alerts per E-Mail - wir verarbeiten sie fuer Sie
|
||||
</p>
|
||||
|
||||
<InfoBox variant="info" title="So funktioniert es" icon="💡" className="mb-6">
|
||||
<p>
|
||||
Google Alerts versendet Benachrichtigungen per E-Mail an Ihr Konto.
|
||||
Sie richten einfach eine Weiterleitung ein - wir uebernehmen die
|
||||
Auswertung, Filterung und Zusammenfassung.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-4">
|
||||
<StepBox step={1} title="Google Alerts oeffnen" isActive>
|
||||
<p className="mb-2">
|
||||
Besuchen Sie <a
|
||||
href="https://www.google.de/alerts"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-amber-500 hover:underline font-medium"
|
||||
>
|
||||
google.de/alerts
|
||||
</a> und melden Sie sich mit Ihrem Google-Konto an.
|
||||
</p>
|
||||
</StepBox>
|
||||
|
||||
<StepBox step={2} title="Alerts erstellen">
|
||||
<p>
|
||||
Geben Sie Suchbegriffe ein (z.B. "{selectedTopics[0] || 'Bildungspolitik'}")
|
||||
und erstellen Sie Alerts. Die Alerts werden an Ihre E-Mail-Adresse gesendet.
|
||||
</p>
|
||||
</StepBox>
|
||||
|
||||
<StepBox step={3} title="E-Mail-Weiterleitung einrichten">
|
||||
<p>
|
||||
Im naechsten Schritt richten Sie eine automatische Weiterleitung
|
||||
der Google Alert E-Mails an uns ein. So verarbeiten wir Ihre Alerts
|
||||
automatisch.
|
||||
</p>
|
||||
</StepBox>
|
||||
</div>
|
||||
|
||||
<TipBox title="Tipp: Mehrere Alerts kombinieren" icon="💡" className="mt-6">
|
||||
<p>
|
||||
Sie koennen beliebig viele Google Alerts erstellen. Alle werden
|
||||
per E-Mail an Sie gesendet und durch die Weiterleitung automatisch
|
||||
verarbeitet - gefiltert, priorisiert und zusammengefasst.
|
||||
</p>
|
||||
</TipBox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: E-Mail Weiterleitung einrichten */}
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
E-Mail Weiterleitung einrichten
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Leiten Sie Ihre Google Alert E-Mails automatisch an uns weiter
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Empfohlene Methode: E-Mail Weiterleitung */}
|
||||
<div className={`p-5 rounded-xl border-2 ${isDark ? 'border-green-500/50 bg-green-500/10' : 'border-green-500 bg-green-50'}`}>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className="text-2xl">📧</span>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h4 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
E-Mail Weiterleitung
|
||||
</h4>
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-600">
|
||||
Empfohlen
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
Richten Sie in Gmail einen Filter ein, der Google Alert E-Mails automatisch weiterleitet.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-3 rounded-lg ${isDark ? 'bg-white/10' : 'bg-white'}`}>
|
||||
<p className={`text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Ihre Weiterleitungsadresse:
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<code className={`flex-1 px-3 py-2 rounded-lg text-sm font-mono ${
|
||||
isDark ? 'bg-white/10 text-amber-300' : 'bg-slate-100 text-amber-600'
|
||||
}`}>
|
||||
alerts@breakpilot.de
|
||||
</code>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText('alerts@breakpilot.de')}
|
||||
className="px-3 py-2 rounded-lg bg-amber-500 text-white text-sm hover:bg-amber-600 transition-all"
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
So richten Sie die Weiterleitung in Gmail ein:
|
||||
</p>
|
||||
<ol className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
<li>1. Oeffnen Sie Gmail → Einstellungen → Filter</li>
|
||||
<li>2. Neuer Filter: Von "googlealerts-noreply@google.com"</li>
|
||||
<li>3. Aktion: Weiterleiten an "alerts@breakpilot.de"</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alternative: RSS (mit Warnung) */}
|
||||
<div className={`p-4 rounded-xl border ${isDark ? 'border-white/10 bg-white/5' : 'border-slate-200 bg-slate-50'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl">📡</span>
|
||||
<div className="flex-1">
|
||||
<h4 className={`font-medium mb-1 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Alternativ: RSS-Feed (eingeschraenkt verfuegbar)
|
||||
</h4>
|
||||
<p className={`text-sm mb-3 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
Google hat die RSS-Option fuer viele Konten entfernt. Falls Sie RSS noch sehen,
|
||||
koennen Sie die Feed-URL hier eingeben:
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://www.google.de/alerts/feeds/... (falls verfuegbar)"
|
||||
value={rssFeedUrl}
|
||||
onChange={(e) => setRssFeedUrl(e.target.value)}
|
||||
className={`w-full px-4 py-2 rounded-lg border text-sm ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/30'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
<p className={`text-xs mt-2 ${isDark ? 'text-amber-400/70' : 'text-amber-600'}`}>
|
||||
⚠️ Die meisten Nutzer sehen keine RSS-Option mehr in Google Alerts.
|
||||
Verwenden Sie in diesem Fall die E-Mail-Weiterleitung.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Sie koennen diesen Schritt auch ueberspringen und die Weiterleitung spaeter einrichten.
|
||||
Die Demo-Alerts werden weiterhin angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Benachrichtigungs-Einstellungen */}
|
||||
{step === 4 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Benachrichtigungen einstellen
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Wie moechten Sie informiert werden?
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Frequenz */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Wie oft moechten Sie Alerts erhalten?
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ id: 'realtime', label: 'Sofort', icon: '⚡', desc: 'Bei jedem neuen Alert' },
|
||||
{ id: 'hourly', label: 'Stuendlich', icon: '🕐', desc: 'Zusammenfassung pro Stunde' },
|
||||
{ id: 'daily', label: 'Taeglich', icon: '📅', desc: 'Einmal am Tag' },
|
||||
].map((freq) => (
|
||||
<button
|
||||
key={freq.id}
|
||||
onClick={() => setNotificationFrequency(freq.id as any)}
|
||||
className={`p-4 rounded-xl border-2 transition-all text-center ${
|
||||
notificationFrequency === freq.id
|
||||
? 'border-amber-500 bg-amber-500/20'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<span className="text-2xl block mb-1">{freq.icon}</span>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{freq.label}</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{freq.desc}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mindest-Wichtigkeit */}
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Mindest-Wichtigkeit fuer Benachrichtigungen
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{[
|
||||
{ id: 'KRITISCH', label: 'Kritisch', color: 'red' },
|
||||
{ id: 'DRINGEND', label: 'Dringend', color: 'orange' },
|
||||
{ id: 'WICHTIG', label: 'Wichtig', color: 'yellow' },
|
||||
{ id: 'PRUEFEN', label: 'Pruefen', color: 'blue' },
|
||||
{ id: 'INFO', label: 'Info', color: 'slate' },
|
||||
].map((imp) => (
|
||||
<button
|
||||
key={imp.id}
|
||||
onClick={() => setMinImportance(imp.id as AlertImportance)}
|
||||
className={`p-2 rounded-lg border-2 transition-all text-center text-xs ${
|
||||
minImportance === imp.id
|
||||
? `border-${imp.color}-500 bg-${imp.color}-500/20`
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{imp.label}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-xs mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Sie erhalten nur Benachrichtigungen fuer Alerts mit dieser Wichtigkeit oder hoeher.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Zusammenfassung */}
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<h4 className={`font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Ihre Einstellungen
|
||||
</h4>
|
||||
<ul className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
<li>• {selectedTopics.length + (customTopic.name ? 1 : 0)} Themen ausgewaehlt</li>
|
||||
<li>• Benachrichtigungen: {notificationFrequency === 'realtime' ? 'Sofort' : notificationFrequency === 'hourly' ? 'Stuendlich' : 'Taeglich'}</li>
|
||||
<li>• Mindest-Wichtigkeit: {minImportance}</li>
|
||||
{rssFeedUrl && <li>• RSS-Feed verbunden</li>}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center gap-4 mt-8">
|
||||
{step > 1 && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className={`px-6 py-3 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
← Zurueck
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className={`px-8 py-3 rounded-xl font-medium transition-all ${
|
||||
canProceed()
|
||||
? 'bg-gradient-to-r from-amber-400 to-orange-500 text-white hover:shadow-xl hover:shadow-orange-500/30 hover:scale-105'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/30 cursor-not-allowed'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{step === totalSteps ? 'Fertig! →' : 'Weiter →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skip Option */}
|
||||
{onSkip && (
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className={`mt-4 text-sm ${isDark ? 'text-white/40 hover:text-white/60' : 'text-slate-400 hover:text-slate-600'}`}
|
||||
>
|
||||
Ueberspringen (spaeter einrichten)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useAlertsB2B, B2BTemplate, Package, getPackageIcon, getPackageLabel } from '@/lib/AlertsB2BContext'
|
||||
import { InfoBox, TipBox, StepBox } from './InfoBox'
|
||||
|
||||
interface B2BMigrationWizardProps {
|
||||
onComplete: () => void
|
||||
onSkip?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
type MigrationMethod = 'email' | 'rss' | 'reconstruct' | null
|
||||
|
||||
export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigrationWizardProps) {
|
||||
const { isDark } = useTheme()
|
||||
const {
|
||||
tenant,
|
||||
updateTenant,
|
||||
settings,
|
||||
updateSettings,
|
||||
availableTemplates,
|
||||
selectTemplate,
|
||||
generateInboundEmail,
|
||||
addSource
|
||||
} = useAlertsB2B()
|
||||
|
||||
const [step, setStep] = useState(1)
|
||||
const [migrationMethod, setMigrationMethod] = useState<MigrationMethod>(null)
|
||||
const [companyName, setCompanyName] = useState(tenant.companyName || '')
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState<string | null>(null)
|
||||
const [inboundEmail, setInboundEmail] = useState('')
|
||||
const [rssUrls, setRssUrls] = useState<string[]>([''])
|
||||
const [alertDescription, setAlertDescription] = useState('')
|
||||
const [testEmailSent, setTestEmailSent] = useState(false)
|
||||
const [selectedRegions, setSelectedRegions] = useState<string[]>(['EUROPE'])
|
||||
const [selectedPackages, setSelectedPackages] = useState<string[]>(['PARKING', 'EV_CHARGING'])
|
||||
|
||||
const totalSteps = 5
|
||||
|
||||
const handleNext = () => {
|
||||
if (step < totalSteps) {
|
||||
// Special handling for step transitions
|
||||
if (step === 1 && companyName.trim()) {
|
||||
updateTenant({ companyName: companyName.trim() })
|
||||
}
|
||||
if (step === 2 && selectedTemplateId) {
|
||||
selectTemplate(selectedTemplateId)
|
||||
}
|
||||
if (step === 3 && migrationMethod === 'email' && !inboundEmail) {
|
||||
setInboundEmail(generateInboundEmail())
|
||||
}
|
||||
setStep(step + 1)
|
||||
} else {
|
||||
completeWizard()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) {
|
||||
setStep(step - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const completeWizard = () => {
|
||||
// Save sources based on migration method
|
||||
if (migrationMethod === 'email' && inboundEmail) {
|
||||
addSource({
|
||||
tenantId: tenant.id,
|
||||
type: 'email',
|
||||
inboundAddress: inboundEmail,
|
||||
label: 'Google Alerts Weiterleitung',
|
||||
active: true
|
||||
})
|
||||
} else if (migrationMethod === 'rss') {
|
||||
rssUrls.filter(url => url.trim()).forEach((url, idx) => {
|
||||
addSource({
|
||||
tenantId: tenant.id,
|
||||
type: 'rss',
|
||||
rssUrl: url.trim(),
|
||||
label: `RSS Feed ${idx + 1}`,
|
||||
active: true
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Update settings
|
||||
updateSettings({
|
||||
migrationCompleted: true,
|
||||
wizardCompleted: true,
|
||||
selectedRegions,
|
||||
selectedPackages: selectedPackages as any[]
|
||||
})
|
||||
|
||||
onComplete()
|
||||
}
|
||||
|
||||
const canProceed = () => {
|
||||
switch (step) {
|
||||
case 1:
|
||||
return companyName.trim().length > 0
|
||||
case 2:
|
||||
return selectedTemplateId !== null
|
||||
case 3:
|
||||
return migrationMethod !== null
|
||||
case 4:
|
||||
if (migrationMethod === 'email') return inboundEmail.length > 0
|
||||
if (migrationMethod === 'rss') return rssUrls.some(url => url.trim().length > 0)
|
||||
if (migrationMethod === 'reconstruct') return alertDescription.trim().length > 10
|
||||
return true
|
||||
case 5:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const selectedTemplate = availableTemplates.find(t => t.templateId === selectedTemplateId)
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background Blobs - Dashboard Style */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
|
||||
isDark ? 'bg-purple-500 opacity-70' : 'bg-purple-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
|
||||
isDark ? 'bg-blue-500 opacity-70' : 'bg-blue-300 opacity-50'
|
||||
}`} />
|
||||
<div className={`absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${
|
||||
isDark ? 'bg-pink-500 opacity-70' : 'bg-pink-300 opacity-50'
|
||||
}`} />
|
||||
</div>
|
||||
|
||||
{/* Blob Animation Styles */}
|
||||
<style jsx>{`
|
||||
@keyframes blob {
|
||||
0% { transform: translate(0px, 0px) scale(1); }
|
||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
||||
100% { transform: translate(0px, 0px) scale(1); }
|
||||
}
|
||||
.animate-blob {
|
||||
animation: blob 7s infinite;
|
||||
}
|
||||
.animation-delay-2000 {
|
||||
animation-delay: 2s;
|
||||
}
|
||||
.animation-delay-4000 {
|
||||
animation-delay: 4s;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col items-center justify-center p-8">
|
||||
{/* Exit Button - Fixed Top Right */}
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className={`fixed top-6 right-6 z-50 flex items-center gap-2 px-4 py-2 rounded-2xl backdrop-blur-xl border transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white/70 hover:bg-white/20 hover:text-white'
|
||||
: 'bg-white/70 border-black/10 text-slate-600 hover:bg-white hover:text-slate-900 shadow-lg'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">Abbrechen</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center text-3xl shadow-lg shadow-purple-500/30">
|
||||
🏢
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
B2B Alerts einrichten
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Bringen Sie Ihre bestehenden Google Alerts mit
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full max-w-3xl mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all ${
|
||||
s === step
|
||||
? 'bg-gradient-to-br from-purple-500 to-pink-500 text-white scale-110 shadow-lg shadow-purple-500/30'
|
||||
: s < step
|
||||
? isDark
|
||||
? 'bg-green-500/30 text-green-300'
|
||||
: 'bg-green-100 text-green-700'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/40'
|
||||
: 'bg-slate-200 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{s < step ? '✓' : s}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all duration-500"
|
||||
style={{ width: `${((step - 1) / (totalSteps - 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<div className={`w-full max-w-3xl backdrop-blur-xl border rounded-3xl p-8 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/80 border-black/10 shadow-xl'
|
||||
}`}>
|
||||
{/* Step 1: Firmenname */}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Willkommen im B2B-Bereich
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Wie heisst Ihr Unternehmen?
|
||||
</p>
|
||||
|
||||
<div className="max-w-md mx-auto space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. Hectronic GmbH"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
className={`w-full px-4 py-3 rounded-xl border text-lg ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<InfoBox variant="info" title="Warum fragen wir das?" icon="💡">
|
||||
<p>Ihr Firmenname wird verwendet, um:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Ihre eindeutige E-Mail-Adresse zu generieren</li>
|
||||
<li>Berichte und Digests zu personalisieren</li>
|
||||
<li>Ihr Dashboard anzupassen</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Template waehlen */}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Branchenvorlage waehlen
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Waehlen Sie eine Vorlage fuer Ihre Branche oder starten Sie leer
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{availableTemplates.map((template) => (
|
||||
<button
|
||||
key={template.templateId}
|
||||
onClick={() => setSelectedTemplateId(template.templateId)}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
selectedTemplateId === template.templateId
|
||||
? 'border-blue-500 bg-blue-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-2xl">
|
||||
🏭
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{template.templateName}
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{template.templateDescription}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{template.guidedConfig.packageSelector.options.map(pkg => (
|
||||
<span
|
||||
key={pkg}
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
template.guidedConfig.packageSelector.default.includes(pkg)
|
||||
? isDark ? 'bg-blue-500/30 text-blue-300' : 'bg-blue-100 text-blue-700'
|
||||
: isDark ? 'bg-white/10 text-white/50' : 'bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{getPackageIcon(pkg)} {getPackageLabel(pkg)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedTemplateId === template.templateId && (
|
||||
<div className="w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Custom option */}
|
||||
<button
|
||||
onClick={() => setSelectedTemplateId('custom')}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
selectedTemplateId === 'custom'
|
||||
? 'border-blue-500 bg-blue-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-2xl ${
|
||||
isDark ? 'bg-white/20' : 'bg-slate-100'
|
||||
}`}>
|
||||
⚙️
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Eigene Konfiguration
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Starten Sie ohne Vorlage und konfigurieren Sie alles selbst
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Migration Method */}
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Nutzen Sie bereits Google Alerts?
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Waehlen Sie, wie Sie Ihre bestehenden Alerts uebernehmen moechten
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Email Forwarding (Recommended) */}
|
||||
<button
|
||||
onClick={() => setMigrationMethod('email')}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
migrationMethod === 'email'
|
||||
? 'border-green-500 bg-green-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center text-2xl">
|
||||
📧
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
E-Mail Weiterleitung
|
||||
</h3>
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-500">
|
||||
Empfohlen
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Leiten Sie Ihre bestehenden Google Alert E-Mails an uns weiter.
|
||||
Keine Aenderung an Ihren Alerts noetig.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* RSS Import */}
|
||||
<button
|
||||
onClick={() => setMigrationMethod('rss')}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
migrationMethod === 'rss'
|
||||
? 'border-blue-500 bg-blue-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-2xl">
|
||||
📡
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
RSS-Feed Import
|
||||
</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDark ? 'bg-amber-500/20 text-amber-400' : 'bg-amber-100 text-amber-700'}`}>
|
||||
Eingeschraenkt
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
RSS-Feeds, falls in Ihrem Google-Konto verfuegbar.
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-amber-400/70' : 'text-amber-600'}`}>
|
||||
⚠️ Google hat RSS fuer viele Konten deaktiviert
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Reconstruction */}
|
||||
<button
|
||||
onClick={() => setMigrationMethod('reconstruct')}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
migrationMethod === 'reconstruct'
|
||||
? 'border-amber-500 bg-amber-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-2xl">
|
||||
🔄
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Rekonstruktion
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Beschreiben Sie, was Sie beobachten moechten. Wir erstellen die
|
||||
optimale Konfiguration fuer Sie.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TipBox title="Kein Neustart noetig" icon="💡" className="mt-6">
|
||||
<p>
|
||||
Ihre bestehenden Google Alerts bleiben bestehen. Wir sind eine zusaetzliche
|
||||
Intelligenzschicht, die filtert, priorisiert und zusammenfasst.
|
||||
</p>
|
||||
</TipBox>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Migration Details */}
|
||||
{step === 4 && (
|
||||
<div>
|
||||
{/* Email Forwarding */}
|
||||
{migrationMethod === 'email' && (
|
||||
<>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
E-Mail Weiterleitung einrichten
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Google Alerts sendet E-Mails - leiten Sie diese einfach an uns weiter
|
||||
</p>
|
||||
|
||||
<InfoBox variant="info" title="So funktioniert es" icon="💡" className="mb-6">
|
||||
<p>
|
||||
Google Alerts versendet Benachrichtigungen per E-Mail an Ihr Konto.
|
||||
Sie richten einen Gmail-Filter ein, der diese E-Mails automatisch weiterleitet -
|
||||
wir uebernehmen die Verarbeitung und Auswertung.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Inbound Email */}
|
||||
<div className={`p-4 rounded-xl border-2 ${isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'}`}>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Ihre eindeutige Weiterleitungsadresse:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inboundEmail}
|
||||
className={`flex-1 px-4 py-3 rounded-lg border font-mono text-sm ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/20 text-white'
|
||||
: 'bg-white border-slate-200 text-slate-900'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(inboundEmail)}
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all"
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="space-y-3">
|
||||
<StepBox step={1} title="Gmail-Einstellungen oeffnen" isActive>
|
||||
Oeffnen Sie <a href="https://mail.google.com/mail/u/0/#settings/filters" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:underline">Gmail → Einstellungen → Filter und blockierte Adressen</a>
|
||||
</StepBox>
|
||||
<StepBox step={2} title="Neuen Filter erstellen">
|
||||
Klicken Sie auf "Neuen Filter erstellen" und geben Sie bei "Von" ein: <code className={`px-2 py-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>googlealerts-noreply@google.com</code>
|
||||
</StepBox>
|
||||
<StepBox step={3} title="Weiterleitung aktivieren">
|
||||
Waehlen Sie "Weiterleiten an" und fuegen Sie die obige Adresse ein. Aktivieren Sie auch "Filter auf passende Konversationen anwenden".
|
||||
</StepBox>
|
||||
</div>
|
||||
|
||||
<TipBox title="Keine Aenderung an Ihren Google Alerts noetig" icon="✨" className="mt-4">
|
||||
<p>
|
||||
Ihre bestehenden Google Alerts bleiben unveraendert. Der Gmail-Filter leitet
|
||||
eingehende Alert-E-Mails automatisch an uns weiter. Sie koennen die E-Mails
|
||||
auch weiterhin in Ihrem Posteingang sehen.
|
||||
</p>
|
||||
</TipBox>
|
||||
|
||||
{/* Test Button */}
|
||||
<div className={`p-4 rounded-xl border ${
|
||||
testEmailSent
|
||||
? isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'
|
||||
: isDark ? 'bg-white/5 border-white/10' : 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{testEmailSent ? '✓ Test-E-Mail empfangen' : 'Verbindung testen'}
|
||||
</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{testEmailSent
|
||||
? 'Die Weiterleitung funktioniert!'
|
||||
: 'Senden Sie eine Test-E-Mail, um die Einrichtung zu pruefen'}
|
||||
</p>
|
||||
</div>
|
||||
{!testEmailSent && (
|
||||
<button
|
||||
onClick={() => setTestEmailSent(true)}
|
||||
className="px-4 py-2 rounded-lg bg-white/20 hover:bg-white/30 transition-all"
|
||||
>
|
||||
Test senden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* RSS Import */}
|
||||
{migrationMethod === 'rss' && (
|
||||
<>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
RSS-Feeds importieren
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Fuegen Sie die RSS-URLs Ihrer Google Alerts hinzu
|
||||
</p>
|
||||
|
||||
{/* Warning Box */}
|
||||
<InfoBox variant="warning" title="Wichtiger Hinweis zu RSS" icon="⚠️" className="mb-6">
|
||||
<p>
|
||||
Google hat die RSS-Option fuer viele Konten entfernt. Falls Sie in Google Alerts
|
||||
kein RSS-Symbol sehen oder die Option "RSS-Feed" nicht verfuegbar ist,
|
||||
nutzen Sie bitte stattdessen die <strong>E-Mail-Weiterleitung</strong>.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setMigrationMethod('email')}
|
||||
className="mt-3 text-sm font-medium text-purple-400 hover:text-purple-300 underline"
|
||||
>
|
||||
→ Zur E-Mail-Weiterleitung wechseln
|
||||
</button>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-4">
|
||||
{rssUrls.map((url, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://www.google.de/alerts/feeds/..."
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
const newUrls = [...rssUrls]
|
||||
newUrls[idx] = e.target.value
|
||||
setRssUrls(newUrls)
|
||||
}}
|
||||
className={`flex-1 px-4 py-3 rounded-lg border ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
{rssUrls.length > 1 && (
|
||||
<button
|
||||
onClick={() => setRssUrls(rssUrls.filter((_, i) => i !== idx))}
|
||||
className={`p-3 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setRssUrls([...rssUrls, ''])}
|
||||
className={`w-full py-3 rounded-lg border-2 border-dashed transition-all ${
|
||||
isDark
|
||||
? 'border-white/20 text-white/60 hover:border-white/40 hover:text-white'
|
||||
: 'border-slate-200 text-slate-500 hover:border-slate-300 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
+ Weiteren Feed hinzufuegen
|
||||
</button>
|
||||
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<p className={`text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Falls RSS verfuegbar ist:
|
||||
</p>
|
||||
<ol className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
<li>1. Oeffnen Sie google.de/alerts</li>
|
||||
<li>2. Suchen Sie nach einem orangefarbenen RSS-Symbol</li>
|
||||
<li>3. Klicken Sie darauf und kopieren Sie die URL</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reconstruction */}
|
||||
{migrationMethod === 'reconstruct' && (
|
||||
<>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Was moechten Sie beobachten?
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Beschreiben Sie Ihre Beobachtungsziele - wir erstellen die optimale Konfiguration
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
placeholder="z.B. Wir beobachten europaweite kommunale Ausschreibungen für Parkscheinautomaten, EV-Ladesäulen mit Bezahlterminals und Tankautomaten. Wir bekommen aktuell zu viele irrelevante Treffer wie News, Stellenanzeigen und Zubehör..."
|
||||
value={alertDescription}
|
||||
onChange={(e) => setAlertDescription(e.target.value)}
|
||||
rows={6}
|
||||
className={`w-full px-4 py-3 rounded-xl border resize-none ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<InfoBox variant="tip" title="Je mehr Details, desto besser" icon="✨">
|
||||
<p>Beschreiben Sie:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Welche Produkte/Services Sie anbieten</li>
|
||||
<li>Welche Kaeufer/Maerkte relevant sind</li>
|
||||
<li>Was Sie aktuell stoert (zu viel News, Jobs, etc.)</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
|
||||
{alertDescription.length > 50 && (
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/10 border border-green-500/30' : 'bg-green-50 border border-green-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🤖</span>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-green-300' : 'text-green-700'}`}>
|
||||
KI-Analyse bereit
|
||||
</p>
|
||||
<p className={`text-sm ${isDark ? 'text-green-300/70' : 'text-green-600'}`}>
|
||||
Wir werden Ihre Beschreibung analysieren und optimale Filter erstellen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5: Notification Settings */}
|
||||
{step === 5 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Benachrichtigungen konfigurieren
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Wie moechten Sie ueber relevante Ausschreibungen informiert werden?
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Regions */}
|
||||
{selectedTemplate && (
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Regionen
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTemplate.guidedConfig.regionSelector.options.map(region => (
|
||||
<button
|
||||
key={region}
|
||||
onClick={() => {
|
||||
if (selectedRegions.includes(region)) {
|
||||
setSelectedRegions(selectedRegions.filter(r => r !== region))
|
||||
} else {
|
||||
setSelectedRegions([...selectedRegions, region])
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
selectedRegions.includes(region)
|
||||
? 'bg-blue-500 text-white'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/60 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{region}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{selectedTemplate && (
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Produktbereiche
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTemplate.guidedConfig.packageSelector.options.map(pkg => (
|
||||
<button
|
||||
key={pkg}
|
||||
onClick={() => {
|
||||
if (selectedPackages.includes(pkg)) {
|
||||
setSelectedPackages(selectedPackages.filter(p => p !== pkg))
|
||||
} else {
|
||||
setSelectedPackages([...selectedPackages, pkg])
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
selectedPackages.includes(pkg)
|
||||
? 'bg-blue-500 text-white'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/60 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{getPackageIcon(pkg)} {getPackageLabel(pkg)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<h4 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Ihre Konfiguration
|
||||
</h4>
|
||||
<ul className={`space-y-2 text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
<li>• Firma: <strong>{companyName}</strong></li>
|
||||
<li>• Template: <strong>{selectedTemplate?.templateName || 'Eigene Konfiguration'}</strong></li>
|
||||
<li>• Migration: <strong>{
|
||||
migrationMethod === 'email' ? 'E-Mail Weiterleitung' :
|
||||
migrationMethod === 'rss' ? 'RSS Import' : 'Rekonstruktion'
|
||||
}</strong></li>
|
||||
<li>• Regionen: <strong>{selectedRegions.join(', ')}</strong></li>
|
||||
<li>• Produkte: <strong>{selectedPackages.map(p => getPackageLabel(p as Package)).join(', ')}</strong></li>
|
||||
<li>• Digest: <strong>Taeglich um 08:00, max. 10 Treffer</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<TipBox title="Bereit fuer den Start" icon="🚀">
|
||||
<p>
|
||||
Nach Abschluss werden wir Ihre Alerts analysieren und nur die wirklich
|
||||
relevanten Ausschreibungen herausfiltern. Erwarten Sie ca. 80-90% weniger
|
||||
irrelevante Treffer.
|
||||
</p>
|
||||
</TipBox>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center gap-4 mt-8">
|
||||
{step > 1 && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className={`px-6 py-3 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
← Zurueck
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className={`px-8 py-3 rounded-xl font-medium transition-all ${
|
||||
canProceed()
|
||||
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-xl hover:shadow-purple-500/30 hover:scale-105'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/30 cursor-not-allowed'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{step === totalSteps ? 'Einrichtung abschliessen →' : 'Weiter →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skip Option */}
|
||||
{onSkip && step === 1 && (
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className={`mt-4 text-sm ${isDark ? 'text-white/40 hover:text-white/60' : 'text-slate-400 hover:text-slate-600'}`}
|
||||
>
|
||||
Ueberspringen (spaeter einrichten)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useMessages } from '@/lib/MessagesContext'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
interface ChatMessage {
|
||||
id: string
|
||||
senderName: string
|
||||
senderAvatar?: string
|
||||
senderInitials: string
|
||||
content: string
|
||||
timestamp: Date
|
||||
conversationId: string
|
||||
isGroup?: boolean
|
||||
}
|
||||
|
||||
interface ChatOverlayProps {
|
||||
/** Auto-dismiss after X milliseconds (0 = manual dismiss only) */
|
||||
autoDismissMs?: number
|
||||
/** Maximum messages to queue */
|
||||
maxQueue?: number
|
||||
/** Enable typewriter effect */
|
||||
typewriterEnabled?: boolean
|
||||
/** Typewriter speed in ms per character */
|
||||
typewriterSpeed?: number
|
||||
/** Enable sound notification */
|
||||
soundEnabled?: boolean
|
||||
}
|
||||
|
||||
export function ChatOverlay({
|
||||
autoDismissMs = 0,
|
||||
maxQueue = 5,
|
||||
typewriterEnabled = true,
|
||||
typewriterSpeed = 30,
|
||||
soundEnabled = false
|
||||
}: ChatOverlayProps) {
|
||||
const { isDark } = useTheme()
|
||||
const router = useRouter()
|
||||
const { conversations, contacts, messages: allMessages } = useMessages()
|
||||
|
||||
const [messageQueue, setMessageQueue] = useState<ChatMessage[]>([])
|
||||
const [currentMessage, setCurrentMessage] = useState<ChatMessage | null>(null)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
const [displayedText, setDisplayedText] = useState('')
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const [replyText, setReplyText] = useState('')
|
||||
const [isReplying, setIsReplying] = useState(false)
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const typewriterRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const dismissTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Initialize audio
|
||||
useEffect(() => {
|
||||
if (soundEnabled && typeof window !== 'undefined') {
|
||||
audioRef.current = new Audio('/sounds/message-pop.mp3')
|
||||
audioRef.current.volume = 0.3
|
||||
}
|
||||
}, [soundEnabled])
|
||||
|
||||
// Simulate incoming messages (for demo - replace with real WebSocket later)
|
||||
useEffect(() => {
|
||||
// Demo: Show a message after 5 seconds
|
||||
const demoTimer = setTimeout(() => {
|
||||
const demoMessage: ChatMessage = {
|
||||
id: `demo-${Date.now()}`,
|
||||
senderName: 'Familie Mueller',
|
||||
senderInitials: 'FM',
|
||||
content: 'Hallo! Lisa hatte heute leider Fieber und konnte nicht zur Schule kommen. Könnten Sie uns bitte die Hausaufgaben für morgen mitteilen?',
|
||||
timestamp: new Date(),
|
||||
conversationId: 'conv1',
|
||||
isGroup: false
|
||||
}
|
||||
addToQueue(demoMessage)
|
||||
}, 5000)
|
||||
|
||||
return () => clearTimeout(demoTimer)
|
||||
}, [])
|
||||
|
||||
// Add message to queue
|
||||
const addToQueue = useCallback((message: ChatMessage) => {
|
||||
setMessageQueue(prev => {
|
||||
if (prev.length >= maxQueue) {
|
||||
return [...prev.slice(1), message]
|
||||
}
|
||||
return [...prev, message]
|
||||
})
|
||||
}, [maxQueue])
|
||||
|
||||
// Process queue - show next message
|
||||
useEffect(() => {
|
||||
if (!currentMessage && messageQueue.length > 0 && !isExiting) {
|
||||
const nextMessage = messageQueue[0]
|
||||
setMessageQueue(prev => prev.slice(1))
|
||||
setCurrentMessage(nextMessage)
|
||||
setIsVisible(true)
|
||||
setDisplayedText('')
|
||||
setIsTyping(true)
|
||||
|
||||
// Play sound
|
||||
if (soundEnabled && audioRef.current) {
|
||||
audioRef.current.play().catch(() => {})
|
||||
}
|
||||
}
|
||||
}, [currentMessage, messageQueue, isExiting, soundEnabled])
|
||||
|
||||
// Typewriter effect
|
||||
useEffect(() => {
|
||||
if (!currentMessage || !isTyping) return
|
||||
|
||||
const fullText = currentMessage.content
|
||||
let charIndex = 0
|
||||
|
||||
if (typewriterEnabled) {
|
||||
typewriterRef.current = setInterval(() => {
|
||||
charIndex++
|
||||
setDisplayedText(fullText.slice(0, charIndex))
|
||||
|
||||
if (charIndex >= fullText.length) {
|
||||
if (typewriterRef.current) {
|
||||
clearInterval(typewriterRef.current)
|
||||
}
|
||||
setIsTyping(false)
|
||||
}
|
||||
}, typewriterSpeed)
|
||||
} else {
|
||||
setDisplayedText(fullText)
|
||||
setIsTyping(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typewriterRef.current) {
|
||||
clearInterval(typewriterRef.current)
|
||||
}
|
||||
}
|
||||
}, [currentMessage, isTyping, typewriterEnabled, typewriterSpeed])
|
||||
|
||||
// Auto-dismiss timer
|
||||
useEffect(() => {
|
||||
if (currentMessage && autoDismissMs > 0 && !isTyping && !isReplying) {
|
||||
dismissTimerRef.current = setTimeout(() => {
|
||||
handleDismiss()
|
||||
}, autoDismissMs)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (dismissTimerRef.current) {
|
||||
clearTimeout(dismissTimerRef.current)
|
||||
}
|
||||
}
|
||||
}, [currentMessage, autoDismissMs, isTyping, isReplying])
|
||||
|
||||
// Dismiss current message
|
||||
const handleDismiss = useCallback(() => {
|
||||
setIsExiting(true)
|
||||
setTimeout(() => {
|
||||
setCurrentMessage(null)
|
||||
setIsVisible(false)
|
||||
setIsExiting(false)
|
||||
setDisplayedText('')
|
||||
setReplyText('')
|
||||
setIsReplying(false)
|
||||
}, 300) // Match exit animation duration
|
||||
}, [])
|
||||
|
||||
// Open full conversation
|
||||
const handleOpenConversation = useCallback(() => {
|
||||
if (currentMessage) {
|
||||
router.push(`/messages?conversation=${currentMessage.conversationId}`)
|
||||
handleDismiss()
|
||||
}
|
||||
}, [currentMessage, router, handleDismiss])
|
||||
|
||||
// Toggle reply mode
|
||||
const handleReplyClick = useCallback(() => {
|
||||
setIsReplying(true)
|
||||
}, [])
|
||||
|
||||
// Send reply
|
||||
const handleSendReply = useCallback(() => {
|
||||
if (!replyText.trim() || !currentMessage) return
|
||||
|
||||
// TODO: Actually send the message via MessagesContext
|
||||
console.log('Sending reply:', replyText, 'to conversation:', currentMessage.conversationId)
|
||||
|
||||
// For now, just dismiss
|
||||
handleDismiss()
|
||||
}, [replyText, currentMessage, handleDismiss])
|
||||
|
||||
// Handle keyboard in reply
|
||||
const handleReplyKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendReply()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setIsReplying(false)
|
||||
setReplyText('')
|
||||
}
|
||||
}, [handleSendReply])
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
// Glassmorphism styles
|
||||
const overlayStyle = isDark
|
||||
? 'bg-slate-900/80 backdrop-blur-2xl border-white/20'
|
||||
: 'bg-white/90 backdrop-blur-2xl border-black/10 shadow-2xl'
|
||||
|
||||
const textColor = isDark ? 'text-white' : 'text-slate-900'
|
||||
const mutedColor = isDark ? 'text-white/60' : 'text-slate-500'
|
||||
|
||||
const buttonPrimary = isDark
|
||||
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-600 to-pink-600 text-white hover:shadow-lg hover:shadow-purple-600/30'
|
||||
|
||||
const buttonSecondary = isDark
|
||||
? 'bg-white/10 text-white/80 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop (subtle) */}
|
||||
<div
|
||||
className={`fixed inset-0 z-40 transition-opacity duration-300 ${
|
||||
isExiting ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
style={{ background: 'transparent', pointerEvents: 'none' }}
|
||||
/>
|
||||
|
||||
{/* Chat Overlay - Slide in from right */}
|
||||
<div
|
||||
className={`fixed top-20 right-6 z-50 w-96 max-w-[calc(100vw-3rem)] transform transition-all duration-300 ease-out ${
|
||||
isExiting
|
||||
? 'translate-x-full opacity-0'
|
||||
: 'translate-x-0 opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div className={`rounded-3xl border p-5 ${overlayStyle}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar */}
|
||||
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center text-lg font-semibold ${
|
||||
isDark ? 'bg-gradient-to-br from-purple-500 to-pink-500' : 'bg-gradient-to-br from-purple-400 to-pink-400'
|
||||
} text-white`}>
|
||||
{currentMessage?.senderInitials}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-semibold ${textColor}`}>
|
||||
{currentMessage?.senderName}
|
||||
</h3>
|
||||
<p className={`text-xs ${mutedColor}`}>
|
||||
{currentMessage?.isGroup ? 'Gruppenchat' : 'Direktnachricht'} • Jetzt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className={`p-2 rounded-xl transition-colors ${
|
||||
isDark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message Content with Typewriter Effect */}
|
||||
<div className={`mb-4 p-4 rounded-2xl ${
|
||||
isDark ? 'bg-white/5' : 'bg-slate-50'
|
||||
}`}>
|
||||
<p className={`text-sm leading-relaxed ${textColor}`}>
|
||||
{displayedText}
|
||||
{isTyping && (
|
||||
<span className={`inline-block w-0.5 h-4 ml-0.5 animate-pulse ${
|
||||
isDark ? 'bg-purple-400' : 'bg-purple-600'
|
||||
}`} />
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reply Input (when replying) */}
|
||||
{isReplying && (
|
||||
<div className="mb-4">
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={handleReplyKeyDown}
|
||||
placeholder="Antwort schreiben..."
|
||||
autoFocus
|
||||
rows={2}
|
||||
className={`w-full px-4 py-3 rounded-xl border text-sm resize-none transition-all focus:outline-none focus:ring-2 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:ring-purple-500/50'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400 focus:ring-purple-500/50'
|
||||
}`}
|
||||
/>
|
||||
<p className={`text-xs mt-1 ${mutedColor}`}>
|
||||
Enter zum Senden • Esc zum Abbrechen
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isReplying ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSendReply}
|
||||
disabled={!replyText.trim()}
|
||||
className={`flex-1 py-2.5 rounded-xl font-medium transition-all disabled:opacity-50 ${buttonPrimary}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
Senden
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsReplying(false); setReplyText('') }}
|
||||
className={`px-4 py-2.5 rounded-xl font-medium transition-all ${buttonSecondary}`}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleReplyClick}
|
||||
className={`flex-1 py-2.5 rounded-xl font-medium transition-all ${buttonPrimary}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
Antworten
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpenConversation}
|
||||
className={`px-4 py-2.5 rounded-xl font-medium transition-all ${buttonSecondary}`}
|
||||
>
|
||||
Öffnen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className={`px-4 py-2.5 rounded-xl font-medium transition-all ${buttonSecondary}`}
|
||||
>
|
||||
Später
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message Queue Indicator */}
|
||||
{messageQueue.length > 0 && (
|
||||
<div className={`mt-2 px-4 py-2 rounded-xl text-center text-sm ${
|
||||
isDark ? 'bg-purple-500/20 text-purple-300' : 'bg-purple-100 text-purple-700'
|
||||
}`}>
|
||||
+{messageQueue.length} weitere Nachricht{messageQueue.length > 1 ? 'en' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CSS for animations */}
|
||||
<style jsx>{`
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Export a function to trigger messages programmatically
|
||||
export function useChatOverlay() {
|
||||
// This would be connected to a global state or event system
|
||||
// For now, return a placeholder
|
||||
return {
|
||||
showMessage: (message: Omit<ChatMessage, 'id' | 'timestamp'>) => {
|
||||
console.log('Would show message:', message)
|
||||
// TODO: Implement global message trigger
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
// Leaflet Komponente dynamisch laden (nur Client-Side)
|
||||
const CityMapLeaflet = dynamic(
|
||||
() => import('./CityMapLeaflet'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-64 rounded-2xl bg-slate-200 dark:bg-white/10 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-blue-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
interface CityMapProps {
|
||||
bundesland: string
|
||||
bundeslandName: string
|
||||
selectedCity: string
|
||||
onSelectCity: (city: string, lat?: number, lng?: number) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Bundesland-Zentren für initiale Kartenposition
|
||||
const bundeslandCenters: Record<string, { lat: number; lng: number; zoom: number }> = {
|
||||
'SH': { lat: 54.2, lng: 9.9, zoom: 8 },
|
||||
'HH': { lat: 53.55, lng: 10.0, zoom: 11 },
|
||||
'MV': { lat: 53.9, lng: 12.4, zoom: 8 },
|
||||
'HB': { lat: 53.1, lng: 8.8, zoom: 11 },
|
||||
'NI': { lat: 52.8, lng: 9.5, zoom: 7 },
|
||||
'BE': { lat: 52.52, lng: 13.4, zoom: 11 },
|
||||
'BB': { lat: 52.4, lng: 13.2, zoom: 8 },
|
||||
'ST': { lat: 51.9, lng: 11.7, zoom: 8 },
|
||||
'NW': { lat: 51.5, lng: 7.5, zoom: 8 },
|
||||
'HE': { lat: 50.6, lng: 9.0, zoom: 8 },
|
||||
'TH': { lat: 50.9, lng: 11.0, zoom: 8 },
|
||||
'SN': { lat: 51.1, lng: 13.2, zoom: 8 },
|
||||
'RP': { lat: 49.9, lng: 7.5, zoom: 8 },
|
||||
'SL': { lat: 49.4, lng: 7.0, zoom: 9 },
|
||||
'BW': { lat: 48.7, lng: 9.0, zoom: 8 },
|
||||
'BY': { lat: 48.8, lng: 11.5, zoom: 7 },
|
||||
}
|
||||
|
||||
export function CityMap({
|
||||
bundesland,
|
||||
bundeslandName,
|
||||
selectedCity,
|
||||
onSelectCity,
|
||||
className = ''
|
||||
}: CityMapProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [searchQuery, setSearchQuery] = useState(selectedCity || '')
|
||||
const [searchResults, setSearchResults] = useState<any[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [markerPosition, setMarkerPosition] = useState<[number, number] | null>(null)
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const center = bundeslandCenters[bundesland] || { lat: 51.2, lng: 10.4, zoom: 6 }
|
||||
|
||||
// Nominatim-Suche mit Debouncing
|
||||
const searchCity = useCallback(async (query: string) => {
|
||||
if (query.length < 2) {
|
||||
setSearchResults([])
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
try {
|
||||
// Nominatim API für Geocoding (OpenStreetMap)
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?` +
|
||||
`q=${encodeURIComponent(query)}, ${bundeslandName}, Deutschland&` +
|
||||
`format=json&addressdetails=1&limit=8&countrycodes=de`,
|
||||
{
|
||||
headers: {
|
||||
'Accept-Language': 'de',
|
||||
}
|
||||
}
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
// Filtere nach relevanten Ergebnissen (Städte, Orte, Gemeinden)
|
||||
const filtered = data.filter((item: any) =>
|
||||
item.type === 'city' ||
|
||||
item.type === 'town' ||
|
||||
item.type === 'village' ||
|
||||
item.type === 'municipality' ||
|
||||
item.type === 'administrative' ||
|
||||
item.class === 'place'
|
||||
)
|
||||
|
||||
setSearchResults(filtered.length > 0 ? filtered : data.slice(0, 5))
|
||||
} catch (error) {
|
||||
console.error('Geocoding error:', error)
|
||||
setSearchResults([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, [bundeslandName])
|
||||
|
||||
// Debounced Search
|
||||
useEffect(() => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
searchCity(searchQuery)
|
||||
}, 400)
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [searchQuery, searchCity])
|
||||
|
||||
// Reverse Geocoding bei Kartenklick
|
||||
const handleMapClick = async (lat: number, lng: number) => {
|
||||
setMarkerPosition([lat, lng])
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?` +
|
||||
`lat=${lat}&lon=${lng}&format=json&addressdetails=1`,
|
||||
{
|
||||
headers: {
|
||||
'Accept-Language': 'de',
|
||||
}
|
||||
}
|
||||
)
|
||||
const data = await response.json()
|
||||
|
||||
// Extrahiere Stadt/Ort aus der Adresse
|
||||
const city = data.address?.city ||
|
||||
data.address?.town ||
|
||||
data.address?.village ||
|
||||
data.address?.municipality ||
|
||||
data.address?.county ||
|
||||
'Unbekannter Ort'
|
||||
|
||||
setSearchQuery(city)
|
||||
onSelectCity(city, lat, lng)
|
||||
} catch (error) {
|
||||
console.error('Reverse geocoding error:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Suchergebnis auswählen
|
||||
const handleSelectResult = (result: any) => {
|
||||
const cityName = result.address?.city ||
|
||||
result.address?.town ||
|
||||
result.address?.village ||
|
||||
result.address?.municipality ||
|
||||
result.display_name.split(',')[0]
|
||||
|
||||
setSearchQuery(cityName)
|
||||
setMarkerPosition([parseFloat(result.lat), parseFloat(result.lon)])
|
||||
onSelectCity(cityName, parseFloat(result.lat), parseFloat(result.lon))
|
||||
setSearchResults([])
|
||||
}
|
||||
|
||||
// Manuelle Eingabe bestätigen
|
||||
const handleManualInput = () => {
|
||||
if (searchQuery.trim()) {
|
||||
onSelectCity(searchQuery.trim())
|
||||
setSearchResults([])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Suchfeld */}
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleManualInput()
|
||||
}
|
||||
}}
|
||||
placeholder={`Stadt in ${bundeslandName} suchen...`}
|
||||
className={`w-full px-5 py-4 pl-12 text-lg rounded-2xl border transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-blue-400'
|
||||
: 'bg-white border-slate-300 text-slate-900 placeholder-slate-400 focus:border-blue-500'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500/30`}
|
||||
/>
|
||||
<svg
|
||||
className={`absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 ${
|
||||
isDark ? 'text-white/40' : 'text-slate-400'
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
{isSearching && (
|
||||
<div className={`absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 border-2 border-t-transparent rounded-full animate-spin ${
|
||||
isDark ? 'border-white/40' : 'border-slate-400'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Suchergebnisse Dropdown */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border shadow-xl z-50 max-h-64 overflow-y-auto ${
|
||||
isDark
|
||||
? 'bg-slate-800/95 border-white/20 backdrop-blur-xl'
|
||||
: 'bg-white/95 border-slate-200 backdrop-blur-xl'
|
||||
}`}>
|
||||
{searchResults.map((result, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => handleSelectResult(result)}
|
||||
className={`w-full px-4 py-3 text-left transition-colors flex items-center gap-3 ${
|
||||
isDark
|
||||
? 'hover:bg-white/10 text-white/90'
|
||||
: 'hover:bg-slate-100 text-slate-800'
|
||||
} ${index > 0 ? (isDark ? 'border-t border-white/10' : 'border-t border-slate-100') : ''}`}
|
||||
>
|
||||
<svg className={`w-4 h-4 flex-shrink-0 ${isDark ? 'text-white/50' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
</svg>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">
|
||||
{result.address?.city || result.address?.town || result.address?.village || result.display_name.split(',')[0]}
|
||||
</p>
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{result.display_name}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Karte */}
|
||||
<div className={`h-64 rounded-2xl overflow-hidden border ${
|
||||
isDark ? 'border-white/20' : 'border-slate-300'
|
||||
}`}>
|
||||
<CityMapLeaflet
|
||||
center={center}
|
||||
zoom={center.zoom}
|
||||
markerPosition={markerPosition}
|
||||
onMapClick={handleMapClick}
|
||||
isDark={isDark}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hinweis */}
|
||||
<p className={`text-xs text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Tippen Sie den Namen ein oder klicken Sie auf die Karte
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { MapContainer, TileLayer, Marker, useMapEvents, useMap } from 'react-leaflet'
|
||||
import L from 'leaflet'
|
||||
import 'leaflet/dist/leaflet.css'
|
||||
|
||||
// Fix für Leaflet Marker Icons in Next.js
|
||||
const DefaultIcon = L.icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
shadowSize: [41, 41]
|
||||
})
|
||||
|
||||
L.Marker.prototype.options.icon = DefaultIcon
|
||||
|
||||
interface CityMapLeafletProps {
|
||||
center: { lat: number; lng: number }
|
||||
zoom: number
|
||||
markerPosition: [number, number] | null
|
||||
onMapClick: (lat: number, lng: number) => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
// Komponente für Klick-Events
|
||||
function MapClickHandler({ onMapClick }: { onMapClick: (lat: number, lng: number) => void }) {
|
||||
useMapEvents({
|
||||
click: (e) => {
|
||||
onMapClick(e.latlng.lat, e.latlng.lng)
|
||||
},
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
// Komponente um Karte neu zu zentrieren
|
||||
function MapCenterUpdater({ center, zoom }: { center: { lat: number; lng: number }; zoom: number }) {
|
||||
const map = useMap()
|
||||
|
||||
useEffect(() => {
|
||||
map.setView([center.lat, center.lng], zoom)
|
||||
}, [map, center.lat, center.lng, zoom])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default function CityMapLeaflet({
|
||||
center,
|
||||
zoom,
|
||||
markerPosition,
|
||||
onMapClick,
|
||||
isDark
|
||||
}: CityMapLeafletProps) {
|
||||
return (
|
||||
<MapContainer
|
||||
center={[center.lat, center.lng]}
|
||||
zoom={zoom}
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url={isDark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
}
|
||||
/>
|
||||
{markerPosition && (
|
||||
<Marker position={markerPosition} icon={DefaultIcon} />
|
||||
)}
|
||||
<MapClickHandler onMapClick={onMapClick} />
|
||||
<MapCenterUpdater center={center} zoom={zoom} />
|
||||
</MapContainer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
|
||||
interface Document {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
uploadedAt: Date
|
||||
category?: string
|
||||
tags?: string[]
|
||||
url?: string
|
||||
}
|
||||
|
||||
interface DocumentSpaceProps {
|
||||
documents: Document[]
|
||||
onDelete?: (id: string) => void
|
||||
onRename?: (id: string, newName: string) => void
|
||||
onOpen?: (doc: Document) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const formatDate = (date: Date): string => {
|
||||
return new Intl.DateTimeFormat('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
export function DocumentSpace({
|
||||
documents,
|
||||
onDelete,
|
||||
onRename,
|
||||
onOpen,
|
||||
className = ''
|
||||
}: DocumentSpaceProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [filterType, setFilterType] = useState<string>('all')
|
||||
const [sortBy, setSortBy] = useState<'name' | 'date' | 'size'>('date')
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc')
|
||||
const [viewMode, setViewMode] = useState<'grid' | 'list'>('list')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [previewDoc, setPreviewDoc] = useState<Document | null>(null)
|
||||
|
||||
// Filtertypen ermitteln
|
||||
const fileTypes = useMemo(() => {
|
||||
const types = new Set(documents.map(d => d.type.split('/')[1] || d.type))
|
||||
return ['all', ...Array.from(types)]
|
||||
}, [documents])
|
||||
|
||||
// Dokumente filtern und sortieren
|
||||
const filteredDocuments = useMemo(() => {
|
||||
let filtered = [...documents]
|
||||
|
||||
// Suchfilter
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
filtered = filtered.filter(d =>
|
||||
d.name.toLowerCase().includes(query) ||
|
||||
d.tags?.some(t => t.toLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
|
||||
// Typfilter
|
||||
if (filterType !== 'all') {
|
||||
filtered = filtered.filter(d =>
|
||||
d.type.includes(filterType)
|
||||
)
|
||||
}
|
||||
|
||||
// Sortieren
|
||||
filtered.sort((a, b) => {
|
||||
let cmp = 0
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
break
|
||||
case 'date':
|
||||
cmp = new Date(a.uploadedAt).getTime() - new Date(b.uploadedAt).getTime()
|
||||
break
|
||||
case 'size':
|
||||
cmp = a.size - b.size
|
||||
break
|
||||
}
|
||||
return sortOrder === 'asc' ? cmp : -cmp
|
||||
})
|
||||
|
||||
return filtered
|
||||
}, [documents, searchQuery, filterType, sortBy, sortOrder])
|
||||
|
||||
const handleStartRename = (doc: Document) => {
|
||||
setEditingId(doc.id)
|
||||
setEditName(doc.name.replace(/\.[^/.]+$/, ''))
|
||||
}
|
||||
|
||||
const handleSaveRename = (doc: Document) => {
|
||||
if (editName.trim() && onRename) {
|
||||
const ext = doc.name.split('.').pop()
|
||||
onRename(doc.id, `${editName.trim()}.${ext}`)
|
||||
}
|
||||
setEditingId(null)
|
||||
setEditName('')
|
||||
}
|
||||
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type.includes('pdf')) return '📄'
|
||||
if (type.includes('image')) return '🖼️'
|
||||
if (type.includes('word') || type.includes('doc')) return '📝'
|
||||
if (type.includes('sheet') || type.includes('excel')) return '📊'
|
||||
return '📎'
|
||||
}
|
||||
|
||||
if (documents.length === 0) {
|
||||
return (
|
||||
<div className={`${className} text-center py-12`}>
|
||||
<div className={`w-16 h-16 mx-auto rounded-2xl flex items-center justify-center mb-4 ${
|
||||
isDark ? 'bg-white/10' : 'bg-slate-100'
|
||||
}`}>
|
||||
<span className="text-3xl">📁</span>
|
||||
</div>
|
||||
<h3 className={`text-lg font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Noch keine Dokumente
|
||||
</h3>
|
||||
<p className={`text-sm mt-2 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
Laden Sie Ihr erstes Dokument hoch, um loszulegen.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
{/* Suche */}
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Dokumente durchsuchen..."
|
||||
className={`w-full pl-10 pr-4 py-2 rounded-xl border text-sm ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/10 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
<svg className={`absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 ${
|
||||
isDark ? 'text-white/40' : 'text-slate-400'
|
||||
}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<select
|
||||
value={filterType}
|
||||
onChange={(e) => setFilterType(e.target.value)}
|
||||
className={`px-3 py-2 rounded-xl border text-sm ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/10 text-white'
|
||||
: 'bg-white border-slate-200 text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{fileTypes.map(type => (
|
||||
<option key={type} value={type}>
|
||||
{type === 'all' ? 'Alle Typen' : type.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Sortierung */}
|
||||
<select
|
||||
value={`${sortBy}-${sortOrder}`}
|
||||
onChange={(e) => {
|
||||
const [by, order] = e.target.value.split('-')
|
||||
setSortBy(by as 'name' | 'date' | 'size')
|
||||
setSortOrder(order as 'asc' | 'desc')
|
||||
}}
|
||||
className={`px-3 py-2 rounded-xl border text-sm ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/10 text-white'
|
||||
: 'bg-white border-slate-200 text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<option value="date-desc">Neueste zuerst</option>
|
||||
<option value="date-asc">Aelteste zuerst</option>
|
||||
<option value="name-asc">Name A-Z</option>
|
||||
<option value="name-desc">Name Z-A</option>
|
||||
<option value="size-desc">Groesste zuerst</option>
|
||||
<option value="size-asc">Kleinste zuerst</option>
|
||||
</select>
|
||||
|
||||
{/* Ansicht */}
|
||||
<div className={`flex rounded-xl border overflow-hidden ${
|
||||
isDark ? 'border-white/10' : 'border-slate-200'
|
||||
}`}>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-2 ${
|
||||
viewMode === 'list'
|
||||
? isDark ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-900'
|
||||
: isDark ? 'text-white/60' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-2 ${
|
||||
viewMode === 'grid'
|
||||
? isDark ? 'bg-white/20 text-white' : 'bg-slate-200 text-slate-900'
|
||||
: isDark ? 'text-white/60' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ergebnisse */}
|
||||
<div className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{filteredDocuments.length} Dokument{filteredDocuments.length !== 1 ? 'e' : ''} gefunden
|
||||
</div>
|
||||
|
||||
{/* Dokumentliste */}
|
||||
{viewMode === 'list' ? (
|
||||
<div className={`rounded-2xl border overflow-hidden ${
|
||||
isDark ? 'bg-white/5 border-white/10' : 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div className="divide-y divide-slate-200 dark:divide-white/10">
|
||||
{filteredDocuments.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={`p-4 flex items-center gap-4 cursor-pointer ${
|
||||
isDark ? 'hover:bg-white/5' : 'hover:bg-slate-50'
|
||||
}`}
|
||||
onClick={() => setPreviewDoc(doc)}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center text-xl ${
|
||||
isDark ? 'bg-white/10' : 'bg-slate-100'
|
||||
}`}>
|
||||
{getFileIcon(doc.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingId === doc.id ? (
|
||||
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveRename(doc)}
|
||||
onBlur={() => handleSaveRename(doc)}
|
||||
autoFocus
|
||||
className={`flex-1 px-2 py-1 rounded border text-sm ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white'
|
||||
: 'bg-white border-slate-300 text-slate-900'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{doc.name}
|
||||
</p>
|
||||
)}
|
||||
<p className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{formatFileSize(doc.size)} · {formatDate(new Date(doc.uploadedAt))}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => handleStartRename(doc)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
title="Umbenennen"
|
||||
>
|
||||
<svg className={`w-4 h-4 ${isDark ? 'text-white/50' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete?.(doc.id)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-red-500/20' : 'hover:bg-red-100'}`}
|
||||
title="Loeschen"
|
||||
>
|
||||
<svg className={`w-4 h-4 ${isDark ? 'text-white/50 hover:text-red-300' : 'text-slate-400 hover:text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filteredDocuments.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className={`rounded-2xl border p-4 cursor-pointer transition-all hover:scale-105 ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/10 hover:bg-white/10'
|
||||
: 'bg-white border-slate-200 hover:shadow-lg'
|
||||
}`}
|
||||
onClick={() => setPreviewDoc(doc)}
|
||||
>
|
||||
<div className={`w-full aspect-square rounded-xl flex items-center justify-center mb-3 ${
|
||||
isDark ? 'bg-white/10' : 'bg-slate-100'
|
||||
}`}>
|
||||
<span className="text-4xl">{getFileIcon(doc.type)}</span>
|
||||
</div>
|
||||
<p className={`font-medium text-sm truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{doc.name}
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{formatFileSize(doc.size)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vorschau-Modal */}
|
||||
{previewDoc && previewDoc.url && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
||||
onClick={() => setPreviewDoc(null)}
|
||||
/>
|
||||
<div className={`relative w-full max-w-4xl max-h-[90vh] rounded-3xl overflow-hidden ${
|
||||
isDark ? 'bg-slate-900' : 'bg-white'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className={`flex items-center justify-between p-4 border-b ${
|
||||
isDark ? 'border-white/10' : 'border-slate-200'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{getFileIcon(previewDoc.type)}</span>
|
||||
<div>
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{previewDoc.name}
|
||||
</h3>
|
||||
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{formatFileSize(previewDoc.size)} · {formatDate(new Date(previewDoc.uploadedAt))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setPreviewDoc(null)}
|
||||
className={`p-2 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-white/60' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Vorschau-Inhalt */}
|
||||
<div className={`p-4 overflow-auto max-h-[calc(90vh-120px)] ${
|
||||
isDark ? 'bg-slate-800' : 'bg-slate-50'
|
||||
}`}>
|
||||
{previewDoc.type.includes('image') ? (
|
||||
<img
|
||||
src={previewDoc.url}
|
||||
alt={previewDoc.name}
|
||||
className="max-w-full h-auto mx-auto rounded-lg shadow-lg"
|
||||
/>
|
||||
) : previewDoc.type.includes('pdf') ? (
|
||||
<iframe
|
||||
src={previewDoc.url}
|
||||
className="w-full h-[70vh] rounded-lg"
|
||||
title={previewDoc.name}
|
||||
/>
|
||||
) : (
|
||||
<div className={`text-center py-12 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
<span className="text-6xl block mb-4">{getFileIcon(previewDoc.type)}</span>
|
||||
<p>Vorschau fuer diesen Dateityp nicht verfuegbar</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
|
||||
interface UploadedDocument {
|
||||
id: string
|
||||
name: string
|
||||
originalName: string
|
||||
size: number
|
||||
type: string
|
||||
uploadedAt: Date
|
||||
status: 'uploading' | 'processing' | 'complete' | 'error'
|
||||
progress: number
|
||||
url?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface DocumentUploadProps {
|
||||
onUploadComplete?: (documents: UploadedDocument[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Formatiere Dateigroesse
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
export function DocumentUpload({ onUploadComplete, className = '' }: DocumentUploadProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [documents, setDocuments] = useState<UploadedDocument[]>([])
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Echter Upload mit lokalem Blob URL fuer Vorschau
|
||||
const uploadFile = useCallback((file: File): Promise<UploadedDocument> => {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const doc: UploadedDocument = {
|
||||
id: `doc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: file.name,
|
||||
originalName: file.name,
|
||||
size: file.size,
|
||||
type: file.type,
|
||||
uploadedAt: new Date(),
|
||||
status: 'uploading',
|
||||
progress: 0
|
||||
}
|
||||
|
||||
// Dokument sofort zur Liste hinzufuegen
|
||||
setDocuments(prev => [...prev, doc])
|
||||
|
||||
try {
|
||||
// Fortschritt auf 30% setzen (Datei wird gelesen)
|
||||
setDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, progress: 30 } : d))
|
||||
|
||||
// Blob URL fuer lokale Vorschau erstellen
|
||||
const blobUrl = URL.createObjectURL(file)
|
||||
|
||||
// Fortschritt auf 60% setzen
|
||||
setDocuments(prev => prev.map(d => d.id === doc.id ? { ...d, progress: 60 } : d))
|
||||
|
||||
// Kurze Verzoegerung fuer visuelles Feedback
|
||||
await new Promise(r => setTimeout(r, 300))
|
||||
|
||||
// Fortschritt auf 100% setzen
|
||||
const completedDoc = {
|
||||
...doc,
|
||||
status: 'complete' as const,
|
||||
progress: 100,
|
||||
url: blobUrl
|
||||
}
|
||||
setDocuments(prev => prev.map(d => d.id === doc.id ? completedDoc : d))
|
||||
resolve(completedDoc)
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error)
|
||||
setDocuments(prev => prev.map(d =>
|
||||
d.id === doc.id ? { ...d, status: 'error' as const, error: 'Upload fehlgeschlagen' } : d
|
||||
))
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}, [])
|
||||
|
||||
// Dateien verarbeiten
|
||||
const handleFiles = useCallback(async (files: FileList | null) => {
|
||||
if (!files || files.length === 0) return
|
||||
|
||||
const fileArray = Array.from(files)
|
||||
const validFiles = fileArray.filter(f =>
|
||||
f.type === 'application/pdf' ||
|
||||
f.type.startsWith('image/') ||
|
||||
f.name.endsWith('.pdf') ||
|
||||
f.name.endsWith('.jpg') ||
|
||||
f.name.endsWith('.jpeg') ||
|
||||
f.name.endsWith('.png')
|
||||
)
|
||||
|
||||
if (validFiles.length === 0) {
|
||||
alert('Bitte nur PDF- oder Bilddateien hochladen.')
|
||||
return
|
||||
}
|
||||
|
||||
const uploadedDocs = await Promise.all(validFiles.map(f => uploadFile(f)))
|
||||
onUploadComplete?.(uploadedDocs)
|
||||
}, [uploadFile, onUploadComplete])
|
||||
|
||||
// Drag & Drop Handler
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}, [])
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
handleFiles(e.dataTransfer.files)
|
||||
}, [handleFiles])
|
||||
|
||||
// Dokument loeschen
|
||||
const handleDelete = useCallback((id: string) => {
|
||||
setDocuments(prev => prev.filter(d => d.id !== id))
|
||||
}, [])
|
||||
|
||||
// Dokument umbenennen starten
|
||||
const handleStartRename = useCallback((doc: UploadedDocument) => {
|
||||
setEditingId(doc.id)
|
||||
setEditName(doc.name.replace(/\.[^/.]+$/, '')) // Name ohne Extension
|
||||
}, [])
|
||||
|
||||
// Umbenennen speichern
|
||||
const handleSaveRename = useCallback((id: string) => {
|
||||
if (editName.trim()) {
|
||||
setDocuments(prev => prev.map(d => {
|
||||
if (d.id === id) {
|
||||
const ext = d.originalName.split('.').pop()
|
||||
return { ...d, name: `${editName.trim()}.${ext}` }
|
||||
}
|
||||
return d
|
||||
}))
|
||||
}
|
||||
setEditingId(null)
|
||||
setEditName('')
|
||||
}, [editName])
|
||||
|
||||
// Datei-Icon basierend auf Typ
|
||||
const getFileIcon = (type: string) => {
|
||||
if (type === 'application/pdf') return '📄'
|
||||
if (type.startsWith('image/')) return '🖼️'
|
||||
return '📎'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{/* Upload-Bereich */}
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={`relative border-2 border-dashed rounded-3xl p-8 text-center cursor-pointer transition-all ${
|
||||
isDragging
|
||||
? isDark
|
||||
? 'border-blue-400 bg-blue-500/20'
|
||||
: 'border-blue-500 bg-blue-50'
|
||||
: isDark
|
||||
? 'border-white/20 bg-white/5 hover:bg-white/10 hover:border-white/30'
|
||||
: 'border-slate-300 bg-slate-50 hover:bg-slate-100 hover:border-slate-400'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png,image/*,application/pdf"
|
||||
onChange={(e) => handleFiles(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center ${
|
||||
isDark ? 'bg-white/10' : 'bg-slate-200'
|
||||
}`}>
|
||||
<svg className={`w-8 h-8 ${isDark ? 'text-white/60' : 'text-slate-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-lg font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{isDragging ? 'Dateien hier ablegen' : 'Dokumente hochladen'}
|
||||
</p>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
Ziehen Sie Dateien hierher oder klicken Sie zum Auswaehlen
|
||||
</p>
|
||||
<p className={`text-xs mt-2 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
|
||||
PDF, JPG, PNG - max. 50 MB pro Datei
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hochgeladene Dokumente */}
|
||||
{documents.length > 0 && (
|
||||
<div className={`rounded-2xl border overflow-hidden ${
|
||||
isDark ? 'bg-white/5 border-white/10' : 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div className={`px-4 py-3 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Hochgeladene Dokumente ({documents.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-200 dark:divide-white/10">
|
||||
{documents.map((doc) => (
|
||||
<div key={doc.id} className={`p-4 flex items-center gap-4 ${
|
||||
isDark ? 'hover:bg-white/5' : 'hover:bg-slate-50'
|
||||
}`}>
|
||||
{/* Icon */}
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-2xl ${
|
||||
doc.status === 'complete'
|
||||
? isDark ? 'bg-green-500/20' : 'bg-green-100'
|
||||
: doc.status === 'error'
|
||||
? isDark ? 'bg-red-500/20' : 'bg-red-100'
|
||||
: isDark ? 'bg-blue-500/20' : 'bg-blue-100'
|
||||
}`}>
|
||||
{getFileIcon(doc.type)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingId === doc.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveRename(doc.id)}
|
||||
onBlur={() => handleSaveRename(doc.id)}
|
||||
autoFocus
|
||||
className={`flex-1 px-2 py-1 rounded border text-sm ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white'
|
||||
: 'bg-white border-slate-300 text-slate-900'
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
.{doc.originalName.split('.').pop()}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{doc.name}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-1">
|
||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{formatFileSize(doc.size)}
|
||||
</span>
|
||||
{doc.status === 'complete' && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
Hochgeladen
|
||||
</span>
|
||||
)}
|
||||
{doc.status === 'uploading' && (
|
||||
<span className={`text-xs ${isDark ? 'text-blue-300' : 'text-blue-600'}`}>
|
||||
{Math.round(doc.progress)}%
|
||||
</span>
|
||||
)}
|
||||
{doc.status === 'error' && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
Fehler
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{doc.status === 'uploading' && (
|
||||
<div className={`mt-2 h-1.5 rounded-full overflow-hidden ${
|
||||
isDark ? 'bg-white/10' : 'bg-slate-200'
|
||||
}`}>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 rounded-full transition-all duration-300"
|
||||
style={{ width: `${doc.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Aktionen */}
|
||||
{doc.status === 'complete' && (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Umbenennen */}
|
||||
<button
|
||||
onClick={() => handleStartRename(doc)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isDark
|
||||
? 'hover:bg-white/10 text-white/60 hover:text-white'
|
||||
: 'hover:bg-slate-100 text-slate-400 hover:text-slate-700'
|
||||
}`}
|
||||
title="Umbenennen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Oeffnen/Vorschau */}
|
||||
{doc.url && (
|
||||
<a
|
||||
href={doc.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isDark
|
||||
? 'hover:bg-white/10 text-white/60 hover:text-white'
|
||||
: 'hover:bg-slate-100 text-slate-400 hover:text-slate-700'
|
||||
}`}
|
||||
title="Oeffnen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Loeschen */}
|
||||
<button
|
||||
onClick={() => handleDelete(doc.id)}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isDark
|
||||
? 'hover:bg-red-500/20 text-white/60 hover:text-red-300'
|
||||
: 'hover:bg-red-100 text-slate-400 hover:text-red-600'
|
||||
}`}
|
||||
title="Loeschen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
|
||||
interface FooterProps {
|
||||
className?: string
|
||||
onOpenCookieSettings?: () => void
|
||||
}
|
||||
|
||||
export function Footer({ className = '', onOpenCookieSettings }: FooterProps) {
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const handleCookieClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
if (onOpenCookieSettings) {
|
||||
onOpenCookieSettings()
|
||||
} else {
|
||||
// Fallback: Alert wenn kein Handler definiert
|
||||
alert('Cookie-Banner wird hier geoeffnet (noch nicht implementiert)')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className={`mt-auto ${className}`}>
|
||||
<div className={`
|
||||
${isDark
|
||||
? 'bg-white/5 border-white/10'
|
||||
: 'bg-black/5 border-black/10'
|
||||
}
|
||||
backdrop-blur-xl border-t py-6 px-8
|
||||
`}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
{/* Links - Rechtlich notwendig */}
|
||||
<nav className="flex flex-wrap items-center justify-center gap-6">
|
||||
<Link
|
||||
href="/impressum"
|
||||
className={`text-sm hover:underline transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-black/60 hover:text-black'
|
||||
}`}
|
||||
>
|
||||
{t('imprint')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/datenschutz"
|
||||
className={`text-sm hover:underline transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-black/60 hover:text-black'
|
||||
}`}
|
||||
>
|
||||
{t('privacy')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/agb"
|
||||
className={`text-sm hover:underline transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-black/60 hover:text-black'
|
||||
}`}
|
||||
>
|
||||
{t('legal')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/kontakt"
|
||||
className={`text-sm hover:underline transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-black/60 hover:text-black'
|
||||
}`}
|
||||
>
|
||||
{t('contact')}
|
||||
</Link>
|
||||
<button
|
||||
onClick={handleCookieClick}
|
||||
className={`text-sm hover:underline transition-colors ${
|
||||
isDark ? 'text-white/60 hover:text-white' : 'text-black/60 hover:text-black'
|
||||
}`}
|
||||
>
|
||||
{t('cookie_settings')}
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Copyright */}
|
||||
<p className={`text-sm ${isDark ? 'text-white/40' : 'text-black/40'}`}>
|
||||
{t('copyright')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
|
||||
interface GermanyMapProps {
|
||||
selectedState: string | null
|
||||
onSelectState: (stateId: string) => void
|
||||
suggestedState?: string | null
|
||||
className?: string
|
||||
}
|
||||
|
||||
// Bundesländer mit Kürzeln und Namen
|
||||
export const bundeslaender: Record<string, string> = {
|
||||
'SH': 'Schleswig-Holstein',
|
||||
'HH': 'Hamburg',
|
||||
'MV': 'Mecklenburg-Vorpommern',
|
||||
'HB': 'Bremen',
|
||||
'NI': 'Niedersachsen',
|
||||
'BE': 'Berlin',
|
||||
'BB': 'Brandenburg',
|
||||
'ST': 'Sachsen-Anhalt',
|
||||
'NW': 'Nordrhein-Westfalen',
|
||||
'HE': 'Hessen',
|
||||
'TH': 'Thüringen',
|
||||
'SN': 'Sachsen',
|
||||
'RP': 'Rheinland-Pfalz',
|
||||
'SL': 'Saarland',
|
||||
'BW': 'Baden-Württemberg',
|
||||
'BY': 'Bayern',
|
||||
}
|
||||
|
||||
// Echte GeoJSON-basierte SVG-Pfade (vereinfacht aber geometrisch korrekt)
|
||||
// Basierend auf Natural Earth / OpenStreetMap Daten, projiziert auf viewBox 0 0 500 600
|
||||
const statePaths: Record<string, { path: string; labelX: number; labelY: number; labelSize: string }> = {
|
||||
'SH': {
|
||||
path: 'M205.2,8.1l5.8,3.2l12.1-1.9l17.8,6.4l11.3-0.8l14.2,10.4l9.4,16.8l-2.4,13.6l-8.2,6.4l-13.1,5.5l-7.3,12.8l-7.5-2.8l-0.5-7.1l-9.3,1.2l-4.6-4.8l-4.8,3.1l-8.5-4.1l-3.4,2.8l-6.5-5.2l-0.2-7.4l6.8-5.9l-5.3-12.2l1.8-9.2l-7.4-8.3l5.2-9.8l4.4-2.6Z',
|
||||
labelX: 240, labelY: 52, labelSize: 'text-[11px]'
|
||||
},
|
||||
'HH': {
|
||||
path: 'M236.8,79.5l10.5,1.2l6.8,8.2l-2.1,9.4l-11.2,3.8l-8.4-5.2l-1.8-9.1l6.2-8.3Z',
|
||||
labelX: 242, labelY: 93, labelSize: 'text-[9px]'
|
||||
},
|
||||
'MV': {
|
||||
path: 'M272.6,27.4l15.8-2.1l22.5,1.8l27.3,8.6l22.8,15.2l8.4,18.6l-4.8,21.4l-18.2,16.8l-32.4,4.6l-26.8-3.2l-20.4-12.6l-8.2-18.4l2.4-13.6l-9.4-16.8l8.6-11.4l12.3-8.9Z',
|
||||
labelX: 335, labelY: 72, labelSize: 'text-[11px]'
|
||||
},
|
||||
'HB': {
|
||||
path: 'M188.4,109.2l12.2-1.6l8.4,6.8l-0.8,12.4l-10.6,5.2l-11.2-4.8l-2.4-10.2l4.4-7.8Z M172.8,136.4l6.2,2.1l4.8,8.2l-5.4,4.1l-7.2-3.8l1.6-10.6Z',
|
||||
labelX: 193, labelY: 123, labelSize: 'text-[8px]'
|
||||
},
|
||||
'NI': {
|
||||
path: 'M117.4,73.8l22.8-7.4l18.6-4.2l17.2,9.8l6.8,12.4l-5.2,8.3l4.6,4.8l9.3-1.2l0.5,7.1l7.5,2.8l-1.2,12.4l-6.2,8.3l1.8,9.1l8.4,5.2l11.2-3.8l6.2,5.4l10.8,2.4l8.2,18.4l20.4,12.6l2.1,16.4l-14.8,24.2l-26.2,18.4l-32.4,8.6l-28.6-2.8l-24.2-10.6l-18.8-18.4l-12.6-22.4l-8.4-16.8l-2.8-21.4l4.2-18.6l8.6-14.2l12.4-8.6l6.2-7.4Z',
|
||||
labelX: 195, labelY: 168, labelSize: 'text-[14px]'
|
||||
},
|
||||
'BE': {
|
||||
path: 'M372.4,166.2l14.6-0.8l9.2,8.4l-1.4,14.2l-12.8,6.4l-13.2-5.8l-2.6-12.4l6.2-10Z',
|
||||
labelX: 378, labelY: 180, labelSize: 'text-[9px]'
|
||||
},
|
||||
'BB': {
|
||||
path: 'M312.8,98.6l32.4-4.6l18.2-16.8l22.4,4.2l18.6,16.8l8.4,28.4l-4.2,34.6l-16.8,32.4l-28.6,22.8l-38.4,4.6l-32.6-14.8l-12.4-28.6l4.2-32.8l14.8-24.2l-2.1-16.4l16.1-5.6Z M372.4,166.2l14.6-0.8l9.2,8.4l-1.4,14.2l-12.8,6.4l-13.2-5.8l-2.6-12.4l6.2-10Z',
|
||||
labelX: 345, labelY: 195, labelSize: 'text-[12px]'
|
||||
},
|
||||
'ST': {
|
||||
path: 'M280.8,152.4l32.6,14.8l-4.2,32.8l-18.4,28.6l-32.8,12.4l-28.4-8.6l-8.6-24.2l8.4-28.4l18.6-18.8l32.8-8.6Z',
|
||||
labelX: 268, labelY: 205, labelSize: 'text-[11px]'
|
||||
},
|
||||
'NW': {
|
||||
path: 'M89.2,164.8l22.6-4.2l24.2,10.6l28.6,2.8l8.4,18.6l-4.8,32.4l-12.6,28.8l-18.4,22.6l-32.4,4.8l-28.6-12.4l-22.4-24.6l-4.2-28.4l8.6-24.8l14.2-18.6l16.8-7.6Z',
|
||||
labelX: 105, labelY: 232, labelSize: 'text-[14px]'
|
||||
},
|
||||
'HE': {
|
||||
path: 'M140.8,226.6l32.4-8.6l26.2-18.4l18.4,8.6l8.6,24.2l-4.8,32.6l-18.4,28.4l-24.6,8.6l-28.4-4.2l-18.4-16.8l12.6-28.8l-3.6-25.6Z',
|
||||
labelX: 178, labelY: 272, labelSize: 'text-[13px]'
|
||||
},
|
||||
'TH': {
|
||||
path: 'M221.6,240.8l28.4,8.6l32.8-12.4l18.4,4.2l8.6,28.4l-14.6,28.6l-28.4,12.4l-32.6-4.8l-18.6-18.4l-8.4-14.2l4.8-32.6l9.6,0.2Z',
|
||||
labelX: 262, labelY: 285, labelSize: 'text-[11px]'
|
||||
},
|
||||
'SN': {
|
||||
path: 'M280.2,200l38.4-4.6l28.6-22.8l22.8,8.6l18.4,24.2l4.2,32.8l-14.6,32.4l-28.4,18.6l-38.6,4.2l-28.4-18.4l-14.6-28.6l-8.6-28.4l8.4-12.6l12.4-5.4Z',
|
||||
labelX: 340, labelY: 255, labelSize: 'text-[12px]'
|
||||
},
|
||||
'RP': {
|
||||
path: 'M54.4,280.8l28.6,12.4l-4.8,28.6l-8.4,32.4l-18.6,28.4l-24.8,4.2l-18.4-22.6l-4.2-32.4l12.4-28.6l18.6-14.8l19.6-7.6Z',
|
||||
labelX: 52, labelY: 340, labelSize: 'text-[11px]'
|
||||
},
|
||||
'SL': {
|
||||
path: 'M26.2,382.6l24.8-4.2l12.4,18.6l-4.8,18.4l-18.6,4.2l-14.2-12.4l-4.2-14.6l4.6-10Z',
|
||||
labelX: 38, labelY: 400, labelSize: 'text-[9px]'
|
||||
},
|
||||
'BW': {
|
||||
path: 'M54.4,401l18.6-28.4l8.4-32.4l4.8-28.6l32.4-4.8l28.4,4.2l24.6-8.6l12.4,18.6l8.6,32.4l-4.2,38.6l-18.4,32.4l-32.6,18.6l-38.4,4.2l-28.6-12.4l-14.2-22.6l-6.2-28.6l4.8-18.4l18.6-4.2l-8.6,28.6l-10.4,12.4Z',
|
||||
labelX: 118, labelY: 420, labelSize: 'text-[13px]'
|
||||
},
|
||||
'BY': {
|
||||
path: 'M150.2,305.6l24.6-8.6l18.4-28.4l32.6,4.8l28.4-12.4l14.6-28.6l28.4,18.4l38.6-4.2l28.4-18.6l18.4,12.4l8.6,38.4l-4.2,48.6l-18.6,38.4l-38.4,28.6l-48.6,12.4l-38.4-4.8l-32.4-18.6l-18.6-32.4l4.2-38.6l-8.6-32.4l-12.4,18.6l-4.6,27.6Z',
|
||||
labelX: 290, labelY: 395, labelSize: 'text-[16px]'
|
||||
},
|
||||
}
|
||||
|
||||
export function GermanyMap({
|
||||
selectedState,
|
||||
onSelectState,
|
||||
suggestedState = null,
|
||||
className = ''
|
||||
}: GermanyMapProps) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const getStateStyle = (stateId: string) => {
|
||||
const isSelected = selectedState === stateId
|
||||
const isSuggested = suggestedState === stateId && !selectedState
|
||||
|
||||
if (isSelected) {
|
||||
return 'fill-blue-500 stroke-blue-700'
|
||||
}
|
||||
if (isSuggested) {
|
||||
return isDark
|
||||
? 'fill-green-500/40 stroke-green-400 animate-pulse'
|
||||
: 'fill-green-200 stroke-green-500 animate-pulse'
|
||||
}
|
||||
return isDark
|
||||
? 'fill-white/10 stroke-white/25 hover:fill-blue-400/30 hover:stroke-blue-400/50'
|
||||
: 'fill-slate-100 stroke-slate-300 hover:fill-blue-100 hover:stroke-blue-400'
|
||||
}
|
||||
|
||||
const getLabelStyle = (stateId: string) => {
|
||||
const isSelected = selectedState === stateId
|
||||
const isSuggested = suggestedState === stateId && !selectedState
|
||||
|
||||
if (isSelected) {
|
||||
return 'fill-white font-bold'
|
||||
}
|
||||
if (isSuggested) {
|
||||
return isDark ? 'fill-green-300 font-semibold' : 'fill-green-700 font-semibold'
|
||||
}
|
||||
return isDark ? 'fill-white/60' : 'fill-slate-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
<svg
|
||||
viewBox="0 0 500 600"
|
||||
className="w-full h-full"
|
||||
style={{ maxHeight: '420px' }}
|
||||
>
|
||||
{/* Hintergrund und Filter */}
|
||||
<defs>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
||||
<feDropShadow dx="2" dy="3" stdDeviation="4" floodOpacity="0.4"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{/* Bundesländer - von groß nach klein für korrekte Überlappung */}
|
||||
{['BY', 'NI', 'BW', 'NW', 'BB', 'MV', 'SN', 'ST', 'HE', 'TH', 'RP', 'SH', 'SL', 'HB', 'HH', 'BE'].map((stateId) => {
|
||||
const state = statePaths[stateId]
|
||||
const isSelected = selectedState === stateId
|
||||
const isSuggested = suggestedState === stateId && !selectedState
|
||||
|
||||
return (
|
||||
<g
|
||||
key={stateId}
|
||||
onClick={() => onSelectState(stateId)}
|
||||
className="cursor-pointer transition-all duration-200"
|
||||
style={{
|
||||
filter: isSelected ? 'url(#shadow)' : isSuggested ? 'url(#glow)' : 'none'
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d={state.path}
|
||||
className={getStateStyle(stateId)}
|
||||
strokeWidth={isSelected ? 2.5 : isSuggested ? 2 : 1.2}
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
<text
|
||||
x={state.labelX}
|
||||
y={state.labelY}
|
||||
className={`${state.labelSize} ${getLabelStyle(stateId)} pointer-events-none select-none`}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
>
|
||||
{stateId}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Hinweis bei vorgeschlagenem Bundesland */}
|
||||
{suggestedState && !selectedState && (
|
||||
<div className={`absolute top-2 left-1/2 -translate-x-1/2 px-3 py-1.5 rounded-lg text-xs ${
|
||||
isDark ? 'bg-green-500/20 text-green-300 border border-green-500/30' : 'bg-green-50 text-green-700 border border-green-200'
|
||||
}`}>
|
||||
Vorschlag: <strong>{bundeslaender[suggestedState]}</strong> (Klicken zum Bestätigen)
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ausgewähltes Bundesland */}
|
||||
{selectedState && (
|
||||
<div className={`absolute bottom-2 left-1/2 -translate-x-1/2 px-4 py-2 rounded-xl backdrop-blur-xl text-center ${
|
||||
isDark ? 'bg-blue-500/30 text-white border border-blue-400/30' : 'bg-blue-100 text-blue-900 border border-blue-200'
|
||||
}`}>
|
||||
<span className="font-semibold">{bundeslaender[selectedState]}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
|
||||
export type InfoBoxVariant = 'info' | 'tip' | 'warning' | 'success' | 'error'
|
||||
|
||||
interface InfoBoxProps {
|
||||
icon?: string
|
||||
title: string
|
||||
children: ReactNode
|
||||
variant?: InfoBoxVariant
|
||||
className?: string
|
||||
collapsible?: boolean
|
||||
defaultExpanded?: boolean
|
||||
}
|
||||
|
||||
export function InfoBox({
|
||||
icon,
|
||||
title,
|
||||
children,
|
||||
variant = 'info',
|
||||
className = '',
|
||||
collapsible = false,
|
||||
defaultExpanded = true
|
||||
}: InfoBoxProps) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
// Farben basierend auf Variante und Theme
|
||||
const getColors = () => {
|
||||
const variants = {
|
||||
info: {
|
||||
dark: 'bg-blue-500/10 border-blue-500/30 text-blue-100',
|
||||
light: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
icon: '💡'
|
||||
},
|
||||
tip: {
|
||||
dark: 'bg-green-500/10 border-green-500/30 text-green-100',
|
||||
light: 'bg-green-50 border-green-200 text-green-800',
|
||||
icon: '✨'
|
||||
},
|
||||
warning: {
|
||||
dark: 'bg-amber-500/10 border-amber-500/30 text-amber-100',
|
||||
light: 'bg-amber-50 border-amber-200 text-amber-800',
|
||||
icon: '⚠️'
|
||||
},
|
||||
success: {
|
||||
dark: 'bg-emerald-500/10 border-emerald-500/30 text-emerald-100',
|
||||
light: 'bg-emerald-50 border-emerald-200 text-emerald-800',
|
||||
icon: '✅'
|
||||
},
|
||||
error: {
|
||||
dark: 'bg-red-500/10 border-red-500/30 text-red-100',
|
||||
light: 'bg-red-50 border-red-200 text-red-800',
|
||||
icon: '❌'
|
||||
}
|
||||
}
|
||||
return variants[variant]
|
||||
}
|
||||
|
||||
const colors = getColors()
|
||||
const displayIcon = icon || colors.icon
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-xl border ${isDark ? colors.dark : colors.light} ${className}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl flex-shrink-0">{displayIcon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-medium mb-1">{title}</h4>
|
||||
<div className={`text-sm ${isDark ? 'opacity-80' : 'opacity-90'}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Spezielle Varianten als eigene Komponenten für Convenience
|
||||
export function TipBox({ title, children, icon, className }: Omit<InfoBoxProps, 'variant'>) {
|
||||
return <InfoBox variant="tip" title={title} icon={icon} className={className}>{children}</InfoBox>
|
||||
}
|
||||
|
||||
export function WarningBox({ title, children, icon, className }: Omit<InfoBoxProps, 'variant'>) {
|
||||
return <InfoBox variant="warning" title={title} icon={icon} className={className}>{children}</InfoBox>
|
||||
}
|
||||
|
||||
export function SuccessBox({ title, children, icon, className }: Omit<InfoBoxProps, 'variant'>) {
|
||||
return <InfoBox variant="success" title={title} icon={icon} className={className}>{children}</InfoBox>
|
||||
}
|
||||
|
||||
// Step-Anleitung Box für Wizards
|
||||
interface StepBoxProps {
|
||||
step: number
|
||||
title: string
|
||||
children: ReactNode
|
||||
isActive?: boolean
|
||||
isCompleted?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function StepBox({
|
||||
step,
|
||||
title,
|
||||
children,
|
||||
isActive = false,
|
||||
isCompleted = false,
|
||||
className = ''
|
||||
}: StepBoxProps) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-xl border transition-all ${
|
||||
isCompleted
|
||||
? isDark
|
||||
? 'bg-green-500/10 border-green-500/30'
|
||||
: 'bg-green-50 border-green-200'
|
||||
: isActive
|
||||
? isDark
|
||||
? 'bg-blue-500/10 border-blue-500/30'
|
||||
: 'bg-blue-50 border-blue-200'
|
||||
: isDark
|
||||
? 'bg-white/5 border-white/10'
|
||||
: 'bg-slate-50 border-slate-200'
|
||||
} ${className}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0 ${
|
||||
isCompleted
|
||||
? 'bg-green-500 text-white'
|
||||
: isActive
|
||||
? 'bg-blue-500 text-white'
|
||||
: isDark
|
||||
? 'bg-white/20 text-white/60'
|
||||
: 'bg-slate-200 text-slate-500'
|
||||
}`}>
|
||||
{isCompleted ? '✓' : step}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className={`font-medium mb-1 ${
|
||||
isCompleted || isActive
|
||||
? isDark ? 'text-white' : 'text-slate-900'
|
||||
: isDark ? 'text-white/60' : 'text-slate-500'
|
||||
}`}>
|
||||
{title}
|
||||
</h4>
|
||||
<div className={`text-sm ${
|
||||
isCompleted || isActive
|
||||
? isDark ? 'text-white/70' : 'text-slate-600'
|
||||
: isDark ? 'text-white/40' : 'text-slate-400'
|
||||
}`}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Feature-Highlight Box
|
||||
interface FeatureBoxProps {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
onClick?: () => void
|
||||
isSelected?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FeatureBox({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
onClick,
|
||||
isSelected = false,
|
||||
className = ''
|
||||
}: FeatureBoxProps) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full p-4 rounded-xl border text-left transition-all ${
|
||||
isSelected
|
||||
? isDark
|
||||
? 'bg-purple-500/20 border-purple-500/50 ring-2 ring-purple-500/30'
|
||||
: 'bg-purple-50 border-purple-300 ring-2 ring-purple-200'
|
||||
: isDark
|
||||
? 'bg-white/5 border-white/10 hover:bg-white/10 hover:border-white/20'
|
||||
: 'bg-white border-slate-200 hover:bg-slate-50 hover:border-slate-300'
|
||||
} ${className}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">{icon}</span>
|
||||
<div>
|
||||
<h4 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{title}</h4>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{description}</p>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="ml-auto">
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
|
||||
isDark ? 'bg-purple-500' : 'bg-purple-500'
|
||||
}`}>
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Language } from '@/lib/i18n'
|
||||
|
||||
interface LanguageDropdownProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function LanguageDropdown({ className = '' }: LanguageDropdownProps) {
|
||||
const { language, setLanguage, availableLanguages } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const dropdownRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Schliessen bei Klick ausserhalb
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Schliessen bei Escape
|
||||
useEffect(() => {
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [])
|
||||
|
||||
const currentLang = availableLanguages[language]
|
||||
|
||||
return (
|
||||
<div ref={dropdownRef} className={`relative ${className}`}>
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 backdrop-blur-xl border rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white hover:bg-white/20'
|
||||
: 'bg-black/5 border-black/10 text-slate-700 hover:bg-black/10'
|
||||
}`}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={isOpen}
|
||||
>
|
||||
<span className="text-lg">{currentLang.flag}</span>
|
||||
<span className="text-sm font-medium hidden sm:inline">{currentLang.name}</span>
|
||||
<svg
|
||||
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''} ${
|
||||
isDark ? 'text-white/60' : 'text-slate-500'
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && (
|
||||
<div className={`absolute right-0 mt-2 w-48 backdrop-blur-2xl border rounded-2xl shadow-xl overflow-hidden z-50 ${
|
||||
isDark
|
||||
? 'bg-slate-900/90 border-white/20'
|
||||
: 'bg-white/95 border-black/10'
|
||||
}`}>
|
||||
<ul role="listbox" className="py-1">
|
||||
{(Object.keys(availableLanguages) as Language[]).map((lang) => {
|
||||
const langInfo = availableLanguages[lang]
|
||||
const isSelected = lang === language
|
||||
return (
|
||||
<li key={lang}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setLanguage(lang)
|
||||
setIsOpen(false)
|
||||
}}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-left transition-all ${
|
||||
isSelected
|
||||
? isDark
|
||||
? 'bg-white/20 text-white'
|
||||
: 'bg-indigo-100 text-slate-900'
|
||||
: isDark
|
||||
? 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<span className="text-lg">{langInfo.flag}</span>
|
||||
<span className="text-sm font-medium">{langInfo.name}</span>
|
||||
{isSelected && (
|
||||
<svg className={`w-4 h-4 ml-auto ${isDark ? 'text-green-400' : 'text-green-600'}`} fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import { useState, ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
// ============================================
|
||||
// TYPES
|
||||
// ============================================
|
||||
|
||||
interface NavItem {
|
||||
id: string
|
||||
name: string
|
||||
href: string
|
||||
icon: ReactNode
|
||||
description?: string
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// NAVIGATION - Hier werden alle Tabs definiert
|
||||
// ============================================
|
||||
|
||||
const navigation: NavItem[] = [
|
||||
{
|
||||
id: 'magic-help',
|
||||
name: 'Magic Help',
|
||||
href: '/magic-help',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
),
|
||||
description: 'Handschrift-OCR & Klausur-Korrektur',
|
||||
},
|
||||
{
|
||||
id: 'meet',
|
||||
name: 'Meet',
|
||||
href: '/meet',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
),
|
||||
description: 'Videokonferenzen & Meetings',
|
||||
},
|
||||
]
|
||||
|
||||
// ============================================
|
||||
// LAYOUT COMPONENT
|
||||
// ============================================
|
||||
|
||||
interface LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function Layout({ children }: LayoutProps) {
|
||||
const pathname = usePathname()
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
const isActive = (href: string) => {
|
||||
if (href === '/') return pathname === '/'
|
||||
return pathname.startsWith(href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* ============================================
|
||||
HEADER
|
||||
============================================ */}
|
||||
<header className="h-14 bg-white border-b border-slate-200 flex items-center justify-between px-4 fixed top-0 left-0 right-0 z-30">
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-primary-500 rounded-lg flex items-center justify-center text-white font-bold text-sm">
|
||||
BP
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold text-primary-500 text-lg leading-none">BreakPilot</div>
|
||||
<div className="text-xs text-slate-400">Studio v2</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Spacer - hier kommen später weitere Header-Elemente */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Platzhalter für spätere Features (Login, Theme, Language) */}
|
||||
<div className="flex items-center gap-2 text-sm text-slate-400">
|
||||
<span className="px-3 py-1 bg-slate-100 rounded-full">Preview Build</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-1 pt-14">
|
||||
{/* ============================================
|
||||
SIDEBAR
|
||||
============================================ */}
|
||||
<aside
|
||||
className={`${
|
||||
sidebarCollapsed ? 'w-16' : 'w-64'
|
||||
} bg-slate-900 text-white flex flex-col transition-all duration-200 fixed left-0 top-14 bottom-0 z-20`}
|
||||
>
|
||||
{/* Collapse Button */}
|
||||
<div className="p-2 border-b border-slate-700">
|
||||
<button
|
||||
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
className="w-full p-2 rounded-lg hover:bg-slate-800 transition-colors flex items-center justify-center"
|
||||
title={sidebarCollapsed ? 'Sidebar erweitern' : 'Sidebar einklappen'}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 transition-transform ${sidebarCollapsed ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4 overflow-y-auto">
|
||||
<ul className="space-y-1 px-2">
|
||||
{navigation.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
|
||||
isActive(item.href)
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
|
||||
}`}
|
||||
title={sidebarCollapsed ? item.name : undefined}
|
||||
>
|
||||
<span className="flex-shrink-0">{item.icon}</span>
|
||||
{!sidebarCollapsed && (
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="block truncate">{item.name}</span>
|
||||
{item.description && (
|
||||
<span className="block text-xs text-slate-400 truncate">{item.description}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{/* Hinweis wenn keine weiteren Tabs */}
|
||||
{navigation.length === 1 && !sidebarCollapsed && (
|
||||
<div className="mx-4 mt-6 p-3 bg-slate-800 rounded-lg text-xs text-slate-400">
|
||||
<p className="font-medium text-slate-300 mb-1">Schritt für Schritt</p>
|
||||
<p>Weitere Module werden nach und nach hinzugefügt.</p>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* Sidebar Footer */}
|
||||
<div className="p-4 border-t border-slate-700">
|
||||
{!sidebarCollapsed && (
|
||||
<div className="text-xs text-slate-500">
|
||||
<p>Studio v2 - Build #1</p>
|
||||
<p className="mt-1">Port 3001</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ============================================
|
||||
MAIN CONTENT
|
||||
============================================ */}
|
||||
<main
|
||||
className={`flex-1 transition-all duration-200 ${
|
||||
sidebarCollapsed ? 'ml-16' : 'ml-64'
|
||||
}`}
|
||||
>
|
||||
<div className="p-6 min-h-[calc(100vh-3.5rem-3rem)]">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* ============================================
|
||||
FOOTER
|
||||
============================================ */}
|
||||
<footer className="h-12 bg-white border-t border-slate-200 flex items-center justify-between px-6 text-sm text-slate-500">
|
||||
<div>BreakPilot Studio v2</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<span>Port 3001</span>
|
||||
<span className="text-slate-300">|</span>
|
||||
<span>Backend: Port 8000</span>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
'use client'
|
||||
|
||||
// ============================================
|
||||
// BREAKPILOT LOGO KOMPONENTEN
|
||||
// Drei Design-Varianten für das Studio
|
||||
// ============================================
|
||||
|
||||
interface LogoProps {
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
showText?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: { icon: 'w-8 h-8', text: 'text-base', subtitle: 'text-[10px]' },
|
||||
md: { icon: 'w-10 h-10', text: 'text-lg', subtitle: 'text-xs' },
|
||||
lg: { icon: 'w-12 h-12', text: 'text-xl', subtitle: 'text-sm' },
|
||||
xl: { icon: 'w-16 h-16', text: 'text-2xl', subtitle: 'text-base' },
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VARIANTE A: Cupertino Clean
|
||||
// Minimalistisch, SF-Style, subtile Schatten
|
||||
// ============================================
|
||||
|
||||
export function LogoCupertinoClean({ size = 'md', showText = true, className = '' }: LogoProps) {
|
||||
const s = sizes[size]
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
{/* Icon: Abgerundetes Quadrat mit Gradient */}
|
||||
<div className={`${s.icon} relative`}>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-red-500 to-red-700 rounded-xl shadow-lg shadow-red-500/20" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
{/* Stilisiertes "BP" mit Piloten-Motiv */}
|
||||
<svg viewBox="0 0 40 40" className="w-3/4 h-3/4">
|
||||
{/* Hintergrund-Kreis (Cockpit-Fenster) */}
|
||||
<circle cx="20" cy="20" r="14" fill="rgba(255,255,255,0.15)" />
|
||||
{/* B und P kombiniert */}
|
||||
<text
|
||||
x="20"
|
||||
y="26"
|
||||
textAnchor="middle"
|
||||
className="fill-white font-bold"
|
||||
style={{ fontSize: '16px', fontFamily: 'system-ui' }}
|
||||
>
|
||||
BP
|
||||
</text>
|
||||
{/* Kleine Flügel-Andeutung */}
|
||||
<path
|
||||
d="M6 22 L12 20 M28 20 L34 22"
|
||||
stroke="rgba(255,255,255,0.5)"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showText && (
|
||||
<div>
|
||||
<div className={`font-semibold text-slate-900 ${s.text} leading-none tracking-tight`}>
|
||||
Break<span className="text-red-600">Pilot</span>
|
||||
</div>
|
||||
<div className={`text-slate-400 ${s.subtitle} mt-0.5`}>Studio</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VARIANTE B: Glassmorphism Pro
|
||||
// Frosted Glass, lebendige Farben, Glow-Effekte
|
||||
// ============================================
|
||||
|
||||
export function LogoGlassmorphism({ size = 'md', showText = true, className = '' }: LogoProps) {
|
||||
const s = sizes[size]
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-4 ${className}`}>
|
||||
{/* Icon: Glasmorphism mit Glow */}
|
||||
<div className={`${s.icon} relative`}>
|
||||
{/* Outer Glow */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-red-400 to-red-600 rounded-2xl blur-md opacity-50" />
|
||||
{/* Glass Card */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-red-400/90 to-red-600/90 backdrop-blur-xl rounded-2xl border border-white/20 shadow-xl" />
|
||||
{/* Content */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg viewBox="0 0 40 40" className="w-3/4 h-3/4">
|
||||
{/* Pilot-Silhouette stilisiert */}
|
||||
<defs>
|
||||
<linearGradient id="glassGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="rgba(255,255,255,0.9)" />
|
||||
<stop offset="100%" stopColor="rgba(255,255,255,0.7)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Stilisierter Flieger/Papierflugzeug */}
|
||||
<path
|
||||
d="M8 28 L20 8 L32 28 L20 22 Z"
|
||||
fill="url(#glassGradient)"
|
||||
opacity="0.9"
|
||||
/>
|
||||
{/* Innerer Akzent */}
|
||||
<path
|
||||
d="M14 24 L20 12 L26 24 L20 20 Z"
|
||||
fill="rgba(255,255,255,0.3)"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showText && (
|
||||
<div>
|
||||
<div className={`font-semibold text-white ${s.text} leading-none`}>
|
||||
BreakPilot
|
||||
</div>
|
||||
<div className={`text-white/60 ${s.subtitle} mt-0.5`}>Studio</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// VARIANTE C: Bento Style
|
||||
// Minimalistisch, Monoweight, Dark Mode optimiert
|
||||
// ============================================
|
||||
|
||||
export function LogoBento({ size = 'md', showText = true, className = '' }: LogoProps) {
|
||||
const s = sizes[size]
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-4 ${className}`}>
|
||||
{/* Icon: Minimal, geometrisch */}
|
||||
<div className={`${s.icon} relative`}>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-red-500 to-red-700 rounded-xl" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<svg viewBox="0 0 40 40" className="w-3/4 h-3/4">
|
||||
{/* Geometrisches B+P Symbol */}
|
||||
{/* B als zwei übereinander liegende Kreise */}
|
||||
<circle cx="16" cy="14" r="6" stroke="white" strokeWidth="2" fill="none" />
|
||||
<circle cx="16" cy="24" r="6" stroke="white" strokeWidth="2" fill="none" />
|
||||
{/* P als Linie mit Kreis */}
|
||||
<line x1="28" y1="10" x2="28" y2="30" stroke="white" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="28" cy="16" r="5" stroke="white" strokeWidth="2" fill="none" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showText && (
|
||||
<div>
|
||||
<span className={`font-semibold text-white ${s.text} tracking-tight`}>
|
||||
BreakPilot
|
||||
</span>
|
||||
<span className={`text-white/40 ${s.subtitle} ml-2`}>Studio</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ICON-ONLY VARIANTEN (für Favicon, App-Icon)
|
||||
// ============================================
|
||||
|
||||
interface IconOnlyProps {
|
||||
variant: 'cupertino' | 'glass' | 'bento'
|
||||
size?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function BPIcon({ variant, size = 40, className = '' }: IconOnlyProps) {
|
||||
const svgSize = `${size}px`
|
||||
|
||||
switch (variant) {
|
||||
case 'cupertino':
|
||||
return (
|
||||
<svg width={svgSize} height={svgSize} viewBox="0 0 40 40" className={className}>
|
||||
<defs>
|
||||
<linearGradient id="cupGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#ef4444" />
|
||||
<stop offset="100%" stopColor="#b91c1c" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="36" height="36" rx="8" fill="url(#cupGrad)" />
|
||||
<circle cx="20" cy="20" r="12" fill="rgba(255,255,255,0.15)" />
|
||||
<text x="20" y="25" textAnchor="middle" fill="white" fontSize="14" fontWeight="bold" fontFamily="system-ui">BP</text>
|
||||
{/* Flügel */}
|
||||
<path d="M6 22 L12 20 M28 20 L34 22" stroke="rgba(255,255,255,0.5)" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'glass':
|
||||
return (
|
||||
<svg width={svgSize} height={svgSize} viewBox="0 0 40 40" className={className}>
|
||||
<defs>
|
||||
<linearGradient id="glassGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#f87171" />
|
||||
<stop offset="100%" stopColor="#dc2626" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="36" height="36" rx="10" fill="url(#glassGrad)" />
|
||||
<rect x="4" y="4" width="32" height="32" rx="8" fill="rgba(255,255,255,0.1)" />
|
||||
<path d="M10 28 L20 8 L30 28 L20 22 Z" fill="rgba(255,255,255,0.9)" />
|
||||
<path d="M14 24 L20 12 L26 24 L20 20 Z" fill="rgba(255,255,255,0.3)" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
case 'bento':
|
||||
return (
|
||||
<svg width={svgSize} height={svgSize} viewBox="0 0 40 40" className={className}>
|
||||
<defs>
|
||||
<linearGradient id="bentoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stopColor="#ef4444" />
|
||||
<stop offset="100%" stopColor="#991b1b" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="2" y="2" width="36" height="36" rx="8" fill="url(#bentoGrad)" />
|
||||
<circle cx="15" cy="14" r="5" stroke="white" strokeWidth="1.5" fill="none" />
|
||||
<circle cx="15" cy="24" r="5" stroke="white" strokeWidth="1.5" fill="none" />
|
||||
<line x1="27" y1="10" x2="27" y2="30" stroke="white" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="27" cy="15" r="4" stroke="white" strokeWidth="1.5" fill="none" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// LOGO SHOWCASE KOMPONENTE
|
||||
// Zeigt alle drei Varianten zum Vergleich
|
||||
// ============================================
|
||||
|
||||
export function LogoShowcase() {
|
||||
return (
|
||||
<div className="p-8 space-y-12">
|
||||
<h2 className="text-2xl font-bold text-center mb-8">Logo-Varianten</h2>
|
||||
|
||||
{/* Variante A */}
|
||||
<div className="bg-white rounded-2xl p-8 shadow-lg">
|
||||
<h3 className="text-lg font-semibold mb-6 text-slate-700">A: Cupertino Clean</h3>
|
||||
<div className="flex items-center gap-12">
|
||||
<LogoCupertinoClean size="sm" />
|
||||
<LogoCupertinoClean size="md" />
|
||||
<LogoCupertinoClean size="lg" />
|
||||
<LogoCupertinoClean size="xl" />
|
||||
<div className="flex gap-4">
|
||||
<BPIcon variant="cupertino" size={32} />
|
||||
<BPIcon variant="cupertino" size={48} />
|
||||
<BPIcon variant="cupertino" size={64} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Variante B */}
|
||||
<div className="bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 rounded-2xl p-8">
|
||||
<h3 className="text-lg font-semibold mb-6 text-white/80">B: Glassmorphism Pro</h3>
|
||||
<div className="flex items-center gap-12">
|
||||
<LogoGlassmorphism size="sm" />
|
||||
<LogoGlassmorphism size="md" />
|
||||
<LogoGlassmorphism size="lg" />
|
||||
<LogoGlassmorphism size="xl" />
|
||||
<div className="flex gap-4">
|
||||
<BPIcon variant="glass" size={32} />
|
||||
<BPIcon variant="glass" size={48} />
|
||||
<BPIcon variant="glass" size={64} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Variante C */}
|
||||
<div className="bg-black rounded-2xl p-8">
|
||||
<h3 className="text-lg font-semibold mb-6 text-white/80">C: Bento Style</h3>
|
||||
<div className="flex items-center gap-12">
|
||||
<LogoBento size="sm" />
|
||||
<LogoBento size="md" />
|
||||
<LogoBento size="lg" />
|
||||
<LogoBento size="xl" />
|
||||
<div className="flex gap-4">
|
||||
<BPIcon variant="bento" size={32} />
|
||||
<BPIcon variant="bento" size={48} />
|
||||
<BPIcon variant="bento" size={64} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,513 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { GermanyMap, bundeslaender } from './GermanyMap'
|
||||
import { CityMap } from './CityMap'
|
||||
import { SchoolSearch } from './SchoolSearch'
|
||||
import { BPIcon } from './Logo'
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
onComplete: (data: OnboardingData) => void
|
||||
}
|
||||
|
||||
export interface OnboardingData {
|
||||
bundesland: string
|
||||
bundeslandName: string
|
||||
city: string
|
||||
cityLat?: number
|
||||
cityLng?: number
|
||||
schoolName: string
|
||||
schoolType: string
|
||||
}
|
||||
|
||||
// Schulformen mit Icons und Beschreibungen
|
||||
const schulformen = [
|
||||
// Allgemeinbildende Schulen
|
||||
{
|
||||
id: 'gymnasium',
|
||||
name: 'Gymnasium',
|
||||
icon: '🎓',
|
||||
description: 'Allgemeinbildend bis Abitur',
|
||||
category: 'allgemein'
|
||||
},
|
||||
{
|
||||
id: 'gesamtschule',
|
||||
name: 'Gesamtschule',
|
||||
icon: '🏫',
|
||||
description: 'Integriert/kooperativ',
|
||||
category: 'allgemein'
|
||||
},
|
||||
{
|
||||
id: 'realschule',
|
||||
name: 'Realschule',
|
||||
icon: '📚',
|
||||
description: 'Mittlerer Abschluss',
|
||||
category: 'allgemein'
|
||||
},
|
||||
{
|
||||
id: 'hauptschule',
|
||||
name: 'Hauptschule',
|
||||
icon: '📖',
|
||||
description: 'Erster Abschluss',
|
||||
category: 'allgemein'
|
||||
},
|
||||
{
|
||||
id: 'mittelschule',
|
||||
name: 'Mittelschule',
|
||||
icon: '📝',
|
||||
description: 'Bayern/Sachsen',
|
||||
category: 'allgemein'
|
||||
},
|
||||
{
|
||||
id: 'oberschule',
|
||||
name: 'Oberschule',
|
||||
icon: '🏛️',
|
||||
description: 'Sachsen/Brandenburg',
|
||||
category: 'allgemein'
|
||||
},
|
||||
{
|
||||
id: 'stadtteilschule',
|
||||
name: 'Stadtteilschule',
|
||||
icon: '🌆',
|
||||
description: 'Hamburg',
|
||||
category: 'allgemein'
|
||||
},
|
||||
{
|
||||
id: 'gemeinschaftsschule',
|
||||
name: 'Gemeinschaftsschule',
|
||||
icon: '🤲',
|
||||
description: 'BW/SH/TH/SL/BE',
|
||||
category: 'allgemein'
|
||||
},
|
||||
// Berufliche Schulen
|
||||
{
|
||||
id: 'berufsschule',
|
||||
name: 'Berufsschule',
|
||||
icon: '🔧',
|
||||
description: 'Duale Ausbildung',
|
||||
category: 'beruflich'
|
||||
},
|
||||
{
|
||||
id: 'berufliches_gymnasium',
|
||||
name: 'Berufl. Gymnasium',
|
||||
icon: '💼',
|
||||
description: 'Fachgebundenes Abitur',
|
||||
category: 'beruflich'
|
||||
},
|
||||
{
|
||||
id: 'fachoberschule',
|
||||
name: 'Fachoberschule',
|
||||
icon: '📊',
|
||||
description: 'Fachhochschulreife',
|
||||
category: 'beruflich'
|
||||
},
|
||||
{
|
||||
id: 'berufsfachschule',
|
||||
name: 'Berufsfachschule',
|
||||
icon: '🛠️',
|
||||
description: 'Vollzeitberufliche Bildung',
|
||||
category: 'beruflich'
|
||||
},
|
||||
// Sonder- und Förderschulen
|
||||
{
|
||||
id: 'foerderschule',
|
||||
name: 'Förderschule',
|
||||
icon: '🤝',
|
||||
description: 'Sonderpädagogisch',
|
||||
category: 'foerder'
|
||||
},
|
||||
{
|
||||
id: 'foerderzentrum',
|
||||
name: 'Förderzentrum',
|
||||
icon: '💚',
|
||||
description: 'Inklusiv/integriert',
|
||||
category: 'foerder'
|
||||
},
|
||||
// Privatschulen & Besondere Formen
|
||||
{
|
||||
id: 'privatschule',
|
||||
name: 'Privatschule',
|
||||
icon: '🏰',
|
||||
description: 'Freier Träger',
|
||||
category: 'privat'
|
||||
},
|
||||
{
|
||||
id: 'internat',
|
||||
name: 'Internat',
|
||||
icon: '🛏️',
|
||||
description: 'Mit Unterbringung',
|
||||
category: 'privat'
|
||||
},
|
||||
{
|
||||
id: 'waldorfschule',
|
||||
name: 'Waldorfschule',
|
||||
icon: '🌿',
|
||||
description: 'Anthroposophisch',
|
||||
category: 'alternativ'
|
||||
},
|
||||
{
|
||||
id: 'montessori',
|
||||
name: 'Montessori-Schule',
|
||||
icon: '🧒',
|
||||
description: 'Montessori-Pädagogik',
|
||||
category: 'alternativ'
|
||||
},
|
||||
// Grundschulen
|
||||
{
|
||||
id: 'grundschule',
|
||||
name: 'Grundschule',
|
||||
icon: '🏠',
|
||||
description: 'Klasse 1-4',
|
||||
category: 'grund'
|
||||
},
|
||||
// Internationale
|
||||
{
|
||||
id: 'internationale_schule',
|
||||
name: 'Internationale Schule',
|
||||
icon: '🌍',
|
||||
description: 'IB/Cambridge',
|
||||
category: 'international'
|
||||
},
|
||||
{
|
||||
id: 'europaeische_schule',
|
||||
name: 'Europäische Schule',
|
||||
icon: '🇪🇺',
|
||||
description: 'EU-Curriculum',
|
||||
category: 'international'
|
||||
},
|
||||
]
|
||||
|
||||
// Kategorien für die Anzeige
|
||||
const schulformKategorien = [
|
||||
{ id: 'allgemein', name: 'Allgemeinbildend', icon: '📚' },
|
||||
{ id: 'beruflich', name: 'Berufsbildend', icon: '💼' },
|
||||
{ id: 'foerder', name: 'Förderschulen', icon: '💚' },
|
||||
{ id: 'privat', name: 'Privat & Internat', icon: '🏰' },
|
||||
{ id: 'alternativ', name: 'Alternative Pädagogik', icon: '🌿' },
|
||||
{ id: 'grund', name: 'Primarstufe', icon: '🏠' },
|
||||
{ id: 'international', name: 'International', icon: '🌍' },
|
||||
]
|
||||
|
||||
export function OnboardingWizard({ onComplete }: OnboardingWizardProps) {
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const [step, setStep] = useState(1)
|
||||
const [data, setData] = useState<Partial<OnboardingData>>({})
|
||||
const [citySearch, setCitySearch] = useState('')
|
||||
const [schoolSearch, setSchoolSearch] = useState('')
|
||||
|
||||
const totalSteps = 4
|
||||
|
||||
const handleNext = () => {
|
||||
if (step < totalSteps) {
|
||||
setStep(step + 1)
|
||||
} else {
|
||||
// Abschluss
|
||||
onComplete(data as OnboardingData)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (step > 1) {
|
||||
setStep(step - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const canProceed = () => {
|
||||
switch (step) {
|
||||
case 1:
|
||||
return !!data.bundesland
|
||||
case 2:
|
||||
return !!data.city && data.city.trim().length > 0
|
||||
case 3:
|
||||
return !!data.schoolName && data.schoolName.trim().length > 0
|
||||
case 4:
|
||||
return !!data.schoolType
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex flex-col ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
||||
}`}>
|
||||
{/* Animated Background */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
|
||||
isDark ? 'bg-purple-500 opacity-50' : 'bg-purple-300 opacity-30'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
|
||||
isDark ? 'bg-blue-500 opacity-50' : 'bg-blue-300 opacity-30'
|
||||
}`} style={{ animationDelay: '1s' }} />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col items-center justify-center p-8">
|
||||
{/* Logo & Willkommen */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex items-center justify-center gap-3 mb-4">
|
||||
<BPIcon variant="cupertino" size={56} />
|
||||
<div className="text-left">
|
||||
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
BreakPilot Studio
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Willkommen! Lassen Sie uns loslegen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="w-full max-w-2xl mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{[1, 2, 3, 4].map((s) => (
|
||||
<div
|
||||
key={s}
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all ${
|
||||
s === step
|
||||
? 'bg-gradient-to-br from-blue-500 to-purple-500 text-white scale-110 shadow-lg'
|
||||
: s < step
|
||||
? isDark
|
||||
? 'bg-green-500/30 text-green-300'
|
||||
: 'bg-green-100 text-green-700'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/40'
|
||||
: 'bg-slate-200 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{s < step ? '✓' : s}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-200'}`}>
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-purple-500 transition-all duration-500"
|
||||
style={{ width: `${((step - 1) / (totalSteps - 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Card */}
|
||||
<div className={`w-full max-w-2xl backdrop-blur-xl border rounded-3xl p-8 ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/80 border-black/10 shadow-xl'
|
||||
}`}>
|
||||
{/* Step 1: Bundesland */}
|
||||
{step === 1 && (
|
||||
<div className="text-center">
|
||||
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
In welchem Bundesland unterrichten Sie?
|
||||
</h2>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Klicken Sie auf Ihr Bundesland in der Karte
|
||||
</p>
|
||||
<GermanyMap
|
||||
selectedState={data.bundesland || null}
|
||||
suggestedState="HH"
|
||||
onSelectState={(stateId) => setData({
|
||||
...data,
|
||||
bundesland: stateId,
|
||||
bundeslandName: bundeslaender[stateId as keyof typeof bundeslaender]
|
||||
})}
|
||||
className="mx-auto max-w-md"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Stadt */}
|
||||
{step === 2 && (
|
||||
<div className="text-center">
|
||||
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
In welcher Stadt arbeiten Sie?
|
||||
</h2>
|
||||
<p className={`mb-4 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Suchen Sie Ihre Stadt oder klicken Sie auf die Karte
|
||||
</p>
|
||||
|
||||
{/* Info Box - Bundesland */}
|
||||
<div className={`inline-flex items-center gap-2 px-4 py-2 rounded-full mb-4 ${
|
||||
isDark ? 'bg-white/10' : 'bg-slate-100'
|
||||
}`}>
|
||||
<span className="text-lg">📍</span>
|
||||
<span className={`text-sm font-medium ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
{data.bundeslandName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* CityMap Komponente */}
|
||||
<CityMap
|
||||
bundesland={data.bundesland || 'HH'}
|
||||
bundeslandName={data.bundeslandName || 'Hamburg'}
|
||||
selectedCity={data.city || ''}
|
||||
onSelectCity={(city, lat, lng) => setData({
|
||||
...data,
|
||||
city,
|
||||
cityLat: lat,
|
||||
cityLng: lng
|
||||
})}
|
||||
className="max-w-lg mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Schule */}
|
||||
{step === 3 && (
|
||||
<div className="text-center">
|
||||
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Wie heißt Ihre Schule?
|
||||
</h2>
|
||||
<p className={`mb-4 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Suchen Sie Ihre Schule oder geben Sie den Namen ein
|
||||
</p>
|
||||
|
||||
{/* SchoolSearch Komponente mit Autocomplete */}
|
||||
<SchoolSearch
|
||||
city={data.city || ''}
|
||||
bundesland={data.bundesland || 'HH'}
|
||||
bundeslandName={data.bundeslandName || 'Hamburg'}
|
||||
selectedSchool={data.schoolName || ''}
|
||||
onSelectSchool={(schoolName, schoolId) => setData({
|
||||
...data,
|
||||
schoolName
|
||||
})}
|
||||
className="max-w-lg mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Schulform */}
|
||||
{step === 4 && (
|
||||
<div className="text-center">
|
||||
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Welche Schulform ist es?
|
||||
</h2>
|
||||
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Wählen Sie die passende Schulform
|
||||
</p>
|
||||
|
||||
{/* Scrollbarer Bereich mit Kategorien */}
|
||||
<div className="max-h-[400px] overflow-y-auto pr-2 space-y-6">
|
||||
{schulformKategorien.map((kategorie) => {
|
||||
const formenInKategorie = schulformen.filter(f => f.category === kategorie.id)
|
||||
if (formenInKategorie.length === 0) return null
|
||||
|
||||
return (
|
||||
<div key={kategorie.id}>
|
||||
{/* Kategorie-Header */}
|
||||
<div className={`flex items-center gap-2 mb-3 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
<span>{kategorie.icon}</span>
|
||||
<span className="text-sm font-medium">{kategorie.name}</span>
|
||||
<div className={`flex-1 h-px ${isDark ? 'bg-white/10' : 'bg-slate-200'}`} />
|
||||
</div>
|
||||
|
||||
{/* Schulformen in dieser Kategorie */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{formenInKategorie.map((form) => {
|
||||
const isSelected = data.schoolType === form.id
|
||||
return (
|
||||
<button
|
||||
key={form.id}
|
||||
onClick={() => setData({ ...data, schoolType: form.id })}
|
||||
className={`p-3 rounded-xl border-2 transition-all hover:scale-105 text-left ${
|
||||
isSelected
|
||||
? 'border-blue-500 bg-blue-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10 hover:border-white/20'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{form.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`font-medium text-sm truncate ${
|
||||
isSelected
|
||||
? isDark ? 'text-blue-300' : 'text-blue-700'
|
||||
: isDark ? 'text-white' : 'text-slate-900'
|
||||
}`}>
|
||||
{form.name}
|
||||
</p>
|
||||
<p className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{form.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
{data.schoolType && (
|
||||
<div className={`mt-6 p-4 rounded-xl ${
|
||||
isDark ? 'bg-white/5' : 'bg-slate-50'
|
||||
}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
<strong>{data.schoolName}</strong>
|
||||
</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{schulformen.find(f => f.id === data.schoolType)?.name} in {data.city}, {data.bundeslandName}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation Buttons */}
|
||||
<div className="flex items-center gap-4 mt-8">
|
||||
{step > 1 && (
|
||||
<button
|
||||
onClick={handleBack}
|
||||
className={`px-6 py-3 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
← Zurück
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className={`px-8 py-3 rounded-xl font-medium transition-all ${
|
||||
canProceed()
|
||||
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:shadow-xl hover:shadow-purple-500/30 hover:scale-105'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/30 cursor-not-allowed'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{step === totalSteps ? 'Los geht\'s! →' : 'Weiter →'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Skip Option */}
|
||||
<button
|
||||
onClick={() => onComplete({
|
||||
bundesland: data.bundesland || 'NI',
|
||||
bundeslandName: data.bundeslandName || 'Niedersachsen',
|
||||
city: data.city || 'Unbekannt',
|
||||
schoolName: data.schoolName || 'Meine Schule',
|
||||
schoolType: data.schoolType || 'gymnasium'
|
||||
})}
|
||||
className={`mt-4 text-sm ${isDark ? 'text-white/40 hover:text-white/60' : 'text-slate-400 hover:text-slate-600'}`}
|
||||
>
|
||||
Überspringen (später einrichten)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
|
||||
interface UploadedFile {
|
||||
id: string
|
||||
sessionId: string
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
uploadedAt: string
|
||||
dataUrl: string
|
||||
}
|
||||
|
||||
interface QRCodeUploadProps {
|
||||
sessionId?: string
|
||||
onClose?: () => void
|
||||
onFileUploaded?: (file: UploadedFile) => void
|
||||
onFilesChanged?: (files: UploadedFile[]) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function QRCodeUpload({
|
||||
sessionId,
|
||||
onClose,
|
||||
onFileUploaded,
|
||||
onFilesChanged,
|
||||
className = ''
|
||||
}: QRCodeUploadProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null)
|
||||
const [uploadUrl, setUploadUrl] = useState<string>('')
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
|
||||
// Format file size
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// Fetch uploads for this session
|
||||
const fetchUploads = useCallback(async () => {
|
||||
if (!sessionId) return
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/uploads?sessionId=${sessionId}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const newFiles = data.uploads || []
|
||||
|
||||
// Check if there are new files
|
||||
if (newFiles.length > uploadedFiles.length) {
|
||||
const newlyAdded = newFiles.slice(uploadedFiles.length)
|
||||
newlyAdded.forEach((file: UploadedFile) => {
|
||||
if (onFileUploaded) {
|
||||
onFileUploaded(file)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
setUploadedFiles(newFiles)
|
||||
|
||||
if (onFilesChanged) {
|
||||
onFilesChanged(newFiles)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch uploads:', error)
|
||||
}
|
||||
}, [sessionId, uploadedFiles.length, onFileUploaded, onFilesChanged])
|
||||
|
||||
// Initialize QR code and start polling
|
||||
useEffect(() => {
|
||||
// Generate Upload-URL
|
||||
let baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
|
||||
|
||||
// Hostname to IP mapping for local network
|
||||
const hostnameToIP: Record<string, string> = {
|
||||
'macmini': '192.168.178.100',
|
||||
'macmini.local': '192.168.178.100',
|
||||
}
|
||||
|
||||
// Replace known hostnames with IP addresses
|
||||
Object.entries(hostnameToIP).forEach(([hostname, ip]) => {
|
||||
if (baseUrl.includes(hostname)) {
|
||||
baseUrl = baseUrl.replace(hostname, ip)
|
||||
}
|
||||
})
|
||||
|
||||
const uploadPath = `/upload/${sessionId || 'new'}`
|
||||
const fullUrl = `${baseUrl}${uploadPath}`
|
||||
setUploadUrl(fullUrl)
|
||||
|
||||
// Generate QR code via external API
|
||||
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(fullUrl)}`
|
||||
setQrCodeUrl(qrApiUrl)
|
||||
setIsLoading(false)
|
||||
|
||||
// Initial fetch
|
||||
fetchUploads()
|
||||
|
||||
// Start polling for new uploads every 3 seconds
|
||||
setIsPolling(true)
|
||||
const pollInterval = setInterval(() => {
|
||||
fetchUploads()
|
||||
}, 3000)
|
||||
|
||||
return () => {
|
||||
clearInterval(pollInterval)
|
||||
setIsPolling(false)
|
||||
}
|
||||
}, [sessionId]) // Note: fetchUploads is intentionally not in deps to avoid re-creating interval
|
||||
|
||||
// Separate effect for fetching when uploadedFiles changes
|
||||
useEffect(() => {
|
||||
// This is just for the callback effect, actual polling is in the other useEffect
|
||||
}, [uploadedFiles, onFilesChanged])
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(uploadUrl)
|
||||
alert('Link kopiert!')
|
||||
} catch (err) {
|
||||
console.error('Kopieren fehlgeschlagen:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const deleteUpload = async (id: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/uploads?id=${id}`, { method: 'DELETE' })
|
||||
if (response.ok) {
|
||||
const newFiles = uploadedFiles.filter(f => f.id !== id)
|
||||
setUploadedFiles(newFiles)
|
||||
if (onFilesChanged) {
|
||||
onFilesChanged(newFiles)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete upload:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<div className={`rounded-3xl border p-6 ${
|
||||
isDark ? 'bg-white/10 border-white/20' : 'bg-white border-slate-200 shadow-lg'
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
|
||||
}`}>
|
||||
<span className="text-xl">📱</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Mit Mobiltelefon hochladen
|
||||
</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
QR-Code scannen oder Link teilen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* QR Code */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className={`p-4 rounded-2xl ${isDark ? 'bg-white' : 'bg-slate-50'}`}>
|
||||
{isLoading ? (
|
||||
<div className="w-[200px] h-[200px] flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : qrCodeUrl ? (
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="QR Code zum Hochladen"
|
||||
className="w-[200px] h-[200px]"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-[200px] h-[200px] flex items-center justify-center text-slate-400">
|
||||
QR-Code nicht verfuegbar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className={`mt-4 text-center text-sm ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Scannen Sie diesen Code mit Ihrem Handy,<br />
|
||||
um Dokumente direkt hochzuladen.
|
||||
</p>
|
||||
|
||||
{/* Polling indicator */}
|
||||
{isPolling && (
|
||||
<div className={`mt-2 flex items-center gap-2 text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
|
||||
Warte auf Uploads...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Uploaded Files List */}
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className={`mt-6 p-4 rounded-xl ${
|
||||
isDark ? 'bg-green-500/10 border border-green-500/20' : 'bg-green-50 border border-green-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-green-300' : 'text-green-700'}`}>
|
||||
{uploadedFiles.length} Datei{uploadedFiles.length !== 1 ? 'en' : ''} empfangen
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{uploadedFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={`flex items-center gap-3 p-2 rounded-lg ${
|
||||
isDark ? 'bg-white/5' : 'bg-white'
|
||||
}`}
|
||||
>
|
||||
<span className="text-lg">
|
||||
{file.type.startsWith('image/') ? '🖼️' : '📄'}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className={`text-sm font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteUpload(file.id)}
|
||||
className={`p-1 rounded transition-colors ${
|
||||
isDark ? 'hover:bg-red-500/20 text-red-400' : 'hover:bg-red-100 text-red-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link teilen */}
|
||||
<div className="mt-6">
|
||||
<p className={`text-xs mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Oder Link teilen:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={uploadUrl}
|
||||
readOnly
|
||||
className={`flex-1 px-3 py-2 rounded-xl text-sm border ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/10 text-white/80'
|
||||
: 'bg-slate-50 border-slate-200 text-slate-700'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
|
||||
}`}
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Network hint - only show if no files uploaded yet */}
|
||||
{uploadedFiles.length === 0 && (
|
||||
<div className={`mt-6 p-4 rounded-xl ${
|
||||
isDark ? 'bg-amber-500/10 border border-amber-500/20' : 'bg-amber-50 border border-amber-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-lg">⚠️</span>
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-amber-300' : 'text-amber-900'}`}>
|
||||
Nur im lokalen Netzwerk
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-amber-300/70' : 'text-amber-700'}`}>
|
||||
Ihr Mobiltelefon muss mit dem gleichen Netzwerk verbunden sein.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tip */}
|
||||
<div className={`mt-4 p-4 rounded-xl ${
|
||||
isDark ? 'bg-blue-500/10 border border-blue-500/20' : 'bg-blue-50 border border-blue-200'
|
||||
}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-lg">💡</span>
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-blue-300' : 'text-blue-900'}`}>
|
||||
Tipp: Mehrere Seiten scannen
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-blue-300/70' : 'text-blue-700'}`}>
|
||||
Sie koennen beliebig viele Fotos hochladen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Export the UploadedFile type for use in other components
|
||||
export type { UploadedFile }
|
||||
@@ -0,0 +1,339 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
|
||||
interface SchoolSearchProps {
|
||||
city: string
|
||||
bundesland: string
|
||||
bundeslandName: string
|
||||
selectedSchool: string
|
||||
onSelectSchool: (schoolName: string, schoolId?: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface SchoolSuggestion {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
address?: string
|
||||
city?: string
|
||||
source: 'api' | 'mock'
|
||||
}
|
||||
|
||||
// Edu-Search API URL - uses HTTPS proxy on port 8089 (edu-search runs on 8088)
|
||||
const getApiUrl = () => {
|
||||
if (typeof window === 'undefined') return 'http://localhost:8088'
|
||||
const { hostname, protocol } = window.location
|
||||
// localhost: direct HTTP to 8088, macmini: HTTPS via nginx proxy on 8089
|
||||
return hostname === 'localhost' ? 'http://localhost:8088' : `${protocol}//${hostname}:8089`
|
||||
}
|
||||
|
||||
// Attribution data for Open Data sources by Bundesland (CTRL-SRC-001)
|
||||
const BUNDESLAND_ATTRIBUTION: Record<string, { source: string; license: string; licenseUrl: string }> = {
|
||||
BW: { source: 'Open Data Baden-Wuerttemberg', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
BY: { source: 'Open Data Bayern', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
BE: { source: 'Datenportal Berlin', license: 'CC-BY', licenseUrl: 'https://creativecommons.org/licenses/by/4.0/' },
|
||||
BB: { source: 'Daten Brandenburg', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
HB: { source: 'Open Data Bremen', license: 'CC-BY', licenseUrl: 'https://creativecommons.org/licenses/by/4.0/' },
|
||||
HH: { source: 'Transparenzportal Hamburg', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
HE: { source: 'Open Data Hessen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
MV: { source: 'Open Data MV', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
NI: { source: 'Open Data Niedersachsen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
NW: { source: 'Open.NRW', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
RP: { source: 'Open Data Rheinland-Pfalz', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
SL: { source: 'Open Data Saarland', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
SN: { source: 'Datenportal Sachsen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
ST: { source: 'Open Data Sachsen-Anhalt', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
SH: { source: 'Open Data Schleswig-Holstein', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
TH: { source: 'Open Data Thueringen', license: 'DL-DE-BY-2.0', licenseUrl: 'https://www.govdata.de/dl-de/by-2-0' },
|
||||
}
|
||||
|
||||
// Category to display type mapping
|
||||
const getCategoryDisplayType = (category?: string): string => {
|
||||
switch (category) {
|
||||
case 'primary': return 'Grundschule'
|
||||
case 'secondary': return 'Sekundarschule'
|
||||
case 'vocational': return 'Berufsschule'
|
||||
case 'special': return 'Foerderschule'
|
||||
default: return 'Schule'
|
||||
}
|
||||
}
|
||||
|
||||
export function SchoolSearch({
|
||||
city,
|
||||
bundesland,
|
||||
bundeslandName,
|
||||
selectedSchool,
|
||||
onSelectSchool,
|
||||
className = ''
|
||||
}: SchoolSearchProps) {
|
||||
const { isDark } = useTheme()
|
||||
const [searchQuery, setSearchQuery] = useState(selectedSchool || '')
|
||||
const [suggestions, setSuggestions] = useState<SchoolSuggestion[]>([])
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Suche nach Schulen (echte API)
|
||||
const searchSchools = useCallback(async (query: string) => {
|
||||
if (query.length < 2) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
|
||||
setIsSearching(true)
|
||||
|
||||
try {
|
||||
// Real API call to edu-search-service
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
limit: '10',
|
||||
})
|
||||
if (city) params.append('city', city)
|
||||
if (bundesland) params.append('state', bundesland)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/schools/search?${params}`)
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
const apiSchools: SchoolSuggestion[] = (data.schools || []).map((school: {
|
||||
id: string
|
||||
name: string
|
||||
school_type_name?: string
|
||||
school_category?: string
|
||||
street?: string
|
||||
city?: string
|
||||
postal_code?: string
|
||||
}) => ({
|
||||
id: school.id,
|
||||
name: school.name,
|
||||
type: school.school_type_name || getCategoryDisplayType(school.school_category),
|
||||
address: school.street ? `${school.street}, ${school.postal_code || ''} ${school.city || ''}`.trim() : undefined,
|
||||
city: school.city,
|
||||
source: 'api' as const,
|
||||
}))
|
||||
setSuggestions(apiSchools)
|
||||
} else {
|
||||
console.error('School search API error:', response.status)
|
||||
setSuggestions([])
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('School search error:', error)
|
||||
setSuggestions([])
|
||||
} finally {
|
||||
setIsSearching(false)
|
||||
}
|
||||
}, [city, bundesland])
|
||||
|
||||
// Debounced Search
|
||||
useEffect(() => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
|
||||
searchTimeoutRef.current = setTimeout(() => {
|
||||
searchSchools(searchQuery)
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [searchQuery, searchSchools])
|
||||
|
||||
// Klick ausserhalb schließt Dropdown
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Schule auswaehlen
|
||||
const handleSelectSchool = (school: SchoolSuggestion) => {
|
||||
setSearchQuery(school.name)
|
||||
onSelectSchool(school.name, school.id)
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
|
||||
// Manuelle Eingabe bestaetigen
|
||||
const handleManualInput = () => {
|
||||
if (searchQuery.trim()) {
|
||||
onSelectSchool(searchQuery.trim())
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Schultyp-Icon
|
||||
const getSchoolTypeIcon = (type: string) => {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'gymnasium': return '🎓'
|
||||
case 'gesamtschule':
|
||||
case 'stadtteilschule': return '🏫'
|
||||
case 'realschule': return '📚'
|
||||
case 'hauptschule':
|
||||
case 'mittelschule': return '📖'
|
||||
case 'grundschule': return '🏠'
|
||||
case 'berufsschule': return '🔧'
|
||||
case 'foerderschule': return '🤝'
|
||||
case 'privatschule': return '🏰'
|
||||
case 'waldorfschule': return '🌿'
|
||||
default: return '🏫'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`} ref={containerRef}>
|
||||
{/* Suchfeld */}
|
||||
<div className="relative">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value)
|
||||
setShowSuggestions(true)
|
||||
}}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleManualInput()
|
||||
}
|
||||
}}
|
||||
placeholder={`Schule in ${city} suchen...`}
|
||||
className={`w-full px-6 py-4 pl-14 text-lg rounded-2xl border transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-blue-400'
|
||||
: 'bg-white border-slate-300 text-slate-900 placeholder-slate-400 focus:border-blue-500'
|
||||
} focus:outline-none focus:ring-2 focus:ring-blue-500/30`}
|
||||
autoFocus
|
||||
/>
|
||||
<svg
|
||||
className={`absolute left-5 top-1/2 -translate-y-1/2 w-5 h-5 ${
|
||||
isDark ? 'text-white/40' : 'text-slate-400'
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
{isSearching && (
|
||||
<div className={`absolute right-4 top-1/2 -translate-y-1/2 w-5 h-5 border-2 border-t-transparent rounded-full animate-spin ${
|
||||
isDark ? 'border-white/40' : 'border-slate-400'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Vorschlaege Dropdown */}
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border shadow-xl z-50 max-h-80 overflow-y-auto ${
|
||||
isDark
|
||||
? 'bg-slate-800/95 border-white/20 backdrop-blur-xl'
|
||||
: 'bg-white/95 border-slate-200 backdrop-blur-xl'
|
||||
}`}>
|
||||
{suggestions.map((school, index) => (
|
||||
<button
|
||||
key={school.id}
|
||||
onClick={() => handleSelectSchool(school)}
|
||||
className={`w-full px-4 py-3 text-left transition-colors flex items-center gap-3 ${
|
||||
isDark
|
||||
? 'hover:bg-white/10 text-white/90'
|
||||
: 'hover:bg-slate-100 text-slate-800'
|
||||
} ${index > 0 ? (isDark ? 'border-t border-white/10' : 'border-t border-slate-100') : ''}`}
|
||||
>
|
||||
<span className="text-xl flex-shrink-0">{getSchoolTypeIcon(school.type)}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium truncate">{school.name}</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
isDark ? 'bg-white/10 text-white/60' : 'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{school.type}
|
||||
</span>
|
||||
{school.address && (
|
||||
<span className={`text-xs truncate ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{school.address}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{school.source === 'api' && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
||||
isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
verifiziert
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{/* Attribution Footer (CTRL-SRC-001) */}
|
||||
{(() => {
|
||||
const attr = BUNDESLAND_ATTRIBUTION[bundesland]
|
||||
return attr ? (
|
||||
<div className={`px-4 py-2 text-xs border-t ${
|
||||
isDark ? 'bg-slate-900/50 border-white/10 text-white/40' : 'bg-slate-50 border-slate-200 text-slate-500'
|
||||
}`}>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<span>Quelle:</span>
|
||||
<span className="font-medium">{attr.source}</span>
|
||||
<span>•</span>
|
||||
<a
|
||||
href={attr.licenseUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`underline hover:no-underline ${isDark ? 'text-blue-400' : 'text-blue-600'}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{attr.license}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keine Ergebnisse Info */}
|
||||
{showSuggestions && searchQuery.length >= 2 && suggestions.length === 0 && !isSearching && (
|
||||
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border p-4 ${
|
||||
isDark
|
||||
? 'bg-slate-800/95 border-white/20 backdrop-blur-xl'
|
||||
: 'bg-white/95 border-slate-200 backdrop-blur-xl'
|
||||
}`}>
|
||||
<p className={`text-sm text-center ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Keine Schulen gefunden. Tippen Sie den Namen ein und druecken Sie Enter.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Standort Info */}
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-2xl">📍</span>
|
||||
<div className="text-left flex-1">
|
||||
<p className={`text-sm ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
<strong>{city}</strong>, {bundeslandName}
|
||||
</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
Ihr Standort
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hinweis */}
|
||||
<p className={`text-xs text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
Waehlen Sie aus den Vorschlaegen oder geben Sie den Namen manuell ein
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { BPIcon } from '@/components/Logo'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useAlerts } from '@/lib/AlertsContext'
|
||||
import { useAlertsB2B } from '@/lib/AlertsB2BContext'
|
||||
import { useMessages } from '@/lib/MessagesContext'
|
||||
import { UserMenu } from '@/components/UserMenu'
|
||||
|
||||
interface SidebarProps {
|
||||
selectedTab?: string
|
||||
onTabChange?: (tab: string) => void
|
||||
}
|
||||
|
||||
export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps) {
|
||||
const [sidebarHovered, setSidebarHovered] = useState(false)
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
const { unreadCount } = useAlerts()
|
||||
const { unreadCount: b2bUnreadCount } = useAlertsB2B()
|
||||
const { unreadCount: messagesUnreadCount } = useMessages()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const navItems = [
|
||||
{ id: 'dashboard', labelKey: 'nav_dashboard', href: '/', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
|
||||
</svg>
|
||||
)},
|
||||
{ id: 'dokumente', labelKey: 'nav_dokumente', href: '/', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
)},
|
||||
{ id: 'klausuren', labelKey: 'nav_klausuren', href: '/', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
)},
|
||||
{ id: 'analytics', labelKey: 'nav_analytics', href: '/', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
)},
|
||||
{ id: 'alerts', labelKey: 'nav_alerts', href: '/alerts', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
|
||||
</svg>
|
||||
), showBadge: true },
|
||||
{ id: 'alerts-b2b', labelKey: 'nav_alerts_b2b', href: '/alerts-b2b', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
), showB2BBadge: true },
|
||||
{ id: 'messages', labelKey: 'nav_messages', href: '/messages', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||
</svg>
|
||||
), showMessagesBadge: true },
|
||||
{ id: 'vokabeln', labelKey: 'nav_vokabeln', href: '/vocab-worksheet', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
)},
|
||||
{ id: 'worksheet-editor', labelKey: 'nav_worksheet_editor', href: '/worksheet-editor', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
)},
|
||||
{ id: 'worksheet-cleanup', labelKey: 'nav_worksheet_cleanup', href: '/worksheet-cleanup', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
)},
|
||||
{ id: 'korrektur', labelKey: 'nav_korrektur', href: '/korrektur', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)},
|
||||
{ id: 'companion', labelKey: 'nav_companion', href: '/companion', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9" strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6l4 2" />
|
||||
</svg>
|
||||
)},
|
||||
{ id: 'meet', labelKey: 'nav_meet', href: '/meet', icon: (
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)},
|
||||
]
|
||||
|
||||
const handleNavClick = (item: typeof navItems[0]) => {
|
||||
// Check if this is an external page (has a specific href)
|
||||
if (item.href !== '/') {
|
||||
router.push(item.href)
|
||||
} else if (item.id === 'vokabeln') {
|
||||
router.push('/vocab-worksheet')
|
||||
} else if (item.id === 'meet') {
|
||||
router.push('/meet')
|
||||
} else if (item.id === 'alerts') {
|
||||
router.push('/alerts')
|
||||
} else {
|
||||
// For dashboard tabs, either navigate or call the callback
|
||||
if (pathname !== '/') {
|
||||
router.push('/')
|
||||
}
|
||||
if (onTabChange) {
|
||||
onTabChange(item.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Determine active item based on pathname or selectedTab
|
||||
const getActiveItem = () => {
|
||||
if (pathname === '/companion') return 'companion'
|
||||
if (pathname === '/meet') return 'meet'
|
||||
if (pathname === '/vocab-worksheet') return 'vokabeln'
|
||||
if (pathname === '/worksheet-editor') return 'worksheet-editor'
|
||||
if (pathname === '/worksheet-cleanup') return 'worksheet-cleanup'
|
||||
if (pathname === '/magic-help') return 'magic-help'
|
||||
if (pathname === '/alerts') return 'alerts'
|
||||
if (pathname === '/alerts-b2b') return 'alerts-b2b'
|
||||
if (pathname === '/messages') return 'messages'
|
||||
if (pathname?.startsWith('/korrektur')) return 'korrektur'
|
||||
return selectedTab
|
||||
}
|
||||
|
||||
const activeItem = getActiveItem()
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="flex-shrink-0 w-[72px] hover:w-[200px] transition-all duration-300 group"
|
||||
onMouseEnter={() => setSidebarHovered(true)}
|
||||
onMouseLeave={() => setSidebarHovered(false)}
|
||||
>
|
||||
<div className={`sticky top-4 h-[calc(100vh-32px)] backdrop-blur-2xl rounded-3xl border flex flex-col p-3 overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/70 border-black/10 shadow-xl'
|
||||
}`}>
|
||||
{/* Logo - Cupertino Clean */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4">
|
||||
<BPIcon variant="cupertino" size={48} className="flex-shrink-0" />
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity duration-300 whitespace-nowrap">
|
||||
<span className={`font-semibold text-lg ${isDark ? 'text-white' : 'text-slate-900'}`}>BreakPilot</span>
|
||||
<span className={`block text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Studio v2</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-2">
|
||||
{navItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => handleNavClick(item)}
|
||||
className={`relative w-full flex items-center gap-4 p-3 rounded-2xl transition-all ${
|
||||
activeItem === item.id
|
||||
? isDark
|
||||
? 'bg-white/20 text-white shadow-lg'
|
||||
: 'bg-indigo-100 text-indigo-900 shadow-lg'
|
||||
: item.id === 'alerts' && unreadCount > 0
|
||||
? isDark
|
||||
? 'text-amber-400 hover:bg-amber-500/10'
|
||||
: 'text-amber-600 hover:bg-amber-50'
|
||||
: item.id === 'alerts-b2b' && b2bUnreadCount > 0
|
||||
? isDark
|
||||
? 'text-blue-400 hover:bg-blue-500/10'
|
||||
: 'text-blue-600 hover:bg-blue-50'
|
||||
: item.id === 'messages' && messagesUnreadCount > 0
|
||||
? isDark
|
||||
? 'text-green-400 hover:bg-green-500/10'
|
||||
: 'text-green-600 hover:bg-green-50'
|
||||
: isDark
|
||||
? 'text-white/60 hover:bg-white/10 hover:text-white'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<span className="relative flex-shrink-0">
|
||||
{item.icon}
|
||||
{item.id === 'alerts' && unreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-red-500 rounded-full text-[10px] text-white flex items-center justify-center font-medium">
|
||||
{unreadCount > 9 ? '9+' : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
{item.id === 'alerts-b2b' && b2bUnreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-blue-500 rounded-full text-[10px] text-white flex items-center justify-center font-medium">
|
||||
{b2bUnreadCount > 9 ? '9+' : b2bUnreadCount}
|
||||
</span>
|
||||
)}
|
||||
{item.id === 'messages' && messagesUnreadCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-green-500 rounded-full text-[10px] text-white flex items-center justify-center font-medium">
|
||||
{messagesUnreadCount > 9 ? '9+' : messagesUnreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-300 whitespace-nowrap flex items-center gap-2">
|
||||
{t(item.labelKey)}
|
||||
{item.id === 'alerts' && unreadCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-amber-500/20 text-amber-500">
|
||||
{unreadCount}
|
||||
</span>
|
||||
)}
|
||||
{item.id === 'alerts-b2b' && b2bUnreadCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-blue-500/20 text-blue-500">
|
||||
{b2bUnreadCount}
|
||||
</span>
|
||||
)}
|
||||
{item.id === 'messages' && messagesUnreadCount > 0 && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] rounded-full bg-green-500/20 text-green-500">
|
||||
{messagesUnreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* User Menu */}
|
||||
<div className={`pt-4 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<UserMenu
|
||||
userName="Lehrer Max"
|
||||
userEmail="max@schule.de"
|
||||
userInitials="LM"
|
||||
isExpanded={sidebarHovered}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
|
||||
interface ThemeToggleProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ThemeToggle({ className = '' }: ThemeToggleProps) {
|
||||
const { toggleTheme, isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className={`p-3 backdrop-blur-xl border rounded-2xl transition-all ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white hover:bg-white/20'
|
||||
: 'bg-black/5 border-black/10 text-slate-700 hover:bg-black/10'
|
||||
} ${className}`}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
title={isDark ? 'Hell' : 'Dunkel'}
|
||||
>
|
||||
{isDark ? (
|
||||
// Sun icon for switching to light mode
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
// Moon icon for switching to dark mode
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
|
||||
interface UserMenuProps {
|
||||
userName: string
|
||||
userEmail: string
|
||||
userInitials: string
|
||||
isExpanded?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function UserMenu({
|
||||
userName,
|
||||
userEmail,
|
||||
userInitials,
|
||||
isExpanded = false,
|
||||
className = ''
|
||||
}: UserMenuProps) {
|
||||
const { t } = useLanguage()
|
||||
const { isDark } = useTheme()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Schliessen bei Klick ausserhalb
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
// Schliessen bei Escape
|
||||
useEffect(() => {
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') setIsOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [])
|
||||
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'settings',
|
||||
labelKey: 'nav_settings',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => {
|
||||
console.log('Settings clicked')
|
||||
setIsOpen(false)
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'logout',
|
||||
labelKey: 'logout',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
),
|
||||
onClick: () => {
|
||||
console.log('Logout clicked')
|
||||
setIsOpen(false)
|
||||
},
|
||||
danger: true
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div ref={menuRef} className={`relative ${className}`}>
|
||||
{/* User Button - Trigger */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={`w-full flex items-center gap-3 p-2 rounded-2xl transition-all ${
|
||||
isOpen
|
||||
? isDark
|
||||
? 'bg-white/20'
|
||||
: 'bg-slate-200'
|
||||
: isDark
|
||||
? 'hover:bg-white/10'
|
||||
: 'hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-cyan-400 to-blue-500 rounded-full flex items-center justify-center text-white font-medium flex-shrink-0">
|
||||
{userInitials}
|
||||
</div>
|
||||
|
||||
{/* Name & Email - nur sichtbar wenn Sidebar expandiert */}
|
||||
<div className={`flex-1 text-left ${isExpanded ? 'opacity-100' : 'opacity-0'} transition-opacity duration-300`}>
|
||||
<p className={`text-sm font-medium whitespace-nowrap ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{userName}
|
||||
</p>
|
||||
<p className={`text-xs whitespace-nowrap ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{userEmail}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Chevron - nur sichtbar wenn Sidebar expandiert */}
|
||||
<svg
|
||||
className={`w-4 h-4 transition-all ${isExpanded ? 'opacity-100' : 'opacity-0'} ${isOpen ? 'rotate-180' : ''} ${
|
||||
isDark ? 'text-white/60' : 'text-slate-500'
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Popup Menu - erscheint oberhalb */}
|
||||
{isOpen && (
|
||||
<div className={`absolute bottom-full left-0 right-0 mb-2 backdrop-blur-2xl border rounded-2xl shadow-xl overflow-hidden z-50 ${
|
||||
isDark
|
||||
? 'bg-slate-900/95 border-white/20'
|
||||
: 'bg-white/95 border-black/10'
|
||||
}`}>
|
||||
{/* User Info Header */}
|
||||
<div className={`px-4 py-3 border-b ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<p className={`text-sm font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{userName}
|
||||
</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{userEmail}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Menu Items */}
|
||||
<div className="py-1">
|
||||
{menuItems.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={item.onClick}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 transition-all ${
|
||||
item.danger
|
||||
? isDark
|
||||
? 'text-red-400 hover:bg-red-500/20'
|
||||
: 'text-red-600 hover:bg-red-50'
|
||||
: isDark
|
||||
? 'text-white/80 hover:bg-white/10 hover:text-white'
|
||||
: 'text-slate-700 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<span className="flex-shrink-0">{item.icon}</span>
|
||||
<span className="text-sm font-medium">{t(item.labelKey)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Settings, MessageSquare, HelpCircle, Timer } from 'lucide-react'
|
||||
import { TeacherSettings, FeedbackType } from '@/lib/companion/types'
|
||||
import { DEFAULT_TEACHER_SETTINGS, STORAGE_KEYS } from '@/lib/companion/constants'
|
||||
|
||||
// Components
|
||||
import { LessonStartForm } from './lesson-mode/LessonStartForm'
|
||||
import { LessonActiveView } from './lesson-mode/LessonActiveView'
|
||||
import { LessonEndedView } from './lesson-mode/LessonEndedView'
|
||||
import { SettingsModal } from './modals/SettingsModal'
|
||||
import { FeedbackModal } from './modals/FeedbackModal'
|
||||
import { OnboardingModal } from './modals/OnboardingModal'
|
||||
|
||||
// Hooks
|
||||
import { useLessonSession } from '@/hooks/companion/useLessonSession'
|
||||
import { useKeyboardShortcuts } from '@/hooks/companion/useKeyboardShortcuts'
|
||||
|
||||
export function CompanionDashboard() {
|
||||
// Modal states
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [showFeedback, setShowFeedback] = useState(false)
|
||||
const [showOnboarding, setShowOnboarding] = useState(false)
|
||||
|
||||
// Settings
|
||||
const [settings, setSettings] = useState<TeacherSettings>(DEFAULT_TEACHER_SETTINGS)
|
||||
|
||||
// Load settings from localStorage
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.SETTINGS)
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored)
|
||||
setSettings({ ...DEFAULT_TEACHER_SETTINGS, ...parsed })
|
||||
} catch {
|
||||
// Invalid stored settings
|
||||
}
|
||||
}
|
||||
|
||||
// Check if onboarding needed
|
||||
const onboardingStored = localStorage.getItem(STORAGE_KEYS.ONBOARDING_STATE)
|
||||
if (!onboardingStored) {
|
||||
setShowOnboarding(true)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Lesson session hook
|
||||
const {
|
||||
session,
|
||||
startLesson,
|
||||
endLesson,
|
||||
clearSession,
|
||||
pauseLesson,
|
||||
resumeLesson,
|
||||
extendTime,
|
||||
skipPhase,
|
||||
saveReflection,
|
||||
addHomework,
|
||||
removeHomework,
|
||||
isPaused,
|
||||
} = useLessonSession({
|
||||
onOvertimeStart: () => {
|
||||
if (settings.soundNotifications) {
|
||||
// TODO: Play notification sound
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Handle pause/resume toggle
|
||||
const handlePauseToggle = useCallback(() => {
|
||||
if (isPaused) {
|
||||
resumeLesson()
|
||||
} else {
|
||||
pauseLesson()
|
||||
}
|
||||
}, [isPaused, pauseLesson, resumeLesson])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
onPauseResume: session ? handlePauseToggle : undefined,
|
||||
onExtend: session && !isPaused ? () => extendTime(5) : undefined,
|
||||
onNextPhase: session && !isPaused ? skipPhase : undefined,
|
||||
onCloseModal: () => {
|
||||
setShowSettings(false)
|
||||
setShowFeedback(false)
|
||||
setShowOnboarding(false)
|
||||
},
|
||||
enabled: settings.showKeyboardShortcuts,
|
||||
})
|
||||
|
||||
// Handle settings save
|
||||
const handleSaveSettings = (newSettings: TeacherSettings) => {
|
||||
setSettings(newSettings)
|
||||
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(newSettings))
|
||||
}
|
||||
|
||||
// Handle feedback submit
|
||||
const handleFeedbackSubmit = async (type: FeedbackType, title: string, description: string) => {
|
||||
const response = await fetch('/api/companion/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
sessionId: session?.sessionId,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to submit feedback')
|
||||
}
|
||||
}
|
||||
|
||||
// Handle onboarding complete
|
||||
const handleOnboardingComplete = (data: { state?: string; schoolType?: string }) => {
|
||||
localStorage.setItem(STORAGE_KEYS.ONBOARDING_STATE, JSON.stringify({
|
||||
...data,
|
||||
completed: true,
|
||||
completedAt: new Date().toISOString(),
|
||||
}))
|
||||
setShowOnboarding(false)
|
||||
setSettings({ ...settings, onboardingCompleted: true })
|
||||
}
|
||||
|
||||
// Determine current view based on session status
|
||||
const renderContent = () => {
|
||||
if (!session) {
|
||||
return <LessonStartForm onStart={startLesson} />
|
||||
}
|
||||
|
||||
if (session.status === 'completed') {
|
||||
return (
|
||||
<LessonEndedView
|
||||
session={session}
|
||||
onSaveReflection={saveReflection}
|
||||
onAddHomework={addHomework}
|
||||
onRemoveHomework={removeHomework}
|
||||
onStartNew={clearSession}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// in_progress or paused
|
||||
return (
|
||||
<LessonActiveView
|
||||
session={session}
|
||||
onPauseToggle={handlePauseToggle}
|
||||
onExtendTime={extendTime}
|
||||
onSkipPhase={skipPhase}
|
||||
onEndLesson={endLesson}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-[calc(100vh-200px)] ${settings.highContrastMode ? 'high-contrast' : ''}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-lg">
|
||||
<Timer className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">Unterrichtsstunde</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Feedback Button */}
|
||||
<button
|
||||
onClick={() => setShowFeedback(true)}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Feedback"
|
||||
>
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Settings Button */}
|
||||
<button
|
||||
onClick={() => setShowSettings(true)}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Einstellungen"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Help Button */}
|
||||
<button
|
||||
onClick={() => setShowOnboarding(true)}
|
||||
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
title="Hilfe"
|
||||
>
|
||||
<HelpCircle className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
{renderContent()}
|
||||
|
||||
{/* Modals */}
|
||||
<SettingsModal
|
||||
isOpen={showSettings}
|
||||
onClose={() => setShowSettings(false)}
|
||||
settings={settings}
|
||||
onSave={handleSaveSettings}
|
||||
/>
|
||||
|
||||
<FeedbackModal
|
||||
isOpen={showFeedback}
|
||||
onClose={() => setShowFeedback(false)}
|
||||
onSubmit={handleFeedbackSubmit}
|
||||
/>
|
||||
|
||||
<OnboardingModal
|
||||
isOpen={showOnboarding}
|
||||
onClose={() => setShowOnboarding(false)}
|
||||
onComplete={handleOnboardingComplete}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Plus, Trash2, BookOpen, Calendar } from 'lucide-react'
|
||||
import { Homework } from '@/lib/companion/types'
|
||||
|
||||
interface HomeworkSectionProps {
|
||||
homeworkList: Homework[]
|
||||
onAdd: (title: string, dueDate: string) => void
|
||||
onRemove: (id: string) => void
|
||||
}
|
||||
|
||||
export function HomeworkSection({ homeworkList, onAdd, onRemove }: HomeworkSectionProps) {
|
||||
const [newTitle, setNewTitle] = useState('')
|
||||
const [newDueDate, setNewDueDate] = useState('')
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
|
||||
// Default due date to next week
|
||||
const getDefaultDueDate = () => {
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + 7)
|
||||
return date.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!newTitle.trim()) return
|
||||
|
||||
onAdd(newTitle.trim(), newDueDate || getDefaultDueDate())
|
||||
setNewTitle('')
|
||||
setNewDueDate('')
|
||||
setIsAdding(false)
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
|
||||
<BookOpen className="w-5 h-5 text-slate-400" />
|
||||
Hausaufgaben
|
||||
</h3>
|
||||
{!isAdding && (
|
||||
<button
|
||||
onClick={() => setIsAdding(true)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Hinzufuegen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Form */}
|
||||
{isAdding && (
|
||||
<form onSubmit={handleSubmit} className="mb-4 p-4 bg-blue-50 rounded-xl">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Aufgabe
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTitle}
|
||||
onChange={(e) => setNewTitle(e.target.value)}
|
||||
placeholder="z.B. Aufgabe 1-5 auf S. 42..."
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
Faellig am
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newDueDate}
|
||||
onChange={(e) => setNewDueDate(e.target.value)}
|
||||
min={new Date().toISOString().split('T')[0]}
|
||||
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!newTitle.trim()}
|
||||
className="flex-1 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsAdding(false)
|
||||
setNewTitle('')
|
||||
setNewDueDate('')
|
||||
}}
|
||||
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Homework List */}
|
||||
{homeworkList.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<BookOpen className="w-10 h-10 text-slate-300 mx-auto mb-2" />
|
||||
<p className="text-slate-500">Keine Hausaufgaben eingetragen</p>
|
||||
<p className="text-sm text-slate-400 mt-1">
|
||||
Fuegen Sie Hausaufgaben hinzu, um sie zu dokumentieren
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{homeworkList.map((hw) => (
|
||||
<div
|
||||
key={hw.id}
|
||||
className="flex items-start gap-3 p-4 bg-slate-50 rounded-xl group"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-slate-900">{hw.title}</p>
|
||||
<div className="flex items-center gap-2 mt-1 text-sm text-slate-500">
|
||||
<Calendar className="w-3 h-3" />
|
||||
<span>Faellig: {formatDate(hw.dueDate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onRemove(hw.id)}
|
||||
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Entfernen"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
|
||||
import { BookOpen, Clock, Users } from 'lucide-react'
|
||||
import { LessonSession } from '@/lib/companion/types'
|
||||
import { VisualPieTimer } from './VisualPieTimer'
|
||||
import { QuickActionsBar } from './QuickActionsBar'
|
||||
import { PhaseTimelineDetailed } from './PhaseTimeline'
|
||||
import {
|
||||
PHASE_COLORS,
|
||||
PHASE_DISPLAY_NAMES,
|
||||
formatTime,
|
||||
getTimerColorStatus,
|
||||
} from '@/lib/companion/constants'
|
||||
|
||||
interface LessonActiveViewProps {
|
||||
session: LessonSession
|
||||
onPauseToggle: () => void
|
||||
onExtendTime: (minutes: number) => void
|
||||
onSkipPhase: () => void
|
||||
onEndLesson: () => void
|
||||
}
|
||||
|
||||
export function LessonActiveView({
|
||||
session,
|
||||
onPauseToggle,
|
||||
onExtendTime,
|
||||
onSkipPhase,
|
||||
onEndLesson,
|
||||
}: LessonActiveViewProps) {
|
||||
const currentPhase = session.phases[session.currentPhaseIndex]
|
||||
const phaseId = currentPhase?.phase || 'einstieg'
|
||||
const phaseColor = PHASE_COLORS[phaseId].hex
|
||||
const phaseName = PHASE_DISPLAY_NAMES[phaseId]
|
||||
|
||||
// Calculate timer values
|
||||
const phaseDurationSeconds = (currentPhase?.duration || 0) * 60
|
||||
const elapsedInPhase = currentPhase?.actualTime || 0
|
||||
const remainingSeconds = phaseDurationSeconds - elapsedInPhase
|
||||
const progress = Math.min(elapsedInPhase / phaseDurationSeconds, 1)
|
||||
const isOvertime = remainingSeconds < 0
|
||||
const colorStatus = getTimerColorStatus(remainingSeconds, isOvertime)
|
||||
|
||||
const isLastPhase = session.currentPhaseIndex === session.phases.length - 1
|
||||
|
||||
// Calculate total elapsed
|
||||
const totalElapsedMinutes = Math.floor(session.elapsedTime / 60)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with Session Info */}
|
||||
<div
|
||||
className="bg-gradient-to-r rounded-xl p-6 text-white"
|
||||
style={{
|
||||
background: `linear-gradient(135deg, ${phaseColor}, ${phaseColor}dd)`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-white/80 text-sm mb-1">
|
||||
<Users className="w-4 h-4" />
|
||||
<span>{session.className}</span>
|
||||
<span className="mx-2">|</span>
|
||||
<BookOpen className="w-4 h-4" />
|
||||
<span>{session.subject}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold">
|
||||
{session.topic || phaseName}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-white/80 text-sm">Gesamtzeit</div>
|
||||
<div className="text-xl font-mono font-bold">
|
||||
{formatTime(session.elapsedTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Timer Section */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-8">
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Visual Pie Timer */}
|
||||
<VisualPieTimer
|
||||
progress={progress}
|
||||
remainingSeconds={remainingSeconds}
|
||||
totalSeconds={phaseDurationSeconds}
|
||||
colorStatus={colorStatus}
|
||||
isPaused={session.isPaused}
|
||||
currentPhaseName={phaseName}
|
||||
phaseColor={phaseColor}
|
||||
onTogglePause={onPauseToggle}
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-8 w-full max-w-md">
|
||||
<QuickActionsBar
|
||||
onExtend={onExtendTime}
|
||||
onPause={onPauseToggle}
|
||||
onResume={onPauseToggle}
|
||||
onSkip={onSkipPhase}
|
||||
onEnd={onEndLesson}
|
||||
isPaused={session.isPaused}
|
||||
isLastPhase={isLastPhase}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase Timeline */}
|
||||
<PhaseTimelineDetailed
|
||||
phases={session.phases.map((p, i) => ({
|
||||
id: p.phase,
|
||||
shortName: p.phase[0].toUpperCase(),
|
||||
displayName: PHASE_DISPLAY_NAMES[p.phase],
|
||||
duration: p.duration,
|
||||
status: p.status === 'active' ? 'active' : p.status === 'completed' ? 'completed' : 'planned',
|
||||
actualTime: p.actualTime,
|
||||
color: PHASE_COLORS[p.phase].hex,
|
||||
}))}
|
||||
currentPhaseIndex={session.currentPhaseIndex}
|
||||
onPhaseClick={(index) => {
|
||||
// Optional: Allow clicking to navigate to a phase
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Lesson Stats */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
||||
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-slate-900">{totalElapsedMinutes}</div>
|
||||
<div className="text-sm text-slate-500">Minuten vergangen</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full mx-auto mb-2"
|
||||
style={{ backgroundColor: phaseColor }}
|
||||
/>
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{session.currentPhaseIndex + 1}/{session.phases.length}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Phase</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
|
||||
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{session.totalPlannedDuration - totalElapsedMinutes}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Minuten verbleibend</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard Shortcuts Hint */}
|
||||
<div className="text-center text-sm text-slate-400">
|
||||
<span className="inline-flex items-center gap-4">
|
||||
<span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">Leertaste</kbd> Pause
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">E</kbd> +5 Min
|
||||
</span>
|
||||
<span>
|
||||
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">N</kbd> Weiter
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle, Clock, BarChart3, Plus, RefreshCw } from 'lucide-react'
|
||||
import { LessonSession } from '@/lib/companion/types'
|
||||
import { HomeworkSection } from './HomeworkSection'
|
||||
import { ReflectionSection } from './ReflectionSection'
|
||||
import {
|
||||
PHASE_COLORS,
|
||||
PHASE_DISPLAY_NAMES,
|
||||
formatTime,
|
||||
formatMinutes,
|
||||
} from '@/lib/companion/constants'
|
||||
|
||||
interface LessonEndedViewProps {
|
||||
session: LessonSession
|
||||
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
|
||||
onAddHomework: (title: string, dueDate: string) => void
|
||||
onRemoveHomework: (id: string) => void
|
||||
onStartNew: () => void
|
||||
}
|
||||
|
||||
export function LessonEndedView({
|
||||
session,
|
||||
onSaveReflection,
|
||||
onAddHomework,
|
||||
onRemoveHomework,
|
||||
onStartNew,
|
||||
}: LessonEndedViewProps) {
|
||||
const [activeTab, setActiveTab] = useState<'summary' | 'homework' | 'reflection'>('summary')
|
||||
|
||||
// Calculate analytics
|
||||
const totalPlannedSeconds = session.totalPlannedDuration * 60
|
||||
const totalActualSeconds = session.elapsedTime
|
||||
const timeDiff = totalActualSeconds - totalPlannedSeconds
|
||||
const timeDiffMinutes = Math.round(timeDiff / 60)
|
||||
|
||||
const startTime = new Date(session.startTime)
|
||||
const endTime = session.endTime ? new Date(session.endTime) : new Date()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Success Header */}
|
||||
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-xl p-6 text-white">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-white/20 rounded-full">
|
||||
<CheckCircle className="w-8 h-8" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Stunde beendet!</h2>
|
||||
<p className="text-green-100">
|
||||
{session.className} - {session.subject}
|
||||
{session.topic && ` - ${session.topic}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-1 flex">
|
||||
{[
|
||||
{ id: 'summary', label: 'Zusammenfassung', icon: BarChart3 },
|
||||
{ id: 'homework', label: 'Hausaufgaben', icon: Plus },
|
||||
{ id: 'reflection', label: 'Reflexion', icon: RefreshCw },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as typeof activeTab)}
|
||||
className={`
|
||||
flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg
|
||||
font-medium transition-all duration-200
|
||||
${activeTab === tab.id
|
||||
? 'bg-slate-900 text-white'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'summary' && (
|
||||
<div className="space-y-6">
|
||||
{/* Time Overview */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<Clock className="w-5 h-5 text-slate-400" />
|
||||
Zeitauswertung
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<div className="text-center p-4 bg-slate-50 rounded-xl">
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{formatTime(totalActualSeconds)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Tatsaechlich</div>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-slate-50 rounded-xl">
|
||||
<div className="text-2xl font-bold text-slate-900">
|
||||
{formatMinutes(session.totalPlannedDuration)}
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">Geplant</div>
|
||||
</div>
|
||||
<div className={`text-center p-4 rounded-xl ${timeDiff > 0 ? 'bg-amber-50' : 'bg-green-50'}`}>
|
||||
<div className={`text-2xl font-bold ${timeDiff > 0 ? 'text-amber-600' : 'text-green-600'}`}>
|
||||
{timeDiffMinutes > 0 ? '+' : ''}{timeDiffMinutes} Min
|
||||
</div>
|
||||
<div className={`text-sm ${timeDiff > 0 ? 'text-amber-500' : 'text-green-500'}`}>
|
||||
Differenz
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Times */}
|
||||
<div className="flex items-center justify-between text-sm text-slate-500 border-t border-slate-100 pt-4">
|
||||
<span>Start: {startTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
<span>Ende: {endTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase Breakdown */}
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5 text-slate-400" />
|
||||
Phasen-Analyse
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
{session.phases.map((phase) => {
|
||||
const plannedSeconds = phase.duration * 60
|
||||
const actualSeconds = phase.actualTime
|
||||
const diff = actualSeconds - plannedSeconds
|
||||
const diffMinutes = Math.round(diff / 60)
|
||||
const percentage = Math.min((actualSeconds / plannedSeconds) * 100, 150)
|
||||
|
||||
return (
|
||||
<div key={phase.phase} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: PHASE_COLORS[phase.phase].hex }}
|
||||
/>
|
||||
<span className="font-medium text-slate-700">
|
||||
{PHASE_DISPLAY_NAMES[phase.phase]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-slate-500">
|
||||
<span>{Math.round(actualSeconds / 60)} / {phase.duration} Min</span>
|
||||
<span className={`
|
||||
px-2 py-0.5 rounded text-xs font-medium
|
||||
${diff > 60 ? 'bg-amber-100 text-amber-700' : diff < -60 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}
|
||||
`}>
|
||||
{diffMinutes > 0 ? '+' : ''}{diffMinutes} Min
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${Math.min(percentage, 100)}%`,
|
||||
backgroundColor: percentage > 100
|
||||
? '#f59e0b' // amber for overtime
|
||||
: PHASE_COLORS[phase.phase].hex,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'homework' && (
|
||||
<HomeworkSection
|
||||
homeworkList={session.homeworkList}
|
||||
onAdd={onAddHomework}
|
||||
onRemove={onRemoveHomework}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'reflection' && (
|
||||
<ReflectionSection
|
||||
reflection={session.reflection}
|
||||
onSave={onSaveReflection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Start New Lesson Button */}
|
||||
<div className="pt-4">
|
||||
<button
|
||||
onClick={onStartNew}
|
||||
className="w-full py-4 px-6 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
Neue Stunde starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Play, Clock, BookOpen, Users, ChevronDown, Info } from 'lucide-react'
|
||||
import { LessonTemplate, PhaseDurations, Class } from '@/lib/companion/types'
|
||||
import {
|
||||
SYSTEM_TEMPLATES,
|
||||
DEFAULT_PHASE_DURATIONS,
|
||||
PHASE_DISPLAY_NAMES,
|
||||
PHASE_ORDER,
|
||||
calculateTotalDuration,
|
||||
formatMinutes,
|
||||
} from '@/lib/companion/constants'
|
||||
|
||||
interface LessonStartFormProps {
|
||||
onStart: (data: {
|
||||
classId: string
|
||||
subject: string
|
||||
topic?: string
|
||||
templateId?: string
|
||||
}) => void
|
||||
loading?: boolean
|
||||
availableClasses?: Class[]
|
||||
}
|
||||
|
||||
// Mock classes for development
|
||||
const MOCK_CLASSES: Class[] = [
|
||||
{ id: 'c1', name: '9a', grade: '9', studentCount: 28 },
|
||||
{ id: 'c2', name: '9b', grade: '9', studentCount: 26 },
|
||||
{ id: 'c3', name: '10a', grade: '10', studentCount: 24 },
|
||||
{ id: 'c4', name: 'Deutsch LK', grade: 'Q1', studentCount: 18 },
|
||||
{ id: 'c5', name: 'Mathe GK', grade: 'Q2', studentCount: 22 },
|
||||
]
|
||||
|
||||
const SUBJECTS = [
|
||||
'Deutsch',
|
||||
'Mathematik',
|
||||
'Englisch',
|
||||
'Biologie',
|
||||
'Physik',
|
||||
'Chemie',
|
||||
'Geschichte',
|
||||
'Geographie',
|
||||
'Politik',
|
||||
'Kunst',
|
||||
'Musik',
|
||||
'Sport',
|
||||
'Informatik',
|
||||
'Sonstiges',
|
||||
]
|
||||
|
||||
export function LessonStartForm({
|
||||
onStart,
|
||||
loading,
|
||||
availableClasses = MOCK_CLASSES,
|
||||
}: LessonStartFormProps) {
|
||||
const [selectedClass, setSelectedClass] = useState('')
|
||||
const [selectedSubject, setSelectedSubject] = useState('')
|
||||
const [topic, setTopic] = useState('')
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<LessonTemplate | null>(
|
||||
SYSTEM_TEMPLATES[0] as LessonTemplate
|
||||
)
|
||||
const [showTemplateDetails, setShowTemplateDetails] = useState(false)
|
||||
|
||||
const totalDuration = selectedTemplate
|
||||
? calculateTotalDuration(selectedTemplate.durations)
|
||||
: calculateTotalDuration(DEFAULT_PHASE_DURATIONS)
|
||||
|
||||
const canStart = selectedClass && selectedSubject
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!canStart) return
|
||||
|
||||
onStart({
|
||||
classId: selectedClass,
|
||||
subject: selectedSubject,
|
||||
topic: topic || undefined,
|
||||
templateId: selectedTemplate?.templateId,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-3 bg-blue-100 rounded-xl">
|
||||
<Play className="w-6 h-6 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Neue Stunde starten</h2>
|
||||
<p className="text-sm text-slate-500">
|
||||
Waehlen Sie Klasse, Fach und Template
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Class Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
<Users className="w-4 h-4 inline mr-2" />
|
||||
Klasse *
|
||||
</label>
|
||||
<select
|
||||
value={selectedClass}
|
||||
onChange={(e) => setSelectedClass(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
required
|
||||
>
|
||||
<option value="">Klasse auswaehlen...</option>
|
||||
{availableClasses.map((cls) => (
|
||||
<option key={cls.id} value={cls.id}>
|
||||
{cls.name} ({cls.studentCount} Schueler)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Subject Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
<BookOpen className="w-4 h-4 inline mr-2" />
|
||||
Fach *
|
||||
</label>
|
||||
<select
|
||||
value={selectedSubject}
|
||||
onChange={(e) => setSelectedSubject(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
required
|
||||
>
|
||||
<option value="">Fach auswaehlen...</option>
|
||||
{SUBJECTS.map((subject) => (
|
||||
<option key={subject} value={subject}>
|
||||
{subject}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Topic (Optional) */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Thema (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={topic}
|
||||
onChange={(e) => setTopic(e.target.value)}
|
||||
placeholder="z.B. Quadratische Funktionen, Gedichtanalyse..."
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Template Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
<Clock className="w-4 h-4 inline mr-2" />
|
||||
Template
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{SYSTEM_TEMPLATES.map((template) => {
|
||||
const tpl = template as LessonTemplate
|
||||
const isSelected = selectedTemplate?.templateId === tpl.templateId
|
||||
const total = calculateTotalDuration(tpl.durations)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tpl.templateId}
|
||||
type="button"
|
||||
onClick={() => setSelectedTemplate(tpl)}
|
||||
className={`
|
||||
w-full p-4 rounded-xl border text-left transition-all
|
||||
${isSelected
|
||||
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/20'
|
||||
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`font-medium ${isSelected ? 'text-blue-900' : 'text-slate-900'}`}>
|
||||
{tpl.name}
|
||||
</p>
|
||||
<p className="text-sm text-slate-500">{tpl.description}</p>
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${isSelected ? 'text-blue-600' : 'text-slate-500'}`}>
|
||||
{formatMinutes(total)}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Template Details Toggle */}
|
||||
{selectedTemplate && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTemplateDetails(!showTemplateDetails)}
|
||||
className="mt-3 flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
Phasendauern anzeigen
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${showTemplateDetails ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Template Details */}
|
||||
{showTemplateDetails && selectedTemplate && (
|
||||
<div className="mt-3 p-4 bg-slate-50 rounded-xl">
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{PHASE_ORDER.map((phaseId) => (
|
||||
<div key={phaseId} className="text-center">
|
||||
<p className="text-xs text-slate-500">{PHASE_DISPLAY_NAMES[phaseId]}</p>
|
||||
<p className="font-medium text-slate-900">
|
||||
{selectedTemplate.durations[phaseId]} Min
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary & Start Button */}
|
||||
<div className="pt-4 border-t border-slate-200">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-sm text-slate-600">
|
||||
Gesamtdauer: <span className="font-semibold">{formatMinutes(totalDuration)}</span>
|
||||
</div>
|
||||
{selectedClass && (
|
||||
<div className="text-sm text-slate-600">
|
||||
Klasse: <span className="font-semibold">
|
||||
{availableClasses.find((c) => c.id === selectedClass)?.name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canStart || loading}
|
||||
className={`
|
||||
w-full py-4 px-6 rounded-xl font-semibold text-lg
|
||||
flex items-center justify-center gap-3
|
||||
transition-all duration-200
|
||||
${canStart && !loading
|
||||
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-lg shadow-blue-500/25'
|
||||
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Stunde wird gestartet...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="w-5 h-5" />
|
||||
Stunde starten
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
|
||||
import { Check } from 'lucide-react'
|
||||
import { formatMinutes } from '@/lib/companion/constants'
|
||||
|
||||
interface PhaseTimelinePhase {
|
||||
id: string
|
||||
shortName: string
|
||||
displayName: string
|
||||
duration: number
|
||||
status: string
|
||||
actualTime?: number
|
||||
color: string
|
||||
}
|
||||
|
||||
interface PhaseTimelineDetailedProps {
|
||||
phases: PhaseTimelinePhase[]
|
||||
currentPhaseIndex: number
|
||||
onPhaseClick?: (index: number) => void
|
||||
}
|
||||
|
||||
export function PhaseTimelineDetailed({
|
||||
phases,
|
||||
currentPhaseIndex,
|
||||
onPhaseClick,
|
||||
}: PhaseTimelineDetailedProps) {
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<h3 className="text-sm font-medium text-slate-500 mb-4">Unterrichtsphasen</h3>
|
||||
|
||||
<div className="flex items-start justify-between">
|
||||
{phases.map((phase, index) => {
|
||||
const isActive = index === currentPhaseIndex
|
||||
const isCompleted = phase.status === 'completed'
|
||||
const isPast = index < currentPhaseIndex
|
||||
|
||||
return (
|
||||
<div key={phase.id} className="flex flex-col items-center flex-1">
|
||||
{/* Top connector line */}
|
||||
<div className="w-full flex items-center mb-2">
|
||||
{index > 0 && (
|
||||
<div
|
||||
className="flex-1 h-1"
|
||||
style={{
|
||||
background: isPast || isCompleted
|
||||
? phases[index - 1].color
|
||||
: '#e2e8f0',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{index === 0 && <div className="flex-1" />}
|
||||
|
||||
{/* Phase Circle */}
|
||||
<button
|
||||
onClick={() => onPhaseClick?.(index)}
|
||||
disabled={!onPhaseClick}
|
||||
className={`
|
||||
relative w-12 h-12 rounded-full
|
||||
flex items-center justify-center
|
||||
font-bold text-lg
|
||||
transition-all duration-300
|
||||
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
|
||||
${isActive ? 'ring-4 ring-offset-2 shadow-lg' : ''}
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: isActive || isCompleted || isPast ? phase.color : '#e2e8f0',
|
||||
color: isActive || isCompleted || isPast ? 'white' : '#64748b',
|
||||
'--tw-ring-color': isActive ? `${phase.color}40` : undefined,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="w-6 h-6" />
|
||||
) : (
|
||||
phase.shortName
|
||||
)}
|
||||
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute inset-0 rounded-full animate-ping opacity-20"
|
||||
style={{ backgroundColor: phase.color }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{index < phases.length - 1 && (
|
||||
<div
|
||||
className="flex-1 h-1"
|
||||
style={{
|
||||
background: isCompleted ? phase.color : '#e2e8f0',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{index === phases.length - 1 && <div className="flex-1" />}
|
||||
</div>
|
||||
|
||||
{/* Phase Label */}
|
||||
<span
|
||||
className={`
|
||||
text-sm font-medium mt-2
|
||||
${isActive ? 'text-slate-900' : 'text-slate-500'}
|
||||
`}
|
||||
>
|
||||
{phase.displayName}
|
||||
</span>
|
||||
|
||||
{/* Duration */}
|
||||
<span
|
||||
className={`
|
||||
text-xs mt-1
|
||||
${isActive ? 'text-slate-700' : 'text-slate-400'}
|
||||
`}
|
||||
>
|
||||
{formatMinutes(phase.duration)}
|
||||
</span>
|
||||
|
||||
{/* Actual time if completed */}
|
||||
{phase.actualTime !== undefined && phase.actualTime > 0 && (
|
||||
<span className="text-xs text-slate-400 mt-0.5">
|
||||
(tatsaechlich: {Math.round(phase.actualTime / 60)} Min)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { Plus, Pause, Play, SkipForward, Square, Clock } from 'lucide-react'
|
||||
|
||||
interface QuickActionsBarProps {
|
||||
onExtend: (minutes: number) => void
|
||||
onPause: () => void
|
||||
onResume: () => void
|
||||
onSkip: () => void
|
||||
onEnd: () => void
|
||||
isPaused: boolean
|
||||
isLastPhase: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function QuickActionsBar({
|
||||
onExtend,
|
||||
onPause,
|
||||
onResume,
|
||||
onSkip,
|
||||
onEnd,
|
||||
isPaused,
|
||||
isLastPhase,
|
||||
disabled,
|
||||
}: QuickActionsBarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center gap-3 p-4 bg-white border border-slate-200 rounded-xl"
|
||||
role="toolbar"
|
||||
aria-label="Steuerung"
|
||||
>
|
||||
{/* Extend +5 Min */}
|
||||
<button
|
||||
onClick={() => onExtend(5)}
|
||||
disabled={disabled || isPaused}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-3 rounded-xl
|
||||
font-medium transition-all duration-200
|
||||
min-w-[52px] justify-center
|
||||
${disabled || isPaused
|
||||
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-blue-50 text-blue-700 hover:bg-blue-100 active:scale-95'
|
||||
}
|
||||
`}
|
||||
title="+5 Minuten (E)"
|
||||
aria-keyshortcuts="e"
|
||||
aria-label="5 Minuten verlaengern"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
<span>5 Min</span>
|
||||
</button>
|
||||
|
||||
{/* Pause / Resume */}
|
||||
<button
|
||||
onClick={isPaused ? onResume : onPause}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-3 rounded-xl
|
||||
font-semibold transition-all duration-200
|
||||
min-w-[52px] min-h-[52px] justify-center
|
||||
${disabled
|
||||
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||
: isPaused
|
||||
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-500/25 active:scale-95'
|
||||
: 'bg-amber-500 text-white hover:bg-amber-600 shadow-lg shadow-amber-500/25 active:scale-95'
|
||||
}
|
||||
`}
|
||||
title={isPaused ? 'Fortsetzen (Leertaste)' : 'Pausieren (Leertaste)'}
|
||||
aria-keyshortcuts="Space"
|
||||
aria-label={isPaused ? 'Stunde fortsetzen' : 'Stunde pausieren'}
|
||||
>
|
||||
{isPaused ? (
|
||||
<>
|
||||
<Play className="w-5 h-5" />
|
||||
<span>Fortsetzen</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Pause className="w-5 h-5" />
|
||||
<span>Pause</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Skip Phase / End Lesson */}
|
||||
{isLastPhase ? (
|
||||
<button
|
||||
onClick={onEnd}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-3 rounded-xl
|
||||
font-medium transition-all duration-200
|
||||
min-w-[52px] justify-center
|
||||
${disabled
|
||||
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-red-50 text-red-700 hover:bg-red-100 active:scale-95'
|
||||
}
|
||||
`}
|
||||
title="Stunde beenden"
|
||||
aria-label="Stunde beenden"
|
||||
>
|
||||
<Square className="w-5 h-5" />
|
||||
<span>Beenden</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onSkip}
|
||||
disabled={disabled || isPaused}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-3 rounded-xl
|
||||
font-medium transition-all duration-200
|
||||
min-w-[52px] justify-center
|
||||
${disabled || isPaused
|
||||
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 active:scale-95'
|
||||
}
|
||||
`}
|
||||
title="Naechste Phase (N)"
|
||||
aria-keyshortcuts="n"
|
||||
aria-label="Zur naechsten Phase springen"
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
<span>Weiter</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact version for mobile or sidebar
|
||||
*/
|
||||
export function QuickActionsCompact({
|
||||
onExtend,
|
||||
onPause,
|
||||
onResume,
|
||||
onSkip,
|
||||
isPaused,
|
||||
isLastPhase,
|
||||
disabled,
|
||||
}: Omit<QuickActionsBarProps, 'onEnd'>) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onExtend(5)}
|
||||
disabled={disabled || isPaused}
|
||||
className={`
|
||||
p-2 rounded-lg transition-all
|
||||
${disabled || isPaused
|
||||
? 'text-slate-300'
|
||||
: 'text-blue-600 hover:bg-blue-50'
|
||||
}
|
||||
`}
|
||||
title="+5 Min"
|
||||
>
|
||||
<Clock className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={isPaused ? onResume : onPause}
|
||||
disabled={disabled}
|
||||
className={`
|
||||
p-2 rounded-lg transition-all
|
||||
${disabled
|
||||
? 'text-slate-300'
|
||||
: isPaused
|
||||
? 'text-green-600 hover:bg-green-50'
|
||||
: 'text-amber-600 hover:bg-amber-50'
|
||||
}
|
||||
`}
|
||||
title={isPaused ? 'Fortsetzen' : 'Pausieren'}
|
||||
>
|
||||
{isPaused ? <Play className="w-5 h-5" /> : <Pause className="w-5 h-5" />}
|
||||
</button>
|
||||
|
||||
{!isLastPhase && (
|
||||
<button
|
||||
onClick={onSkip}
|
||||
disabled={disabled || isPaused}
|
||||
className={`
|
||||
p-2 rounded-lg transition-all
|
||||
${disabled || isPaused
|
||||
? 'text-slate-300'
|
||||
: 'text-slate-600 hover:bg-slate-50'
|
||||
}
|
||||
`}
|
||||
title="Naechste Phase"
|
||||
>
|
||||
<SkipForward className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Star, Save, CheckCircle } from 'lucide-react'
|
||||
import { LessonReflection } from '@/lib/companion/types'
|
||||
|
||||
interface ReflectionSectionProps {
|
||||
reflection?: LessonReflection
|
||||
onSave: (rating: number, notes: string, nextSteps: string) => void
|
||||
}
|
||||
|
||||
export function ReflectionSection({ reflection, onSave }: ReflectionSectionProps) {
|
||||
const [rating, setRating] = useState(reflection?.rating || 0)
|
||||
const [notes, setNotes] = useState(reflection?.notes || '')
|
||||
const [nextSteps, setNextSteps] = useState(reflection?.nextSteps || '')
|
||||
const [hoverRating, setHoverRating] = useState(0)
|
||||
const [saved, setSaved] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (reflection) {
|
||||
setRating(reflection.rating)
|
||||
setNotes(reflection.notes)
|
||||
setNextSteps(reflection.nextSteps)
|
||||
}
|
||||
}, [reflection])
|
||||
|
||||
const handleSave = () => {
|
||||
if (rating === 0) return
|
||||
onSave(rating, notes, nextSteps)
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2000)
|
||||
}
|
||||
|
||||
const ratingLabels = [
|
||||
'', // 0
|
||||
'Verbesserungsbedarf',
|
||||
'Okay',
|
||||
'Gut',
|
||||
'Sehr gut',
|
||||
'Ausgezeichnet',
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-6">
|
||||
{/* Star Rating */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">
|
||||
Wie lief die Stunde?
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
{[1, 2, 3, 4, 5].map((star) => {
|
||||
const isFilled = star <= (hoverRating || rating)
|
||||
return (
|
||||
<button
|
||||
key={star}
|
||||
type="button"
|
||||
onClick={() => setRating(star)}
|
||||
onMouseEnter={() => setHoverRating(star)}
|
||||
onMouseLeave={() => setHoverRating(0)}
|
||||
className="p-1 transition-transform hover:scale-110"
|
||||
aria-label={`${star} Stern${star > 1 ? 'e' : ''}`}
|
||||
>
|
||||
<Star
|
||||
className={`w-8 h-8 ${
|
||||
isFilled
|
||||
? 'fill-amber-400 text-amber-400'
|
||||
: 'text-slate-300'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{(hoverRating || rating) > 0 && (
|
||||
<span className="ml-3 text-sm text-slate-600">
|
||||
{ratingLabels[hoverRating || rating]}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Notizen zur Stunde
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Was lief gut? Was koennte besser laufen? Besondere Vorkommnisse..."
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Next Steps */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Naechste Schritte
|
||||
</label>
|
||||
<textarea
|
||||
value={nextSteps}
|
||||
onChange={(e) => setNextSteps(e.target.value)}
|
||||
placeholder="Was muss fuer die naechste Stunde vorbereitet werden? Follow-ups..."
|
||||
rows={3}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={rating === 0}
|
||||
className={`
|
||||
w-full py-3 px-6 rounded-xl font-semibold
|
||||
flex items-center justify-center gap-2
|
||||
transition-all duration-200
|
||||
${saved
|
||||
? 'bg-green-600 text-white'
|
||||
: rating === 0
|
||||
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{saved ? (
|
||||
<>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
Gespeichert!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-5 h-5" />
|
||||
Reflexion speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Previous Reflection Info */}
|
||||
{reflection?.savedAt && (
|
||||
<p className="text-center text-sm text-slate-400">
|
||||
Zuletzt gespeichert: {new Date(reflection.savedAt).toLocaleString('de-DE')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import { Pause, Play } from 'lucide-react'
|
||||
import { TimerColorStatus } from '@/lib/companion/types'
|
||||
import {
|
||||
PIE_TIMER_RADIUS,
|
||||
PIE_TIMER_CIRCUMFERENCE,
|
||||
PIE_TIMER_STROKE_WIDTH,
|
||||
PIE_TIMER_SIZE,
|
||||
TIMER_COLOR_CLASSES,
|
||||
TIMER_BG_COLORS,
|
||||
formatTime,
|
||||
} from '@/lib/companion/constants'
|
||||
|
||||
interface VisualPieTimerProps {
|
||||
progress: number // 0-1 (how much time has elapsed)
|
||||
remainingSeconds: number
|
||||
totalSeconds: number
|
||||
colorStatus: TimerColorStatus
|
||||
isPaused: boolean
|
||||
currentPhaseName: string
|
||||
phaseColor: string
|
||||
onTogglePause?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
const sizeConfig = {
|
||||
sm: { outer: 120, viewBox: 100, radius: 38, stroke: 6, fontSize: 'text-lg' },
|
||||
md: { outer: 180, viewBox: 100, radius: 40, stroke: 7, fontSize: 'text-2xl' },
|
||||
lg: { outer: 240, viewBox: 100, radius: 42, stroke: 8, fontSize: 'text-4xl' },
|
||||
}
|
||||
|
||||
export function VisualPieTimer({
|
||||
progress,
|
||||
remainingSeconds,
|
||||
totalSeconds,
|
||||
colorStatus,
|
||||
isPaused,
|
||||
currentPhaseName,
|
||||
phaseColor,
|
||||
onTogglePause,
|
||||
size = 'lg',
|
||||
}: VisualPieTimerProps) {
|
||||
const config = sizeConfig[size]
|
||||
const circumference = 2 * Math.PI * config.radius
|
||||
|
||||
// Calculate stroke-dashoffset for progress
|
||||
// Progress goes from 0 (full) to 1 (empty), so offset decreases as time passes
|
||||
const strokeDashoffset = circumference * (1 - progress)
|
||||
|
||||
// For overtime, show a pulsing full circle
|
||||
const isOvertime = colorStatus === 'overtime'
|
||||
const displayTime = formatTime(remainingSeconds)
|
||||
|
||||
// Get color classes based on status
|
||||
const colorClasses = TIMER_COLOR_CLASSES[colorStatus]
|
||||
const bgColorClass = TIMER_BG_COLORS[colorStatus]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{/* Timer Circle */}
|
||||
<div
|
||||
className={`relative ${bgColorClass} rounded-full p-4 transition-colors duration-300`}
|
||||
style={{ width: config.outer, height: config.outer }}
|
||||
>
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox={`0 0 ${config.viewBox} ${config.viewBox}`}
|
||||
className="transform -rotate-90"
|
||||
>
|
||||
{/* Background circle */}
|
||||
<circle
|
||||
cx={config.viewBox / 2}
|
||||
cy={config.viewBox / 2}
|
||||
r={config.radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={config.stroke}
|
||||
className="text-slate-200"
|
||||
/>
|
||||
|
||||
{/* Progress circle */}
|
||||
<circle
|
||||
cx={config.viewBox / 2}
|
||||
cy={config.viewBox / 2}
|
||||
r={config.radius}
|
||||
fill="none"
|
||||
stroke={isOvertime ? '#dc2626' : phaseColor}
|
||||
strokeWidth={config.stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={isOvertime ? 0 : strokeDashoffset}
|
||||
className={`transition-all duration-100 ${isOvertime ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Center Content */}
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
{/* Time Display */}
|
||||
<span
|
||||
className={`
|
||||
font-mono font-bold ${config.fontSize}
|
||||
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
|
||||
`}
|
||||
>
|
||||
{displayTime}
|
||||
</span>
|
||||
|
||||
{/* Phase Name */}
|
||||
<span className="text-sm text-slate-500 mt-1">
|
||||
{currentPhaseName}
|
||||
</span>
|
||||
|
||||
{/* Paused Indicator */}
|
||||
{isPaused && (
|
||||
<span className="text-xs text-amber-600 font-medium mt-1 flex items-center gap-1">
|
||||
<Pause className="w-3 h-3" />
|
||||
Pausiert
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Overtime Badge */}
|
||||
{isOvertime && (
|
||||
<span className="absolute -bottom-2 px-2 py-0.5 bg-red-600 text-white text-xs font-bold rounded-full">
|
||||
+{Math.abs(Math.floor(remainingSeconds / 60))} Min
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pause/Play Button (overlay) */}
|
||||
{onTogglePause && (
|
||||
<button
|
||||
onClick={onTogglePause}
|
||||
className={`
|
||||
absolute inset-0 rounded-full
|
||||
flex items-center justify-center
|
||||
opacity-0 hover:opacity-100
|
||||
bg-black/20 backdrop-blur-sm
|
||||
transition-opacity duration-200
|
||||
`}
|
||||
aria-label={isPaused ? 'Fortsetzen' : 'Pausieren'}
|
||||
>
|
||||
{isPaused ? (
|
||||
<Play className="w-12 h-12 text-white" />
|
||||
) : (
|
||||
<Pause className="w-12 h-12 text-white" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Status Text */}
|
||||
<div className="mt-4 text-center">
|
||||
{isOvertime ? (
|
||||
<p className="text-red-600 font-semibold animate-pulse">
|
||||
Ueberzogen - Zeit fuer die naechste Phase!
|
||||
</p>
|
||||
) : colorStatus === 'critical' ? (
|
||||
<p className="text-red-500 font-medium">
|
||||
Weniger als 2 Minuten verbleibend
|
||||
</p>
|
||||
) : colorStatus === 'warning' ? (
|
||||
<p className="text-amber-500">
|
||||
Weniger als 5 Minuten verbleibend
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact timer for header/toolbar
|
||||
*/
|
||||
export function CompactTimer({
|
||||
remainingSeconds,
|
||||
colorStatus,
|
||||
isPaused,
|
||||
phaseName,
|
||||
phaseColor,
|
||||
}: {
|
||||
remainingSeconds: number
|
||||
colorStatus: TimerColorStatus
|
||||
isPaused: boolean
|
||||
phaseName: string
|
||||
phaseColor: string
|
||||
}) {
|
||||
const isOvertime = colorStatus === 'overtime'
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2 bg-white border border-slate-200 rounded-xl">
|
||||
{/* Phase indicator */}
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: phaseColor }}
|
||||
/>
|
||||
|
||||
{/* Phase name */}
|
||||
<span className="text-sm font-medium text-slate-600">{phaseName}</span>
|
||||
|
||||
{/* Time */}
|
||||
<span
|
||||
className={`
|
||||
font-mono font-bold
|
||||
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
|
||||
`}
|
||||
>
|
||||
{formatTime(remainingSeconds)}
|
||||
</span>
|
||||
|
||||
{/* Paused badge */}
|
||||
{isPaused && (
|
||||
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
|
||||
Pausiert
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { X, MessageSquare, Bug, Lightbulb, Send, CheckCircle } from 'lucide-react'
|
||||
import { FeedbackType } from '@/lib/companion/types'
|
||||
|
||||
interface FeedbackModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSubmit: (type: FeedbackType, title: string, description: string) => Promise<void>
|
||||
}
|
||||
|
||||
const feedbackTypes: { id: FeedbackType; label: string; icon: typeof Bug; color: string }[] = [
|
||||
{ id: 'bug', label: 'Bug melden', icon: Bug, color: 'text-red-600 bg-red-50' },
|
||||
{ id: 'feature', label: 'Feature-Wunsch', icon: Lightbulb, color: 'text-amber-600 bg-amber-50' },
|
||||
{ id: 'feedback', label: 'Allgemeines Feedback', icon: MessageSquare, color: 'text-blue-600 bg-blue-50' },
|
||||
]
|
||||
|
||||
export function FeedbackModal({ isOpen, onClose, onSubmit }: FeedbackModalProps) {
|
||||
const [type, setType] = useState<FeedbackType>('feedback')
|
||||
const [title, setTitle] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [isSuccess, setIsSuccess] = useState(false)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!title.trim() || !description.trim()) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onSubmit(type, title.trim(), description.trim())
|
||||
setIsSuccess(true)
|
||||
setTimeout(() => {
|
||||
setIsSuccess(false)
|
||||
setTitle('')
|
||||
setDescription('')
|
||||
setType('feedback')
|
||||
onClose()
|
||||
}, 2000)
|
||||
} catch (error) {
|
||||
console.error('Failed to submit feedback:', error)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-blue-100 rounded-xl">
|
||||
<MessageSquare className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Feedback senden</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Success State */}
|
||||
{isSuccess ? (
|
||||
<div className="p-12 text-center">
|
||||
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-slate-900 mb-2">Vielen Dank!</h3>
|
||||
<p className="text-slate-600">Ihr Feedback wurde erfolgreich gesendet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit}>
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Feedback Type */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">
|
||||
Art des Feedbacks
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{feedbackTypes.map((ft) => (
|
||||
<button
|
||||
key={ft.id}
|
||||
type="button"
|
||||
onClick={() => setType(ft.id)}
|
||||
className={`
|
||||
p-4 rounded-xl border-2 text-center transition-all
|
||||
${type === ft.id
|
||||
? 'border-blue-500 bg-blue-50'
|
||||
: 'border-slate-200 hover:border-slate-300'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg ${ft.color} flex items-center justify-center mx-auto mb-2`}>
|
||||
<ft.icon className="w-5 h-5" />
|
||||
</div>
|
||||
<span className={`text-sm font-medium ${type === ft.id ? 'text-blue-700' : 'text-slate-700'}`}>
|
||||
{ft.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Titel *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={
|
||||
type === 'bug'
|
||||
? 'z.B. Timer stoppt nach Pause nicht mehr'
|
||||
: type === 'feature'
|
||||
? 'z.B. Materialien an Stunde anhaengen'
|
||||
: 'z.B. Super nuetzliches Tool!'
|
||||
}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Beschreibung *
|
||||
</label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={
|
||||
type === 'bug'
|
||||
? 'Bitte beschreiben Sie den Fehler moeglichst genau. Was haben Sie gemacht? Was ist passiert? Was haetten Sie erwartet?'
|
||||
: type === 'feature'
|
||||
? 'Beschreiben Sie die gewuenschte Funktion. Warum waere sie hilfreich?'
|
||||
: 'Teilen Sie uns Ihre Gedanken mit...'
|
||||
}
|
||||
rows={5}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-slate-200 bg-slate-50">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim() || !description.trim() || isSubmitting}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
|
||||
transition-all duration-200
|
||||
${!title.trim() || !description.trim() || isSubmitting
|
||||
? 'bg-slate-200 text-slate-500 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Senden...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-4 h-4" />
|
||||
Absenden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { ChevronRight, ChevronLeft, Check, GraduationCap, Settings, Timer } from 'lucide-react'
|
||||
|
||||
interface OnboardingModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onComplete: (data: { state?: string; schoolType?: string }) => void
|
||||
}
|
||||
|
||||
const STATES = [
|
||||
'Baden-Wuerttemberg',
|
||||
'Bayern',
|
||||
'Berlin',
|
||||
'Brandenburg',
|
||||
'Bremen',
|
||||
'Hamburg',
|
||||
'Hessen',
|
||||
'Mecklenburg-Vorpommern',
|
||||
'Niedersachsen',
|
||||
'Nordrhein-Westfalen',
|
||||
'Rheinland-Pfalz',
|
||||
'Saarland',
|
||||
'Sachsen',
|
||||
'Sachsen-Anhalt',
|
||||
'Schleswig-Holstein',
|
||||
'Thueringen',
|
||||
]
|
||||
|
||||
const SCHOOL_TYPES = [
|
||||
'Grundschule',
|
||||
'Hauptschule',
|
||||
'Realschule',
|
||||
'Gymnasium',
|
||||
'Gesamtschule',
|
||||
'Berufsschule',
|
||||
'Foerderschule',
|
||||
'Andere',
|
||||
]
|
||||
|
||||
interface Step {
|
||||
id: number
|
||||
title: string
|
||||
description: string
|
||||
icon: typeof GraduationCap
|
||||
}
|
||||
|
||||
const steps: Step[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Willkommen',
|
||||
description: 'Der Companion hilft Ihnen bei der Unterrichtsplanung und -durchfuehrung.',
|
||||
icon: GraduationCap,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Ihre Schule',
|
||||
description: 'Waehlen Sie Ihr Bundesland und Ihre Schulform.',
|
||||
icon: Settings,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Bereit!',
|
||||
description: 'Sie koennen jetzt mit dem Lesson-Modus starten.',
|
||||
icon: Timer,
|
||||
},
|
||||
]
|
||||
|
||||
export function OnboardingModal({ isOpen, onClose, onComplete }: OnboardingModalProps) {
|
||||
const [currentStep, setCurrentStep] = useState(1)
|
||||
const [selectedState, setSelectedState] = useState('')
|
||||
const [selectedSchoolType, setSelectedSchoolType] = useState('')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const canProceed = () => {
|
||||
if (currentStep === 2) {
|
||||
return selectedState !== '' && selectedSchoolType !== ''
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 3) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
} else {
|
||||
onComplete({
|
||||
state: selectedState,
|
||||
schoolType: selectedSchoolType,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const currentStepData = steps[currentStep - 1]
|
||||
const Icon = currentStepData.icon
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 overflow-hidden">
|
||||
{/* Progress Bar */}
|
||||
<div className="h-1 bg-slate-100">
|
||||
<div
|
||||
className="h-full bg-blue-600 transition-all duration-300"
|
||||
style={{ width: `${(currentStep / 3) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8">
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center justify-center gap-2 mb-8">
|
||||
{steps.map((step) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`
|
||||
w-3 h-3 rounded-full transition-all
|
||||
${step.id === currentStep
|
||||
? 'bg-blue-600 scale-125'
|
||||
: step.id < currentStep
|
||||
? 'bg-blue-600'
|
||||
: 'bg-slate-200'
|
||||
}
|
||||
`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Icon */}
|
||||
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
|
||||
<Icon className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
|
||||
{/* Title & Description */}
|
||||
<h2 className="text-2xl font-bold text-slate-900 text-center mb-2">
|
||||
{currentStepData.title}
|
||||
</h2>
|
||||
<p className="text-slate-600 text-center mb-8">
|
||||
{currentStepData.description}
|
||||
</p>
|
||||
|
||||
{/* Step Content */}
|
||||
{currentStep === 1 && (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-blue-50 rounded-xl">
|
||||
<div className="text-2xl mb-1">5</div>
|
||||
<div className="text-xs text-slate-600">Phasen</div>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-xl">
|
||||
<div className="text-2xl mb-1">45</div>
|
||||
<div className="text-xs text-slate-600">Minuten</div>
|
||||
</div>
|
||||
<div className="p-4 bg-purple-50 rounded-xl">
|
||||
<div className="text-2xl mb-1">∞</div>
|
||||
<div className="text-xs text-slate-600">Flexibel</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-4">
|
||||
Einstieg → Erarbeitung → Sicherung → Transfer → Reflexion
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 2 && (
|
||||
<div className="space-y-4">
|
||||
{/* State Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Bundesland
|
||||
</label>
|
||||
<select
|
||||
value={selectedState}
|
||||
onChange={(e) => setSelectedState(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
>
|
||||
<option value="">Bitte waehlen...</option>
|
||||
{STATES.map((state) => (
|
||||
<option key={state} value={state}>
|
||||
{state}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* School Type Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">
|
||||
Schulform
|
||||
</label>
|
||||
<select
|
||||
value={selectedSchoolType}
|
||||
onChange={(e) => setSelectedSchoolType(e.target.value)}
|
||||
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
>
|
||||
<option value="">Bitte waehlen...</option>
|
||||
{SCHOOL_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 3 && (
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
|
||||
<Check className="w-10 h-10 text-green-600" />
|
||||
</div>
|
||||
<div className="p-4 bg-slate-50 rounded-xl">
|
||||
<p className="text-sm text-slate-600">
|
||||
<strong>Bundesland:</strong> {selectedState || 'Nicht angegeben'}
|
||||
</p>
|
||||
<p className="text-sm text-slate-600">
|
||||
<strong>Schulform:</strong> {selectedSchoolType || 'Nicht angegeben'}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Sie koennen diese Einstellungen jederzeit aendern.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
|
||||
<button
|
||||
onClick={currentStep === 1 ? onClose : handleBack}
|
||||
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
|
||||
>
|
||||
{currentStep === 1 ? (
|
||||
'Ueberspringen'
|
||||
) : (
|
||||
<>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
Zurueck
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className={`
|
||||
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
|
||||
transition-all duration-200
|
||||
${canProceed()
|
||||
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{currentStep === 3 ? (
|
||||
<>
|
||||
<Check className="w-4 h-4" />
|
||||
Fertig
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Weiter
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, Settings, Save, RotateCcw } from 'lucide-react'
|
||||
import { TeacherSettings, PhaseDurations } from '@/lib/companion/types'
|
||||
import {
|
||||
DEFAULT_TEACHER_SETTINGS,
|
||||
DEFAULT_PHASE_DURATIONS,
|
||||
PHASE_ORDER,
|
||||
PHASE_DISPLAY_NAMES,
|
||||
PHASE_COLORS,
|
||||
calculateTotalDuration,
|
||||
} from '@/lib/companion/constants'
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
settings: TeacherSettings
|
||||
onSave: (settings: TeacherSettings) => void
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
settings,
|
||||
onSave,
|
||||
}: SettingsModalProps) {
|
||||
const [localSettings, setLocalSettings] = useState<TeacherSettings>(settings)
|
||||
const [durations, setDurations] = useState<PhaseDurations>(settings.defaultPhaseDurations)
|
||||
|
||||
useEffect(() => {
|
||||
setLocalSettings(settings)
|
||||
setDurations(settings.defaultPhaseDurations)
|
||||
}, [settings])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const totalDuration = calculateTotalDuration(durations)
|
||||
|
||||
const handleDurationChange = (phase: keyof PhaseDurations, value: number) => {
|
||||
const newDurations = { ...durations, [phase]: Math.max(1, Math.min(60, value)) }
|
||||
setDurations(newDurations)
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
setDurations(DEFAULT_PHASE_DURATIONS)
|
||||
setLocalSettings(DEFAULT_TEACHER_SETTINGS)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
const newSettings: TeacherSettings = {
|
||||
...localSettings,
|
||||
defaultPhaseDurations: durations,
|
||||
}
|
||||
onSave(newSettings)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-slate-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 bg-slate-100 rounded-xl">
|
||||
<Settings className="w-5 h-5 text-slate-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-slate-900">Einstellungen</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-slate-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6 overflow-y-auto max-h-[60vh]">
|
||||
{/* Phase Durations */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">
|
||||
Standard-Phasendauern (Minuten)
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{PHASE_ORDER.map((phase) => (
|
||||
<div key={phase} className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-2 w-32">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: PHASE_COLORS[phase].hex }}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">
|
||||
{PHASE_DISPLAY_NAMES[phase]}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={durations[phase]}
|
||||
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value) || 1)}
|
||||
className="w-20 px-3 py-2 border border-slate-200 rounded-lg text-center focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={45}
|
||||
value={durations[phase]}
|
||||
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value))}
|
||||
className="flex-1"
|
||||
style={{
|
||||
accentColor: PHASE_COLORS[phase].hex,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 p-3 bg-slate-50 rounded-xl flex items-center justify-between">
|
||||
<span className="text-sm text-slate-600">Gesamtdauer:</span>
|
||||
<span className="font-semibold text-slate-900">{totalDuration} Minuten</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Other Settings */}
|
||||
<div className="space-y-4 pt-4 border-t border-slate-200">
|
||||
<h3 className="text-sm font-medium text-slate-700 mb-4">
|
||||
Weitere Einstellungen
|
||||
</h3>
|
||||
|
||||
{/* Auto Advance */}
|
||||
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Automatischer Phasenwechsel
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
Phasen automatisch wechseln wenn Zeit abgelaufen
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.autoAdvancePhases}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, autoAdvancePhases: e.target.checked })
|
||||
}
|
||||
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Sound Notifications */}
|
||||
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Ton-Benachrichtigungen
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
Signalton bei Phasenende und Warnungen
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.soundNotifications}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, soundNotifications: e.target.checked })
|
||||
}
|
||||
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Keyboard Shortcuts */}
|
||||
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Tastaturkuerzel anzeigen
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
Hinweise zu Tastaturkuerzeln einblenden
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.showKeyboardShortcuts}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, showKeyboardShortcuts: e.target.checked })
|
||||
}
|
||||
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* High Contrast */}
|
||||
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Hoher Kontrast
|
||||
</span>
|
||||
<p className="text-xs text-slate-500">
|
||||
Bessere Sichtbarkeit durch erhoehten Kontrast
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={localSettings.highContrastMode}
|
||||
onChange={(e) =>
|
||||
setLocalSettings({ ...localSettings, highContrastMode: e.target.checked })
|
||||
}
|
||||
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Zuruecksetzen
|
||||
</button>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { GeoJSONPolygon } from '@/app/geo-lernwelt/types'
|
||||
|
||||
// MapLibre GL JS types (imported dynamically)
|
||||
type MapInstance = {
|
||||
on: (event: string, callback: (...args: unknown[]) => void) => void
|
||||
addSource: (id: string, source: object) => void
|
||||
addLayer: (layer: object) => void
|
||||
getSource: (id: string) => { setData: (data: object) => void } | undefined
|
||||
removeLayer: (id: string) => void
|
||||
removeSource: (id: string) => void
|
||||
getCanvas: () => { style: { cursor: string } }
|
||||
remove: () => void
|
||||
fitBounds: (bounds: [[number, number], [number, number]], options?: object) => void
|
||||
}
|
||||
|
||||
interface AOISelectorProps {
|
||||
onPolygonDrawn: (polygon: GeoJSONPolygon) => void
|
||||
initialPolygon?: GeoJSONPolygon | null
|
||||
maxAreaKm2?: number
|
||||
geoServiceUrl: string
|
||||
}
|
||||
|
||||
// Germany bounds
|
||||
const GERMANY_BOUNDS: [[number, number], [number, number]] = [
|
||||
[5.87, 47.27],
|
||||
[15.04, 55.06],
|
||||
]
|
||||
|
||||
// Default center (Germany)
|
||||
const DEFAULT_CENTER: [number, number] = [10.45, 51.16]
|
||||
const DEFAULT_ZOOM = 6
|
||||
|
||||
export default function AOISelector({
|
||||
onPolygonDrawn,
|
||||
initialPolygon,
|
||||
maxAreaKm2 = 4,
|
||||
geoServiceUrl,
|
||||
}: AOISelectorProps) {
|
||||
const mapContainerRef = useRef<HTMLDivElement>(null)
|
||||
const mapRef = useRef<MapInstance | null>(null)
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [drawPoints, setDrawPoints] = useState<[number, number][]>([])
|
||||
const [mapReady, setMapReady] = useState(false)
|
||||
const [areaKm2, setAreaKm2] = useState<number | null>(null)
|
||||
const [validationError, setValidationError] = useState<string | null>(null)
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
if (!mapContainerRef.current) return
|
||||
|
||||
const initMap = async () => {
|
||||
const maplibregl = await import('maplibre-gl')
|
||||
// CSS is loaded via CDN in head to avoid Next.js dynamic import issues
|
||||
if (!document.querySelector('link[href*="maplibre-gl.css"]')) {
|
||||
const link = document.createElement('link')
|
||||
link.rel = 'stylesheet'
|
||||
link.href = 'https://unpkg.com/maplibre-gl@5.0.1/dist/maplibre-gl.css'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: mapContainerRef.current!,
|
||||
style: {
|
||||
version: 8,
|
||||
name: 'GeoEdu Map',
|
||||
sources: {
|
||||
osm: {
|
||||
type: 'raster',
|
||||
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
|
||||
tileSize: 256,
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
},
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'osm-tiles',
|
||||
type: 'raster',
|
||||
source: 'osm',
|
||||
minzoom: 0,
|
||||
maxzoom: 19,
|
||||
},
|
||||
],
|
||||
},
|
||||
center: DEFAULT_CENTER,
|
||||
zoom: DEFAULT_ZOOM,
|
||||
maxBounds: [
|
||||
[GERMANY_BOUNDS[0][0] - 1, GERMANY_BOUNDS[0][1] - 1],
|
||||
[GERMANY_BOUNDS[1][0] + 1, GERMANY_BOUNDS[1][1] + 1],
|
||||
],
|
||||
}) as unknown as MapInstance
|
||||
|
||||
map.on('load', () => {
|
||||
// Add drawing layer sources
|
||||
map.addSource('draw-polygon', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
},
|
||||
})
|
||||
|
||||
map.addSource('draw-points', {
|
||||
type: 'geojson',
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [],
|
||||
},
|
||||
})
|
||||
|
||||
// Add polygon fill layer
|
||||
map.addLayer({
|
||||
id: 'draw-polygon-fill',
|
||||
type: 'fill',
|
||||
source: 'draw-polygon',
|
||||
paint: {
|
||||
'fill-color': '#3b82f6',
|
||||
'fill-opacity': 0.3,
|
||||
},
|
||||
})
|
||||
|
||||
// Add polygon outline layer
|
||||
map.addLayer({
|
||||
id: 'draw-polygon-outline',
|
||||
type: 'line',
|
||||
source: 'draw-polygon',
|
||||
paint: {
|
||||
'line-color': '#3b82f6',
|
||||
'line-width': 2,
|
||||
},
|
||||
})
|
||||
|
||||
// Add points layer
|
||||
map.addLayer({
|
||||
id: 'draw-points-layer',
|
||||
type: 'circle',
|
||||
source: 'draw-points',
|
||||
paint: {
|
||||
'circle-radius': 6,
|
||||
'circle-color': '#3b82f6',
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-stroke-width': 2,
|
||||
},
|
||||
})
|
||||
|
||||
mapRef.current = map
|
||||
setMapReady(true)
|
||||
|
||||
// Load initial polygon if provided
|
||||
if (initialPolygon) {
|
||||
loadPolygon(map, initialPolygon)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
initMap()
|
||||
|
||||
return () => {
|
||||
if (mapRef.current) {
|
||||
mapRef.current.remove()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update polygon when initialPolygon changes
|
||||
useEffect(() => {
|
||||
if (mapReady && mapRef.current && initialPolygon) {
|
||||
loadPolygon(mapRef.current, initialPolygon)
|
||||
}
|
||||
}, [initialPolygon, mapReady])
|
||||
|
||||
const loadPolygon = (map: MapInstance, polygon: GeoJSONPolygon) => {
|
||||
const source = map.getSource('draw-polygon')
|
||||
if (source) {
|
||||
source.setData({
|
||||
type: 'Feature',
|
||||
geometry: polygon,
|
||||
properties: {},
|
||||
})
|
||||
}
|
||||
|
||||
// Calculate and validate area
|
||||
validatePolygon(polygon)
|
||||
|
||||
// Fit bounds to polygon
|
||||
const coords = polygon.coordinates[0]
|
||||
const bounds = coords.reduce<[[number, number], [number, number]]>(
|
||||
(acc, coord) => [
|
||||
[Math.min(acc[0][0], coord[0]), Math.min(acc[0][1], coord[1])],
|
||||
[Math.max(acc[1][0], coord[0]), Math.max(acc[1][1], coord[1])],
|
||||
],
|
||||
[
|
||||
[Infinity, Infinity],
|
||||
[-Infinity, -Infinity],
|
||||
]
|
||||
)
|
||||
|
||||
map.fitBounds(bounds, { padding: 50 })
|
||||
}
|
||||
|
||||
const validatePolygon = async (polygon: GeoJSONPolygon) => {
|
||||
try {
|
||||
const res = await fetch(`${geoServiceUrl}/api/v1/aoi/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(polygon),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
setAreaKm2(data.area_km2)
|
||||
|
||||
if (!data.valid) {
|
||||
setValidationError(data.error || 'Ungueltiges Polygon')
|
||||
} else if (!data.within_germany) {
|
||||
setValidationError('Gebiet muss innerhalb Deutschlands liegen')
|
||||
} else if (!data.within_size_limit) {
|
||||
setValidationError(`Gebiet zu gross (max. ${maxAreaKm2} km²)`)
|
||||
} else {
|
||||
setValidationError(null)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to client-side calculation
|
||||
console.error('Validation error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMapClick = useCallback(
|
||||
(e: { lngLat: { lng: number; lat: number } }) => {
|
||||
if (!isDrawing || !mapRef.current) return
|
||||
|
||||
const newPoint: [number, number] = [e.lngLat.lng, e.lngLat.lat]
|
||||
const newPoints = [...drawPoints, newPoint]
|
||||
setDrawPoints(newPoints)
|
||||
|
||||
// Update points layer
|
||||
const pointsSource = mapRef.current.getSource('draw-points')
|
||||
if (pointsSource) {
|
||||
pointsSource.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: newPoints.map((pt) => ({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: pt },
|
||||
properties: {},
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
// Update polygon preview if we have at least 3 points
|
||||
if (newPoints.length >= 3) {
|
||||
const polygonCoords = [...newPoints, newPoints[0]]
|
||||
const polygonSource = mapRef.current.getSource('draw-polygon')
|
||||
if (polygonSource) {
|
||||
polygonSource.setData({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [polygonCoords],
|
||||
},
|
||||
properties: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
[isDrawing, drawPoints]
|
||||
)
|
||||
|
||||
// Attach click handler when drawing
|
||||
useEffect(() => {
|
||||
if (!mapReady || !mapRef.current) return
|
||||
|
||||
const map = mapRef.current
|
||||
|
||||
if (isDrawing) {
|
||||
map.getCanvas().style.cursor = 'crosshair'
|
||||
map.on('click', handleMapClick as (...args: unknown[]) => void)
|
||||
} else {
|
||||
map.getCanvas().style.cursor = ''
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Note: maplibre doesn't have off() in the same way, handled by cleanup
|
||||
}
|
||||
}, [isDrawing, mapReady, handleMapClick])
|
||||
|
||||
const startDrawing = () => {
|
||||
setIsDrawing(true)
|
||||
setDrawPoints([])
|
||||
setAreaKm2(null)
|
||||
setValidationError(null)
|
||||
|
||||
// Clear existing polygon
|
||||
if (mapRef.current) {
|
||||
const polygonSource = mapRef.current.getSource('draw-polygon')
|
||||
const pointsSource = mapRef.current.getSource('draw-points')
|
||||
if (polygonSource) {
|
||||
polygonSource.setData({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
if (pointsSource) {
|
||||
pointsSource.setData({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finishDrawing = () => {
|
||||
if (drawPoints.length < 3) {
|
||||
setValidationError('Mindestens 3 Punkte erforderlich')
|
||||
return
|
||||
}
|
||||
|
||||
setIsDrawing(false)
|
||||
|
||||
// Close the polygon
|
||||
const closedCoords = [...drawPoints, drawPoints[0]]
|
||||
const polygon: GeoJSONPolygon = {
|
||||
type: 'Polygon',
|
||||
coordinates: [closedCoords],
|
||||
}
|
||||
|
||||
// Clear points layer
|
||||
if (mapRef.current) {
|
||||
const pointsSource = mapRef.current.getSource('draw-points')
|
||||
if (pointsSource) {
|
||||
pointsSource.setData({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
}
|
||||
|
||||
// Validate and callback
|
||||
validatePolygon(polygon)
|
||||
onPolygonDrawn(polygon)
|
||||
}
|
||||
|
||||
const cancelDrawing = () => {
|
||||
setIsDrawing(false)
|
||||
setDrawPoints([])
|
||||
|
||||
// Clear layers
|
||||
if (mapRef.current) {
|
||||
const polygonSource = mapRef.current.getSource('draw-polygon')
|
||||
const pointsSource = mapRef.current.getSource('draw-points')
|
||||
if (polygonSource) {
|
||||
polygonSource.setData({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
if (pointsSource) {
|
||||
pointsSource.setData({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full">
|
||||
{/* Map Container */}
|
||||
<div ref={mapContainerRef} className="w-full h-full" />
|
||||
|
||||
{/* Drawing Toolbar */}
|
||||
<div className="absolute top-4 left-4 flex gap-2">
|
||||
{!isDrawing ? (
|
||||
<button
|
||||
onClick={startDrawing}
|
||||
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg shadow-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"
|
||||
/>
|
||||
</svg>
|
||||
Gebiet zeichnen
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={finishDrawing}
|
||||
disabled={drawPoints.length < 3}
|
||||
className="px-4 py-2 bg-green-500 hover:bg-green-600 disabled:bg-gray-500 text-white rounded-lg shadow-lg transition-colors"
|
||||
>
|
||||
✓ Fertig ({drawPoints.length} Punkte)
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelDrawing}
|
||||
className="px-4 py-2 bg-red-500 hover:bg-red-600 text-white rounded-lg shadow-lg transition-colors"
|
||||
>
|
||||
✕ Abbrechen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Drawing Instructions */}
|
||||
{isDrawing && (
|
||||
<div className="absolute top-4 right-4 bg-black/70 text-white px-4 py-2 rounded-lg text-sm max-w-xs">
|
||||
<p>Klicke auf die Karte um Punkte zu setzen.</p>
|
||||
<p className="text-white/60 mt-1">Mindestens 3 Punkte erforderlich.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Area Info */}
|
||||
{areaKm2 !== null && (
|
||||
<div className="absolute bottom-4 left-4 bg-black/70 text-white px-4 py-2 rounded-lg">
|
||||
<div className="text-sm">
|
||||
<span className="text-white/60">Flaeche: </span>
|
||||
<span className={areaKm2 > maxAreaKm2 ? 'text-red-400' : 'text-green-400'}>
|
||||
{areaKm2.toFixed(2)} km²
|
||||
</span>
|
||||
<span className="text-white/40"> / {maxAreaKm2} km² max</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Validation Error */}
|
||||
{validationError && (
|
||||
<div className="absolute bottom-4 right-4 bg-red-500/90 text-white px-4 py-2 rounded-lg text-sm max-w-xs">
|
||||
{validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{!mapReady && (
|
||||
<div className="absolute inset-0 bg-slate-800 flex items-center justify-center">
|
||||
<div className="text-white/60 flex flex-col items-center gap-2">
|
||||
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
<span>Karte wird geladen...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { LearningNode } from '@/app/geo-lernwelt/types'
|
||||
|
||||
interface UnityViewerProps {
|
||||
aoiId: string
|
||||
manifestUrl?: string
|
||||
learningNodes: LearningNode[]
|
||||
geoServiceUrl: string
|
||||
}
|
||||
|
||||
interface UnityInstance {
|
||||
SendMessage: (objectName: string, methodName: string, value?: string | number) => void
|
||||
}
|
||||
|
||||
export default function UnityViewer({
|
||||
aoiId,
|
||||
manifestUrl,
|
||||
learningNodes,
|
||||
geoServiceUrl,
|
||||
}: UnityViewerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const [unityInstance, setUnityInstance] = useState<UnityInstance | null>(null)
|
||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedNode, setSelectedNode] = useState<LearningNode | null>(null)
|
||||
const [showNodePanel, setShowNodePanel] = useState(false)
|
||||
|
||||
// Placeholder mode when Unity build is not available
|
||||
const [placeholderMode, setPlaceholderMode] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
// Check if Unity build exists
|
||||
// For now, always use placeholder mode since Unity build needs to be created separately
|
||||
setPlaceholderMode(true)
|
||||
setIsLoading(false)
|
||||
}, [])
|
||||
|
||||
// Unity message handler (for when Unity build exists)
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(window as any).handleUnityMessage = (message: string) => {
|
||||
try {
|
||||
const data = JSON.parse(message)
|
||||
|
||||
switch (data.type) {
|
||||
case 'nodeSelected':
|
||||
const node = learningNodes.find((n) => n.id === data.nodeId)
|
||||
if (node) {
|
||||
setSelectedNode(node)
|
||||
setShowNodePanel(true)
|
||||
}
|
||||
break
|
||||
|
||||
case 'terrainLoaded':
|
||||
console.log('Unity terrain loaded')
|
||||
break
|
||||
|
||||
case 'error':
|
||||
setError(data.message)
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing Unity message:', e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (window as any).handleUnityMessage
|
||||
}
|
||||
}
|
||||
}, [learningNodes])
|
||||
|
||||
const sendToUnity = useCallback(
|
||||
(objectName: string, methodName: string, value?: string | number) => {
|
||||
if (unityInstance) {
|
||||
unityInstance.SendMessage(objectName, methodName, value)
|
||||
}
|
||||
},
|
||||
[unityInstance]
|
||||
)
|
||||
|
||||
// Send AOI data to Unity when ready
|
||||
useEffect(() => {
|
||||
if (unityInstance && manifestUrl) {
|
||||
sendToUnity('TerrainManager', 'LoadManifest', manifestUrl)
|
||||
|
||||
// Send learning nodes
|
||||
const nodesJson = JSON.stringify(learningNodes)
|
||||
sendToUnity('LearningNodeManager', 'LoadNodes', nodesJson)
|
||||
}
|
||||
}, [unityInstance, manifestUrl, learningNodes, sendToUnity])
|
||||
|
||||
const handleNodeClick = (node: LearningNode) => {
|
||||
setSelectedNode(node)
|
||||
setShowNodePanel(true)
|
||||
|
||||
if (unityInstance) {
|
||||
sendToUnity('CameraController', 'FocusOnNode', node.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseNodePanel = () => {
|
||||
setShowNodePanel(false)
|
||||
setSelectedNode(null)
|
||||
}
|
||||
|
||||
// Placeholder 3D view (when Unity is not available)
|
||||
const PlaceholderView = () => (
|
||||
<div className="w-full h-full bg-gradient-to-b from-sky-400 to-sky-200 relative overflow-hidden">
|
||||
{/* Sky */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-sky-500 via-sky-400 to-sky-300" />
|
||||
|
||||
{/* Sun */}
|
||||
<div className="absolute top-8 right-12 w-16 h-16 bg-yellow-300 rounded-full shadow-lg shadow-yellow-400/50" />
|
||||
|
||||
{/* Clouds */}
|
||||
<div className="absolute top-12 left-8 w-24 h-8 bg-white/80 rounded-full blur-sm" />
|
||||
<div className="absolute top-20 left-20 w-16 h-6 bg-white/70 rounded-full blur-sm" />
|
||||
<div className="absolute top-16 right-32 w-20 h-7 bg-white/75 rounded-full blur-sm" />
|
||||
|
||||
{/* Mountains */}
|
||||
<svg
|
||||
className="absolute bottom-0 left-0 w-full h-1/2"
|
||||
viewBox="0 0 1200 400"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{/* Background mountains */}
|
||||
<path d="M0,400 L200,150 L400,300 L600,100 L800,250 L1000,80 L1200,200 L1200,400 Z" fill="#4ade80" fillOpacity="0.5" />
|
||||
{/* Foreground mountains */}
|
||||
<path d="M0,400 L150,200 L300,350 L500,150 L700,300 L900,120 L1100,280 L1200,180 L1200,400 Z" fill="#22c55e" />
|
||||
{/* Ground */}
|
||||
<path d="M0,400 L0,350 Q300,320 600,350 Q900,380 1200,340 L1200,400 Z" fill="#15803d" />
|
||||
</svg>
|
||||
|
||||
{/* Learning Nodes as Markers */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="relative w-full h-full">
|
||||
{learningNodes.map((node, idx) => {
|
||||
// Position nodes across the view
|
||||
const positions = [
|
||||
{ left: '20%', top: '55%' },
|
||||
{ left: '40%', top: '45%' },
|
||||
{ left: '60%', top: '50%' },
|
||||
{ left: '75%', top: '55%' },
|
||||
{ left: '30%', top: '60%' },
|
||||
]
|
||||
const pos = positions[idx % positions.length]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => handleNodeClick(node)}
|
||||
style={{ left: pos.left, top: pos.top }}
|
||||
className="absolute transform -translate-x-1/2 -translate-y-1/2 group"
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Marker pin */}
|
||||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center text-white font-bold shadow-lg group-hover:bg-blue-600 transition-colors animate-bounce">
|
||||
{idx + 1}
|
||||
</div>
|
||||
{/* Pulse effect */}
|
||||
<div className="absolute inset-0 w-8 h-8 bg-blue-500 rounded-full animate-ping opacity-25" />
|
||||
{/* Label */}
|
||||
<div className="absolute top-10 left-1/2 transform -translate-x-1/2 bg-black/70 text-white text-xs px-2 py-1 rounded whitespace-nowrap opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{node.title}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3D View Notice */}
|
||||
<div className="absolute bottom-4 left-4 bg-black/50 text-white px-4 py-2 rounded-lg text-sm">
|
||||
<p className="font-medium">Vorschau-Modus</p>
|
||||
<p className="text-white/70 text-xs">
|
||||
Unity WebGL-Build wird fuer die volle 3D-Ansicht benoetigt
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls hint */}
|
||||
<div className="absolute bottom-4 right-4 bg-black/50 text-white px-4 py-2 rounded-lg text-sm">
|
||||
<p className="text-white/70">Klicke auf die Marker um Lernstationen anzuzeigen</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full bg-slate-900">
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-slate-900 flex flex-col items-center justify-center z-10">
|
||||
<div className="w-64 mb-4">
|
||||
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 transition-all duration-300"
|
||||
style={{ width: `${loadingProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white/60 text-sm">Lade 3D-Lernwelt... {loadingProgress}%</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="absolute inset-0 bg-slate-900 flex items-center justify-center z-10">
|
||||
<div className="text-center p-8">
|
||||
<div className="text-red-400 text-6xl mb-4">⚠️</div>
|
||||
<p className="text-white text-lg mb-2">Fehler beim Laden</p>
|
||||
<p className="text-white/60 text-sm max-w-md">{error}</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setError(null)
|
||||
setIsLoading(true)
|
||||
setLoadingProgress(0)
|
||||
}}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg"
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unity Canvas or Placeholder */}
|
||||
{placeholderMode ? (
|
||||
<PlaceholderView />
|
||||
) : (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
id="unity-canvas"
|
||||
className="w-full h-full"
|
||||
tabIndex={-1}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Learning Node Panel */}
|
||||
{showNodePanel && selectedNode && (
|
||||
<div className="absolute top-4 right-4 w-80 bg-white/95 dark:bg-slate-800/95 backdrop-blur-lg rounded-2xl shadow-2xl overflow-hidden z-20">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-blue-500 to-purple-500 p-4 text-white">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm opacity-80">Station {learningNodes.indexOf(selectedNode) + 1}</span>
|
||||
<button
|
||||
onClick={handleCloseNodePanel}
|
||||
className="w-6 h-6 rounded-full bg-white/20 hover:bg-white/30 flex items-center justify-center"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mt-1">{selectedNode.title}</h3>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Question */}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">Aufgabe</h4>
|
||||
<p className="text-slate-800 dark:text-white">{selectedNode.question}</p>
|
||||
</div>
|
||||
|
||||
{/* Hints */}
|
||||
{selectedNode.hints.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-1">Hinweise</h4>
|
||||
<ul className="list-disc list-inside text-sm text-slate-600 dark:text-slate-300 space-y-1">
|
||||
{selectedNode.hints.map((hint, idx) => (
|
||||
<li key={idx}>{hint}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer (collapsible) */}
|
||||
<details className="bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||
<summary className="p-3 cursor-pointer text-green-700 dark:text-green-400 font-medium text-sm">
|
||||
Loesung anzeigen
|
||||
</summary>
|
||||
<div className="px-3 pb-3">
|
||||
<p className="text-green-800 dark:text-green-300 text-sm">{selectedNode.answer}</p>
|
||||
{selectedNode.explanation && (
|
||||
<p className="text-green-600 dark:text-green-400 text-xs mt-2 italic">
|
||||
{selectedNode.explanation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Points */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-slate-200 dark:border-slate-700">
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">Punkte</span>
|
||||
<span className="font-bold text-blue-500">{selectedNode.points}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node List (minimized) */}
|
||||
{!showNodePanel && learningNodes.length > 0 && (
|
||||
<div className="absolute top-4 right-4 bg-black/70 rounded-xl p-3 z-20 max-h-64 overflow-y-auto">
|
||||
<h4 className="text-white text-sm font-medium mb-2">Lernstationen</h4>
|
||||
<div className="space-y-1">
|
||||
{learningNodes.map((node, idx) => (
|
||||
<button
|
||||
key={node.id}
|
||||
onClick={() => handleNodeClick(node)}
|
||||
className="w-full text-left px-3 py-2 rounded-lg bg-white/10 hover:bg-white/20 transition-colors text-white text-sm flex items-center gap-2"
|
||||
>
|
||||
<span className="w-5 h-5 bg-blue-500 rounded-full flex items-center justify-center text-xs">
|
||||
{idx + 1}
|
||||
</span>
|
||||
<span className="truncate">{node.title}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* GeoEdu Components
|
||||
* Exports all geo-lernwelt components
|
||||
*/
|
||||
|
||||
export { default as AOISelector } from './AOISelector'
|
||||
export { default as UnityViewer } from './UnityViewer'
|
||||
@@ -0,0 +1,310 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import type { Annotation, AnnotationType, AnnotationPosition } from '@/app/korrektur/types'
|
||||
import { ANNOTATION_COLORS } from '@/app/korrektur/types'
|
||||
|
||||
interface AnnotationLayerProps {
|
||||
annotations: Annotation[]
|
||||
selectedAnnotation: string | null
|
||||
currentTool: AnnotationType | null
|
||||
onAnnotationCreate: (position: AnnotationPosition, type: AnnotationType) => void
|
||||
onAnnotationSelect: (id: string | null) => void
|
||||
onAnnotationDelete: (id: string) => void
|
||||
isEditable?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AnnotationLayer({
|
||||
annotations,
|
||||
selectedAnnotation,
|
||||
currentTool,
|
||||
onAnnotationCreate,
|
||||
onAnnotationSelect,
|
||||
onAnnotationDelete,
|
||||
isEditable = true,
|
||||
className = '',
|
||||
}: AnnotationLayerProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [isDrawing, setIsDrawing] = useState(false)
|
||||
const [drawStart, setDrawStart] = useState<{ x: number; y: number } | null>(null)
|
||||
const [drawEnd, setDrawEnd] = useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
const getRelativePosition = useCallback(
|
||||
(e: React.MouseEvent | MouseEvent): { x: number; y: number } => {
|
||||
if (!containerRef.current) return { x: 0, y: 0 }
|
||||
const rect = containerRef.current.getBoundingClientRect()
|
||||
return {
|
||||
x: ((e.clientX - rect.left) / rect.width) * 100,
|
||||
y: ((e.clientY - rect.top) / rect.height) * 100,
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (!isEditable || !currentTool) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
const pos = getRelativePosition(e)
|
||||
setIsDrawing(true)
|
||||
setDrawStart(pos)
|
||||
setDrawEnd(pos)
|
||||
onAnnotationSelect(null)
|
||||
}
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isDrawing || !drawStart) return
|
||||
const pos = getRelativePosition(e)
|
||||
setDrawEnd(pos)
|
||||
},
|
||||
[isDrawing, drawStart, getRelativePosition]
|
||||
)
|
||||
|
||||
const handleMouseUp = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isDrawing || !drawStart || !drawEnd || !currentTool) {
|
||||
setIsDrawing(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate rectangle bounds
|
||||
const minX = Math.min(drawStart.x, drawEnd.x)
|
||||
const minY = Math.min(drawStart.y, drawEnd.y)
|
||||
const maxX = Math.max(drawStart.x, drawEnd.x)
|
||||
const maxY = Math.max(drawStart.y, drawEnd.y)
|
||||
|
||||
// Minimum size check (at least 2% width/height)
|
||||
const width = maxX - minX
|
||||
const height = maxY - minY
|
||||
|
||||
if (width >= 1 && height >= 1) {
|
||||
const position: AnnotationPosition = {
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: width,
|
||||
height: height,
|
||||
}
|
||||
onAnnotationCreate(position, currentTool)
|
||||
}
|
||||
|
||||
setIsDrawing(false)
|
||||
setDrawStart(null)
|
||||
setDrawEnd(null)
|
||||
},
|
||||
[isDrawing, drawStart, drawEnd, currentTool, onAnnotationCreate]
|
||||
)
|
||||
|
||||
const handleAnnotationClick = (e: React.MouseEvent, id: string) => {
|
||||
e.stopPropagation()
|
||||
onAnnotationSelect(selectedAnnotation === id ? null : id)
|
||||
}
|
||||
|
||||
const handleBackgroundClick = () => {
|
||||
onAnnotationSelect(null)
|
||||
}
|
||||
|
||||
// Drawing preview rectangle
|
||||
const drawingRect =
|
||||
isDrawing && drawStart && drawEnd
|
||||
? {
|
||||
x: Math.min(drawStart.x, drawEnd.x),
|
||||
y: Math.min(drawStart.y, drawEnd.y),
|
||||
width: Math.abs(drawEnd.x - drawStart.x),
|
||||
height: Math.abs(drawEnd.y - drawStart.y),
|
||||
}
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`absolute inset-0 ${className}`}
|
||||
style={{
|
||||
pointerEvents: isEditable && currentTool ? 'auto' : 'none',
|
||||
cursor: currentTool ? 'crosshair' : 'default',
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onClick={handleBackgroundClick}
|
||||
>
|
||||
<svg
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ pointerEvents: 'none' }}
|
||||
>
|
||||
{/* Existing Annotations */}
|
||||
{annotations.map((annotation) => {
|
||||
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
|
||||
const isSelected = selectedAnnotation === annotation.id
|
||||
|
||||
return (
|
||||
<g
|
||||
key={annotation.id}
|
||||
style={{ pointerEvents: 'auto', cursor: 'pointer' }}
|
||||
onClick={(e) => handleAnnotationClick(e, annotation.id)}
|
||||
>
|
||||
{/* Highlight Rectangle */}
|
||||
<rect
|
||||
x={`${annotation.position.x}%`}
|
||||
y={`${annotation.position.y}%`}
|
||||
width={`${annotation.position.width}%`}
|
||||
height={`${annotation.position.height}%`}
|
||||
fill={`${color}20`}
|
||||
stroke={color}
|
||||
strokeWidth={isSelected ? 3 : 2}
|
||||
strokeDasharray={annotation.type === 'comment' ? '4 2' : 'none'}
|
||||
rx="4"
|
||||
ry="4"
|
||||
className="transition-all"
|
||||
/>
|
||||
|
||||
{/* Type Indicator */}
|
||||
<circle
|
||||
cx={`${annotation.position.x}%`}
|
||||
cy={`${annotation.position.y}%`}
|
||||
r="8"
|
||||
fill={color}
|
||||
className="drop-shadow-sm"
|
||||
/>
|
||||
|
||||
{/* Severity Indicator */}
|
||||
{annotation.severity === 'critical' && (
|
||||
<circle
|
||||
cx={`${annotation.position.x + annotation.position.width}%`}
|
||||
cy={`${annotation.position.y}%`}
|
||||
r="6"
|
||||
fill="#ef4444"
|
||||
className="animate-pulse"
|
||||
/>
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Drawing Preview */}
|
||||
{drawingRect && currentTool && (
|
||||
<rect
|
||||
x={`${drawingRect.x}%`}
|
||||
y={`${drawingRect.y}%`}
|
||||
width={`${drawingRect.width}%`}
|
||||
height={`${drawingRect.height}%`}
|
||||
fill={`${ANNOTATION_COLORS[currentTool]}30`}
|
||||
stroke={ANNOTATION_COLORS[currentTool]}
|
||||
strokeWidth={2}
|
||||
strokeDasharray="4 2"
|
||||
rx="4"
|
||||
ry="4"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Selected Annotation Popup */}
|
||||
{selectedAnnotation && (
|
||||
<AnnotationPopup
|
||||
annotation={annotations.find((a) => a.id === selectedAnnotation)!}
|
||||
onDelete={() => onAnnotationDelete(selectedAnnotation)}
|
||||
onClose={() => onAnnotationSelect(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ANNOTATION POPUP
|
||||
// =============================================================================
|
||||
|
||||
interface AnnotationPopupProps {
|
||||
annotation: Annotation
|
||||
onDelete: () => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function AnnotationPopup({ annotation, onDelete, onClose }: AnnotationPopupProps) {
|
||||
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
|
||||
|
||||
const typeLabels: Record<AnnotationType, string> = {
|
||||
rechtschreibung: 'Rechtschreibung',
|
||||
grammatik: 'Grammatik',
|
||||
inhalt: 'Inhalt',
|
||||
struktur: 'Struktur',
|
||||
stil: 'Stil',
|
||||
comment: 'Kommentar',
|
||||
highlight: 'Markierung',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute z-10 w-64 rounded-xl bg-slate-800/95 backdrop-blur-sm border border-white/10 shadow-xl overflow-hidden"
|
||||
style={{
|
||||
left: `${Math.min(annotation.position.x + annotation.position.width, 70)}%`,
|
||||
top: `${annotation.position.y}%`,
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-3 py-2 flex items-center justify-between"
|
||||
style={{ backgroundColor: `${color}30` }}
|
||||
>
|
||||
<span className="text-white font-medium text-sm" style={{ color }}>
|
||||
{typeLabels[annotation.type]}
|
||||
</span>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-white/10 text-white/60"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-3 space-y-2">
|
||||
{annotation.text && (
|
||||
<p className="text-white/80 text-sm">{annotation.text}</p>
|
||||
)}
|
||||
|
||||
{annotation.suggestion && (
|
||||
<div className="p-2 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<p className="text-green-400 text-xs font-medium mb-1">Vorschlag:</p>
|
||||
<p className="text-white/70 text-sm">{annotation.suggestion}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Severity Badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
annotation.severity === 'critical'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: annotation.severity === 'major'
|
||||
? 'bg-orange-500/20 text-orange-400'
|
||||
: 'bg-gray-500/20 text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{annotation.severity === 'critical'
|
||||
? 'Kritisch'
|
||||
: annotation.severity === 'major'
|
||||
? 'Wichtig'
|
||||
: 'Hinweis'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-3 py-2 border-t border-white/10 flex gap-2">
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex-1 px-3 py-1.5 rounded-lg bg-red-500/20 text-red-400 text-sm hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
'use client'
|
||||
|
||||
import type { AnnotationType } from '@/app/korrektur/types'
|
||||
import { ANNOTATION_COLORS } from '@/app/korrektur/types'
|
||||
|
||||
interface AnnotationToolbarProps {
|
||||
selectedTool: AnnotationType | null
|
||||
onToolSelect: (tool: AnnotationType | null) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const tools: Array<{
|
||||
type: AnnotationType
|
||||
label: string
|
||||
shortcut: string
|
||||
icon: React.ReactNode
|
||||
}> = [
|
||||
{
|
||||
type: 'rechtschreibung',
|
||||
label: 'Rechtschreibung',
|
||||
shortcut: 'R',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'grammatik',
|
||||
label: 'Grammatik',
|
||||
shortcut: 'G',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'inhalt',
|
||||
label: 'Inhalt',
|
||||
shortcut: 'I',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'struktur',
|
||||
label: 'Struktur',
|
||||
shortcut: 'S',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'stil',
|
||||
label: 'Stil',
|
||||
shortcut: 'T',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'comment',
|
||||
label: 'Kommentar',
|
||||
shortcut: 'K',
|
||||
icon: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function AnnotationToolbar({
|
||||
selectedTool,
|
||||
onToolSelect,
|
||||
className = '',
|
||||
}: AnnotationToolbarProps) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1 p-2 bg-white/5 rounded-xl ${className}`}>
|
||||
{tools.map((tool) => {
|
||||
const isSelected = selectedTool === tool.type
|
||||
const color = ANNOTATION_COLORS[tool.type]
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tool.type}
|
||||
onClick={() => onToolSelect(isSelected ? null : tool.type)}
|
||||
className={`relative p-2 rounded-lg transition-all ${
|
||||
isSelected
|
||||
? 'bg-white/20 shadow-lg'
|
||||
: 'hover:bg-white/10'
|
||||
}`}
|
||||
style={{
|
||||
color: isSelected ? color : 'rgba(255, 255, 255, 0.6)',
|
||||
}}
|
||||
title={`${tool.label} (${tool.shortcut})`}
|
||||
>
|
||||
{tool.icon}
|
||||
|
||||
{/* Shortcut Badge */}
|
||||
<span
|
||||
className={`absolute -bottom-1 -right-1 w-4 h-4 rounded text-[10px] font-bold flex items-center justify-center ${
|
||||
isSelected ? 'bg-white/20' : 'bg-white/10'
|
||||
}`}
|
||||
style={{ color: isSelected ? color : 'rgba(255, 255, 255, 0.4)' }}
|
||||
>
|
||||
{tool.shortcut}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Divider */}
|
||||
<div className="w-px h-8 bg-white/10 mx-2" />
|
||||
|
||||
{/* Clear Tool Button */}
|
||||
<button
|
||||
onClick={() => onToolSelect(null)}
|
||||
className={`p-2 rounded-lg transition-all ${
|
||||
selectedTool === null
|
||||
? 'bg-white/20 text-white'
|
||||
: 'hover:bg-white/10 text-white/60'
|
||||
}`}
|
||||
title="Auswahl (Esc)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ANNOTATION LEGEND
|
||||
// =============================================================================
|
||||
|
||||
export function AnnotationLegend({ className = '' }: { className?: string }) {
|
||||
return (
|
||||
<div className={`flex flex-wrap gap-3 text-xs ${className}`}>
|
||||
{tools.map((tool) => (
|
||||
<div key={tool.type} className="flex items-center gap-1.5">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: ANNOTATION_COLORS[tool.type] }}
|
||||
/>
|
||||
<span className="text-white/60">{tool.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
'use client'
|
||||
|
||||
import { useMemo } from 'react'
|
||||
import type { CriteriaScores, Annotation } from '@/app/korrektur/types'
|
||||
import { DEFAULT_CRITERIA, ANNOTATION_COLORS, calculateGrade, getGradeLabel } from '@/app/korrektur/types'
|
||||
|
||||
interface CriteriaPanelProps {
|
||||
scores: CriteriaScores
|
||||
annotations: Annotation[]
|
||||
onScoreChange: (criterion: string, value: number) => void
|
||||
onLoadEHSuggestions?: (criterion: string) => void
|
||||
isLoading?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CriteriaPanel({
|
||||
scores,
|
||||
annotations,
|
||||
onScoreChange,
|
||||
onLoadEHSuggestions,
|
||||
isLoading = false,
|
||||
className = '',
|
||||
}: CriteriaPanelProps) {
|
||||
// Count annotations per criterion
|
||||
const annotationCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const annotation of annotations) {
|
||||
const type = annotation.linked_criterion || annotation.type
|
||||
counts[type] = (counts[type] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
}, [annotations])
|
||||
|
||||
// Calculate total grade
|
||||
const { totalWeightedScore, totalWeight, gradePoints, gradeLabel } = useMemo(() => {
|
||||
let weightedScore = 0
|
||||
let weight = 0
|
||||
|
||||
for (const [criterion, config] of Object.entries(DEFAULT_CRITERIA)) {
|
||||
const score = scores[criterion]
|
||||
if (score !== undefined) {
|
||||
weightedScore += score * config.weight
|
||||
weight += config.weight
|
||||
}
|
||||
}
|
||||
|
||||
const percentage = weight > 0 ? weightedScore / weight : 0
|
||||
const grade = calculateGrade(percentage)
|
||||
const label = getGradeLabel(grade)
|
||||
|
||||
return {
|
||||
totalWeightedScore: weightedScore,
|
||||
totalWeight: weight,
|
||||
gradePoints: grade,
|
||||
gradeLabel: label,
|
||||
}
|
||||
}, [scores])
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Criteria List */}
|
||||
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
|
||||
const score = scores[criterion] || 0
|
||||
const annotationCount = annotationCounts[criterion] || 0
|
||||
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
|
||||
|
||||
return (
|
||||
<CriterionCard
|
||||
key={criterion}
|
||||
id={criterion}
|
||||
name={config.name}
|
||||
weight={config.weight}
|
||||
score={score}
|
||||
annotationCount={annotationCount}
|
||||
color={color}
|
||||
onScoreChange={(value) => onScoreChange(criterion, value)}
|
||||
onLoadSuggestions={
|
||||
onLoadEHSuggestions
|
||||
? () => onLoadEHSuggestions(criterion)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Total Score */}
|
||||
<div className="p-4 rounded-2xl bg-gradient-to-r from-purple-500/20 to-pink-500/20 border border-purple-500/30">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white/60 text-sm">Gesamtnote</span>
|
||||
<span className="text-2xl font-bold text-white">
|
||||
{gradePoints} Punkte
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-white/40 text-xs">
|
||||
{Math.round(totalWeightedScore / totalWeight)}% gewichtet
|
||||
</span>
|
||||
<span className="text-lg font-semibold text-purple-300">
|
||||
({gradeLabel})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CRITERION CARD
|
||||
// =============================================================================
|
||||
|
||||
interface CriterionCardProps {
|
||||
id: string
|
||||
name: string
|
||||
weight: number
|
||||
score: number
|
||||
annotationCount: number
|
||||
color: string
|
||||
onScoreChange: (value: number) => void
|
||||
onLoadSuggestions?: () => void
|
||||
}
|
||||
|
||||
function CriterionCard({
|
||||
id,
|
||||
name,
|
||||
weight,
|
||||
score,
|
||||
annotationCount,
|
||||
color,
|
||||
onScoreChange,
|
||||
onLoadSuggestions,
|
||||
}: CriterionCardProps) {
|
||||
const gradePoints = calculateGrade(score)
|
||||
const gradeLabel = getGradeLabel(gradePoints)
|
||||
|
||||
return (
|
||||
<div className="p-4 rounded-2xl bg-white/5 border border-white/10">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-white font-medium">{name}</span>
|
||||
<span className="text-white/40 text-xs">({weight}%)</span>
|
||||
</div>
|
||||
<span className="text-white/60 text-sm">
|
||||
{gradePoints} P ({gradeLabel})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Slider */}
|
||||
<div className="relative mb-3">
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={score}
|
||||
onChange={(e) => onScoreChange(Number(e.target.value))}
|
||||
className="w-full h-2 bg-white/10 rounded-full appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, ${color} ${score}%, rgba(255,255,255,0.1) ${score}%)`,
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between mt-1 text-xs text-white/30">
|
||||
<span>0</span>
|
||||
<span>25</span>
|
||||
<span>50</span>
|
||||
<span>75</span>
|
||||
<span>100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{annotationCount > 0 && (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{ backgroundColor: `${color}20`, color }}
|
||||
>
|
||||
{annotationCount} Anmerkung{annotationCount !== 1 ? 'en' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{onLoadSuggestions && (id === 'inhalt' || id === 'struktur') && (
|
||||
<button
|
||||
onClick={onLoadSuggestions}
|
||||
className="text-xs text-purple-400 hover:text-purple-300 transition-colors flex items-center gap-1"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
EH-Vorschlaege
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPACT CRITERIA SUMMARY
|
||||
// =============================================================================
|
||||
|
||||
interface CriteriaSummaryProps {
|
||||
scores: CriteriaScores
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CriteriaSummary({ scores, className = '' }: CriteriaSummaryProps) {
|
||||
const { gradePoints, gradeLabel } = useMemo(() => {
|
||||
let weightedScore = 0
|
||||
let weight = 0
|
||||
|
||||
for (const [criterion, config] of Object.entries(DEFAULT_CRITERIA)) {
|
||||
const score = scores[criterion]
|
||||
if (score !== undefined) {
|
||||
weightedScore += score * config.weight
|
||||
weight += config.weight
|
||||
}
|
||||
}
|
||||
|
||||
const percentage = weight > 0 ? weightedScore / weight : 0
|
||||
const grade = calculateGrade(percentage)
|
||||
const label = getGradeLabel(grade)
|
||||
|
||||
return { gradePoints: grade, gradeLabel: label }
|
||||
}, [scores])
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
|
||||
const score = scores[criterion] || 0
|
||||
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={criterion}
|
||||
className="flex items-center gap-1"
|
||||
title={`${config.name}: ${score}%`}
|
||||
>
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-white/60 text-xs">{score}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="w-px h-4 bg-white/20" />
|
||||
<span className="text-white font-medium text-sm">
|
||||
{gradePoints} ({gradeLabel})
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
|
||||
interface DocumentViewerProps {
|
||||
fileUrl: string
|
||||
fileType: 'pdf' | 'image'
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
children?: React.ReactNode // For annotation overlay
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function DocumentViewer({
|
||||
fileUrl,
|
||||
fileType,
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange,
|
||||
children,
|
||||
className = '',
|
||||
}: DocumentViewerProps) {
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 })
|
||||
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleZoomIn = () => setZoom((prev) => Math.min(prev + 0.25, 3))
|
||||
const handleZoomOut = () => setZoom((prev) => Math.max(prev - 0.25, 0.5))
|
||||
const handleFit = () => {
|
||||
setZoom(1)
|
||||
setPosition({ x: 0, y: 0 })
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (e.button !== 1 && !e.ctrlKey) return // Middle click or Ctrl+click for pan
|
||||
setIsDragging(true)
|
||||
setStartPos({ x: e.clientX - position.x, y: e.clientY - position.y })
|
||||
}
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging) return
|
||||
setPosition({
|
||||
x: e.clientX - startPos.x,
|
||||
y: e.clientY - startPos.y,
|
||||
})
|
||||
},
|
||||
[isDragging, startPos]
|
||||
)
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === '+' || e.key === '=') {
|
||||
e.preventDefault()
|
||||
handleZoomIn()
|
||||
} else if (e.key === '-') {
|
||||
e.preventDefault()
|
||||
handleZoomOut()
|
||||
} else if (e.key === '0') {
|
||||
e.preventDefault()
|
||||
handleFit()
|
||||
} else if (e.key === 'ArrowLeft' && currentPage > 1) {
|
||||
e.preventDefault()
|
||||
onPageChange(currentPage - 1)
|
||||
} else if (e.key === 'ArrowRight' && currentPage < totalPages) {
|
||||
e.preventDefault()
|
||||
onPageChange(currentPage + 1)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [currentPage, totalPages, onPageChange])
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col h-full ${className}`}>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-white/5 border-b border-white/10">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleZoomOut}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors"
|
||||
title="Verkleinern (-)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM13 10H7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-white/60 text-sm min-w-[60px] text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={handleZoomIn}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors"
|
||||
title="Vergroessern (+)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleFit}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors"
|
||||
title="Einpassen (0)"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Navigation */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage - 1)}
|
||||
disabled={currentPage <= 1}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors disabled:opacity-30"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="text-white/60 text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}
|
||||
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors disabled:opacity-30"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Document Area */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-hidden bg-slate-800/50 relative"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isDragging ? 'grabbing' : 'default' }}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{
|
||||
transform: `translate(${position.x}px, ${position.y}px) scale(${zoom})`,
|
||||
transformOrigin: 'center center',
|
||||
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Document Image/PDF */}
|
||||
<div className="relative">
|
||||
{fileType === 'image' ? (
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt="Schuelerarbeit"
|
||||
className="max-w-full max-h-full object-contain shadow-2xl"
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<iframe
|
||||
src={`${fileUrl}#page=${currentPage}`}
|
||||
className="w-[800px] h-[1000px] bg-white shadow-2xl"
|
||||
title="PDF Dokument"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Annotation Overlay */}
|
||||
{children && (
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page Thumbnails (for multi-page) */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex gap-2 p-3 bg-white/5 border-t border-white/10 overflow-x-auto">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onPageChange(page)}
|
||||
className={`flex-shrink-0 w-12 h-16 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all ${
|
||||
page === currentPage
|
||||
? 'border-purple-500 bg-purple-500/20 text-white'
|
||||
: 'border-white/10 bg-white/5 text-white/60 hover:border-white/30'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import type { EHSuggestion } from '@/app/korrektur/types'
|
||||
import { DEFAULT_CRITERIA, ANNOTATION_COLORS, NIBIS_ATTRIBUTION } from '@/app/korrektur/types'
|
||||
|
||||
interface EHSuggestionPanelProps {
|
||||
suggestions: EHSuggestion[]
|
||||
isLoading: boolean
|
||||
onLoadSuggestions: (criterion?: string) => void
|
||||
onInsertSuggestion?: (text: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function EHSuggestionPanel({
|
||||
suggestions,
|
||||
isLoading,
|
||||
onLoadSuggestions,
|
||||
onInsertSuggestion,
|
||||
className = '',
|
||||
}: EHSuggestionPanelProps) {
|
||||
const [selectedCriterion, setSelectedCriterion] = useState<string | undefined>(undefined)
|
||||
const [expandedSuggestion, setExpandedSuggestion] = useState<string | null>(null)
|
||||
|
||||
const handleLoadSuggestions = () => {
|
||||
onLoadSuggestions(selectedCriterion)
|
||||
}
|
||||
|
||||
// Group suggestions by criterion
|
||||
const groupedSuggestions = suggestions.reduce((acc, suggestion) => {
|
||||
if (!acc[suggestion.criterion]) {
|
||||
acc[suggestion.criterion] = []
|
||||
}
|
||||
acc[suggestion.criterion].push(suggestion)
|
||||
return acc
|
||||
}, {} as Record<string, EHSuggestion[]>)
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${className}`}>
|
||||
{/* Header with Attribution (CTRL-SRC-002) */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-white font-semibold">EH-Vorschlaege</h3>
|
||||
<p className="text-white/40 text-xs">Aus 500+ NiBiS Dokumenten</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attribution Notice */}
|
||||
<div className="p-2 rounded-lg bg-white/5 border border-white/10">
|
||||
<div className="flex items-center gap-2 text-xs text-white/50">
|
||||
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>
|
||||
Quelle: {NIBIS_ATTRIBUTION.publisher} •{' '}
|
||||
<a
|
||||
href={NIBIS_ATTRIBUTION.license_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:underline"
|
||||
>
|
||||
{NIBIS_ATTRIBUTION.license}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Criterion Filter */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedCriterion(undefined)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
selectedCriterion === undefined
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-white/10 text-white/60 hover:bg-white/20'
|
||||
}`}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
{Object.entries(DEFAULT_CRITERIA).map(([id, config]) => {
|
||||
const color = ANNOTATION_COLORS[id as keyof typeof ANNOTATION_COLORS] || '#6b7280'
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setSelectedCriterion(id)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||
selectedCriterion === id
|
||||
? 'text-white'
|
||||
: 'text-white/60 hover:bg-white/20'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: selectedCriterion === id ? color : 'rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
{config.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Load Button */}
|
||||
<button
|
||||
onClick={handleLoadSuggestions}
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-medium hover:shadow-lg hover:shadow-blue-500/30 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
RAG-Suche laeuft...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
EH-Vorschlaege laden
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Suggestions List */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{Object.entries(groupedSuggestions).map(([criterion, criterionSuggestions]) => {
|
||||
const config = DEFAULT_CRITERIA[criterion]
|
||||
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
|
||||
|
||||
return (
|
||||
<div key={criterion} className="space-y-2">
|
||||
{/* Criterion Header */}
|
||||
<div className="flex items-center gap-2 sticky top-0 bg-slate-900/95 backdrop-blur-sm py-1">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
<span className="text-white/60 text-xs font-medium">
|
||||
{config?.name || criterion}
|
||||
</span>
|
||||
<span className="text-white/30 text-xs">
|
||||
({criterionSuggestions.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{criterionSuggestions.map((suggestion, index) => (
|
||||
<SuggestionCard
|
||||
key={`${criterion}-${index}`}
|
||||
suggestion={suggestion}
|
||||
color={color}
|
||||
isExpanded={expandedSuggestion === `${criterion}-${index}`}
|
||||
onToggle={() =>
|
||||
setExpandedSuggestion(
|
||||
expandedSuggestion === `${criterion}-${index}`
|
||||
? null
|
||||
: `${criterion}-${index}`
|
||||
)
|
||||
}
|
||||
onInsert={onInsertSuggestion}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && suggestions.length === 0 && (
|
||||
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-white/10 flex items-center justify-center mx-auto mb-3">
|
||||
<svg className="w-6 h-6 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-white/60 text-sm">
|
||||
Klicken Sie auf "EH-Vorschlaege laden" um<br />
|
||||
relevante Bewertungskriterien zu finden.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// SUGGESTION CARD
|
||||
// =============================================================================
|
||||
|
||||
interface SuggestionCardProps {
|
||||
suggestion: EHSuggestion
|
||||
color: string
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onInsert?: (text: string) => void
|
||||
}
|
||||
|
||||
function SuggestionCard({
|
||||
suggestion,
|
||||
color,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onInsert,
|
||||
}: SuggestionCardProps) {
|
||||
const relevancePercent = Math.round(suggestion.relevance_score * 100)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl bg-white/5 border border-white/10 overflow-hidden transition-all"
|
||||
style={{ borderLeftColor: color, borderLeftWidth: '3px' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="w-full px-3 py-2 flex items-center justify-between hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div
|
||||
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold"
|
||||
style={{
|
||||
backgroundColor: `${color}20`,
|
||||
color: color,
|
||||
}}
|
||||
>
|
||||
{relevancePercent}%
|
||||
</div>
|
||||
<p className="text-white/70 text-sm truncate text-left">
|
||||
{suggestion.excerpt.slice(0, 60)}...
|
||||
</p>
|
||||
</div>
|
||||
<svg
|
||||
className={`w-4 h-4 text-white/40 transition-transform flex-shrink-0 ml-2 ${
|
||||
isExpanded ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Expanded Content */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 pb-3 space-y-2">
|
||||
<p className="text-white/60 text-sm whitespace-pre-wrap">
|
||||
{suggestion.excerpt}
|
||||
</p>
|
||||
|
||||
{/* Source Attribution */}
|
||||
<div className="flex items-center gap-2 text-xs text-white/40 pt-1 border-t border-white/10">
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<span>
|
||||
{suggestion.source_document || 'NiBiS Kerncurriculum'} ({NIBIS_ATTRIBUTION.license})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{onInsert && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => onInsert(suggestion.excerpt)}
|
||||
className="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white/70 text-xs hover:bg-white/20 hover:text-white transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Einfuegen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Insert with citation (CTRL-SRC-002)
|
||||
const citation = `\n\n[Quelle: ${suggestion.source_document || 'NiBiS Kerncurriculum'}, ${NIBIS_ATTRIBUTION.publisher}, ${NIBIS_ATTRIBUTION.license}]`
|
||||
onInsert(suggestion.excerpt + citation)
|
||||
}}
|
||||
className="px-3 py-2 rounded-lg bg-blue-500/20 text-blue-300 text-xs hover:bg-blue-500/30 transition-colors flex items-center justify-center gap-1"
|
||||
title="Mit Quellenangabe einfuegen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.172 13.828a4 4 0 015.656 0l4-4a4 4 0 00-5.656-5.656l-1.102 1.101" />
|
||||
</svg>
|
||||
+ Zitat
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
|
||||
interface GutachtenEditorProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onGenerate?: () => void
|
||||
isGenerating?: boolean
|
||||
placeholder?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GutachtenEditor({
|
||||
value,
|
||||
onChange,
|
||||
onGenerate,
|
||||
isGenerating = false,
|
||||
placeholder = 'Gutachten hier eingeben oder generieren lassen...',
|
||||
className = '',
|
||||
}: GutachtenEditorProps) {
|
||||
const [isFocused, setIsFocused] = useState(false)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = `${Math.max(200, textarea.scrollHeight)}px`
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Word count
|
||||
const wordCount = value.trim() ? value.trim().split(/\s+/).length : 0
|
||||
const charCount = value.length
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${className}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-white font-semibold">Gutachten</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/40 text-xs">
|
||||
{wordCount} Woerter / {charCount} Zeichen
|
||||
</span>
|
||||
{onGenerate && (
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={isGenerating}
|
||||
className="px-3 py-1.5 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white text-sm font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Generiere...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
KI Generieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Editor */}
|
||||
<div
|
||||
className={`relative rounded-2xl transition-all ${
|
||||
isFocused
|
||||
? 'ring-2 ring-purple-500 ring-offset-2 ring-offset-slate-900'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={placeholder}
|
||||
className="w-full min-h-[200px] p-4 rounded-2xl bg-white/5 border border-white/10 text-white placeholder-white/30 resize-none focus:outline-none"
|
||||
/>
|
||||
|
||||
{/* Loading Overlay */}
|
||||
{isGenerating && (
|
||||
<div className="absolute inset-0 bg-slate-900/80 backdrop-blur-sm rounded-2xl flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
|
||||
<p className="text-white/60 text-sm">Gutachten wird generiert...</p>
|
||||
<p className="text-white/40 text-xs mt-1">Nutzt 500+ NiBiS Dokumente</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quick Insert Buttons */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<QuickInsertButton
|
||||
label="Einleitung"
|
||||
onClick={() => onChange(value + '\n\nEinleitung:\n')}
|
||||
/>
|
||||
<QuickInsertButton
|
||||
label="Staerken"
|
||||
onClick={() => onChange(value + '\n\nStaerken der Arbeit:\n- ')}
|
||||
/>
|
||||
<QuickInsertButton
|
||||
label="Schwaechen"
|
||||
onClick={() => onChange(value + '\n\nVerbesserungsmoeglichkeiten:\n- ')}
|
||||
/>
|
||||
<QuickInsertButton
|
||||
label="Fazit"
|
||||
onClick={() => onChange(value + '\n\nGesamteindruck:\n')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// QUICK INSERT BUTTON
|
||||
// =============================================================================
|
||||
|
||||
interface QuickInsertButtonProps {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function QuickInsertButton({ label, onClick }: QuickInsertButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="px-3 py-1 rounded-lg bg-white/5 border border-white/10 text-white/60 text-xs hover:bg-white/10 hover:text-white transition-colors"
|
||||
>
|
||||
+ {label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GUTACHTEN PREVIEW (Read-only)
|
||||
// =============================================================================
|
||||
|
||||
interface GutachtenPreviewProps {
|
||||
value: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function GutachtenPreview({ value, className = '' }: GutachtenPreviewProps) {
|
||||
if (!value) {
|
||||
return (
|
||||
<div className={`p-4 rounded-2xl bg-white/5 border border-white/10 text-white/40 text-center ${className}`}>
|
||||
Kein Gutachten vorhanden
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Split into paragraphs for better rendering
|
||||
const paragraphs = value.split('\n\n').filter(Boolean)
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-2xl bg-white/5 border border-white/10 space-y-4 ${className}`}>
|
||||
{paragraphs.map((paragraph, index) => {
|
||||
// Check if it's a heading (ends with :)
|
||||
const lines = paragraph.split('\n')
|
||||
const firstLine = lines[0]
|
||||
const isHeading = firstLine.endsWith(':')
|
||||
|
||||
if (isHeading) {
|
||||
return (
|
||||
<div key={index}>
|
||||
<h4 className="text-white font-semibold mb-2">{firstLine}</h4>
|
||||
{lines.slice(1).map((line, lineIndex) => (
|
||||
<p key={lineIndex} className="text-white/70 text-sm">
|
||||
{line}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<p key={index} className="text-white/70 text-sm whitespace-pre-wrap">
|
||||
{paragraph}
|
||||
</p>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { DocumentViewer } from './DocumentViewer'
|
||||
export { AnnotationLayer } from './AnnotationLayer'
|
||||
export { AnnotationToolbar, AnnotationLegend } from './AnnotationToolbar'
|
||||
export { CriteriaPanel, CriteriaSummary } from './CriteriaPanel'
|
||||
export { GutachtenEditor, GutachtenPreview } from './GutachtenEditor'
|
||||
export { EHSuggestionPanel } from './EHSuggestionPanel'
|
||||
@@ -0,0 +1,496 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { MOTION, SHADOWS, SHADOWS_DARK, LAYERS } from '@/lib/spatial-ui/depth-system'
|
||||
import { usePerformance } from '@/lib/spatial-ui/PerformanceContext'
|
||||
|
||||
/**
|
||||
* FloatingMessage - Cinematic message notification overlay
|
||||
*
|
||||
* Features:
|
||||
* - Slides in from right with spring animation
|
||||
* - Typewriter effect for message text
|
||||
* - Glassmorphism with depth
|
||||
* - Auto-dismiss with progress indicator
|
||||
* - Quick reply without leaving context
|
||||
* - Queue system for multiple messages
|
||||
*/
|
||||
|
||||
export interface FloatingMessageData {
|
||||
id: string
|
||||
senderName: string
|
||||
senderInitials: string
|
||||
senderAvatar?: string
|
||||
content: string
|
||||
timestamp: Date
|
||||
conversationId: string
|
||||
isGroup?: boolean
|
||||
priority?: 'normal' | 'high' | 'urgent'
|
||||
}
|
||||
|
||||
interface FloatingMessageProps {
|
||||
/** Auto-dismiss after X ms (0 = manual only) */
|
||||
autoDismissMs?: number
|
||||
/** Max messages in queue */
|
||||
maxQueue?: number
|
||||
/** Position on screen */
|
||||
position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'
|
||||
/** Offset from edge */
|
||||
offset?: { x: number; y: number }
|
||||
/** Callback when message is opened */
|
||||
onOpen?: (message: FloatingMessageData) => void
|
||||
/** Callback when reply is sent */
|
||||
onReply?: (message: FloatingMessageData, replyText: string) => void
|
||||
/** Callback when dismissed */
|
||||
onDismiss?: (message: FloatingMessageData) => void
|
||||
}
|
||||
|
||||
// Demo messages for testing
|
||||
const DEMO_MESSAGES: Omit<FloatingMessageData, 'id' | 'timestamp'>[] = [
|
||||
{
|
||||
senderName: 'Familie Mueller',
|
||||
senderInitials: 'FM',
|
||||
content: 'Guten Tag! Lisa hatte heute leider Fieber und konnte nicht zur Schule kommen. Koennten Sie uns bitte die Hausaufgaben mitteilen?',
|
||||
conversationId: 'conv1',
|
||||
isGroup: false,
|
||||
priority: 'normal',
|
||||
},
|
||||
{
|
||||
senderName: 'Kollegium 7a',
|
||||
senderInitials: 'K7',
|
||||
content: 'Erinnerung: Morgen findet die Klassenkonferenz um 14:00 Uhr statt. Bitte alle Notenlisten vorbereiten.',
|
||||
conversationId: 'conv2',
|
||||
isGroup: true,
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
senderName: 'Schulleitung',
|
||||
senderInitials: 'SL',
|
||||
content: 'Wichtig: Die Abiturklausuren muessen bis Freitag korrigiert sein. Bei Fragen wenden Sie sich an das Sekretariat.',
|
||||
conversationId: 'conv3',
|
||||
isGroup: false,
|
||||
priority: 'urgent',
|
||||
},
|
||||
]
|
||||
|
||||
export function FloatingMessage({
|
||||
autoDismissMs = 8000,
|
||||
maxQueue = 5,
|
||||
position = 'top-right',
|
||||
offset = { x: 24, y: 80 },
|
||||
onOpen,
|
||||
onReply,
|
||||
onDismiss,
|
||||
}: FloatingMessageProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { settings, reportAnimationStart, reportAnimationEnd } = usePerformance()
|
||||
|
||||
const [messageQueue, setMessageQueue] = useState<FloatingMessageData[]>([])
|
||||
const [currentMessage, setCurrentMessage] = useState<FloatingMessageData | null>(null)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
const [displayedText, setDisplayedText] = useState('')
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const [isReplying, setIsReplying] = useState(false)
|
||||
const [replyText, setReplyText] = useState('')
|
||||
const [dismissProgress, setDismissProgress] = useState(0)
|
||||
|
||||
const typewriterRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const dismissTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const progressRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Demo: Add messages periodically
|
||||
useEffect(() => {
|
||||
const addDemoMessage = (index: number) => {
|
||||
const demo = DEMO_MESSAGES[index % DEMO_MESSAGES.length]
|
||||
const message: FloatingMessageData = {
|
||||
...demo,
|
||||
id: `msg-${Date.now()}-${index}`,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
addToQueue(message)
|
||||
}
|
||||
|
||||
// First message after 3 seconds
|
||||
const timer1 = setTimeout(() => addDemoMessage(0), 3000)
|
||||
// Second message after 15 seconds
|
||||
const timer2 = setTimeout(() => addDemoMessage(1), 15000)
|
||||
// Third message after 30 seconds
|
||||
const timer3 = setTimeout(() => addDemoMessage(2), 30000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer1)
|
||||
clearTimeout(timer2)
|
||||
clearTimeout(timer3)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Add message to queue
|
||||
const addToQueue = useCallback(
|
||||
(message: FloatingMessageData) => {
|
||||
setMessageQueue((prev) => {
|
||||
if (prev.length >= maxQueue) {
|
||||
return [...prev.slice(1), message]
|
||||
}
|
||||
return [...prev, message]
|
||||
})
|
||||
},
|
||||
[maxQueue]
|
||||
)
|
||||
|
||||
// Process queue
|
||||
useEffect(() => {
|
||||
if (!currentMessage && messageQueue.length > 0 && !isExiting) {
|
||||
const next = messageQueue[0]
|
||||
setMessageQueue((prev) => prev.slice(1))
|
||||
setCurrentMessage(next)
|
||||
setIsVisible(true)
|
||||
setDisplayedText('')
|
||||
setIsTyping(true)
|
||||
setDismissProgress(0)
|
||||
reportAnimationStart()
|
||||
}
|
||||
}, [currentMessage, messageQueue, isExiting, reportAnimationStart])
|
||||
|
||||
// Typewriter effect
|
||||
useEffect(() => {
|
||||
if (!currentMessage || !isTyping) return
|
||||
|
||||
const fullText = currentMessage.content
|
||||
let charIndex = 0
|
||||
|
||||
if (settings.enableTypewriter) {
|
||||
const speed = Math.round(25 * settings.animationSpeed)
|
||||
typewriterRef.current = setInterval(() => {
|
||||
charIndex++
|
||||
setDisplayedText(fullText.slice(0, charIndex))
|
||||
|
||||
if (charIndex >= fullText.length) {
|
||||
if (typewriterRef.current) clearInterval(typewriterRef.current)
|
||||
setIsTyping(false)
|
||||
}
|
||||
}, speed)
|
||||
} else {
|
||||
setDisplayedText(fullText)
|
||||
setIsTyping(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typewriterRef.current) clearInterval(typewriterRef.current)
|
||||
}
|
||||
}, [currentMessage, isTyping, settings.enableTypewriter, settings.animationSpeed])
|
||||
|
||||
// Auto-dismiss timer with progress
|
||||
useEffect(() => {
|
||||
if (!currentMessage || autoDismissMs <= 0 || isTyping || isReplying) return
|
||||
|
||||
const startTime = Date.now()
|
||||
const updateProgress = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(100, (elapsed / autoDismissMs) * 100)
|
||||
setDismissProgress(progress)
|
||||
|
||||
if (progress >= 100) {
|
||||
handleDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
progressRef.current = setInterval(updateProgress, 50)
|
||||
dismissTimerRef.current = setTimeout(handleDismiss, autoDismissMs)
|
||||
|
||||
return () => {
|
||||
if (progressRef.current) clearInterval(progressRef.current)
|
||||
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
|
||||
}
|
||||
}, [currentMessage, autoDismissMs, isTyping, isReplying])
|
||||
|
||||
// Dismiss handler
|
||||
const handleDismiss = useCallback(() => {
|
||||
if (progressRef.current) clearInterval(progressRef.current)
|
||||
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
|
||||
|
||||
setIsExiting(true)
|
||||
const exitDuration = Math.round(MOTION.accelerate.duration * settings.animationSpeed)
|
||||
|
||||
setTimeout(() => {
|
||||
if (currentMessage) {
|
||||
onDismiss?.(currentMessage)
|
||||
}
|
||||
setCurrentMessage(null)
|
||||
setIsVisible(false)
|
||||
setIsExiting(false)
|
||||
setDisplayedText('')
|
||||
setReplyText('')
|
||||
setIsReplying(false)
|
||||
setDismissProgress(0)
|
||||
reportAnimationEnd()
|
||||
}, exitDuration)
|
||||
}, [currentMessage, onDismiss, reportAnimationEnd, settings.animationSpeed])
|
||||
|
||||
// Open conversation
|
||||
const handleOpen = useCallback(() => {
|
||||
if (currentMessage) {
|
||||
onOpen?.(currentMessage)
|
||||
handleDismiss()
|
||||
}
|
||||
}, [currentMessage, onOpen, handleDismiss])
|
||||
|
||||
// Start reply
|
||||
const handleReplyClick = useCallback(() => {
|
||||
setIsReplying(true)
|
||||
// Cancel auto-dismiss while replying
|
||||
if (progressRef.current) clearInterval(progressRef.current)
|
||||
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
|
||||
}, [])
|
||||
|
||||
// Send reply
|
||||
const handleSendReply = useCallback(() => {
|
||||
if (!replyText.trim() || !currentMessage) return
|
||||
onReply?.(currentMessage, replyText)
|
||||
handleDismiss()
|
||||
}, [replyText, currentMessage, onReply, handleDismiss])
|
||||
|
||||
// Cancel reply
|
||||
const handleCancelReply = useCallback(() => {
|
||||
setIsReplying(false)
|
||||
setReplyText('')
|
||||
}, [])
|
||||
|
||||
// Keyboard handler
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendReply()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (isReplying) {
|
||||
handleCancelReply()
|
||||
} else {
|
||||
handleDismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSendReply, isReplying, handleCancelReply, handleDismiss]
|
||||
)
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
// Position styles
|
||||
const positionStyles: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
zIndex: LAYERS.overlay.zIndex,
|
||||
...(position.includes('top') ? { top: offset.y } : { bottom: offset.y }),
|
||||
...(position.includes('right') ? { right: offset.x } : { left: offset.x }),
|
||||
}
|
||||
|
||||
// Animation styles
|
||||
const animationDuration = Math.round(MOTION.decelerate.duration * settings.animationSpeed)
|
||||
const exitDuration = Math.round(MOTION.accelerate.duration * settings.animationSpeed)
|
||||
|
||||
const transformOrigin = position.includes('right') ? 'right' : 'left'
|
||||
const slideDirection = position.includes('right') ? 'translateX(120%)' : 'translateX(-120%)'
|
||||
|
||||
// Priority color
|
||||
const priorityColor =
|
||||
currentMessage?.priority === 'urgent'
|
||||
? 'from-red-500 to-orange-500'
|
||||
: currentMessage?.priority === 'high'
|
||||
? 'from-amber-500 to-yellow-500'
|
||||
: 'from-purple-500 to-pink-500'
|
||||
|
||||
// Material styles - Ultra transparent for floating effect (4%)
|
||||
const cardBg = isDark
|
||||
? 'rgba(255, 255, 255, 0.04)'
|
||||
: 'rgba(255, 255, 255, 0.08)'
|
||||
|
||||
const shadow = '0 8px 32px rgba(0, 0, 0, 0.3)'
|
||||
const blur = settings.enableBlur ? `blur(${Math.round(24 * settings.blurIntensity)}px) saturate(180%)` : 'none'
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...positionStyles,
|
||||
transform: isExiting ? slideDirection : 'translateX(0)',
|
||||
opacity: isExiting ? 0 : 1,
|
||||
transition: `
|
||||
transform ${isExiting ? exitDuration : animationDuration}ms ${
|
||||
isExiting ? MOTION.accelerate.easing : MOTION.spring.easing
|
||||
},
|
||||
opacity ${isExiting ? exitDuration : animationDuration}ms ${MOTION.standard.easing}
|
||||
`,
|
||||
transformOrigin,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-96 max-w-[calc(100vw-3rem)] rounded-3xl overflow-hidden"
|
||||
style={{
|
||||
background: cardBg,
|
||||
backdropFilter: blur,
|
||||
WebkitBackdropFilter: blur,
|
||||
boxShadow: shadow,
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
}}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
{autoDismissMs > 0 && !isReplying && (
|
||||
<div className="h-1 w-full bg-black/5 dark:bg-white/5">
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r ${priorityColor} transition-all duration-100`}
|
||||
style={{ width: `${100 - dismissProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`w-12 h-12 rounded-2xl flex items-center justify-center text-lg font-semibold text-white bg-gradient-to-br ${priorityColor}`}
|
||||
style={{
|
||||
boxShadow: isDark
|
||||
? '0 4px 12px rgba(0,0,0,0.3)'
|
||||
: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
{currentMessage?.senderInitials}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">
|
||||
{currentMessage?.senderName}
|
||||
</h3>
|
||||
<p className="text-xs text-white/50">
|
||||
{currentMessage?.isGroup ? 'Gruppenchat' : 'Direktnachricht'} • Jetzt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="p-2 rounded-xl transition-all hover:bg-white/10 text-white/50"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div
|
||||
className="mb-4 p-4 rounded-2xl"
|
||||
style={{ background: 'rgba(255, 255, 255, 0.06)' }}
|
||||
>
|
||||
<p className="text-sm leading-relaxed text-white/90">
|
||||
{displayedText}
|
||||
{isTyping && (
|
||||
<span
|
||||
className={`inline-block w-0.5 h-4 ml-0.5 animate-pulse ${
|
||||
isDark ? 'bg-purple-400' : 'bg-purple-600'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reply input */}
|
||||
{isReplying && (
|
||||
<div className="mb-4">
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Antwort schreiben..."
|
||||
autoFocus
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 rounded-xl text-sm resize-none transition-all focus:outline-none focus:ring-2 bg-white/10 border border-white/20 text-white placeholder-white/40 focus:ring-purple-500/50"
|
||||
/>
|
||||
<p className="text-xs mt-1.5 text-white/40">
|
||||
Enter zum Senden • Esc zum Abbrechen
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isReplying ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSendReply}
|
||||
disabled={!replyText.trim()}
|
||||
className={`flex-1 py-2.5 rounded-xl font-medium transition-all disabled:opacity-50 bg-gradient-to-r ${priorityColor} text-white hover:shadow-lg`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
Senden
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelReply}
|
||||
className="px-4 py-2.5 rounded-xl font-medium transition-all bg-white/10 text-white/80 hover:bg-white/20"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleReplyClick}
|
||||
className={`flex-1 py-2.5 rounded-xl font-medium transition-all bg-gradient-to-r ${priorityColor} text-white hover:shadow-lg`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||
/>
|
||||
</svg>
|
||||
Antworten
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="px-4 py-2.5 rounded-xl font-medium transition-all bg-white/10 text-white/80 hover:bg-white/20"
|
||||
>
|
||||
Oeffnen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-4 py-2.5 rounded-xl font-medium transition-all bg-white/10 text-white/80 hover:bg-white/20"
|
||||
>
|
||||
Spaeter
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue indicator */}
|
||||
{messageQueue.length > 0 && (
|
||||
<div
|
||||
className="px-5 py-3 text-center text-sm font-medium border-t bg-white/5 text-purple-300 border-white/10"
|
||||
>
|
||||
+{messageQueue.length} weitere Nachricht{messageQueue.length > 1 ? 'en' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef, useCallback, useMemo } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import {
|
||||
SHADOWS,
|
||||
SHADOWS_DARK,
|
||||
MOTION,
|
||||
PARALLAX,
|
||||
MATERIALS,
|
||||
calculateParallax,
|
||||
} from '@/lib/spatial-ui/depth-system'
|
||||
import { usePerformance } from '@/lib/spatial-ui/PerformanceContext'
|
||||
|
||||
/**
|
||||
* SpatialCard - A card component with depth-aware interactions
|
||||
*
|
||||
* Features:
|
||||
* - Dynamic shadows that respond to hover/active states
|
||||
* - Subtle parallax effect based on cursor position
|
||||
* - Material-based styling (glass, solid, acrylic)
|
||||
* - Elevation changes with physics-based motion
|
||||
* - Performance-adaptive (degrades gracefully)
|
||||
*/
|
||||
|
||||
export type CardMaterial = 'solid' | 'glass' | 'thinGlass' | 'thickGlass' | 'acrylic'
|
||||
export type CardElevation = 'flat' | 'raised' | 'floating'
|
||||
|
||||
interface SpatialCardProps {
|
||||
children: React.ReactNode
|
||||
/** Material style */
|
||||
material?: CardMaterial
|
||||
/** Base elevation level */
|
||||
elevation?: CardElevation
|
||||
/** Enable hover lift effect */
|
||||
hoverLift?: boolean
|
||||
/** Enable subtle parallax on hover */
|
||||
parallax?: boolean
|
||||
/** Parallax intensity (uses PARALLAX constants) */
|
||||
parallaxIntensity?: number
|
||||
/** Click handler */
|
||||
onClick?: () => void
|
||||
/** Additional CSS classes */
|
||||
className?: string
|
||||
/** Custom padding */
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
/** Border radius */
|
||||
rounded?: 'none' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
||||
/** Glow color on hover (RGB values) */
|
||||
glowColor?: string
|
||||
/** Disable all effects */
|
||||
static?: boolean
|
||||
}
|
||||
|
||||
const PADDING_MAP = {
|
||||
none: '',
|
||||
sm: 'p-3',
|
||||
md: 'p-4',
|
||||
lg: 'p-6',
|
||||
}
|
||||
|
||||
const ROUNDED_MAP = {
|
||||
none: 'rounded-none',
|
||||
md: 'rounded-md',
|
||||
lg: 'rounded-lg',
|
||||
xl: 'rounded-xl',
|
||||
'2xl': 'rounded-2xl',
|
||||
'3xl': 'rounded-3xl',
|
||||
}
|
||||
|
||||
const ELEVATION_SHADOWS = {
|
||||
flat: { rest: 'none', hover: 'sm', active: 'xs' },
|
||||
raised: { rest: 'sm', hover: 'lg', active: 'md' },
|
||||
floating: { rest: 'lg', hover: 'xl', active: 'lg' },
|
||||
}
|
||||
|
||||
export function SpatialCard({
|
||||
children,
|
||||
material = 'glass',
|
||||
elevation = 'raised',
|
||||
hoverLift = true,
|
||||
parallax = false,
|
||||
parallaxIntensity = PARALLAX.subtle,
|
||||
onClick,
|
||||
className = '',
|
||||
padding = 'md',
|
||||
rounded = '2xl',
|
||||
glowColor,
|
||||
static: isStatic = false,
|
||||
}: SpatialCardProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { settings, reportAnimationStart, reportAnimationEnd, canStartAnimation } = usePerformance()
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [parallaxOffset, setParallaxOffset] = useState({ x: 0, y: 0 })
|
||||
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const animatingRef = useRef(false)
|
||||
|
||||
// Determine if effects should be enabled
|
||||
const effectsEnabled = !isStatic && settings.enableShadows
|
||||
const parallaxEnabled = parallax && settings.enableParallax && !isStatic
|
||||
|
||||
// Shadow key type (excludes glow function)
|
||||
type ShadowKey = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
||||
|
||||
// Get current shadow based on state
|
||||
const currentShadow = useMemo((): string => {
|
||||
if (!effectsEnabled) return 'none'
|
||||
|
||||
const shadows = ELEVATION_SHADOWS[elevation]
|
||||
const shadowKey = (isActive ? shadows.active : isHovered ? shadows.hover : shadows.rest) as ShadowKey
|
||||
const shadowSet = isDark ? SHADOWS_DARK : SHADOWS
|
||||
|
||||
// Get base shadow as string (not the glow function)
|
||||
const baseShadow = shadowSet[shadowKey] as string
|
||||
|
||||
// Add glow effect on hover if specified
|
||||
if (isHovered && glowColor && settings.enableShadows) {
|
||||
const glowFn = isDark ? SHADOWS_DARK.glow : SHADOWS.glow
|
||||
return `${baseShadow}, ${glowFn(glowColor, 0.2)}`
|
||||
}
|
||||
|
||||
return baseShadow
|
||||
}, [effectsEnabled, elevation, isActive, isHovered, isDark, glowColor, settings.enableShadows])
|
||||
|
||||
// Get material styles
|
||||
const materialStyles = useMemo(() => {
|
||||
const mat = MATERIALS[material]
|
||||
const bg = isDark ? mat.backgroundDark : mat.background
|
||||
const border = isDark ? mat.borderDark : mat.border
|
||||
const blur = settings.enableBlur ? mat.blur * settings.blurIntensity : 0
|
||||
|
||||
return {
|
||||
background: bg,
|
||||
backdropFilter: blur > 0 ? `blur(${blur}px)` : 'none',
|
||||
WebkitBackdropFilter: blur > 0 ? `blur(${blur}px)` : 'none',
|
||||
borderColor: border,
|
||||
}
|
||||
}, [material, isDark, settings.enableBlur, settings.blurIntensity])
|
||||
|
||||
// Calculate transform based on state
|
||||
const transform = useMemo(() => {
|
||||
if (isStatic || !settings.enableSpringAnimations) {
|
||||
return 'translateY(0) scale(1)'
|
||||
}
|
||||
|
||||
let y = 0
|
||||
let scale = 1
|
||||
|
||||
if (isActive) {
|
||||
y = 1
|
||||
scale = 0.98
|
||||
} else if (isHovered && hoverLift) {
|
||||
y = -3
|
||||
scale = 1.01
|
||||
}
|
||||
|
||||
// Add parallax offset
|
||||
const px = parallaxEnabled ? parallaxOffset.x : 0
|
||||
const py = parallaxEnabled ? parallaxOffset.y : 0
|
||||
|
||||
return `translateY(${y}px) translateX(${px}px) translateZ(${py}px) scale(${scale})`
|
||||
}, [isStatic, settings.enableSpringAnimations, isActive, isHovered, hoverLift, parallaxEnabled, parallaxOffset])
|
||||
|
||||
// Get transition timing
|
||||
const transitionDuration = useMemo(() => {
|
||||
const base = isActive ? MOTION.micro.duration : MOTION.standard.duration
|
||||
return Math.round(base * settings.animationSpeed)
|
||||
}, [isActive, settings.animationSpeed])
|
||||
|
||||
// Handlers
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (isStatic) return
|
||||
if (canStartAnimation() && !animatingRef.current) {
|
||||
animatingRef.current = true
|
||||
reportAnimationStart()
|
||||
}
|
||||
setIsHovered(true)
|
||||
}, [isStatic, canStartAnimation, reportAnimationStart])
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsHovered(false)
|
||||
setParallaxOffset({ x: 0, y: 0 })
|
||||
if (animatingRef.current) {
|
||||
animatingRef.current = false
|
||||
reportAnimationEnd()
|
||||
}
|
||||
}, [reportAnimationEnd])
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!parallaxEnabled || !cardRef.current) return
|
||||
|
||||
const rect = cardRef.current.getBoundingClientRect()
|
||||
const offset = calculateParallax(
|
||||
e.clientX,
|
||||
e.clientY,
|
||||
rect,
|
||||
parallaxIntensity * settings.parallaxIntensity
|
||||
)
|
||||
setParallaxOffset(offset)
|
||||
},
|
||||
[parallaxEnabled, parallaxIntensity, settings.parallaxIntensity]
|
||||
)
|
||||
|
||||
const handleMouseDown = useCallback(() => {
|
||||
if (!isStatic) setIsActive(true)
|
||||
}, [isStatic])
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsActive(false)
|
||||
}, [])
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
onClick?.()
|
||||
}, [onClick])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`
|
||||
border
|
||||
${PADDING_MAP[padding]}
|
||||
${ROUNDED_MAP[rounded]}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
...materialStyles,
|
||||
boxShadow: currentShadow,
|
||||
transform,
|
||||
transition: `
|
||||
box-shadow ${transitionDuration}ms ${MOTION.standard.easing},
|
||||
transform ${transitionDuration}ms ${settings.enableSpringAnimations ? MOTION.spring.easing : MOTION.standard.easing},
|
||||
background ${transitionDuration}ms ${MOTION.standard.easing}
|
||||
`,
|
||||
willChange: effectsEnabled ? 'transform, box-shadow' : 'auto',
|
||||
}}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SpatialCardHeader - Header section for SpatialCard
|
||||
*/
|
||||
export function SpatialCardHeader({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex items-center justify-between mb-4
|
||||
${isDark ? 'text-white' : 'text-slate-900'}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SpatialCardContent - Main content area
|
||||
*/
|
||||
export function SpatialCardContent({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return <div className={className}>{children}</div>
|
||||
}
|
||||
|
||||
/**
|
||||
* SpatialCardFooter - Footer section
|
||||
*/
|
||||
export function SpatialCardFooter({
|
||||
children,
|
||||
className = '',
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
mt-4 pt-4 border-t
|
||||
${isDark ? 'border-white/10' : 'border-slate-200'}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Spatial UI Components
|
||||
*
|
||||
* A collection of components built on the Spatial UI design system.
|
||||
* These components implement depth-aware interactions, adaptive quality,
|
||||
* and cinematic visual effects.
|
||||
*/
|
||||
|
||||
export { SpatialCard, SpatialCardHeader, SpatialCardContent, SpatialCardFooter } from './SpatialCard'
|
||||
export type { CardMaterial, CardElevation } from './SpatialCard'
|
||||
|
||||
export { FloatingMessage } from './FloatingMessage'
|
||||
export type { FloatingMessageData } from './FloatingMessage'
|
||||
@@ -0,0 +1,244 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { VoiceAPI, VoiceMessage, VoiceTask } from '@/lib/voice/voice-api'
|
||||
import { VoiceIndicator } from './VoiceIndicator'
|
||||
|
||||
interface VoiceCaptureProps {
|
||||
onTranscript?: (text: string, isFinal: boolean) => void
|
||||
onIntent?: (intent: string, parameters: Record<string, unknown>) => void
|
||||
onResponse?: (text: string) => void
|
||||
onTaskCreated?: (task: VoiceTask) => void
|
||||
onError?: (error: Error) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Voice capture component with microphone button
|
||||
* Handles WebSocket connection and audio streaming
|
||||
*/
|
||||
export function VoiceCapture({
|
||||
onTranscript,
|
||||
onIntent,
|
||||
onResponse,
|
||||
onTaskCreated,
|
||||
onError,
|
||||
className = '',
|
||||
}: VoiceCaptureProps) {
|
||||
const voiceApiRef = useRef<VoiceAPI | null>(null)
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const [status, setStatus] = useState<string>('idle')
|
||||
const [audioLevel, setAudioLevel] = useState(0)
|
||||
const [transcript, setTranscript] = useState<string>('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Initialize voice API
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const api = new VoiceAPI()
|
||||
await api.initialize()
|
||||
voiceApiRef.current = api
|
||||
|
||||
// Set up event handlers
|
||||
api.setOnMessage(handleMessage)
|
||||
api.setOnError(handleError)
|
||||
api.setOnStatusChange(handleStatusChange)
|
||||
|
||||
setIsInitialized(true)
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize voice API:', e)
|
||||
setError('Sprachdienst konnte nicht initialisiert werden')
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
voiceApiRef.current?.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(message: VoiceMessage) => {
|
||||
switch (message.type) {
|
||||
case 'transcript':
|
||||
setTranscript(message.text)
|
||||
onTranscript?.(message.text, message.final)
|
||||
break
|
||||
|
||||
case 'intent':
|
||||
onIntent?.(message.intent, message.parameters)
|
||||
break
|
||||
|
||||
case 'response':
|
||||
onResponse?.(message.text)
|
||||
break
|
||||
|
||||
case 'task_created':
|
||||
onTaskCreated?.({
|
||||
id: message.task_id,
|
||||
session_id: '',
|
||||
type: message.task_type,
|
||||
state: message.state,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
result_available: false,
|
||||
})
|
||||
break
|
||||
|
||||
case 'error':
|
||||
setError(message.message)
|
||||
onError?.(new Error(message.message))
|
||||
break
|
||||
}
|
||||
},
|
||||
[onTranscript, onIntent, onResponse, onTaskCreated, onError]
|
||||
)
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: Error) => {
|
||||
setError(error.message)
|
||||
setIsListening(false)
|
||||
onError?.(error)
|
||||
},
|
||||
[onError]
|
||||
)
|
||||
|
||||
const handleStatusChange = useCallback((newStatus: string) => {
|
||||
setStatus(newStatus)
|
||||
|
||||
if (newStatus === 'connected') {
|
||||
setIsConnected(true)
|
||||
} else if (newStatus === 'disconnected') {
|
||||
setIsConnected(false)
|
||||
setIsListening(false)
|
||||
} else if (newStatus === 'listening') {
|
||||
setIsListening(true)
|
||||
} else if (newStatus === 'processing') {
|
||||
setIsListening(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleListening = async () => {
|
||||
if (!voiceApiRef.current) return
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
|
||||
if (isListening) {
|
||||
// Stop listening
|
||||
voiceApiRef.current.stopCapture()
|
||||
setIsListening(false)
|
||||
} else {
|
||||
// Start listening
|
||||
if (!isConnected) {
|
||||
await voiceApiRef.current.connect()
|
||||
}
|
||||
await voiceApiRef.current.startCapture()
|
||||
setIsListening(true)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle listening:', e)
|
||||
setError('Mikrofon konnte nicht aktiviert werden')
|
||||
}
|
||||
}
|
||||
|
||||
const interrupt = () => {
|
||||
voiceApiRef.current?.interrupt()
|
||||
}
|
||||
|
||||
if (!isInitialized) {
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<div className="animate-spin w-6 h-6 border-2 border-gray-300 border-t-blue-500 rounded-full" />
|
||||
<span className="text-sm text-gray-500">Initialisiere...</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-4 ${className}`}>
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded-lg text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main controls */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Microphone button */}
|
||||
<button
|
||||
onClick={toggleListening}
|
||||
disabled={status === 'processing'}
|
||||
className={`
|
||||
relative w-16 h-16 rounded-full flex items-center justify-center
|
||||
transition-all duration-200 focus:outline-none focus:ring-4
|
||||
${
|
||||
isListening
|
||||
? 'bg-red-500 hover:bg-red-600 focus:ring-red-200'
|
||||
: 'bg-blue-500 hover:bg-blue-600 focus:ring-blue-200'
|
||||
}
|
||||
${status === 'processing' ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Microphone icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="white"
|
||||
className="w-8 h-8"
|
||||
>
|
||||
{isListening ? (
|
||||
// Stop icon
|
||||
<path d="M6 6h12v12H6z" />
|
||||
) : (
|
||||
// Microphone icon
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1 1.93c-3.94-.49-7-3.85-7-7.93h2c0 2.76 2.24 5 5 5s5-2.24 5-5h2c0 4.08-3.06 7.44-7 7.93V18h4v2H8v-2h4v-2.07z" />
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* Pulsing ring when listening */}
|
||||
{isListening && (
|
||||
<span className="absolute inset-0 rounded-full animate-ping bg-red-400 opacity-25" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Status indicator */}
|
||||
<VoiceIndicator
|
||||
isListening={isListening}
|
||||
audioLevel={audioLevel}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
{/* Interrupt button (when responding) */}
|
||||
{status === 'responding' && (
|
||||
<button
|
||||
onClick={interrupt}
|
||||
className="px-4 py-2 text-sm bg-gray-200 hover:bg-gray-300 rounded-lg"
|
||||
>
|
||||
Unterbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transcript display */}
|
||||
{transcript && (
|
||||
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
|
||||
<p className="text-sm text-gray-500 mb-1">Erkannt:</p>
|
||||
<p className="text-gray-800">{transcript}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Instructions */}
|
||||
<p className="text-xs text-gray-400">
|
||||
{isListening
|
||||
? 'Sprechen Sie jetzt... Klicken Sie erneut zum Beenden.'
|
||||
: 'Klicken Sie auf das Mikrofon und sprechen Sie Ihren Befehl.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { VoiceAPI, VoiceMessage, VoiceTask } from '@/lib/voice/voice-api'
|
||||
import { VoiceIndicator } from './VoiceIndicator'
|
||||
|
||||
interface Message {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: Date
|
||||
intent?: string
|
||||
task?: VoiceTask
|
||||
}
|
||||
|
||||
interface VoiceCommandBarProps {
|
||||
onTaskCreated?: (task: VoiceTask) => void
|
||||
onTaskApproved?: (taskId: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Full voice command bar with conversation history
|
||||
* Shows transcript, responses, and pending tasks
|
||||
*/
|
||||
export function VoiceCommandBar({
|
||||
onTaskCreated,
|
||||
onTaskApproved,
|
||||
className = '',
|
||||
}: VoiceCommandBarProps) {
|
||||
const voiceApiRef = useRef<VoiceAPI | null>(null)
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [isConnected, setIsConnected] = useState(false)
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const [status, setStatus] = useState<string>('idle')
|
||||
const [messages, setMessages] = useState<Message[]>([])
|
||||
const [pendingTasks, setPendingTasks] = useState<VoiceTask[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Initialize voice API
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
try {
|
||||
const api = new VoiceAPI()
|
||||
await api.initialize()
|
||||
voiceApiRef.current = api
|
||||
|
||||
api.setOnMessage(handleMessage)
|
||||
api.setOnError(handleError)
|
||||
api.setOnStatusChange(handleStatusChange)
|
||||
|
||||
setIsInitialized(true)
|
||||
} catch (e) {
|
||||
console.error('Failed to initialize:', e)
|
||||
setError('Sprachdienst konnte nicht initialisiert werden')
|
||||
}
|
||||
}
|
||||
|
||||
init()
|
||||
|
||||
return () => {
|
||||
voiceApiRef.current?.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(message: VoiceMessage) => {
|
||||
switch (message.type) {
|
||||
case 'transcript':
|
||||
if (message.final) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `msg-${Date.now()}`,
|
||||
role: 'user',
|
||||
content: message.text,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
])
|
||||
}
|
||||
break
|
||||
|
||||
case 'intent':
|
||||
// Update last user message with intent
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const lastUserMsg = [...updated].reverse().find((m) => m.role === 'user')
|
||||
if (lastUserMsg) {
|
||||
lastUserMsg.intent = message.intent
|
||||
}
|
||||
return updated
|
||||
})
|
||||
break
|
||||
|
||||
case 'response':
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `msg-${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: message.text,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
])
|
||||
break
|
||||
|
||||
case 'task_created':
|
||||
const task: VoiceTask = {
|
||||
id: message.task_id,
|
||||
session_id: '',
|
||||
type: message.task_type,
|
||||
state: message.state,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
result_available: false,
|
||||
}
|
||||
setPendingTasks((prev) => [...prev, task])
|
||||
onTaskCreated?.(task)
|
||||
|
||||
// Update last assistant message with task
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev]
|
||||
const lastAssistantMsg = [...updated].reverse().find((m) => m.role === 'assistant')
|
||||
if (lastAssistantMsg) {
|
||||
lastAssistantMsg.task = task
|
||||
}
|
||||
return updated
|
||||
})
|
||||
break
|
||||
|
||||
case 'error':
|
||||
setError(message.message)
|
||||
break
|
||||
}
|
||||
},
|
||||
[onTaskCreated]
|
||||
)
|
||||
|
||||
const handleError = useCallback((error: Error) => {
|
||||
setError(error.message)
|
||||
setIsListening(false)
|
||||
}, [])
|
||||
|
||||
const handleStatusChange = useCallback((newStatus: string) => {
|
||||
setStatus(newStatus)
|
||||
setIsConnected(newStatus !== 'idle' && newStatus !== 'disconnected')
|
||||
setIsListening(newStatus === 'listening')
|
||||
}, [])
|
||||
|
||||
const toggleListening = async () => {
|
||||
if (!voiceApiRef.current) return
|
||||
|
||||
try {
|
||||
setError(null)
|
||||
|
||||
if (isListening) {
|
||||
voiceApiRef.current.stopCapture()
|
||||
} else {
|
||||
if (!isConnected) {
|
||||
await voiceApiRef.current.connect()
|
||||
}
|
||||
await voiceApiRef.current.startCapture()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle listening:', e)
|
||||
setError('Mikrofon konnte nicht aktiviert werden')
|
||||
}
|
||||
}
|
||||
|
||||
const approveTask = async (taskId: string) => {
|
||||
try {
|
||||
await voiceApiRef.current?.approveTask(taskId)
|
||||
setPendingTasks((prev) => prev.filter((t) => t.id !== taskId))
|
||||
onTaskApproved?.(taskId)
|
||||
} catch (e) {
|
||||
console.error('Failed to approve task:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const rejectTask = async (taskId: string) => {
|
||||
try {
|
||||
await voiceApiRef.current?.rejectTask(taskId)
|
||||
setPendingTasks((prev) => prev.filter((t) => t.id !== taskId))
|
||||
} catch (e) {
|
||||
console.error('Failed to reject task:', e)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInitialized) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center p-8 ${className}`}>
|
||||
<div className="animate-spin w-8 h-8 border-2 border-gray-300 border-t-blue-500 rounded-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col bg-white rounded-xl shadow-lg overflow-hidden ${className}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-6 h-6 text-blue-500"
|
||||
>
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
|
||||
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
|
||||
</svg>
|
||||
<span className="font-medium text-gray-800">Breakpilot Voice</span>
|
||||
</div>
|
||||
<VoiceIndicator isListening={isListening} status={status} />
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-4 min-h-[200px] max-h-[400px]">
|
||||
{messages.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
<p className="mb-2">Willkommen bei Breakpilot Voice!</p>
|
||||
<p className="text-sm">
|
||||
Klicken Sie auf das Mikrofon und sprechen Sie Ihren Befehl.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[80%] rounded-lg px-4 py-2 ${
|
||||
msg.role === 'user'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
<p>{msg.content}</p>
|
||||
{msg.intent && (
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
Intent: {msg.intent}
|
||||
</p>
|
||||
)}
|
||||
{msg.task && msg.task.state === 'ready' && (
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={() => approveTask(msg.task!.id)}
|
||||
className="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600"
|
||||
>
|
||||
Bestaetigen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => rejectTask(msg.task!.id)}
|
||||
className="px-2 py-1 text-xs bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="px-4 py-2 bg-red-50 border-t border-red-200 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="p-4 bg-gray-50 border-t">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Microphone button */}
|
||||
<button
|
||||
onClick={toggleListening}
|
||||
disabled={status === 'processing'}
|
||||
className={`
|
||||
w-12 h-12 rounded-full flex items-center justify-center
|
||||
transition-all duration-200 focus:outline-none focus:ring-4
|
||||
${
|
||||
isListening
|
||||
? 'bg-red-500 hover:bg-red-600 focus:ring-red-200'
|
||||
: 'bg-blue-500 hover:bg-blue-600 focus:ring-blue-200'
|
||||
}
|
||||
${status === 'processing' ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
`}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="white"
|
||||
className="w-6 h-6"
|
||||
>
|
||||
{isListening ? (
|
||||
<path d="M6 6h12v12H6z" />
|
||||
) : (
|
||||
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3zm-1 1.93c-3.94-.49-7-3.85-7-7.93h2c0 2.76 2.24 5 5 5s5-2.24 5-5h2c0 4.08-3.06 7.44-7 7.93V18h4v2H8v-2h4v-2.07z" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Text hint */}
|
||||
<div className="flex-1 text-sm text-gray-500">
|
||||
{isListening
|
||||
? 'Ich hoere zu... Sprechen Sie jetzt.'
|
||||
: status === 'processing'
|
||||
? 'Verarbeite...'
|
||||
: 'Tippen Sie auf das Mikrofon um zu sprechen'}
|
||||
</div>
|
||||
|
||||
{/* Pending tasks indicator */}
|
||||
{pendingTasks.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">
|
||||
{pendingTasks.length} Aufgabe(n)
|
||||
</span>
|
||||
<span className="w-2 h-2 bg-yellow-400 rounded-full animate-pulse" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface VoiceIndicatorProps {
|
||||
isListening: boolean
|
||||
audioLevel?: number // 0-100
|
||||
status?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual indicator for voice activity
|
||||
* Shows audio level and status
|
||||
*/
|
||||
export function VoiceIndicator({
|
||||
isListening,
|
||||
audioLevel = 0,
|
||||
status = 'idle',
|
||||
}: VoiceIndicatorProps) {
|
||||
const [bars, setBars] = useState<number[]>([0, 0, 0, 0, 0])
|
||||
|
||||
// Animate bars based on audio level
|
||||
useEffect(() => {
|
||||
if (!isListening) {
|
||||
setBars([0, 0, 0, 0, 0])
|
||||
return
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setBars((prev) =>
|
||||
prev.map(() => {
|
||||
const base = audioLevel / 100
|
||||
const variance = Math.random() * 0.4
|
||||
return Math.min(1, base + variance)
|
||||
})
|
||||
)
|
||||
}, 100)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isListening, audioLevel])
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
idle: 'bg-gray-400',
|
||||
connected: 'bg-blue-500',
|
||||
listening: 'bg-green-500',
|
||||
processing: 'bg-yellow-500',
|
||||
responding: 'bg-purple-500',
|
||||
error: 'bg-red-500',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
idle: 'Bereit',
|
||||
connected: 'Verbunden',
|
||||
listening: 'Hoert zu...',
|
||||
processing: 'Verarbeitet...',
|
||||
responding: 'Antwortet...',
|
||||
error: 'Fehler',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status dot */}
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full ${statusColors[status] || statusColors.idle} ${
|
||||
isListening ? 'animate-pulse' : ''
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* Audio level bars */}
|
||||
<div className="flex items-end gap-0.5 h-6">
|
||||
{bars.map((level, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-1 rounded-full transition-all duration-100 ${
|
||||
isListening ? 'bg-green-500' : 'bg-gray-300'
|
||||
}`}
|
||||
style={{
|
||||
height: `${Math.max(4, level * 24)}px`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status text */}
|
||||
<span className="text-sm text-gray-600">
|
||||
{statusLabels[status] || status}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Voice Components
|
||||
*/
|
||||
export { VoiceCapture } from './VoiceCapture'
|
||||
export { VoiceIndicator } from './VoiceIndicator'
|
||||
export { VoiceCommandBar } from './VoiceCommandBar'
|
||||
@@ -0,0 +1,296 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
import type { AIImageStyle } from '@/app/worksheet-editor/types'
|
||||
|
||||
interface AIImageGeneratorProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const AI_STYLES: { id: AIImageStyle; name: string; description: string }[] = [
|
||||
{ id: 'educational', name: 'Bildung', description: 'Klare, lehrreiche Illustrationen' },
|
||||
{ id: 'cartoon', name: 'Cartoon', description: 'Bunte, kindgerechte Zeichnungen' },
|
||||
{ id: 'realistic', name: 'Realistisch', description: 'Fotorealistische Darstellungen' },
|
||||
{ id: 'sketch', name: 'Skizze', description: 'Handgezeichneter Stil' },
|
||||
{ id: 'clipart', name: 'Clipart', description: 'Einfache, flache Grafiken' },
|
||||
]
|
||||
|
||||
const SIZE_OPTIONS = [
|
||||
{ width: 256, height: 256, label: '256 x 256' },
|
||||
{ width: 512, height: 512, label: '512 x 512' },
|
||||
{ width: 512, height: 256, label: '512 x 256 (Breit)' },
|
||||
{ width: 256, height: 512, label: '256 x 512 (Hoch)' },
|
||||
]
|
||||
|
||||
export function AIImageGenerator({ isOpen, onClose }: AIImageGeneratorProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const { canvas, setActiveTool, saveToHistory } = useWorksheet()
|
||||
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [style, setStyle] = useState<AIImageStyle>('educational')
|
||||
const [sizeIndex, setSizeIndex] = useState(1) // Default: 512x512
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const selectedSize = SIZE_OPTIONS[sizeIndex]
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) {
|
||||
setError('Bitte geben Sie eine Beschreibung ein.')
|
||||
return
|
||||
}
|
||||
|
||||
setIsGenerating(true)
|
||||
setError(null)
|
||||
setPreviewUrl(null)
|
||||
|
||||
const { hostname, protocol } = window.location
|
||||
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/api/v1/worksheet/ai-image`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt,
|
||||
style,
|
||||
width: selectedSize.width,
|
||||
height: selectedSize.height
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json()
|
||||
throw new Error(data.detail || 'Bildgenerierung fehlgeschlagen')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.error) {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
|
||||
setPreviewUrl(data.image_base64)
|
||||
} catch (err: any) {
|
||||
console.error('AI Image generation failed:', err)
|
||||
setError(err.message || 'Verbindung zum KI-Server fehlgeschlagen. Bitte überprüfen Sie, ob Ollama läuft.')
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInsert = () => {
|
||||
if (!previewUrl || !canvas) return
|
||||
|
||||
// Add image to canvas
|
||||
if ((canvas as any).addImage) {
|
||||
(canvas as any).addImage(previewUrl)
|
||||
}
|
||||
|
||||
// Reset and close
|
||||
setPrompt('')
|
||||
setPreviewUrl(null)
|
||||
setActiveTool('select')
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// Glassmorphism styles
|
||||
const overlayStyle = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50'
|
||||
const modalStyle = isDark
|
||||
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
||||
: 'backdrop-blur-xl bg-white/90 border border-black/10 shadow-2xl'
|
||||
|
||||
const inputStyle = isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400'
|
||||
: 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500'
|
||||
|
||||
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
|
||||
|
||||
return (
|
||||
<div className={overlayStyle} onClick={onClose}>
|
||||
<div
|
||||
className={`fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-xl rounded-3xl p-6 ${modalStyle}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
|
||||
}`}>
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-purple-300' : 'text-purple-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
KI-Bild generieren
|
||||
</h2>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Beschreiben Sie das gewünschte Bild
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-xl transition-colors ${
|
||||
isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Prompt Input */}
|
||||
<div className="mb-4">
|
||||
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="z.B. Ein freundlicher Cartoon-Hund, der ein Buch liest"
|
||||
rows={3}
|
||||
className={`w-full px-4 py-3 rounded-xl border resize-none focus:outline-none focus:ring-2 focus:ring-purple-500/50 ${inputStyle}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Style Selection */}
|
||||
<div className="mb-4">
|
||||
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
|
||||
Stil
|
||||
</label>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{AI_STYLES.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
onClick={() => setStyle(s.id)}
|
||||
className={`p-3 rounded-xl text-center transition-all ${
|
||||
style === s.id
|
||||
? isDark
|
||||
? 'bg-purple-500/30 text-purple-300 border border-purple-400/50'
|
||||
: 'bg-purple-100 text-purple-700 border border-purple-300'
|
||||
: isDark
|
||||
? 'bg-white/5 text-white/70 border border-transparent hover:bg-white/10'
|
||||
: 'bg-slate-50 text-slate-600 border border-transparent hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xs font-medium">{s.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Size Selection */}
|
||||
<div className="mb-6">
|
||||
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
|
||||
Größe
|
||||
</label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{SIZE_OPTIONS.map((size, index) => (
|
||||
<button
|
||||
key={size.label}
|
||||
onClick={() => setSizeIndex(index)}
|
||||
className={`py-2 px-3 rounded-xl text-xs font-medium transition-all ${
|
||||
sizeIndex === index
|
||||
? isDark
|
||||
? 'bg-purple-500/30 text-purple-300 border border-purple-400/50'
|
||||
: 'bg-purple-100 text-purple-700 border border-purple-300'
|
||||
: isDark
|
||||
? 'bg-white/5 text-white/70 border border-transparent hover:bg-white/10'
|
||||
: 'bg-slate-50 text-slate-600 border border-transparent hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
{size.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className={`mb-4 p-3 rounded-xl ${
|
||||
isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-600'
|
||||
}`}>
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview */}
|
||||
{previewUrl && (
|
||||
<div className="mb-6">
|
||||
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
|
||||
Vorschau
|
||||
</label>
|
||||
<div className={`rounded-xl overflow-hidden ${
|
||||
isDark ? 'bg-white/5' : 'bg-slate-100'
|
||||
}`}>
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Generated preview"
|
||||
className="w-full h-auto"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
className={`flex-1 py-3 rounded-xl font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
isGenerating || !prompt.trim()
|
||||
? isDark
|
||||
? 'bg-white/10 text-white/30 cursor-not-allowed'
|
||||
: 'bg-slate-100 text-slate-400 cursor-not-allowed'
|
||||
: isDark
|
||||
? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
}`}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<svg className="animate-spin w-5 h-5" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Generiere...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
Generieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{previewUrl && (
|
||||
<button
|
||||
onClick={handleInsert}
|
||||
className={`flex-1 py-3 rounded-xl font-medium transition-all ${
|
||||
isDark
|
||||
? 'bg-green-500/30 text-green-300 hover:bg-green-500/40'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
}`}
|
||||
>
|
||||
Einfügen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
|
||||
interface AIPromptBarProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function AIPromptBar({ className = '' }: AIPromptBarProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { canvas, saveToHistory } = useWorksheet()
|
||||
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [lastResult, setLastResult] = useState<string | null>(null)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [promptHistory, setPromptHistory] = useState<string[]>([])
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Load prompt history from localStorage
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('worksheet_prompt_history')
|
||||
if (stored) {
|
||||
try {
|
||||
setPromptHistory(JSON.parse(stored))
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Save prompt to history
|
||||
const addToHistory = (newPrompt: string) => {
|
||||
const updated = [newPrompt, ...promptHistory.filter(p => p !== newPrompt)].slice(0, 10)
|
||||
setPromptHistory(updated)
|
||||
localStorage.setItem('worksheet_prompt_history', JSON.stringify(updated))
|
||||
}
|
||||
|
||||
// Handle AI prompt submission
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!prompt.trim() || !canvas || isLoading) return
|
||||
|
||||
setIsLoading(true)
|
||||
setLastResult(null)
|
||||
addToHistory(prompt.trim())
|
||||
|
||||
try {
|
||||
// Get current canvas state
|
||||
const canvasJSON = JSON.stringify(canvas.toJSON())
|
||||
|
||||
// Get API base URL (use same protocol as page)
|
||||
const { hostname, protocol } = window.location
|
||||
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
||||
|
||||
// Send prompt to AI endpoint with timeout (3 minutes for large models)
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 180000) // 3 minutes
|
||||
|
||||
try {
|
||||
const response = await fetch(`${apiBase}/api/v1/worksheet/ai-modify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
prompt: prompt.trim(),
|
||||
canvas_json: canvasJSON,
|
||||
model: 'qwen2.5vl:32b'
|
||||
}),
|
||||
signal: controller.signal
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
throw new Error(error.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Apply changes to canvas if we got new JSON
|
||||
if (result.modified_canvas_json) {
|
||||
const parsedJSON = JSON.parse(result.modified_canvas_json)
|
||||
|
||||
// Preserve current background if not specified in response
|
||||
const currentBg = canvas.backgroundColor
|
||||
|
||||
canvas.loadFromJSON(parsedJSON, () => {
|
||||
// Ensure white background is set (Fabric.js sometimes loses it)
|
||||
if (!canvas.backgroundColor || canvas.backgroundColor === 'transparent' || canvas.backgroundColor === '#000000') {
|
||||
canvas.backgroundColor = parsedJSON.background || currentBg || '#ffffff'
|
||||
}
|
||||
canvas.renderAll()
|
||||
saveToHistory(`AI: ${prompt.trim().substring(0, 30)}`)
|
||||
})
|
||||
setLastResult(result.message || 'Aenderungen angewendet')
|
||||
} else if (result.message) {
|
||||
setLastResult(result.message)
|
||||
}
|
||||
|
||||
setPrompt('')
|
||||
} catch (fetchError) {
|
||||
clearTimeout(timeoutId)
|
||||
throw fetchError
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('AI prompt error:', error)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
setLastResult('Zeitüberschreitung - das KI-Modell braucht zu lange. Bitte versuchen Sie es erneut.')
|
||||
} else {
|
||||
setLastResult(`Fehler: ${error instanceof Error ? error.message : 'Unbekannter Fehler'}`)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [prompt, canvas, isLoading, saveToHistory])
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
if (e.key === 'ArrowUp' && promptHistory.length > 0 && !prompt) {
|
||||
e.preventDefault()
|
||||
setPrompt(promptHistory[0])
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setPrompt('')
|
||||
setShowHistory(false)
|
||||
inputRef.current?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
// Example prompts
|
||||
const examplePrompts = [
|
||||
'Fuege eine Ueberschrift "Arbeitsblatt" oben hinzu',
|
||||
'Erstelle ein 3x4 Raster fuer Aufgaben',
|
||||
'Fuege Linien fuer Schueler-Antworten hinzu',
|
||||
'Mache alle Texte groesser',
|
||||
'Zentriere alle Elemente',
|
||||
'Fuege Nummerierung 1-10 hinzu'
|
||||
]
|
||||
|
||||
// Glassmorphism styles
|
||||
const barStyle = isDark
|
||||
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
||||
: 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-lg'
|
||||
|
||||
const inputStyle = isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400 focus:ring-purple-400/30'
|
||||
: 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500 focus:ring-purple-500/30'
|
||||
|
||||
const buttonStyle = isDark
|
||||
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-600 to-pink-600 text-white hover:shadow-lg hover:shadow-purple-600/30'
|
||||
|
||||
return (
|
||||
<div className={`rounded-2xl p-4 ${barStyle} ${className}`}>
|
||||
{/* Prompt Input */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<span className="text-xl">✨</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setShowHistory(true)}
|
||||
onBlur={() => setTimeout(() => setShowHistory(false), 200)}
|
||||
placeholder="Beschreibe, was du aendern moechtest... (z.B. 'Fuege eine Ueberschrift hinzu')"
|
||||
disabled={isLoading}
|
||||
className={`w-full px-4 py-3 rounded-xl border text-sm transition-all focus:outline-none focus:ring-2 ${inputStyle} ${
|
||||
isLoading ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* History Dropdown */}
|
||||
{showHistory && promptHistory.length > 0 && !prompt && (
|
||||
<div className={`absolute top-full left-0 right-0 mt-2 rounded-xl border overflow-hidden z-50 ${
|
||||
isDark ? 'bg-slate-800 border-white/20' : 'bg-white border-slate-200 shadow-lg'
|
||||
}`}>
|
||||
<div className={`px-3 py-2 text-xs font-medium ${isDark ? 'text-white/50' : 'text-slate-400'}`}>
|
||||
Letzte Prompts
|
||||
</div>
|
||||
{promptHistory.map((historyItem, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setPrompt(historyItem)}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
|
||||
isDark ? 'text-white/80 hover:bg-white/10' : 'text-slate-700 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{historyItem}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={isLoading || !prompt.trim()}
|
||||
className={`flex-shrink-0 px-5 py-3 rounded-xl font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed ${buttonStyle}`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>KI denkt...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>Anwenden</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Result Message */}
|
||||
{lastResult && (
|
||||
<div className={`mt-3 px-4 py-2 rounded-lg text-sm ${
|
||||
lastResult.startsWith('Fehler')
|
||||
? isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-700'
|
||||
: isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{lastResult}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Example Prompts */}
|
||||
{!prompt && !isLoading && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{examplePrompts.slice(0, 4).map((example, idx) => (
|
||||
<button
|
||||
key={idx}
|
||||
onClick={() => setPrompt(example)}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white/70 hover:bg-white/20 hover:text-white'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{example}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
|
||||
interface CanvasControlsProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CanvasControls({ className = '' }: CanvasControlsProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const {
|
||||
zoom,
|
||||
setZoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomToFit,
|
||||
showGrid,
|
||||
setShowGrid,
|
||||
snapToGrid,
|
||||
setSnapToGrid,
|
||||
gridSize,
|
||||
setGridSize
|
||||
} = useWorksheet()
|
||||
|
||||
// Glassmorphism styles
|
||||
const controlsStyle = isDark
|
||||
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
||||
: 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-xl'
|
||||
|
||||
const buttonStyle = (active: boolean) => isDark
|
||||
? active
|
||||
? 'bg-purple-500/30 text-purple-300'
|
||||
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
: active
|
||||
? 'bg-purple-100 text-purple-700'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||
|
||||
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-4 p-3 rounded-2xl ${controlsStyle} ${className}`}>
|
||||
{/* Zoom Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={zoomOut}
|
||||
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-colors ${buttonStyle(false)}`}
|
||||
title="Verkleinern"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div className={`w-16 text-center text-sm font-medium ${labelStyle}`}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={zoomIn}
|
||||
className={`w-8 h-8 flex items-center justify-center rounded-lg transition-colors ${buttonStyle(false)}`}
|
||||
title="Vergrößern"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={zoomToFit}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg transition-colors ${buttonStyle(false)}`}
|
||||
title="An Fenster anpassen"
|
||||
>
|
||||
Fit
|
||||
</button>
|
||||
|
||||
{/* Zoom Slider */}
|
||||
<input
|
||||
type="range"
|
||||
min="25"
|
||||
max="400"
|
||||
value={zoom * 100}
|
||||
onChange={(e) => setZoom(parseInt(e.target.value) / 100)}
|
||||
className="w-24"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className={`h-8 border-l ${isDark ? 'border-white/20' : 'border-slate-200'}`} />
|
||||
|
||||
{/* Grid Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowGrid(!showGrid)}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${buttonStyle(showGrid)}`}
|
||||
title="Raster anzeigen"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v14a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 9h16M4 13h16M4 17h16M9 4v16M13 4v16M17 4v16" />
|
||||
</svg>
|
||||
Raster
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setSnapToGrid(!snapToGrid)}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg transition-colors flex items-center gap-2 ${buttonStyle(snapToGrid)}`}
|
||||
title="Am Raster ausrichten"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
Snap
|
||||
</button>
|
||||
|
||||
{/* Grid Size */}
|
||||
<select
|
||||
value={gridSize}
|
||||
onChange={(e) => setGridSize(parseInt(e.target.value))}
|
||||
className={`px-2 py-1.5 text-sm rounded-lg border ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white'
|
||||
: 'bg-white/50 border-black/10 text-slate-900'
|
||||
}`}
|
||||
title="Rastergröße"
|
||||
>
|
||||
<option value="5">5mm</option>
|
||||
<option value="10">10mm</option>
|
||||
<option value="15">15mm</option>
|
||||
<option value="20">20mm</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,765 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
|
||||
interface CleanupPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
interface CleanupCapabilities {
|
||||
opencv_available: boolean
|
||||
lama_available: boolean
|
||||
paddleocr_available: boolean
|
||||
}
|
||||
|
||||
interface PreviewResult {
|
||||
has_handwriting: boolean
|
||||
confidence: number
|
||||
handwriting_ratio: number
|
||||
image_width: number
|
||||
image_height: number
|
||||
estimated_times_ms: {
|
||||
detection: number
|
||||
inpainting: number
|
||||
reconstruction: number
|
||||
total: number
|
||||
}
|
||||
capabilities: {
|
||||
lama_available: boolean
|
||||
}
|
||||
}
|
||||
|
||||
interface PipelineResult {
|
||||
success: boolean
|
||||
handwriting_detected: boolean
|
||||
handwriting_removed: boolean
|
||||
layout_reconstructed: boolean
|
||||
cleaned_image_base64?: string
|
||||
fabric_json?: any
|
||||
metadata: any
|
||||
}
|
||||
|
||||
export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { canvas, saveToHistory } = useWorksheet()
|
||||
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
||||
const [cleanedUrl, setCleanedUrl] = useState<string | null>(null)
|
||||
const [maskUrl, setMaskUrl] = useState<string | null>(null)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isPreviewing, setIsPreviewing] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
|
||||
const [pipelineResult, setPipelineResult] = useState<PipelineResult | null>(null)
|
||||
const [capabilities, setCapabilities] = useState<CleanupCapabilities | null>(null)
|
||||
|
||||
// Options
|
||||
const [removeHandwriting, setRemoveHandwriting] = useState(true)
|
||||
const [reconstructLayout, setReconstructLayout] = useState(true)
|
||||
const [inpaintingMethod, setInpaintingMethod] = useState<string>('auto')
|
||||
|
||||
// Step tracking
|
||||
const [currentStep, setCurrentStep] = useState<'upload' | 'preview' | 'result'>('upload')
|
||||
|
||||
const getApiUrl = useCallback(() => {
|
||||
if (typeof window === 'undefined') return 'http://localhost:8086'
|
||||
const { hostname, protocol } = window.location
|
||||
return hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
||||
}, [])
|
||||
|
||||
// Load capabilities on mount
|
||||
const loadCapabilities = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/capabilities`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setCapabilities(data)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load capabilities:', err)
|
||||
}
|
||||
}, [getApiUrl])
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = useCallback((selectedFile: File) => {
|
||||
setFile(selectedFile)
|
||||
setError(null)
|
||||
setPreviewResult(null)
|
||||
setPipelineResult(null)
|
||||
setCleanedUrl(null)
|
||||
setMaskUrl(null)
|
||||
|
||||
// Create preview URL
|
||||
const url = URL.createObjectURL(selectedFile)
|
||||
setPreviewUrl(url)
|
||||
setCurrentStep('upload')
|
||||
}, [])
|
||||
|
||||
// Handle drop
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
const droppedFile = e.dataTransfer.files[0]
|
||||
if (droppedFile && droppedFile.type.startsWith('image/')) {
|
||||
handleFileSelect(droppedFile)
|
||||
}
|
||||
}, [handleFileSelect])
|
||||
|
||||
// Preview cleanup
|
||||
const handlePreview = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
setIsPreviewing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/preview-cleanup`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
setPreviewResult(result)
|
||||
setCurrentStep('preview')
|
||||
|
||||
// Also load capabilities
|
||||
await loadCapabilities()
|
||||
} catch (err) {
|
||||
console.error('Preview failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen')
|
||||
} finally {
|
||||
setIsPreviewing(false)
|
||||
}
|
||||
}, [file, getApiUrl, loadCapabilities])
|
||||
|
||||
// Run full cleanup pipeline
|
||||
const handleCleanup = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
setIsProcessing(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
formData.append('remove_handwriting', String(removeHandwriting))
|
||||
formData.append('reconstruct', String(reconstructLayout))
|
||||
formData.append('inpainting_method', inpaintingMethod)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result: PipelineResult = await response.json()
|
||||
setPipelineResult(result)
|
||||
|
||||
// Create cleaned image URL
|
||||
if (result.cleaned_image_base64) {
|
||||
const cleanedBlob = await fetch(`data:image/png;base64,${result.cleaned_image_base64}`).then(r => r.blob())
|
||||
setCleanedUrl(URL.createObjectURL(cleanedBlob))
|
||||
}
|
||||
|
||||
setCurrentStep('result')
|
||||
} catch (err) {
|
||||
console.error('Cleanup failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Bereinigung fehlgeschlagen')
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [file, removeHandwriting, reconstructLayout, inpaintingMethod, getApiUrl])
|
||||
|
||||
// Import to canvas
|
||||
const handleImportToCanvas = useCallback(async () => {
|
||||
if (!pipelineResult?.fabric_json || !canvas) return
|
||||
|
||||
try {
|
||||
// Clear canvas and load new content
|
||||
canvas.clear()
|
||||
canvas.loadFromJSON(pipelineResult.fabric_json, () => {
|
||||
canvas.renderAll()
|
||||
saveToHistory('Imported: Cleaned worksheet')
|
||||
})
|
||||
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Import failed:', err)
|
||||
setError('Import in Canvas fehlgeschlagen')
|
||||
}
|
||||
}, [pipelineResult, canvas, saveToHistory, onClose])
|
||||
|
||||
// Get detection mask
|
||||
const handleGetMask = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('image', file)
|
||||
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting/mask`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
setMaskUrl(URL.createObjectURL(blob))
|
||||
} catch (err) {
|
||||
console.error('Mask fetch failed:', err)
|
||||
}
|
||||
}, [file, getApiUrl])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// Styles
|
||||
const overlayStyle = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50'
|
||||
const modalStyle = isDark
|
||||
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
||||
: 'backdrop-blur-xl bg-white/90 border border-black/10 shadow-2xl'
|
||||
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
|
||||
const cardStyle = isDark
|
||||
? 'bg-white/5 border-white/10 hover:bg-white/10'
|
||||
: 'bg-white/50 border-slate-200 hover:bg-slate-50'
|
||||
|
||||
return (
|
||||
<div className={overlayStyle} onClick={onClose}>
|
||||
<div
|
||||
className={`fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-4xl max-h-[90vh] rounded-3xl p-6 overflow-hidden flex flex-col ${modalStyle}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
isDark ? 'bg-orange-500/20' : 'bg-orange-100'
|
||||
}`}>
|
||||
<svg className={`w-7 h-7 ${isDark ? 'text-orange-300' : 'text-orange-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Arbeitsblatt bereinigen
|
||||
</h2>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Handschrift entfernen und Layout rekonstruieren
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-xl transition-colors ${
|
||||
isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
{['upload', 'preview', 'result'].map((step, idx) => (
|
||||
<div key={step} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-medium ${
|
||||
currentStep === step
|
||||
? isDark ? 'bg-purple-500 text-white' : 'bg-purple-600 text-white'
|
||||
: idx < ['upload', 'preview', 'result'].indexOf(currentStep)
|
||||
? isDark ? 'bg-green-500 text-white' : 'bg-green-600 text-white'
|
||||
: isDark ? 'bg-white/10 text-white/50' : 'bg-slate-200 text-slate-400'
|
||||
}`}>
|
||||
{idx < ['upload', 'preview', 'result'].indexOf(currentStep) ? '✓' : idx + 1}
|
||||
</div>
|
||||
{idx < 2 && (
|
||||
<div className={`w-12 h-0.5 ${
|
||||
idx < ['upload', 'preview', 'result'].indexOf(currentStep)
|
||||
? isDark ? 'bg-green-500' : 'bg-green-600'
|
||||
: isDark ? 'bg-white/20' : 'bg-slate-200'
|
||||
}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className={`p-4 rounded-xl mb-4 ${
|
||||
isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Upload */}
|
||||
{currentStep === 'upload' && (
|
||||
<div className="space-y-6">
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-2xl p-8 text-center cursor-pointer transition-all ${
|
||||
isDark
|
||||
? 'border-white/20 hover:border-purple-400/50 hover:bg-white/5'
|
||||
: 'border-slate-300 hover:border-purple-400 hover:bg-purple-50/30'
|
||||
}`}
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onClick={() => document.getElementById('file-input')?.click()}
|
||||
>
|
||||
<input
|
||||
id="file-input"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
|
||||
className="hidden"
|
||||
/>
|
||||
{previewUrl ? (
|
||||
<div className="space-y-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Preview"
|
||||
className="max-h-64 mx-auto rounded-xl shadow-lg"
|
||||
/>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{file?.name}
|
||||
</p>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Klicke zum Ändern oder ziehe eine andere Datei hierher
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className={`w-16 h-16 mx-auto mb-4 ${isDark ? 'text-white/30' : 'text-slate-300'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
<p className={`text-lg font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Bild hochladen
|
||||
</p>
|
||||
<p className={labelStyle}>
|
||||
Ziehe ein Bild hierher oder klicke zum Auswählen
|
||||
</p>
|
||||
<p className={`text-xs mt-2 ${labelStyle}`}>
|
||||
Unterstützt: PNG, JPG, JPEG
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
{file && (
|
||||
<div className="space-y-4">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Optionen
|
||||
</h3>
|
||||
|
||||
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${cardStyle}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeHandwriting}
|
||||
onChange={(e) => setRemoveHandwriting(e.target.checked)}
|
||||
className="w-5 h-5 rounded"
|
||||
/>
|
||||
<div>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Handschrift entfernen
|
||||
</span>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Erkennt und entfernt handgeschriebene Inhalte
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${cardStyle}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reconstructLayout}
|
||||
onChange={(e) => setReconstructLayout(e.target.checked)}
|
||||
className="w-5 h-5 rounded"
|
||||
/>
|
||||
<div>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Layout rekonstruieren
|
||||
</span>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Erstellt bearbeitbare Fabric.js Objekte
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{removeHandwriting && (
|
||||
<div className="space-y-2">
|
||||
<label className={`block text-sm font-medium ${labelStyle}`}>
|
||||
Inpainting-Methode
|
||||
</label>
|
||||
<select
|
||||
value={inpaintingMethod}
|
||||
onChange={(e) => setInpaintingMethod(e.target.value)}
|
||||
className={`w-full p-3 rounded-xl border ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white'
|
||||
: 'bg-white border-slate-200 text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<option value="auto">Automatisch (empfohlen)</option>
|
||||
<option value="opencv_telea">OpenCV Telea (schnell)</option>
|
||||
<option value="opencv_ns">OpenCV NS (glatter)</option>
|
||||
{capabilities?.lama_available && (
|
||||
<option value="lama">LaMa (beste Qualität)</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Preview */}
|
||||
{currentStep === 'preview' && previewResult && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Detection Result */}
|
||||
<div className={`p-4 rounded-xl border ${cardStyle}`}>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Erkennungsergebnis
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Handschrift gefunden:</span>
|
||||
<span className={previewResult.has_handwriting
|
||||
? isDark ? 'text-orange-300' : 'text-orange-600'
|
||||
: isDark ? 'text-green-300' : 'text-green-600'
|
||||
}>
|
||||
{previewResult.has_handwriting ? 'Ja' : 'Nein'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Konfidenz:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
{(previewResult.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Handschrift-Anteil:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
{(previewResult.handwriting_ratio * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Bildgröße:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
{previewResult.image_width} × {previewResult.image_height}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Estimates */}
|
||||
<div className={`p-4 rounded-xl border ${cardStyle}`}>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Geschätzte Zeit
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Erkennung:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
~{Math.round(previewResult.estimated_times_ms.detection / 1000)}s
|
||||
</span>
|
||||
</div>
|
||||
{removeHandwriting && previewResult.has_handwriting && (
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Bereinigung:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
~{Math.round(previewResult.estimated_times_ms.inpainting / 1000)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{reconstructLayout && (
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Layout:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
~{Math.round(previewResult.estimated_times_ms.reconstruction / 1000)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex justify-between pt-2 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Gesamt:</span>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
~{Math.round(previewResult.estimated_times_ms.total / 1000)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Images */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Original
|
||||
</h3>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Original"
|
||||
className="w-full rounded-xl shadow-lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Maske
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleGetMask}
|
||||
className={`text-sm px-3 py-1 rounded-lg ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Maske laden
|
||||
</button>
|
||||
</div>
|
||||
{maskUrl ? (
|
||||
<img
|
||||
src={maskUrl}
|
||||
alt="Mask"
|
||||
className="w-full rounded-xl shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className={`aspect-video rounded-xl flex items-center justify-center ${
|
||||
isDark ? 'bg-white/5' : 'bg-slate-100'
|
||||
}`}>
|
||||
<span className={labelStyle}>Klicke "Maske laden" zum Anzeigen</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Result */}
|
||||
{currentStep === 'result' && pipelineResult && (
|
||||
<div className="space-y-6">
|
||||
{/* Status */}
|
||||
<div className={`p-4 rounded-xl ${
|
||||
pipelineResult.success
|
||||
? isDark ? 'bg-green-500/20' : 'bg-green-50'
|
||||
: isDark ? 'bg-red-500/20' : 'bg-red-50'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{pipelineResult.success ? (
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-green-300' : 'text-green-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-red-300' : 'text-red-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
<div>
|
||||
<h3 className={`font-medium ${
|
||||
pipelineResult.success
|
||||
? isDark ? 'text-green-300' : 'text-green-700'
|
||||
: isDark ? 'text-red-300' : 'text-red-700'
|
||||
}`}>
|
||||
{pipelineResult.success ? 'Erfolgreich bereinigt' : 'Bereinigung fehlgeschlagen'}
|
||||
</h3>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
{pipelineResult.handwriting_detected
|
||||
? pipelineResult.handwriting_removed
|
||||
? 'Handschrift wurde erkannt und entfernt'
|
||||
: 'Handschrift erkannt, aber nicht entfernt'
|
||||
: 'Keine Handschrift gefunden'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result Images */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Original
|
||||
</h3>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Original"
|
||||
className="w-full rounded-xl shadow-lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Bereinigt
|
||||
</h3>
|
||||
{cleanedUrl ? (
|
||||
<img
|
||||
src={cleanedUrl}
|
||||
alt="Cleaned"
|
||||
className="w-full rounded-xl shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className={`aspect-video rounded-xl flex items-center justify-center ${
|
||||
isDark ? 'bg-white/5' : 'bg-slate-100'
|
||||
}`}>
|
||||
<span className={labelStyle}>Kein Bild</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layout Info */}
|
||||
{pipelineResult.layout_reconstructed && pipelineResult.metadata?.layout && (
|
||||
<div className={`p-4 rounded-xl border ${cardStyle}`}>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Layout-Rekonstruktion
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<span className={labelStyle}>Elemente:</span>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{pipelineResult.metadata.layout.element_count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={labelStyle}>Tabellen:</span>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{pipelineResult.metadata.layout.table_count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={labelStyle}>Größe:</span>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{pipelineResult.metadata.layout.page_width} × {pipelineResult.metadata.layout.page_height}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 flex items-center justify-between">
|
||||
<div>
|
||||
{currentStep !== 'upload' && (
|
||||
<button
|
||||
onClick={() => setCurrentStep(currentStep === 'result' ? 'preview' : 'upload')}
|
||||
className={`px-4 py-2 rounded-xl font-medium transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
← Zurück
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
{currentStep === 'upload' && file && (
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={isPreviewing}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
|
||||
isDark
|
||||
? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{isPreviewing ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
Analysiere...
|
||||
</>
|
||||
) : (
|
||||
'Vorschau'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentStep === 'preview' && (
|
||||
<button
|
||||
onClick={handleCleanup}
|
||||
disabled={isProcessing}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-orange-500 to-red-500 text-white hover:shadow-lg hover:shadow-orange-500/30'
|
||||
: 'bg-gradient-to-r from-orange-600 to-red-600 text-white hover:shadow-lg hover:shadow-orange-600/30'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Verarbeite...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
Bereinigen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentStep === 'result' && pipelineResult?.success && (
|
||||
<button
|
||||
onClick={handleImportToCanvas}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/30'
|
||||
: 'bg-gradient-to-r from-green-600 to-emerald-600 text-white hover:shadow-lg hover:shadow-green-600/30'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
In Editor übernehmen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
|
||||
interface Session {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
vocabulary_count: number
|
||||
page_count: number
|
||||
status: string
|
||||
created_at?: string
|
||||
}
|
||||
|
||||
interface DocumentImporterProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function DocumentImporter({ isOpen, onClose }: DocumentImporterProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const { canvas, saveToHistory } = useWorksheet()
|
||||
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [selectedSession, setSelectedSession] = useState<Session | null>(null)
|
||||
const [selectedPage, setSelectedPage] = useState(1)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [includeImages, setIncludeImages] = useState(true)
|
||||
|
||||
// Load available sessions
|
||||
const loadSessions = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { hostname, protocol } = window.location
|
||||
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
||||
const response = await fetch(`${apiBase}/api/v1/worksheet/sessions/available`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
setSessions(data.sessions || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load sessions:', err)
|
||||
setError('Konnte Sessions nicht laden. Ist der Server erreichbar?')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSessions()
|
||||
}
|
||||
}, [isOpen, loadSessions])
|
||||
|
||||
// Handle import
|
||||
const handleImport = async () => {
|
||||
if (!selectedSession || !canvas) return
|
||||
|
||||
setIsImporting(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const { hostname, protocol } = window.location
|
||||
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
||||
const response = await fetch(`${apiBase}/api/v1/worksheet/reconstruct-from-session`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
session_id: selectedSession.id,
|
||||
page_number: selectedPage,
|
||||
include_images: includeImages,
|
||||
regenerate_graphics: false
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
throw new Error(errorData.detail || `HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
// Load canvas JSON
|
||||
if (result.canvas_json) {
|
||||
const canvasData = JSON.parse(result.canvas_json)
|
||||
|
||||
// Clear current canvas and load new content
|
||||
canvas.clear()
|
||||
canvas.loadFromJSON(canvasData, () => {
|
||||
canvas.renderAll()
|
||||
saveToHistory(`Imported: ${selectedSession.name} Page ${selectedPage}`)
|
||||
})
|
||||
|
||||
// Close modal
|
||||
onClose()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Import failed:', err)
|
||||
setError(err instanceof Error ? err.message : 'Import fehlgeschlagen')
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// Glassmorphism styles
|
||||
const overlayStyle = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50'
|
||||
const modalStyle = isDark
|
||||
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
||||
: 'backdrop-blur-xl bg-white/90 border border-black/10 shadow-2xl'
|
||||
|
||||
const cardStyle = (selected: boolean) => isDark
|
||||
? selected
|
||||
? 'bg-purple-500/30 border-purple-400/50'
|
||||
: 'bg-white/5 border-white/10 hover:bg-white/10'
|
||||
: selected
|
||||
? 'bg-purple-100 border-purple-300'
|
||||
: 'bg-white/50 border-slate-200 hover:bg-slate-50'
|
||||
|
||||
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
|
||||
|
||||
return (
|
||||
<div className={overlayStyle} onClick={onClose}>
|
||||
<div
|
||||
className={`fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-2xl max-h-[80vh] rounded-3xl p-6 overflow-hidden flex flex-col ${modalStyle}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
isDark ? 'bg-blue-500/20' : 'bg-blue-100'
|
||||
}`}>
|
||||
<svg className={`w-7 h-7 ${isDark ? 'text-blue-300' : 'text-blue-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Dokument importieren
|
||||
</h2>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Rekonstruiere ein Arbeitsblatt aus einer Vokabel-Session
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-xl transition-colors ${
|
||||
isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="w-8 h-8 border-4 border-purple-400 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className={`p-4 rounded-xl mb-4 ${
|
||||
isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Sessions */}
|
||||
{!isLoading && sessions.length === 0 && (
|
||||
<div className={`text-center py-12 ${labelStyle}`}>
|
||||
<svg className="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-lg font-medium mb-2">Keine Sessions gefunden</p>
|
||||
<p className="text-sm opacity-70">
|
||||
Verarbeite zuerst ein Dokument im Vokabel-Arbeitsblatt Generator
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session List */}
|
||||
{!isLoading && sessions.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
|
||||
Wähle eine Session:
|
||||
</label>
|
||||
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => {
|
||||
setSelectedSession(session)
|
||||
setSelectedPage(1)
|
||||
}}
|
||||
className={`w-full p-4 rounded-xl border text-left transition-all ${cardStyle(selectedSession?.id === session.id)}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{session.name}
|
||||
</h3>
|
||||
{session.description && (
|
||||
<p className={`text-sm mt-1 ${labelStyle}`}>
|
||||
{session.description}
|
||||
</p>
|
||||
)}
|
||||
<div className={`flex items-center gap-4 mt-2 text-xs ${labelStyle}`}>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
|
||||
</svg>
|
||||
{session.vocabulary_count} Vokabeln
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{session.page_count} Seiten
|
||||
</span>
|
||||
<span className={`px-2 py-0.5 rounded-full ${
|
||||
session.status === 'completed'
|
||||
? isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700'
|
||||
: isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{session.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{selectedSession?.id === session.id && (
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center ${
|
||||
isDark ? 'bg-purple-500' : 'bg-purple-600'
|
||||
}`}>
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Page Selection */}
|
||||
{selectedSession && selectedSession.page_count > 1 && (
|
||||
<div className="mt-6">
|
||||
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
|
||||
Welche Seite importieren?
|
||||
</label>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{Array.from({ length: selectedSession.page_count }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setSelectedPage(page)}
|
||||
className={`w-10 h-10 rounded-lg font-medium transition-all ${
|
||||
selectedPage === page
|
||||
? isDark
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-purple-600 text-white'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
{selectedSession && (
|
||||
<div className="mt-6 space-y-3">
|
||||
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>
|
||||
Optionen:
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-3 p-3 rounded-xl border cursor-pointer transition-all ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/10 hover:bg-white/10'
|
||||
: 'bg-white/50 border-slate-200 hover:bg-slate-50'
|
||||
}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeImages}
|
||||
onChange={(e) => setIncludeImages(e.target.checked)}
|
||||
className="w-5 h-5 rounded"
|
||||
/>
|
||||
<div>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Bilder extrahieren
|
||||
</span>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Versuche Grafiken aus dem Original-PDF zu übernehmen
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-6 flex items-center justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={!selectedSession || isImporting}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30'
|
||||
: 'bg-gradient-to-r from-purple-600 to-pink-600 text-white hover:shadow-lg hover:shadow-purple-600/30'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Rekonstruiere...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Importieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
import type { EditorTool } from '@/app/worksheet-editor/types'
|
||||
|
||||
interface EditorToolbarProps {
|
||||
onOpenAIGenerator: () => void
|
||||
onOpenDocumentImporter: () => void
|
||||
onOpenCleanupPanel?: () => void
|
||||
onOpenOCRImport?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
interface ToolButtonProps {
|
||||
tool: EditorTool
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
isActive: boolean
|
||||
onClick: () => void
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
function ToolButton({ tool, icon, label, isActive, onClick, isDark }: ToolButtonProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={label}
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
|
||||
isActive
|
||||
? isDark
|
||||
? 'bg-purple-500/30 text-purple-300 shadow-lg'
|
||||
: 'bg-purple-100 text-purple-700 shadow-lg'
|
||||
: isDark
|
||||
? 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function EditorToolbar({ onOpenAIGenerator, onOpenDocumentImporter, onOpenCleanupPanel, onOpenOCRImport, className = '' }: EditorToolbarProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const {
|
||||
activeTool,
|
||||
setActiveTool,
|
||||
canvas,
|
||||
canUndo,
|
||||
canRedo,
|
||||
undo,
|
||||
redo,
|
||||
} = useWorksheet()
|
||||
|
||||
const handleToolClick = (tool: EditorTool) => {
|
||||
setActiveTool(tool)
|
||||
}
|
||||
|
||||
const handleImageUpload = () => {
|
||||
fileInputRef.current?.click()
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !canvas) return
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
const url = event.target?.result as string
|
||||
if ((canvas as any).addImage) {
|
||||
(canvas as any).addImage(url)
|
||||
}
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
|
||||
// Reset input
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// Glassmorphism styles
|
||||
const toolbarStyle = isDark
|
||||
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
||||
: 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-xl'
|
||||
|
||||
const dividerStyle = isDark
|
||||
? 'border-white/20'
|
||||
: 'border-slate-200'
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-2 p-2 rounded-2xl ${toolbarStyle} ${className}`}>
|
||||
{/* Selection Tool */}
|
||||
<ToolButton
|
||||
tool="select"
|
||||
isActive={activeTool === 'select'}
|
||||
onClick={() => handleToolClick('select')}
|
||||
isDark={isDark}
|
||||
label="Auswählen"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={`border-t ${dividerStyle}`} />
|
||||
|
||||
{/* Text Tool */}
|
||||
<ToolButton
|
||||
tool="text"
|
||||
isActive={activeTool === 'text'}
|
||||
onClick={() => handleToolClick('text')}
|
||||
isDark={isDark}
|
||||
label="Text"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={`border-t ${dividerStyle}`} />
|
||||
|
||||
{/* Shape Tools */}
|
||||
<ToolButton
|
||||
tool="rectangle"
|
||||
isActive={activeTool === 'rectangle'}
|
||||
onClick={() => handleToolClick('rectangle')}
|
||||
isDark={isDark}
|
||||
label="Rechteck"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 18h16M4 6v12M20 6v12" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<ToolButton
|
||||
tool="circle"
|
||||
isActive={activeTool === 'circle'}
|
||||
onClick={() => handleToolClick('circle')}
|
||||
isDark={isDark}
|
||||
label="Kreis"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9" strokeWidth={1.5} />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<ToolButton
|
||||
tool="line"
|
||||
isActive={activeTool === 'line'}
|
||||
onClick={() => handleToolClick('line')}
|
||||
isDark={isDark}
|
||||
label="Linie"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 20L20 4" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<ToolButton
|
||||
tool="arrow"
|
||||
isActive={activeTool === 'arrow'}
|
||||
onClick={() => handleToolClick('arrow')}
|
||||
isDark={isDark}
|
||||
label="Pfeil"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={`border-t ${dividerStyle}`} />
|
||||
|
||||
{/* Image Tools */}
|
||||
<ToolButton
|
||||
tool="image"
|
||||
isActive={activeTool === 'image'}
|
||||
onClick={handleImageUpload}
|
||||
isDark={isDark}
|
||||
label="Bild hochladen"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* AI Image Generator */}
|
||||
<ToolButton
|
||||
tool="ai-image"
|
||||
isActive={activeTool === 'ai-image'}
|
||||
onClick={onOpenAIGenerator}
|
||||
isDark={isDark}
|
||||
label="KI-Bild generieren"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={`border-t ${dividerStyle}`} />
|
||||
|
||||
{/* Document Import */}
|
||||
<button
|
||||
onClick={onOpenDocumentImporter}
|
||||
title="Dokument importieren"
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
|
||||
isDark
|
||||
? 'text-blue-300 hover:bg-blue-500/20 hover:text-blue-200'
|
||||
: 'text-blue-600 hover:bg-blue-100 hover:text-blue-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 3v6a1 1 0 001 1h6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Cleanup Panel - Handwriting Removal */}
|
||||
{onOpenCleanupPanel && (
|
||||
<button
|
||||
onClick={onOpenCleanupPanel}
|
||||
title="Arbeitsblatt bereinigen (Handschrift entfernen)"
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
|
||||
isDark
|
||||
? 'text-orange-300 hover:bg-orange-500/20 hover:text-orange-200'
|
||||
: 'text-orange-600 hover:bg-orange-100 hover:text-orange-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* OCR Import */}
|
||||
{onOpenOCRImport && (
|
||||
<button
|
||||
onClick={onOpenOCRImport}
|
||||
title="OCR Daten importieren (aus Grid Analyse)"
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
|
||||
isDark
|
||||
? 'text-green-300 hover:bg-green-500/20 hover:text-green-200'
|
||||
: 'text-green-600 hover:bg-green-100 hover:text-green-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className={`border-t ${dividerStyle}`} />
|
||||
|
||||
{/* Table Tool */}
|
||||
<ToolButton
|
||||
tool="table"
|
||||
isActive={activeTool === 'table'}
|
||||
onClick={() => handleToolClick('table')}
|
||||
isDark={isDark}
|
||||
label="Tabelle"
|
||||
icon={
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={`border-t ${dividerStyle} mt-auto`} />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<button
|
||||
onClick={undo}
|
||||
disabled={!canUndo}
|
||||
title="Rückgängig (Ctrl+Z)"
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
|
||||
canUndo
|
||||
? isDark
|
||||
? 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||
: isDark
|
||||
? 'text-white/30 cursor-not-allowed'
|
||||
: 'text-slate-300 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={redo}
|
||||
disabled={!canRedo}
|
||||
title="Wiederholen (Ctrl+Y)"
|
||||
className={`w-12 h-12 flex items-center justify-center rounded-xl transition-all ${
|
||||
canRedo
|
||||
? isDark
|
||||
? 'text-white/70 hover:bg-white/10 hover:text-white'
|
||||
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
|
||||
: isDark
|
||||
? 'text-white/30 cursor-not-allowed'
|
||||
: 'text-slate-300 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 10h-10a8 8 0 00-8 8v2M21 10l-6 6m6-6l-6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
|
||||
interface ExportPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ExportPanel({ isOpen, onClose }: ExportPanelProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const { exportToPDF, exportToImage, document, saveDocument, isDirty } = useWorksheet()
|
||||
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [exportFormat, setExportFormat] = useState<'pdf' | 'png' | 'jpg'>('pdf')
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
|
||||
try {
|
||||
let blob: Blob
|
||||
let filename: string
|
||||
|
||||
if (exportFormat === 'pdf') {
|
||||
blob = await exportToPDF()
|
||||
filename = `${document?.title || 'Arbeitsblatt'}.pdf`
|
||||
} else {
|
||||
blob = await exportToImage(exportFormat)
|
||||
filename = `${document?.title || 'Arbeitsblatt'}.${exportFormat}`
|
||||
}
|
||||
|
||||
// Download file
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = window.document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
window.document.body.appendChild(a)
|
||||
a.click()
|
||||
window.document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error)
|
||||
alert('Export fehlgeschlagen. Bitte versuchen Sie es erneut.')
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setIsExporting(true)
|
||||
try {
|
||||
await saveDocument()
|
||||
alert('Dokument gespeichert!')
|
||||
} catch (error) {
|
||||
console.error('Save failed:', error)
|
||||
alert('Speichern fehlgeschlagen.')
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// Glassmorphism styles
|
||||
const overlayStyle = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50'
|
||||
const modalStyle = isDark
|
||||
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
||||
: 'backdrop-blur-xl bg-white/90 border border-black/10 shadow-2xl'
|
||||
|
||||
const buttonStyle = (active: boolean) => isDark
|
||||
? active
|
||||
? 'bg-purple-500/30 text-purple-300 border border-purple-400/50'
|
||||
: 'bg-white/5 text-white/70 border border-transparent hover:bg-white/10'
|
||||
: active
|
||||
? 'bg-purple-100 text-purple-700 border border-purple-300'
|
||||
: 'bg-slate-50 text-slate-600 border border-transparent hover:bg-slate-100'
|
||||
|
||||
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
|
||||
|
||||
return (
|
||||
<div className={overlayStyle} onClick={onClose}>
|
||||
<div
|
||||
className={`fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md rounded-3xl p-6 ${modalStyle}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center ${
|
||||
isDark ? 'bg-purple-500/20' : 'bg-purple-100'
|
||||
}`}>
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-purple-300' : 'text-purple-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Exportieren
|
||||
</h2>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Arbeitsblatt speichern oder exportieren
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-xl transition-colors ${
|
||||
isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Save Section */}
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isExporting}
|
||||
className={`w-full py-4 rounded-xl font-medium transition-all flex items-center justify-center gap-3 ${
|
||||
isDark
|
||||
? 'bg-green-500/30 text-green-300 hover:bg-green-500/40'
|
||||
: 'bg-green-600 text-white hover:bg-green-700'
|
||||
} ${isExporting ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
|
||||
</svg>
|
||||
Speichern
|
||||
{isDirty && (
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs ${
|
||||
isDark ? 'bg-yellow-500/30 text-yellow-300' : 'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
Ungespeichert
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Export Format Selection */}
|
||||
<div className="mb-4">
|
||||
<label className={`block text-sm font-medium mb-3 ${labelStyle}`}>
|
||||
Export-Format
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<button
|
||||
onClick={() => setExportFormat('pdf')}
|
||||
className={`py-4 rounded-xl transition-all flex flex-col items-center gap-2 ${buttonStyle(exportFormat === 'pdf')}`}
|
||||
>
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">PDF</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setExportFormat('png')}
|
||||
className={`py-4 rounded-xl transition-all flex flex-col items-center gap-2 ${buttonStyle(exportFormat === 'png')}`}
|
||||
>
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">PNG</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setExportFormat('jpg')}
|
||||
className={`py-4 rounded-xl transition-all flex flex-col items-center gap-2 ${buttonStyle(exportFormat === 'jpg')}`}
|
||||
>
|
||||
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">JPG</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Button */}
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className={`w-full py-4 rounded-xl font-medium transition-all flex items-center justify-center gap-2 ${
|
||||
isDark
|
||||
? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
} ${isExporting ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{isExporting ? (
|
||||
<>
|
||||
<svg className="animate-spin w-5 h-5" 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 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Exportiere...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Als {exportFormat.toUpperCase()} exportieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
import type { EditorTool } from '@/app/worksheet-editor/types'
|
||||
|
||||
// Fabric.js types
|
||||
declare const fabric: any
|
||||
|
||||
// A4 dimensions in pixels at 96 DPI
|
||||
const A4_WIDTH = 794 // 210mm * 3.78
|
||||
const A4_HEIGHT = 1123 // 297mm * 3.78
|
||||
|
||||
interface FabricCanvasProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FabricCanvas({ className = '' }: FabricCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { isDark } = useTheme()
|
||||
const [fabricLoaded, setFabricLoaded] = useState(false)
|
||||
const [fabricCanvas, setFabricCanvas] = useState<any>(null)
|
||||
|
||||
const {
|
||||
setCanvas,
|
||||
activeTool,
|
||||
setActiveTool,
|
||||
setSelectedObjects,
|
||||
zoom,
|
||||
showGrid,
|
||||
snapToGrid,
|
||||
gridSize,
|
||||
saveToHistory,
|
||||
document
|
||||
} = useWorksheet()
|
||||
|
||||
// Load Fabric.js dynamically
|
||||
useEffect(() => {
|
||||
const loadFabric = async () => {
|
||||
if (typeof window !== 'undefined' && !(window as any).fabric) {
|
||||
const fabricModule = await import('fabric')
|
||||
// Fabric 6.x exports directly, not via .fabric
|
||||
;(window as any).fabric = fabricModule
|
||||
}
|
||||
setFabricLoaded(true)
|
||||
}
|
||||
loadFabric()
|
||||
}, [])
|
||||
|
||||
// Initialize canvas
|
||||
useEffect(() => {
|
||||
if (!fabricLoaded || !canvasRef.current || fabricCanvas) return
|
||||
|
||||
const fabric = (window as any).fabric
|
||||
if (!fabric) return
|
||||
|
||||
try {
|
||||
const canvas = new fabric.Canvas(canvasRef.current, {
|
||||
width: A4_WIDTH,
|
||||
height: A4_HEIGHT,
|
||||
backgroundColor: '#ffffff',
|
||||
selection: true,
|
||||
preserveObjectStacking: true,
|
||||
enableRetinaScaling: true,
|
||||
})
|
||||
|
||||
// Store canvas reference
|
||||
setFabricCanvas(canvas)
|
||||
setCanvas(canvas)
|
||||
|
||||
return () => {
|
||||
canvas.dispose()
|
||||
setFabricCanvas(null)
|
||||
setCanvas(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize Fabric.js canvas:', error)
|
||||
}
|
||||
}, [fabricLoaded, setCanvas])
|
||||
|
||||
// Save initial history entry after canvas is ready
|
||||
useEffect(() => {
|
||||
if (fabricCanvas && saveToHistory) {
|
||||
// Small delay to ensure canvas is fully initialized
|
||||
const timeout = setTimeout(() => {
|
||||
saveToHistory('initial')
|
||||
}, 100)
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [fabricCanvas, saveToHistory])
|
||||
|
||||
// Handle selection events
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) return
|
||||
|
||||
const handleSelection = () => {
|
||||
const activeObjects = fabricCanvas.getActiveObjects()
|
||||
setSelectedObjects(activeObjects)
|
||||
}
|
||||
|
||||
const handleSelectionCleared = () => {
|
||||
setSelectedObjects([])
|
||||
}
|
||||
|
||||
fabricCanvas.on('selection:created', handleSelection)
|
||||
fabricCanvas.on('selection:updated', handleSelection)
|
||||
fabricCanvas.on('selection:cleared', handleSelectionCleared)
|
||||
|
||||
return () => {
|
||||
fabricCanvas.off('selection:created', handleSelection)
|
||||
fabricCanvas.off('selection:updated', handleSelection)
|
||||
fabricCanvas.off('selection:cleared', handleSelectionCleared)
|
||||
}
|
||||
}, [fabricCanvas, setSelectedObjects])
|
||||
|
||||
// Handle object modifications
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) return
|
||||
|
||||
const handleModified = () => {
|
||||
saveToHistory('object:modified')
|
||||
}
|
||||
|
||||
fabricCanvas.on('object:modified', handleModified)
|
||||
fabricCanvas.on('object:added', handleModified)
|
||||
fabricCanvas.on('object:removed', handleModified)
|
||||
|
||||
return () => {
|
||||
fabricCanvas.off('object:modified', handleModified)
|
||||
fabricCanvas.off('object:added', handleModified)
|
||||
fabricCanvas.off('object:removed', handleModified)
|
||||
}
|
||||
}, [fabricCanvas, saveToHistory])
|
||||
|
||||
// Handle zoom
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) return
|
||||
fabricCanvas.setZoom(zoom)
|
||||
fabricCanvas.setDimensions({
|
||||
width: A4_WIDTH * zoom,
|
||||
height: A4_HEIGHT * zoom
|
||||
})
|
||||
fabricCanvas.renderAll()
|
||||
}, [fabricCanvas, zoom])
|
||||
|
||||
// Draw grid
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) return
|
||||
|
||||
// Remove existing grid
|
||||
const existingGrid = fabricCanvas.getObjects().filter((obj: any) => obj.isGrid)
|
||||
existingGrid.forEach((obj: any) => fabricCanvas.remove(obj))
|
||||
|
||||
if (showGrid) {
|
||||
const fabric = (window as any).fabric
|
||||
const gridSpacing = gridSize * 3.78 // Convert mm to pixels
|
||||
|
||||
// Vertical lines
|
||||
for (let x = gridSpacing; x < A4_WIDTH; x += gridSpacing) {
|
||||
const line = new fabric.Line([x, 0, x, A4_HEIGHT], {
|
||||
stroke: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
|
||||
strokeWidth: 0.5,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
isGrid: true,
|
||||
})
|
||||
fabricCanvas.add(line)
|
||||
fabricCanvas.sendObjectToBack(line)
|
||||
}
|
||||
|
||||
// Horizontal lines
|
||||
for (let y = gridSpacing; y < A4_HEIGHT; y += gridSpacing) {
|
||||
const line = new fabric.Line([0, y, A4_WIDTH, y], {
|
||||
stroke: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
|
||||
strokeWidth: 0.5,
|
||||
selectable: false,
|
||||
evented: false,
|
||||
isGrid: true,
|
||||
})
|
||||
fabricCanvas.add(line)
|
||||
fabricCanvas.sendObjectToBack(line)
|
||||
}
|
||||
}
|
||||
|
||||
fabricCanvas.renderAll()
|
||||
}, [fabricCanvas, showGrid, gridSize, isDark])
|
||||
|
||||
// Handle snap to grid
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) return
|
||||
|
||||
if (snapToGrid) {
|
||||
const gridSpacing = gridSize * 3.78
|
||||
|
||||
fabricCanvas.on('object:moving', (e: any) => {
|
||||
const obj = e.target
|
||||
obj.set({
|
||||
left: Math.round(obj.left / gridSpacing) * gridSpacing,
|
||||
top: Math.round(obj.top / gridSpacing) * gridSpacing
|
||||
})
|
||||
})
|
||||
}
|
||||
}, [fabricCanvas, snapToGrid, gridSize])
|
||||
|
||||
// Handle tool changes
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) return
|
||||
|
||||
const fabric = (window as any).fabric
|
||||
|
||||
// Reset drawing mode
|
||||
fabricCanvas.isDrawingMode = false
|
||||
fabricCanvas.selection = activeTool === 'select'
|
||||
|
||||
// Handle canvas click based on tool
|
||||
const handleMouseDown = (e: any) => {
|
||||
if (e.target) return // Clicked on an object
|
||||
|
||||
const pointer = fabricCanvas.getPointer(e.e)
|
||||
|
||||
switch (activeTool) {
|
||||
case 'text': {
|
||||
const text = new fabric.IText('Text eingeben', {
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
fontFamily: 'Arial',
|
||||
fontSize: 16,
|
||||
fill: isDark ? '#ffffff' : '#000000',
|
||||
})
|
||||
fabricCanvas.add(text)
|
||||
fabricCanvas.setActiveObject(text)
|
||||
text.enterEditing()
|
||||
setActiveTool('select')
|
||||
break
|
||||
}
|
||||
|
||||
case 'rectangle': {
|
||||
const rect = new fabric.Rect({
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
width: 100,
|
||||
height: 60,
|
||||
fill: 'transparent',
|
||||
stroke: isDark ? '#ffffff' : '#000000',
|
||||
strokeWidth: 2,
|
||||
})
|
||||
fabricCanvas.add(rect)
|
||||
fabricCanvas.setActiveObject(rect)
|
||||
setActiveTool('select')
|
||||
break
|
||||
}
|
||||
|
||||
case 'circle': {
|
||||
const circle = new fabric.Circle({
|
||||
left: pointer.x,
|
||||
top: pointer.y,
|
||||
radius: 40,
|
||||
fill: 'transparent',
|
||||
stroke: isDark ? '#ffffff' : '#000000',
|
||||
strokeWidth: 2,
|
||||
})
|
||||
fabricCanvas.add(circle)
|
||||
fabricCanvas.setActiveObject(circle)
|
||||
setActiveTool('select')
|
||||
break
|
||||
}
|
||||
|
||||
case 'line': {
|
||||
const line = new fabric.Line([pointer.x, pointer.y, pointer.x + 100, pointer.y], {
|
||||
stroke: isDark ? '#ffffff' : '#000000',
|
||||
strokeWidth: 2,
|
||||
})
|
||||
fabricCanvas.add(line)
|
||||
fabricCanvas.setActiveObject(line)
|
||||
setActiveTool('select')
|
||||
break
|
||||
}
|
||||
|
||||
case 'arrow': {
|
||||
// Create arrow using path
|
||||
const arrowPath = `M ${pointer.x} ${pointer.y} L ${pointer.x + 80} ${pointer.y} L ${pointer.x + 70} ${pointer.y - 8} M ${pointer.x + 80} ${pointer.y} L ${pointer.x + 70} ${pointer.y + 8}`
|
||||
const arrow = new fabric.Path(arrowPath, {
|
||||
fill: 'transparent',
|
||||
stroke: isDark ? '#ffffff' : '#000000',
|
||||
strokeWidth: 2,
|
||||
})
|
||||
fabricCanvas.add(arrow)
|
||||
fabricCanvas.setActiveObject(arrow)
|
||||
setActiveTool('select')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fabricCanvas.on('mouse:down', handleMouseDown)
|
||||
|
||||
return () => {
|
||||
fabricCanvas.off('mouse:down', handleMouseDown)
|
||||
}
|
||||
}, [fabricCanvas, activeTool, isDark, setActiveTool])
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!fabricCanvas) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't handle if typing in input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
|
||||
// Delete selected objects
|
||||
if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
const activeObjects = fabricCanvas.getActiveObjects()
|
||||
if (activeObjects.length > 0) {
|
||||
activeObjects.forEach((obj: any) => {
|
||||
if (!obj.isGrid) {
|
||||
fabricCanvas.remove(obj)
|
||||
}
|
||||
})
|
||||
fabricCanvas.discardActiveObject()
|
||||
fabricCanvas.renderAll()
|
||||
saveToHistory('delete')
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl/Cmd shortcuts
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
switch (e.key.toLowerCase()) {
|
||||
case 'c': // Copy
|
||||
if (fabricCanvas.getActiveObject()) {
|
||||
fabricCanvas.getActiveObject().clone((cloned: any) => {
|
||||
(window as any)._clipboard = cloned
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'v': // Paste
|
||||
if ((window as any)._clipboard) {
|
||||
(window as any)._clipboard.clone((cloned: any) => {
|
||||
cloned.set({
|
||||
left: cloned.left + 20,
|
||||
top: cloned.top + 20,
|
||||
})
|
||||
fabricCanvas.add(cloned)
|
||||
fabricCanvas.setActiveObject(cloned)
|
||||
fabricCanvas.renderAll()
|
||||
saveToHistory('paste')
|
||||
})
|
||||
}
|
||||
break
|
||||
|
||||
case 'a': // Select all
|
||||
e.preventDefault()
|
||||
const objects = fabricCanvas.getObjects().filter((obj: any) => !obj.isGrid)
|
||||
const fabric = (window as any).fabric
|
||||
const selection = new fabric.ActiveSelection(objects, { canvas: fabricCanvas })
|
||||
fabricCanvas.setActiveObject(selection)
|
||||
fabricCanvas.renderAll()
|
||||
break
|
||||
|
||||
case 'd': // Duplicate
|
||||
e.preventDefault()
|
||||
if (fabricCanvas.getActiveObject()) {
|
||||
fabricCanvas.getActiveObject().clone((cloned: any) => {
|
||||
cloned.set({
|
||||
left: cloned.left + 20,
|
||||
top: cloned.top + 20,
|
||||
})
|
||||
fabricCanvas.add(cloned)
|
||||
fabricCanvas.setActiveObject(cloned)
|
||||
fabricCanvas.renderAll()
|
||||
saveToHistory('duplicate')
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [fabricCanvas, saveToHistory])
|
||||
|
||||
// Add image to canvas
|
||||
const addImage = useCallback((url: string) => {
|
||||
if (!fabricCanvas) return
|
||||
|
||||
const fabric = (window as any).fabric
|
||||
fabric.Image.fromURL(url, (img: any) => {
|
||||
// Scale image to fit within canvas
|
||||
const maxWidth = A4_WIDTH * 0.8
|
||||
const maxHeight = A4_HEIGHT * 0.5
|
||||
const scale = Math.min(maxWidth / img.width, maxHeight / img.height, 1)
|
||||
|
||||
img.set({
|
||||
left: (A4_WIDTH - img.width * scale) / 2,
|
||||
top: (A4_HEIGHT - img.height * scale) / 2,
|
||||
scaleX: scale,
|
||||
scaleY: scale,
|
||||
})
|
||||
|
||||
fabricCanvas.add(img)
|
||||
fabricCanvas.setActiveObject(img)
|
||||
fabricCanvas.renderAll()
|
||||
saveToHistory('image:added')
|
||||
setActiveTool('select')
|
||||
}, { crossOrigin: 'anonymous' })
|
||||
}, [fabricCanvas, saveToHistory, setActiveTool])
|
||||
|
||||
// Expose addImage to context
|
||||
useEffect(() => {
|
||||
if (fabricCanvas) {
|
||||
(fabricCanvas as any).addImage = addImage
|
||||
}
|
||||
}, [fabricCanvas, addImage])
|
||||
|
||||
// Background styling
|
||||
const canvasContainerStyle = isDark
|
||||
? 'bg-slate-800 shadow-2xl'
|
||||
: 'bg-slate-200 shadow-xl'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`flex items-center justify-center overflow-auto p-8 ${className}`}
|
||||
style={{ minHeight: '100%' }}
|
||||
>
|
||||
<div className={`rounded-lg overflow-hidden ${canvasContainerStyle}`}>
|
||||
<canvas ref={canvasRef} id="worksheet-canvas" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* OCR Import Panel
|
||||
*
|
||||
* Loads OCR export data from the klausur-service API (shared between admin-v2 and studio-v2)
|
||||
* and imports recognized words as editable text objects onto the Fabric.js canvas.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
import type { OCRExportData, OCRWord } from '@/lib/worksheet-editor/ocr-integration'
|
||||
import { createTextProps, getColumnColor } from '@/lib/worksheet-editor/ocr-integration'
|
||||
|
||||
interface OCRImportPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function OCRImportPanel({ isOpen, onClose }: OCRImportPanelProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { canvas, saveToHistory } = useWorksheet()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ocrData, setOcrData] = useState<OCRExportData | null>(null)
|
||||
const [selectedWords, setSelectedWords] = useState<Set<number>>(new Set())
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [importSuccess, setImportSuccess] = useState(false)
|
||||
|
||||
// Load OCR data when panel opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
loadOCRData()
|
||||
}, [isOpen])
|
||||
|
||||
const loadOCRData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setOcrData(null)
|
||||
setSelectedWords(new Set())
|
||||
setImportSuccess(false)
|
||||
|
||||
try {
|
||||
const res = await fetch('/klausur-api/api/v1/vocab/ocr-export/latest')
|
||||
if (!res.ok) {
|
||||
throw new Error('not_found')
|
||||
}
|
||||
const data: OCRExportData = await res.json()
|
||||
setOcrData(data)
|
||||
// Select all words by default
|
||||
setSelectedWords(new Set(data.words.map((_, i) => i)))
|
||||
} catch {
|
||||
setError(
|
||||
'Keine OCR-Daten gefunden. Bitte zuerst im OCR-Compare Tool "Zum Editor exportieren" klicken.'
|
||||
)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toggleWord = useCallback((index: number) => {
|
||||
setSelectedWords(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(index)) {
|
||||
next.delete(index)
|
||||
} else {
|
||||
next.add(index)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
if (!ocrData) return
|
||||
setSelectedWords(new Set(ocrData.words.map((_, i) => i)))
|
||||
}, [ocrData])
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
setSelectedWords(new Set())
|
||||
}, [])
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!ocrData || !canvas || selectedWords.size === 0) return
|
||||
|
||||
setImporting(true)
|
||||
|
||||
try {
|
||||
// Dynamic import of fabric to avoid SSR issues
|
||||
const { IText } = await import('fabric')
|
||||
|
||||
// Save current state for undo
|
||||
if (saveToHistory) {
|
||||
saveToHistory('OCR Import')
|
||||
}
|
||||
|
||||
const wordsToImport = ocrData.words.filter((_, i) => selectedWords.has(i))
|
||||
|
||||
for (const word of wordsToImport) {
|
||||
const props = createTextProps(word)
|
||||
|
||||
const textObj = new IText(props.text, {
|
||||
left: props.left,
|
||||
top: props.top,
|
||||
fontSize: props.fontSize,
|
||||
fontFamily: props.fontFamily,
|
||||
fill: props.fill,
|
||||
editable: true,
|
||||
})
|
||||
|
||||
canvas.add(textObj)
|
||||
}
|
||||
|
||||
canvas.renderAll()
|
||||
setImportSuccess(true)
|
||||
|
||||
// Close after brief delay
|
||||
setTimeout(() => {
|
||||
onClose()
|
||||
}, 1500)
|
||||
} catch (e) {
|
||||
console.error('Import failed:', e)
|
||||
setError('Import fehlgeschlagen. Bitte erneut versuchen.')
|
||||
} finally {
|
||||
setImporting(false)
|
||||
}
|
||||
}, [ocrData, canvas, selectedWords, saveToHistory, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// Count column types
|
||||
const columnSummary = ocrData
|
||||
? ocrData.words.reduce<Record<string, number>>((acc, w) => {
|
||||
acc[w.column_type] = (acc[w.column_type] || 0) + 1
|
||||
return acc
|
||||
}, {})
|
||||
: {}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={`relative w-full max-w-2xl max-h-[85vh] rounded-3xl overflow-hidden flex flex-col ${
|
||||
isDark
|
||||
? 'bg-slate-900/95 border border-white/10'
|
||||
: 'bg-white/95 border border-slate-200'
|
||||
} backdrop-blur-xl`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`flex items-center justify-between px-6 py-4 border-b ${
|
||||
isDark ? 'border-white/10' : 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<h2
|
||||
className={`text-xl font-bold ${
|
||||
isDark ? 'text-white' : 'text-slate-900'
|
||||
}`}
|
||||
>
|
||||
OCR Daten importieren
|
||||
</h2>
|
||||
<p
|
||||
className={`text-sm mt-0.5 ${
|
||||
isDark ? 'text-white/50' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
Erkannte Texte aus der Grid-Analyse einfuegen
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-lg transition-colors ${
|
||||
isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className={`w-5 h-5 ${isDark ? 'text-white' : 'text-slate-600'}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4">
|
||||
{/* Loading */}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-green-500 border-t-transparent rounded-full" />
|
||||
<p
|
||||
className={`mt-3 text-sm ${
|
||||
isDark ? 'text-white/50' : 'text-slate-500'
|
||||
}`}
|
||||
>
|
||||
Lade OCR-Daten...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div
|
||||
className={`p-4 rounded-xl text-center ${
|
||||
isDark
|
||||
? 'bg-red-500/10 text-red-300'
|
||||
: 'bg-red-50 text-red-700'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="w-8 h-8 mx-auto mb-2 opacity-50"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm">{error}</p>
|
||||
<button
|
||||
onClick={loadOCRData}
|
||||
className={`mt-3 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success State */}
|
||||
{importSuccess && (
|
||||
<div
|
||||
className={`p-6 rounded-xl text-center ${
|
||||
isDark
|
||||
? 'bg-green-500/10 text-green-300'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}
|
||||
>
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto mb-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
<p className="font-medium">
|
||||
{selectedWords.size} Texte erfolgreich importiert!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OCR Data Display */}
|
||||
{ocrData && !importSuccess && (
|
||||
<>
|
||||
{/* Summary */}
|
||||
<div
|
||||
className={`p-4 rounded-xl mb-4 ${
|
||||
isDark ? 'bg-white/5' : 'bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap gap-3 mb-2">
|
||||
<span
|
||||
className={`px-3 py-1 rounded-lg text-sm font-medium ${
|
||||
isDark
|
||||
? 'bg-green-500/20 text-green-300'
|
||||
: 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
{ocrData.words.length} Woerter
|
||||
</span>
|
||||
{Object.entries(columnSummary).map(([type, count]) => (
|
||||
<span
|
||||
key={type}
|
||||
className="px-3 py-1 rounded-lg text-sm font-medium"
|
||||
style={{
|
||||
backgroundColor: getColumnColor(type as any) + '15',
|
||||
color: getColumnColor(type as any),
|
||||
}}
|
||||
>
|
||||
{type === 'english'
|
||||
? 'Englisch'
|
||||
: type === 'german'
|
||||
? 'Deutsch'
|
||||
: type === 'example'
|
||||
? 'Beispiel'
|
||||
: 'Unbekannt'}
|
||||
: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p
|
||||
className={`text-xs ${
|
||||
isDark ? 'text-white/40' : 'text-slate-400'
|
||||
}`}
|
||||
>
|
||||
Session: {ocrData.session_id.slice(0, 8)}... | Seite{' '}
|
||||
{ocrData.page_number} | Exportiert:{' '}
|
||||
{new Date(ocrData.exported_at).toLocaleString('de-DE')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Selection Controls */}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span
|
||||
className={`text-sm font-medium ${
|
||||
isDark ? 'text-white/70' : 'text-slate-600'
|
||||
}`}
|
||||
>
|
||||
{selectedWords.size} von {ocrData.words.length} ausgewaehlt
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Alle
|
||||
</button>
|
||||
<button
|
||||
onClick={deselectAll}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Keine
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Word List */}
|
||||
<div className="space-y-1 max-h-[40vh] overflow-y-auto">
|
||||
{ocrData.words.map((word, idx) => (
|
||||
<label
|
||||
key={idx}
|
||||
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${
|
||||
selectedWords.has(idx)
|
||||
? isDark
|
||||
? 'bg-green-500/10'
|
||||
: 'bg-green-50'
|
||||
: isDark
|
||||
? 'hover:bg-white/5'
|
||||
: 'hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWords.has(idx)}
|
||||
onChange={() => toggleWord(idx)}
|
||||
className="rounded border-slate-300"
|
||||
/>
|
||||
<span
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: getColumnColor(word.column_type),
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className={`text-sm flex-1 ${
|
||||
isDark ? 'text-white' : 'text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{word.text}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-2 py-0.5 rounded ${
|
||||
isDark ? 'bg-white/10 text-white/40' : 'bg-slate-100 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{word.column_type === 'english'
|
||||
? 'EN'
|
||||
: word.column_type === 'german'
|
||||
? 'DE'
|
||||
: word.column_type === 'example'
|
||||
? 'Ex'
|
||||
: '?'}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{ocrData && !importSuccess && (
|
||||
<div
|
||||
className={`px-6 py-4 border-t ${
|
||||
isDark ? 'border-white/10' : 'border-slate-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${
|
||||
isDark
|
||||
? 'text-white/60 hover:bg-white/10'
|
||||
: 'text-slate-600 hover:bg-slate-100'
|
||||
}`}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={importing || selectedWords.size === 0}
|
||||
className={`px-6 py-2.5 rounded-xl text-sm font-medium transition-colors flex items-center gap-2 ${
|
||||
selectedWords.size > 0
|
||||
? 'bg-green-600 text-white hover:bg-green-700'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/30 cursor-not-allowed'
|
||||
: 'bg-slate-200 text-slate-400 cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{importing ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{selectedWords.size} Texte importieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useLanguage } from '@/lib/LanguageContext'
|
||||
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
|
||||
interface PageNavigatorProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function PageNavigator({ className = '' }: PageNavigatorProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { t } = useLanguage()
|
||||
const {
|
||||
document,
|
||||
currentPageIndex,
|
||||
setCurrentPageIndex,
|
||||
addPage,
|
||||
deletePage,
|
||||
canvas
|
||||
} = useWorksheet()
|
||||
|
||||
const pages = document?.pages || []
|
||||
|
||||
const handlePageChange = (index: number) => {
|
||||
if (!canvas || !document || index === currentPageIndex) return
|
||||
|
||||
// Save current page state
|
||||
const currentPage = document.pages[currentPageIndex]
|
||||
if (currentPage) {
|
||||
currentPage.canvasJSON = JSON.stringify(canvas.toJSON())
|
||||
}
|
||||
|
||||
// Load new page
|
||||
setCurrentPageIndex(index)
|
||||
const newPage = document.pages[index]
|
||||
if (newPage?.canvasJSON) {
|
||||
canvas.loadFromJSON(JSON.parse(newPage.canvasJSON), () => {
|
||||
canvas.renderAll()
|
||||
})
|
||||
} else {
|
||||
// Clear canvas for new page
|
||||
canvas.clear()
|
||||
canvas.backgroundColor = '#ffffff'
|
||||
canvas.renderAll()
|
||||
}
|
||||
}
|
||||
|
||||
// Glassmorphism styles
|
||||
const containerStyle = isDark
|
||||
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
||||
: 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-xl'
|
||||
|
||||
const pageButtonStyle = (isActive: boolean) => isDark
|
||||
? isActive
|
||||
? 'bg-purple-500/30 text-purple-300 border-purple-400/50'
|
||||
: 'bg-white/5 text-white/70 border-white/10 hover:bg-white/10'
|
||||
: isActive
|
||||
? 'bg-purple-100 text-purple-700 border-purple-300'
|
||||
: 'bg-white/50 text-slate-600 border-slate-200 hover:bg-slate-100'
|
||||
|
||||
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 p-3 rounded-2xl ${containerStyle} ${className}`}>
|
||||
{/* Page Label */}
|
||||
<span className={`text-sm font-medium ${labelStyle}`}>
|
||||
Seiten:
|
||||
</span>
|
||||
|
||||
{/* Page Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{pages.map((page, index) => (
|
||||
<div key={page.id} className="relative group">
|
||||
<button
|
||||
onClick={() => handlePageChange(index)}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-lg border text-sm font-medium transition-all ${pageButtonStyle(index === currentPageIndex)}`}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
|
||||
{/* Delete button (visible on hover, not for single page) */}
|
||||
{pages.length > 1 && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
deletePage(index)
|
||||
}}
|
||||
className={`absolute -top-2 -right-2 w-5 h-5 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity ${
|
||||
isDark
|
||||
? 'bg-red-500/80 text-white hover:bg-red-500'
|
||||
: 'bg-red-500 text-white hover:bg-red-600'
|
||||
}`}
|
||||
title="Seite löschen"
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add Page Button */}
|
||||
<button
|
||||
onClick={addPage}
|
||||
className={`w-10 h-10 flex items-center justify-center rounded-lg border border-dashed transition-all ${
|
||||
isDark
|
||||
? 'border-white/30 text-white/50 hover:border-white/50 hover:text-white/70 hover:bg-white/5'
|
||||
: 'border-slate-300 text-slate-400 hover:border-slate-400 hover:text-slate-500 hover:bg-slate-50'
|
||||
}`}
|
||||
title="Seite hinzufügen"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Page Info */}
|
||||
<span className={`text-sm ${labelStyle}`}>
|
||||
{currentPageIndex + 1} / {pages.length}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user