fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,361 @@
'use client'
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from 'react'
/**
* Activity Tracking System
*
* Tracks user activities and calculates time savings compared to manual work.
* Used for the "Zeitersparnis" dashboard tile and weekly reports.
*/
// =============================================================================
// TYPES
// =============================================================================
export type ActivityType =
| 'vocab_extraction'
| 'klausur_correction'
| 'worksheet_creation'
| 'fairness_check'
| 'gutachten_generation'
export interface ActivityMetadata {
vocabCount?: number
studentCount?: number
criteriaCount?: number
elementCount?: number
pageCount?: number
description?: string
}
export interface ActivityLog {
id: string
type: ActivityType
startedAt: string // ISO string for serialization
completedAt: string
actualSeconds: number
estimatedManualSeconds: number
savedSeconds: number
metadata: ActivityMetadata
}
export interface ActivityStats {
todaySavedSeconds: number
weekSavedSeconds: number
monthSavedSeconds: number
totalSavedSeconds: number
activityCount: number
lastActivity: ActivityLog | null
}
interface ActiveActivity {
type: ActivityType
startedAt: Date
metadata: ActivityMetadata
}
interface ActivityContextType {
// Start tracking an activity
startActivity: (type: ActivityType, metadata?: ActivityMetadata) => void
// Complete the current activity
completeActivity: (additionalMetadata?: ActivityMetadata) => ActivityLog | null
// Cancel without saving
cancelActivity: () => void
// Check if currently tracking
isTracking: boolean
currentActivity: ActiveActivity | null
// Get statistics
stats: ActivityStats
// Get recent activities
recentActivities: ActivityLog[]
// Refresh stats from storage
refreshStats: () => void
}
// =============================================================================
// ESTIMATION FORMULAS
// =============================================================================
/**
* Calculate estimated manual time based on activity type and metadata
*/
function calculateManualEstimate(type: ActivityType, metadata: ActivityMetadata): number {
switch (type) {
case 'vocab_extraction': {
// Typing: ~25 seconds per vocab (word + translation + example)
// Layout: 15 minutes base
const vocabCount = metadata.vocabCount || 20
return (vocabCount * 25) + (15 * 60)
}
case 'klausur_correction': {
// Reading + grading + gutachten: ~45 minutes per student
const studentCount = metadata.studentCount || 1
return studentCount * 45 * 60
}
case 'worksheet_creation': {
// Design + formatting: ~30 seconds per element + 20 min base
const elementCount = metadata.elementCount || 10
return (elementCount * 30) + (20 * 60)
}
case 'fairness_check': {
// Manual comparison: ~5 minutes per student
const studentCount = metadata.studentCount || 10
return studentCount * 5 * 60
}
case 'gutachten_generation': {
// Writing gutachten manually: ~15 minutes per student
const studentCount = metadata.studentCount || 1
return studentCount * 15 * 60
}
default:
return 10 * 60 // 10 minutes default
}
}
// =============================================================================
// STORAGE
// =============================================================================
const STORAGE_KEY = 'breakpilot_activity_logs'
function loadActivities(): ActivityLog[] {
if (typeof window === 'undefined') return []
try {
const stored = localStorage.getItem(STORAGE_KEY)
return stored ? JSON.parse(stored) : []
} catch {
return []
}
}
function saveActivities(activities: ActivityLog[]): void {
if (typeof window === 'undefined') return
try {
// Keep only last 1000 activities to prevent storage overflow
const trimmed = activities.slice(-1000)
localStorage.setItem(STORAGE_KEY, JSON.stringify(trimmed))
} catch (e) {
console.error('Failed to save activities:', e)
}
}
function addActivity(activity: ActivityLog): void {
const activities = loadActivities()
activities.push(activity)
saveActivities(activities)
}
// =============================================================================
// STATISTICS CALCULATION
// =============================================================================
function calculateStats(activities: ActivityLog[]): ActivityStats {
const now = new Date()
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const weekStart = new Date(todayStart)
weekStart.setDate(weekStart.getDate() - weekStart.getDay() + 1) // Monday
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1)
let todaySavedSeconds = 0
let weekSavedSeconds = 0
let monthSavedSeconds = 0
let totalSavedSeconds = 0
for (const activity of activities) {
const activityDate = new Date(activity.completedAt)
totalSavedSeconds += activity.savedSeconds
if (activityDate >= monthStart) {
monthSavedSeconds += activity.savedSeconds
}
if (activityDate >= weekStart) {
weekSavedSeconds += activity.savedSeconds
}
if (activityDate >= todayStart) {
todaySavedSeconds += activity.savedSeconds
}
}
const lastActivity = activities.length > 0 ? activities[activities.length - 1] : null
return {
todaySavedSeconds,
weekSavedSeconds,
monthSavedSeconds,
totalSavedSeconds,
activityCount: activities.length,
lastActivity,
}
}
// =============================================================================
// CONTEXT
// =============================================================================
const ActivityContext = createContext<ActivityContextType | null>(null)
export function ActivityProvider({ children }: { children: React.ReactNode }) {
const [currentActivity, setCurrentActivity] = useState<ActiveActivity | null>(null)
const [stats, setStats] = useState<ActivityStats>({
todaySavedSeconds: 0,
weekSavedSeconds: 0,
monthSavedSeconds: 0,
totalSavedSeconds: 0,
activityCount: 0,
lastActivity: null,
})
const [recentActivities, setRecentActivities] = useState<ActivityLog[]>([])
// Load initial stats
useEffect(() => {
refreshStats()
}, [])
const refreshStats = useCallback(() => {
const activities = loadActivities()
setStats(calculateStats(activities))
setRecentActivities(activities.slice(-10).reverse())
}, [])
const startActivity = useCallback((type: ActivityType, metadata: ActivityMetadata = {}) => {
setCurrentActivity({
type,
startedAt: new Date(),
metadata,
})
}, [])
const completeActivity = useCallback((additionalMetadata: ActivityMetadata = {}): ActivityLog | null => {
if (!currentActivity) return null
const completedAt = new Date()
const actualSeconds = Math.round((completedAt.getTime() - currentActivity.startedAt.getTime()) / 1000)
const mergedMetadata = { ...currentActivity.metadata, ...additionalMetadata }
const estimatedManualSeconds = calculateManualEstimate(currentActivity.type, mergedMetadata)
const savedSeconds = Math.max(0, estimatedManualSeconds - actualSeconds)
const log: ActivityLog = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
type: currentActivity.type,
startedAt: currentActivity.startedAt.toISOString(),
completedAt: completedAt.toISOString(),
actualSeconds,
estimatedManualSeconds,
savedSeconds,
metadata: mergedMetadata,
}
addActivity(log)
setCurrentActivity(null)
refreshStats()
return log
}, [currentActivity, refreshStats])
const cancelActivity = useCallback(() => {
setCurrentActivity(null)
}, [])
return (
<ActivityContext.Provider
value={{
startActivity,
completeActivity,
cancelActivity,
isTracking: currentActivity !== null,
currentActivity,
stats,
recentActivities,
refreshStats,
}}
>
{children}
</ActivityContext.Provider>
)
}
export function useActivity() {
const context = useContext(ActivityContext)
if (!context) {
// Return a no-op version if not wrapped in provider
return {
startActivity: () => {},
completeActivity: () => null,
cancelActivity: () => {},
isTracking: false,
currentActivity: null,
stats: {
todaySavedSeconds: 0,
weekSavedSeconds: 0,
monthSavedSeconds: 0,
totalSavedSeconds: 0,
activityCount: 0,
lastActivity: null,
},
recentActivities: [],
refreshStats: () => {},
}
}
return context
}
// =============================================================================
// UTILITY FUNCTIONS
// =============================================================================
/**
* Format seconds as human-readable duration
*/
export function formatDuration(seconds: number): string {
if (seconds < 60) {
return `${seconds}s`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
return minutes > 0 ? `${hours}h ${minutes}min` : `${hours}h`
}
return `${minutes}min`
}
/**
* Format seconds as compact duration (for tiles)
*/
export function formatDurationCompact(seconds: number): { value: string; unit: string } {
if (seconds < 60) {
return { value: String(seconds), unit: 's' }
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
if (hours > 0) {
const decimal = Math.round((minutes / 60) * 10) / 10
return { value: String(hours + (decimal > 0 ? decimal : 0)), unit: 'h' }
}
return { value: String(minutes), unit: 'min' }
}
/**
* Get activity type display name
*/
export function getActivityTypeName(type: ActivityType): string {
const names: Record<ActivityType, string> = {
vocab_extraction: 'Vokabelextraktion',
klausur_correction: 'Klausurkorrektur',
worksheet_creation: 'Arbeitsblatt erstellt',
fairness_check: 'Fairness-Check',
gutachten_generation: 'Gutachten generiert',
}
return names[type] || type
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,334 @@
'use client'
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
// Types
export type AlertImportance = 'KRITISCH' | 'DRINGEND' | 'WICHTIG' | 'PRUEFEN' | 'INFO'
export interface AlertSource {
title: string
url: string
domain: string
}
export interface Alert {
id: string
title: string
summary: string // LLM-generierte Zusammenfassung
source: string
importance: AlertImportance
timestamp: Date
isRead: boolean
sources: AlertSource[]
topicId?: string
}
export interface Topic {
id: string
name: string
keywords: string[]
googleAlertUrl?: string
rssFeedUrl?: string
isActive: boolean
icon?: string
}
export interface AlertsSettings {
notificationFrequency: 'realtime' | 'hourly' | 'daily'
minImportance: AlertImportance
wizardCompleted: boolean
}
interface AlertsContextType {
alerts: Alert[]
unreadCount: number
isLoading: boolean
error: string | null
fetchAlerts: () => Promise<void>
markAsRead: (id: string) => void
markAllAsRead: () => void
topics: Topic[]
addTopic: (topic: Topic) => void
updateTopic: (id: string, updates: Partial<Topic>) => void
removeTopic: (id: string) => void
settings: AlertsSettings
updateSettings: (settings: Partial<AlertsSettings>) => void
}
const AlertsContext = createContext<AlertsContextType | null>(null)
// LocalStorage Keys
const ALERTS_KEY = 'bp_alerts'
const TOPICS_KEY = 'bp_alerts_topics'
const SETTINGS_KEY = 'bp_alerts_settings'
// Default settings
const defaultSettings: AlertsSettings = {
notificationFrequency: 'daily',
minImportance: 'INFO',
wizardCompleted: false
}
// Vordefinierte Themen fuer Lehrer
export const lehrerThemen: Omit<Topic, 'id'>[] = [
{ name: 'Bildungspolitik', icon: '📜', keywords: ['Kultusministerium', 'Schulreform', 'Lehrplan', 'Bildungsminister'], isActive: false },
{ name: 'Digitale Bildung', icon: '💻', keywords: ['iPad Schule', 'digitale Medien', 'E-Learning', 'Tablet Unterricht'], isActive: false },
{ name: 'Inklusion', icon: '🤝', keywords: ['Förderschule', 'Integration', 'barrierefreies Lernen', 'Inklusion Schule'], isActive: false },
{ name: 'Abitur & Prüfungen', icon: '📝', keywords: ['Abitur', 'Zentralabitur', 'Prüfungsordnung', 'Abiturprüfung'], isActive: false },
{ name: 'Lehrerberuf', icon: '👩‍🏫', keywords: ['Lehrkräftemangel', 'Besoldung', 'Quereinsteiger', 'Lehrergehalt'], isActive: false },
{ name: 'KI in der Schule', icon: '🤖', keywords: ['ChatGPT Schule', 'KI Unterricht', 'künstliche Intelligenz Bildung'], isActive: false },
]
// Mock-Alerts für Demo (werden später durch Backend ersetzt)
const mockAlerts: Alert[] = [
{
id: 'alert-1',
title: 'Niedersachsen plant Digitalisierungsoffensive an Schulen',
summary: 'Das Kultusministerium Niedersachsen kündigt ein 50-Millionen-Programm zur Digitalisierung an. Alle weiterführenden Schulen sollen bis 2027 mit interaktiven Displays ausgestattet werden. Fortbildungen für Lehrkräfte sind Teil des Pakets.',
source: 'Hannoversche Allgemeine',
importance: 'WICHTIG',
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
isRead: false,
sources: [
{ title: 'Digitalisierung in Niedersachsens Schulen', url: 'https://example.com/1', domain: 'haz.de' }
],
topicId: 'digitale-bildung'
},
{
id: 'alert-2',
title: 'Neue Richtlinien für ChatGPT im Unterricht veröffentlicht',
summary: 'Die KMK hat Empfehlungen zum Umgang mit KI-Tools im Unterricht herausgegeben. Lehrkräfte sollen KI als Werkzeug nutzen dürfen, Prüfungsleistungen müssen jedoch eigenständig erbracht werden.',
source: 'Der Spiegel',
importance: 'DRINGEND',
timestamp: new Date(Date.now() - 5 * 60 * 60 * 1000),
isRead: false,
sources: [
{ title: 'KMK-Richtlinien zu KI', url: 'https://example.com/2', domain: 'spiegel.de' }
],
topicId: 'ki-schule'
},
{
id: 'alert-3',
title: 'Lehrerverband fordert bessere Besoldung',
summary: 'Der Deutsche Lehrerverband kritisiert die aktuelle Besoldungssituation. Besonders Grundschullehrkräfte seien im Vergleich zu anderen Bundesländern benachteiligt.',
source: 'Zeit Online',
importance: 'PRUEFEN',
timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000),
isRead: true,
sources: [
{ title: 'Lehrergehälter im Vergleich', url: 'https://example.com/3', domain: 'zeit.de' }
],
topicId: 'lehrerberuf'
}
]
export function AlertsProvider({ children }: { children: ReactNode }) {
const [alerts, setAlerts] = useState<Alert[]>([])
const [topics, setTopics] = useState<Topic[]>([])
const [settings, setSettings] = useState<AlertsSettings>(defaultSettings)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [mounted, setMounted] = useState(false)
// Nach dem ersten Render: Daten aus localStorage laden
useEffect(() => {
const storedAlerts = localStorage.getItem(ALERTS_KEY)
const storedTopics = localStorage.getItem(TOPICS_KEY)
const storedSettings = localStorage.getItem(SETTINGS_KEY)
if (storedAlerts) {
try {
const parsed = JSON.parse(storedAlerts)
// Konvertiere timestamp strings zu Date objects
setAlerts(parsed.map((a: any) => ({ ...a, timestamp: new Date(a.timestamp) })))
} catch (e) {
console.error('Error parsing stored alerts:', e)
setAlerts(mockAlerts)
}
} else {
// Demo-Alerts laden
setAlerts(mockAlerts)
}
if (storedTopics) {
try {
setTopics(JSON.parse(storedTopics))
} catch (e) {
console.error('Error parsing stored topics:', e)
}
}
if (storedSettings) {
try {
setSettings({ ...defaultSettings, ...JSON.parse(storedSettings) })
} catch (e) {
console.error('Error parsing stored settings:', e)
}
}
setMounted(true)
}, [])
// Alerts in localStorage speichern
useEffect(() => {
if (mounted && alerts.length > 0) {
localStorage.setItem(ALERTS_KEY, JSON.stringify(alerts))
}
}, [alerts, mounted])
// Topics in localStorage speichern
useEffect(() => {
if (mounted) {
localStorage.setItem(TOPICS_KEY, JSON.stringify(topics))
}
}, [topics, mounted])
// Settings in localStorage speichern
useEffect(() => {
if (mounted) {
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
}
}, [settings, mounted])
// Alerts vom Backend abrufen
const fetchAlerts = useCallback(async () => {
setIsLoading(true)
setError(null)
try {
// TODO: Backend-Integration
// const response = await fetch('http://macmini:8000/api/alerts/')
// const data = await response.json()
// setAlerts(data)
// Für jetzt: Mock-Daten verwenden
await new Promise(resolve => setTimeout(resolve, 500))
// Alerts sind bereits geladen
} catch (err) {
setError('Fehler beim Laden der Alerts')
console.error('Error fetching alerts:', err)
} finally {
setIsLoading(false)
}
}, [])
// Alert als gelesen markieren
const markAsRead = useCallback((id: string) => {
setAlerts(prev => prev.map(alert =>
alert.id === id ? { ...alert, isRead: true } : alert
))
}, [])
// Alle Alerts als gelesen markieren
const markAllAsRead = useCallback(() => {
setAlerts(prev => prev.map(alert => ({ ...alert, isRead: true })))
}, [])
// Topic hinzufügen
const addTopic = useCallback((topic: Topic) => {
setTopics(prev => [...prev, topic])
}, [])
// Topic aktualisieren
const updateTopic = useCallback((id: string, updates: Partial<Topic>) => {
setTopics(prev => prev.map(topic =>
topic.id === id ? { ...topic, ...updates } : topic
))
}, [])
// Topic entfernen
const removeTopic = useCallback((id: string) => {
setTopics(prev => prev.filter(topic => topic.id !== id))
}, [])
// Settings aktualisieren
const updateSettings = useCallback((updates: Partial<AlertsSettings>) => {
setSettings(prev => ({ ...prev, ...updates }))
}, [])
// Ungelesene Alerts zählen
const unreadCount = alerts.filter(a => !a.isRead).length
// Während SSR: Default-Werte anzeigen
if (!mounted) {
return (
<AlertsContext.Provider
value={{
alerts: [],
unreadCount: 0,
isLoading: false,
error: null,
fetchAlerts: async () => {},
markAsRead: () => {},
markAllAsRead: () => {},
topics: [],
addTopic: () => {},
updateTopic: () => {},
removeTopic: () => {},
settings: defaultSettings,
updateSettings: () => {},
}}
>
{children}
</AlertsContext.Provider>
)
}
return (
<AlertsContext.Provider
value={{
alerts,
unreadCount,
isLoading,
error,
fetchAlerts,
markAsRead,
markAllAsRead,
topics,
addTopic,
updateTopic,
removeTopic,
settings,
updateSettings,
}}
>
{children}
</AlertsContext.Provider>
)
}
// Hook fuer einfache Verwendung
export function useAlerts() {
const context = useContext(AlertsContext)
if (!context) {
throw new Error('useAlerts must be used within an AlertsProvider')
}
return context
}
// Hilfsfunktion: Importance-Farben
export function getImportanceColor(importance: AlertImportance, isDark: boolean): string {
const colors = {
'KRITISCH': isDark ? 'bg-red-500/20 text-red-300 border-red-500/30' : 'bg-red-100 text-red-700 border-red-200',
'DRINGEND': isDark ? 'bg-orange-500/20 text-orange-300 border-orange-500/30' : 'bg-orange-100 text-orange-700 border-orange-200',
'WICHTIG': isDark ? 'bg-yellow-500/20 text-yellow-300 border-yellow-500/30' : 'bg-yellow-100 text-yellow-700 border-yellow-200',
'PRUEFEN': isDark ? 'bg-blue-500/20 text-blue-300 border-blue-500/30' : 'bg-blue-100 text-blue-700 border-blue-200',
'INFO': isDark ? 'bg-slate-500/20 text-slate-300 border-slate-500/30' : 'bg-slate-100 text-slate-600 border-slate-200',
}
return colors[importance]
}
// Hilfsfunktion: Relative Zeitangabe
export function getRelativeTime(date: Date): string {
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / (1000 * 60))
const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
if (diffMins < 1) return 'Gerade eben'
if (diffMins < 60) return `vor ${diffMins} Min.`
if (diffHours < 24) return `vor ${diffHours} Std.`
if (diffDays === 1) return 'Gestern'
if (diffDays < 7) return `vor ${diffDays} Tagen`
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}

View File

@@ -0,0 +1,90 @@
'use client'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
import {
Language,
defaultLanguage,
getStoredLanguage,
setStoredLanguage,
translations,
availableLanguages,
isRTL,
} from './i18n'
interface LanguageContextType {
language: Language
setLanguage: (lang: Language) => void
t: (key: string) => string
isRTL: boolean
availableLanguages: typeof availableLanguages
}
const LanguageContext = createContext<LanguageContextType | null>(null)
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>(defaultLanguage)
const [mounted, setMounted] = useState(false)
// Nach dem ersten Render: Sprache aus localStorage laden
useEffect(() => {
const stored = getStoredLanguage()
setLanguageState(stored)
setMounted(true)
}, [])
// Sprache setzen und speichern
const setLanguage = (lang: Language) => {
setLanguageState(lang)
setStoredLanguage(lang)
// Optional: document.dir fuer RTL setzen
if (typeof document !== 'undefined') {
document.documentElement.dir = isRTL(lang) ? 'rtl' : 'ltr'
document.documentElement.lang = lang
}
}
// Uebersetzungsfunktion
const t = (key: string): string => {
return translations[language][key] || translations[defaultLanguage][key] || key
}
// Waehrend SSR: Default anzeigen
if (!mounted) {
return (
<LanguageContext.Provider
value={{
language: defaultLanguage,
setLanguage: () => {},
t: (key) => translations[defaultLanguage][key] || key,
isRTL: false,
availableLanguages,
}}
>
{children}
</LanguageContext.Provider>
)
}
return (
<LanguageContext.Provider
value={{
language,
setLanguage,
t,
isRTL: isRTL(language),
availableLanguages,
}}
>
{children}
</LanguageContext.Provider>
)
}
// Hook fuer einfache Verwendung
export function useLanguage() {
const context = useContext(LanguageContext)
if (!context) {
throw new Error('useLanguage must be used within a LanguageProvider')
}
return context
}

View File

@@ -0,0 +1,925 @@
'use client'
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
// ============================================
// TYPES
// ============================================
export interface Contact {
id: string
name: string
email?: string
phone?: string
role: 'parent' | 'teacher' | 'staff' | 'student'
student_name?: string
class_name?: string
notes?: string
tags: string[]
avatar_url?: string
preferred_channel: 'email' | 'matrix' | 'pwa'
online: boolean
last_seen?: string
created_at: string
updated_at: string
}
export interface Message {
id: string
conversation_id: string
sender_id: string // "self" for own messages
content: string
content_type: 'text' | 'file' | 'image' | 'voice'
file_url?: string
file_name?: string
timestamp: string
read: boolean
read_at?: string
delivered: boolean
send_email: boolean
email_sent: boolean
email_sent_at?: string
email_error?: string
reply_to?: string // ID of message being replied to
reactions?: { emoji: string; user_id: string }[]
}
export interface Conversation {
id: string
participant_ids: string[]
group_id?: string
created_at: string
updated_at: string
last_message?: string
last_message_time?: string
unread_count: number
is_group: boolean
title?: string
typing?: boolean // Someone is typing
pinned?: boolean
muted?: boolean
archived?: boolean
}
export interface MessageTemplate {
id: string
name: string
content: string
created_at: string
}
export interface MessagesStats {
total_contacts: number
total_conversations: number
total_messages: number
unread_messages: number
}
// ============================================
// CONTEXT INTERFACE
// ============================================
interface MessagesContextType {
// Data
contacts: Contact[]
conversations: Conversation[]
messages: Record<string, Message[]> // conversationId -> messages
templates: MessageTemplate[]
stats: MessagesStats
// Computed
unreadCount: number
recentConversations: Conversation[]
// Actions
fetchContacts: () => Promise<void>
fetchConversations: () => Promise<void>
fetchMessages: (conversationId: string) => Promise<Message[]>
sendMessage: (conversationId: string, content: string, sendEmail?: boolean, replyTo?: string) => Promise<Message | null>
markAsRead: (conversationId: string) => Promise<void>
createConversation: (contactId: string) => Promise<Conversation | null>
addReaction: (messageId: string, emoji: string) => void
deleteMessage: (conversationId: string, messageId: string) => void
pinConversation: (conversationId: string) => void
muteConversation: (conversationId: string) => void
// State
isLoading: boolean
error: string | null
currentConversationId: string | null
setCurrentConversationId: (id: string | null) => void
}
const MessagesContext = createContext<MessagesContextType | null>(null)
// ============================================
// MOCK DATA - Realistic German school context
// ============================================
const mockContacts: Contact[] = [
{
id: 'contact_mueller',
name: 'Familie Mueller',
email: 'familie.mueller@gmail.com',
phone: '+49 170 1234567',
role: 'parent',
student_name: 'Max Mueller',
class_name: '10a',
notes: 'Bevorzugt Kommunikation per E-Mail',
tags: ['aktiv', 'Elternbeirat'],
preferred_channel: 'email',
online: false,
last_seen: new Date(Date.now() - 1800000).toISOString(),
created_at: new Date(Date.now() - 86400000 * 30).toISOString(),
updated_at: new Date().toISOString()
},
{
id: 'contact_schmidt',
name: 'Petra Schmidt',
email: 'p.schmidt@web.de',
phone: '+49 171 9876543',
role: 'parent',
student_name: 'Lisa Schmidt',
class_name: '10a',
tags: ['responsive'],
preferred_channel: 'pwa',
online: true,
created_at: new Date(Date.now() - 86400000 * 60).toISOString(),
updated_at: new Date().toISOString()
},
{
id: 'contact_weber',
name: 'Sabine Weber',
email: 's.weber@schule-musterstadt.de',
role: 'teacher',
tags: ['Fachschaft Deutsch', 'Klassenleitung 9b'],
preferred_channel: 'pwa',
online: true,
last_seen: new Date().toISOString(),
created_at: new Date(Date.now() - 86400000 * 90).toISOString(),
updated_at: new Date().toISOString()
},
{
id: 'contact_hoffmann',
name: 'Thomas Hoffmann',
email: 't.hoffmann@schule-musterstadt.de',
role: 'teacher',
tags: ['Fachschaft Mathe', 'Oberstufenkoordinator'],
preferred_channel: 'pwa',
online: false,
last_seen: new Date(Date.now() - 3600000 * 2).toISOString(),
created_at: new Date(Date.now() - 86400000 * 120).toISOString(),
updated_at: new Date().toISOString()
},
{
id: 'contact_becker',
name: 'Familie Becker',
email: 'becker.familie@gmx.de',
phone: '+49 172 5551234',
role: 'parent',
student_name: 'Tim Becker',
class_name: '10a',
tags: [],
preferred_channel: 'email',
online: false,
last_seen: new Date(Date.now() - 86400000).toISOString(),
created_at: new Date(Date.now() - 86400000 * 45).toISOString(),
updated_at: new Date().toISOString()
},
{
id: 'contact_klein',
name: 'Monika Klein',
email: 'm.klein@schule-musterstadt.de',
role: 'staff',
tags: ['Sekretariat'],
preferred_channel: 'pwa',
online: true,
created_at: new Date(Date.now() - 86400000 * 180).toISOString(),
updated_at: new Date().toISOString()
},
{
id: 'contact_fischer',
name: 'Familie Fischer',
email: 'fischer@t-online.de',
phone: '+49 173 4445566',
role: 'parent',
student_name: 'Anna Fischer',
class_name: '11b',
tags: ['Foerderverein'],
preferred_channel: 'pwa',
online: false,
last_seen: new Date(Date.now() - 7200000).toISOString(),
created_at: new Date(Date.now() - 86400000 * 75).toISOString(),
updated_at: new Date().toISOString()
},
{
id: 'contact_meyer',
name: 'Dr. Hans Meyer',
email: 'h.meyer@schule-musterstadt.de',
role: 'teacher',
tags: ['Schulleitung', 'Stellvertretender Schulleiter'],
preferred_channel: 'email',
online: false,
last_seen: new Date(Date.now() - 3600000).toISOString(),
created_at: new Date(Date.now() - 86400000 * 365).toISOString(),
updated_at: new Date().toISOString()
}
]
const mockConversations: Conversation[] = [
{
id: 'conv_mueller',
participant_ids: ['contact_mueller'],
created_at: new Date(Date.now() - 86400000 * 7).toISOString(),
updated_at: new Date(Date.now() - 300000).toISOString(),
last_message: 'Vielen Dank fuer die Info! Max freut sich schon auf die Klassenfahrt 🎉',
last_message_time: new Date(Date.now() - 300000).toISOString(),
unread_count: 2,
is_group: false,
title: 'Familie Mueller',
pinned: true
},
{
id: 'conv_schmidt',
participant_ids: ['contact_schmidt'],
created_at: new Date(Date.now() - 86400000 * 14).toISOString(),
updated_at: new Date(Date.now() - 3600000).toISOString(),
last_message: 'Lisa war heute krank, sie kommt morgen wieder.',
last_message_time: new Date(Date.now() - 3600000).toISOString(),
unread_count: 0,
is_group: false,
title: 'Petra Schmidt'
},
{
id: 'conv_weber',
participant_ids: ['contact_weber'],
created_at: new Date(Date.now() - 86400000 * 30).toISOString(),
updated_at: new Date(Date.now() - 7200000).toISOString(),
last_message: 'Koenntest du mir die Klausuraufgaben bis Freitag schicken? 📝',
last_message_time: new Date(Date.now() - 7200000).toISOString(),
unread_count: 1,
is_group: false,
title: 'Sabine Weber',
typing: true
},
{
id: 'conv_hoffmann',
participant_ids: ['contact_hoffmann'],
created_at: new Date(Date.now() - 86400000 * 5).toISOString(),
updated_at: new Date(Date.now() - 86400000).toISOString(),
last_message: 'Die Notenkonferenz ist am 15.02. um 14:00 Uhr.',
last_message_time: new Date(Date.now() - 86400000).toISOString(),
unread_count: 0,
is_group: false,
title: 'Thomas Hoffmann'
},
{
id: 'conv_becker',
participant_ids: ['contact_becker'],
created_at: new Date(Date.now() - 86400000 * 3).toISOString(),
updated_at: new Date(Date.now() - 172800000).toISOString(),
last_message: 'Wir haben die Einverstaendniserklaerung unterschrieben.',
last_message_time: new Date(Date.now() - 172800000).toISOString(),
unread_count: 0,
is_group: false,
title: 'Familie Becker',
muted: true
},
{
id: 'conv_fachschaft',
participant_ids: ['contact_weber', 'contact_hoffmann', 'contact_meyer'],
created_at: new Date(Date.now() - 86400000 * 60).toISOString(),
updated_at: new Date(Date.now() - 14400000).toISOString(),
last_message: 'Sabine: Hat jemand die neuen Lehrplaene schon gelesen?',
last_message_time: new Date(Date.now() - 14400000).toISOString(),
unread_count: 3,
is_group: true,
title: 'Fachschaft Deutsch 📚'
}
]
const mockMessages: Record<string, Message[]> = {
'conv_mueller': [
{
id: 'msg_m1',
conversation_id: 'conv_mueller',
sender_id: 'self',
content: 'Guten Tag Frau Mueller,\n\nich moechte Sie ueber die anstehende Klassenfahrt nach Berlin informieren. Die Reise findet vom 15.-19. April statt.',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000).toISOString(),
read: true,
delivered: true,
send_email: true,
email_sent: true,
email_sent_at: new Date(Date.now() - 86400000).toISOString()
},
{
id: 'msg_m2',
conversation_id: 'conv_mueller',
sender_id: 'self',
content: 'Die Kosten belaufen sich auf 280 Euro pro Schueler. Bitte ueberweisen Sie den Betrag bis zum 01.03. auf das Schulkonto.',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000 + 60000).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
},
{
id: 'msg_m3',
conversation_id: 'conv_mueller',
sender_id: 'contact_mueller',
content: 'Vielen Dank fuer die Information! Wir werden den Betrag diese Woche ueberweisen.',
content_type: 'text',
timestamp: new Date(Date.now() - 3600000).toISOString(),
read: false,
delivered: true,
send_email: false,
email_sent: false,
reactions: [{ emoji: '👍', user_id: 'self' }]
},
{
id: 'msg_m4',
conversation_id: 'conv_mueller',
sender_id: 'contact_mueller',
content: 'Vielen Dank fuer die Info! Max freut sich schon auf die Klassenfahrt 🎉',
content_type: 'text',
timestamp: new Date(Date.now() - 300000).toISOString(),
read: false,
delivered: true,
send_email: false,
email_sent: false
}
],
'conv_schmidt': [
{
id: 'msg_s1',
conversation_id: 'conv_schmidt',
sender_id: 'contact_schmidt',
content: 'Guten Morgen! Lisa ist heute leider krank und kann nicht zur Schule kommen.',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000 * 2).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
},
{
id: 'msg_s2',
conversation_id: 'conv_schmidt',
sender_id: 'self',
content: 'Gute Besserung an Lisa! 🤒 Soll ich ihr die Hausaufgaben zukommen lassen?',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000 * 2 + 1800000).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
},
{
id: 'msg_s3',
conversation_id: 'conv_schmidt',
sender_id: 'contact_schmidt',
content: 'Das waere sehr nett, vielen Dank! 🙏',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000 * 2 + 3600000).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
},
{
id: 'msg_s4',
conversation_id: 'conv_schmidt',
sender_id: 'self',
content: 'Hier sind die Hausaufgaben fuer diese Woche:\n\n📖 Deutsch: Seite 45-48 lesen\n📝 Mathe: Aufgaben 1-5 auf Seite 112\n🔬 Bio: Referat vorbereiten',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000).toISOString(),
read: true,
delivered: true,
send_email: true,
email_sent: true
},
{
id: 'msg_s5',
conversation_id: 'conv_schmidt',
sender_id: 'contact_schmidt',
content: 'Lisa war heute krank, sie kommt morgen wieder.',
content_type: 'text',
timestamp: new Date(Date.now() - 3600000).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
}
],
'conv_weber': [
{
id: 'msg_w1',
conversation_id: 'conv_weber',
sender_id: 'contact_weber',
content: 'Hi! Hast du schon die neuen Abi-Themen gesehen?',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000 * 3).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
},
{
id: 'msg_w2',
conversation_id: 'conv_weber',
sender_id: 'self',
content: 'Ja, habe ich! Finde ich ganz gut machbar dieses Jahr. 📚',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000 * 3 + 1800000).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
},
{
id: 'msg_w3',
conversation_id: 'conv_weber',
sender_id: 'contact_weber',
content: 'Koenntest du mir die Klausuraufgaben bis Freitag schicken? 📝',
content_type: 'text',
timestamp: new Date(Date.now() - 7200000).toISOString(),
read: false,
delivered: true,
send_email: false,
email_sent: false
}
],
'conv_hoffmann': [
{
id: 'msg_h1',
conversation_id: 'conv_hoffmann',
sender_id: 'contact_hoffmann',
content: 'Kurze Info: Die Notenkonferenz ist am 15.02. um 14:00 Uhr.',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000 * 2).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
},
{
id: 'msg_h2',
conversation_id: 'conv_hoffmann',
sender_id: 'self',
content: 'Danke fuer die Info! Bin dabei. 👍',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000 * 2 + 3600000).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
},
{
id: 'msg_h3',
conversation_id: 'conv_hoffmann',
sender_id: 'contact_hoffmann',
content: 'Die Notenkonferenz ist am 15.02. um 14:00 Uhr.',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
}
],
'conv_becker': [
{
id: 'msg_b1',
conversation_id: 'conv_becker',
sender_id: 'self',
content: 'Guten Tag Familie Becker,\n\nbitte vergessen Sie nicht, die Einverstaendniserklaerung fuer den Schwimmunterricht zu unterschreiben.',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000 * 4).toISOString(),
read: true,
delivered: true,
send_email: true,
email_sent: true
},
{
id: 'msg_b2',
conversation_id: 'conv_becker',
sender_id: 'contact_becker',
content: 'Wir haben die Einverstaendniserklaerung unterschrieben.',
content_type: 'text',
timestamp: new Date(Date.now() - 172800000).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
}
],
'conv_fachschaft': [
{
id: 'msg_f1',
conversation_id: 'conv_fachschaft',
sender_id: 'contact_meyer',
content: 'Liebe Kolleginnen und Kollegen,\n\ndie neuen Lehrplaene sind jetzt online verfuegbar.',
content_type: 'text',
timestamp: new Date(Date.now() - 86400000).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
},
{
id: 'msg_f2',
conversation_id: 'conv_fachschaft',
sender_id: 'contact_hoffmann',
content: 'Danke fuer die Info! Werde ich mir heute Abend anschauen.',
content_type: 'text',
timestamp: new Date(Date.now() - 72000000).toISOString(),
read: true,
delivered: true,
send_email: false,
email_sent: false
},
{
id: 'msg_f3',
conversation_id: 'conv_fachschaft',
sender_id: 'contact_weber',
content: 'Hat jemand die neuen Lehrplaene schon gelesen?',
content_type: 'text',
timestamp: new Date(Date.now() - 14400000).toISOString(),
read: false,
delivered: true,
send_email: false,
email_sent: false
},
{
id: 'msg_f4',
conversation_id: 'conv_fachschaft',
sender_id: 'contact_hoffmann',
content: 'Noch nicht komplett, aber sieht interessant aus! 📖',
content_type: 'text',
timestamp: new Date(Date.now() - 10800000).toISOString(),
read: false,
delivered: true,
send_email: false,
email_sent: false
},
{
id: 'msg_f5',
conversation_id: 'conv_fachschaft',
sender_id: 'contact_meyer',
content: 'Wir sollten naechste Woche eine Besprechung ansetzen.',
content_type: 'text',
timestamp: new Date(Date.now() - 7200000).toISOString(),
read: false,
delivered: true,
send_email: false,
email_sent: false
}
]
}
const mockTemplates: MessageTemplate[] = [
{
id: 'tpl_1',
name: 'Krankmeldung bestaetigen',
content: 'Vielen Dank fuer die Krankmeldung. Gute Besserung! 🤒',
created_at: new Date().toISOString()
},
{
id: 'tpl_2',
name: 'Hausaufgaben senden',
content: 'Hier sind die Hausaufgaben fuer diese Woche:\n\n📖 Deutsch: \n📝 Mathe: \n🔬 Bio: ',
created_at: new Date().toISOString()
},
{
id: 'tpl_3',
name: 'Elterngespraech anfragen',
content: 'Guten Tag,\n\nich wuerde gerne ein Elterngespraech mit Ihnen vereinbaren. Wann haetten Sie Zeit?',
created_at: new Date().toISOString()
},
{
id: 'tpl_4',
name: 'Termin bestaetigen',
content: 'Vielen Dank, der Termin ist bestaetigt. Ich freue mich auf unser Gespraech! 📅',
created_at: new Date().toISOString()
}
]
// ============================================
// PROVIDER
// ============================================
export function MessagesProvider({ children }: { children: ReactNode }) {
const [contacts, setContacts] = useState<Contact[]>(mockContacts)
const [conversations, setConversations] = useState<Conversation[]>(mockConversations)
const [messages, setMessages] = useState<Record<string, Message[]>>(mockMessages)
const [templates, setTemplates] = useState<MessageTemplate[]>(mockTemplates)
const [stats, setStats] = useState<MessagesStats>({
total_contacts: mockContacts.length,
total_conversations: mockConversations.length,
total_messages: Object.values(mockMessages).flat().length,
unread_messages: mockConversations.reduce((sum, c) => sum + c.unread_count, 0)
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null)
const [mounted, setMounted] = useState(false)
// Initialize
useEffect(() => {
setMounted(true)
}, [])
// Computed: unread count
const unreadCount = conversations.reduce((sum, c) => sum + c.unread_count, 0)
// Computed: recent conversations (sorted by last_message_time, pinned first)
const recentConversations = [...conversations]
.sort((a, b) => {
// Pinned conversations first
if (a.pinned && !b.pinned) return -1
if (!a.pinned && b.pinned) return 1
// Then by last_message_time
const aTime = a.last_message_time ? new Date(a.last_message_time).getTime() : 0
const bTime = b.last_message_time ? new Date(b.last_message_time).getTime() : 0
return bTime - aTime
})
// Actions
const fetchContacts = useCallback(async () => {
// Using mock data directly
setContacts(mockContacts)
}, [])
const fetchConversations = useCallback(async () => {
// Using mock data directly
setConversations(mockConversations)
}, [])
const fetchMessages = useCallback(async (conversationId: string): Promise<Message[]> => {
return messages[conversationId] || []
}, [messages])
const sendMessage = useCallback(async (
conversationId: string,
content: string,
sendEmail: boolean = false,
replyTo?: string
): Promise<Message | null> => {
const newMsg: Message = {
id: `msg_${Date.now()}`,
conversation_id: conversationId,
sender_id: 'self',
content,
content_type: 'text',
timestamp: new Date().toISOString(),
read: true,
delivered: true,
send_email: sendEmail,
email_sent: sendEmail,
reply_to: replyTo
}
setMessages(prev => ({
...prev,
[conversationId]: [...(prev[conversationId] || []), newMsg]
}))
// Update conversation
setConversations(prev => prev.map(c =>
c.id === conversationId
? {
...c,
last_message: content.length > 50 ? content.slice(0, 50) + '...' : content,
last_message_time: newMsg.timestamp,
updated_at: newMsg.timestamp
}
: c
))
return newMsg
}, [])
const markAsRead = useCallback(async (conversationId: string) => {
setMessages(prev => ({
...prev,
[conversationId]: (prev[conversationId] || []).map(m => ({ ...m, read: true }))
}))
setConversations(prev => prev.map(c =>
c.id === conversationId ? { ...c, unread_count: 0 } : c
))
}, [])
const createConversation = useCallback(async (contactId: string): Promise<Conversation | null> => {
// Check if conversation exists
const existing = conversations.find(c =>
!c.is_group && c.participant_ids.includes(contactId)
)
if (existing) return existing
// Create new conversation
const contact = contacts.find(c => c.id === contactId)
const newConv: Conversation = {
id: `conv_${Date.now()}`,
participant_ids: [contactId],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
unread_count: 0,
is_group: false,
title: contact?.name || 'Neue Konversation'
}
setConversations(prev => [newConv, ...prev])
setMessages(prev => ({ ...prev, [newConv.id]: [] }))
return newConv
}, [conversations, contacts])
const addReaction = useCallback((messageId: string, emoji: string) => {
setMessages(prev => {
const newMessages = { ...prev }
for (const convId of Object.keys(newMessages)) {
newMessages[convId] = newMessages[convId].map(msg => {
if (msg.id === messageId) {
const reactions = msg.reactions || []
const existingIndex = reactions.findIndex(r => r.user_id === 'self')
if (existingIndex >= 0) {
// Toggle or change reaction
if (reactions[existingIndex].emoji === emoji) {
reactions.splice(existingIndex, 1)
} else {
reactions[existingIndex].emoji = emoji
}
} else {
reactions.push({ emoji, user_id: 'self' })
}
return { ...msg, reactions }
}
return msg
})
}
return newMessages
})
}, [])
const deleteMessage = useCallback((conversationId: string, messageId: string) => {
setMessages(prev => ({
...prev,
[conversationId]: (prev[conversationId] || []).filter(m => m.id !== messageId)
}))
}, [])
const pinConversation = useCallback((conversationId: string) => {
setConversations(prev => prev.map(c =>
c.id === conversationId ? { ...c, pinned: !c.pinned } : c
))
}, [])
const muteConversation = useCallback((conversationId: string) => {
setConversations(prev => prev.map(c =>
c.id === conversationId ? { ...c, muted: !c.muted } : c
))
}, [])
// SSR safety
if (!mounted) {
return (
<MessagesContext.Provider
value={{
contacts: [],
conversations: [],
messages: {},
templates: [],
stats: { total_contacts: 0, total_conversations: 0, total_messages: 0, unread_messages: 0 },
unreadCount: 0,
recentConversations: [],
fetchContacts: async () => {},
fetchConversations: async () => {},
fetchMessages: async () => [],
sendMessage: async () => null,
markAsRead: async () => {},
createConversation: async () => null,
addReaction: () => {},
deleteMessage: () => {},
pinConversation: () => {},
muteConversation: () => {},
isLoading: false,
error: null,
currentConversationId: null,
setCurrentConversationId: () => {}
}}
>
{children}
</MessagesContext.Provider>
)
}
return (
<MessagesContext.Provider
value={{
contacts,
conversations,
messages,
templates,
stats,
unreadCount,
recentConversations,
fetchContacts,
fetchConversations,
fetchMessages,
sendMessage,
markAsRead,
createConversation,
addReaction,
deleteMessage,
pinConversation,
muteConversation,
isLoading,
error,
currentConversationId,
setCurrentConversationId
}}
>
{children}
</MessagesContext.Provider>
)
}
export function useMessages() {
const context = useContext(MessagesContext)
if (!context) {
throw new Error('useMessages must be used within a MessagesProvider')
}
return context
}
// ============================================
// HELPER FUNCTIONS
// ============================================
export function formatMessageTime(timestamp: string): string {
const date = new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMs / 3600000)
const diffDays = Math.floor(diffMs / 86400000)
if (diffMins < 1) return 'Gerade eben'
if (diffMins < 60) return `${diffMins} Min.`
if (diffHours < 24) return `${diffHours} Std.`
if (diffDays === 1) return 'Gestern'
if (diffDays < 7) return `${diffDays} Tage`
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
}
export function formatMessageDate(timestamp: string): string {
const date = new Date(timestamp)
const now = new Date()
const diffDays = Math.floor((now.getTime() - date.getTime()) / 86400000)
if (diffDays === 0) return 'Heute'
if (diffDays === 1) return 'Gestern'
if (diffDays < 7) {
return date.toLocaleDateString('de-DE', { weekday: 'long' })
}
return date.toLocaleDateString('de-DE', { day: '2-digit', month: 'long', year: 'numeric' })
}
export function getContactInitials(name: string): string {
const parts = name.split(' ').filter(p => p.length > 0)
if (parts.length >= 2) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase()
}
return name.slice(0, 2).toUpperCase()
}
export function getRoleLabel(role: Contact['role']): string {
const labels: Record<Contact['role'], string> = {
parent: 'Eltern',
teacher: 'Lehrkraft',
staff: 'Verwaltung',
student: 'Schueler/in'
}
return labels[role] || role
}
export function getRoleColor(role: Contact['role'], isDark: boolean): string {
const colors: Record<Contact['role'], { dark: string; light: string }> = {
parent: { dark: 'bg-blue-500/20 text-blue-300', light: 'bg-blue-100 text-blue-700' },
teacher: { dark: 'bg-purple-500/20 text-purple-300', light: 'bg-purple-100 text-purple-700' },
staff: { dark: 'bg-amber-500/20 text-amber-300', light: 'bg-amber-100 text-amber-700' },
student: { dark: 'bg-green-500/20 text-green-300', light: 'bg-green-100 text-green-700' }
}
return isDark ? colors[role].dark : colors[role].light
}
// Emoji categories for picker
export const emojiCategories = {
'Häufig': ['👍', '❤️', '😊', '😂', '🙏', '👏', '🎉', '✅', '📝', '📚'],
'Smileys': ['😀', '😃', '😄', '😁', '😅', '😂', '🤣', '😊', '😇', '🙂', '😉', '😌', '😍', '🥰', '😘'],
'Gesten': ['👍', '👎', '👌', '✌️', '🤞', '🤝', '👏', '🙌', '👋', '✋', '🤚', '🖐️', '🙏'],
'Symbole': ['❤️', '💙', '💚', '💛', '🧡', '💜', '✅', '❌', '⭐', '🌟', '💯', '📌', '📎'],
'Schule': ['📚', '📖', '📝', '✏️', '📓', '📕', '📗', '📘', '🎓', '🏫', '📅', '⏰', '🔔']
}

View File

@@ -0,0 +1,71 @@
'use client'
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
type Theme = 'dark' | 'light'
interface ThemeContextType {
theme: Theme
toggleTheme: () => void
isDark: boolean
}
const ThemeContext = createContext<ThemeContextType | null>(null)
const STORAGE_KEY = 'bp_theme'
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('dark')
const [mounted, setMounted] = useState(false)
// Nach dem ersten Render: Theme aus localStorage laden
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null
if (stored && (stored === 'dark' || stored === 'light')) {
setTheme(stored)
}
setMounted(true)
}, [])
// Theme wechseln und speichern
const toggleTheme = () => {
const newTheme = theme === 'dark' ? 'light' : 'dark'
setTheme(newTheme)
localStorage.setItem(STORAGE_KEY, newTheme)
}
// Waehrend SSR: Default anzeigen
if (!mounted) {
return (
<ThemeContext.Provider
value={{
theme: 'dark',
toggleTheme: () => {},
isDark: true,
}}
>
{children}
</ThemeContext.Provider>
)
}
return (
<ThemeContext.Provider
value={{
theme,
toggleTheme,
isDark: theme === 'dark',
}}
>
{children}
</ThemeContext.Provider>
)
}
export function useTheme() {
const context = useContext(ThemeContext)
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider')
}
return context
}

View File

@@ -0,0 +1,276 @@
'use client'
import {
createContext,
useContext,
useReducer,
useCallback,
ReactNode,
} from 'react'
import {
AOIResponse,
AOITheme,
AOIQuality,
Difficulty,
GeoJSONPolygon,
LearningNode,
GeoLernweltState,
} from '@/app/geo-lernwelt/types'
// Initial state
const initialState: GeoLernweltState = {
currentAOI: null,
drawnPolygon: null,
selectedTheme: 'topographie',
quality: 'medium',
difficulty: 'mittel',
learningNodes: [],
selectedNode: null,
isDrawing: false,
isLoading: false,
error: null,
unityReady: false,
unityProgress: 0,
}
// Action types
type GeoAction =
| { type: 'SET_AOI'; payload: AOIResponse | null }
| { type: 'SET_POLYGON'; payload: GeoJSONPolygon | null }
| { type: 'SET_THEME'; payload: AOITheme }
| { type: 'SET_QUALITY'; payload: AOIQuality }
| { type: 'SET_DIFFICULTY'; payload: Difficulty }
| { type: 'SET_LEARNING_NODES'; payload: LearningNode[] }
| { type: 'ADD_LEARNING_NODE'; payload: LearningNode }
| { type: 'UPDATE_LEARNING_NODE'; payload: LearningNode }
| { type: 'REMOVE_LEARNING_NODE'; payload: string }
| { type: 'SELECT_NODE'; payload: LearningNode | null }
| { type: 'SET_DRAWING'; payload: boolean }
| { type: 'SET_LOADING'; payload: boolean }
| { type: 'SET_ERROR'; payload: string | null }
| { type: 'SET_UNITY_READY'; payload: boolean }
| { type: 'SET_UNITY_PROGRESS'; payload: number }
| { type: 'RESET' }
// Reducer
function geoReducer(state: GeoLernweltState, action: GeoAction): GeoLernweltState {
switch (action.type) {
case 'SET_AOI':
return { ...state, currentAOI: action.payload }
case 'SET_POLYGON':
return { ...state, drawnPolygon: action.payload }
case 'SET_THEME':
return { ...state, selectedTheme: action.payload }
case 'SET_QUALITY':
return { ...state, quality: action.payload }
case 'SET_DIFFICULTY':
return { ...state, difficulty: action.payload }
case 'SET_LEARNING_NODES':
return { ...state, learningNodes: action.payload }
case 'ADD_LEARNING_NODE':
return {
...state,
learningNodes: [...state.learningNodes, action.payload],
}
case 'UPDATE_LEARNING_NODE':
return {
...state,
learningNodes: state.learningNodes.map((node) =>
node.id === action.payload.id ? action.payload : node
),
}
case 'REMOVE_LEARNING_NODE':
return {
...state,
learningNodes: state.learningNodes.filter(
(node) => node.id !== action.payload
),
selectedNode:
state.selectedNode?.id === action.payload ? null : state.selectedNode,
}
case 'SELECT_NODE':
return { ...state, selectedNode: action.payload }
case 'SET_DRAWING':
return { ...state, isDrawing: action.payload }
case 'SET_LOADING':
return { ...state, isLoading: action.payload }
case 'SET_ERROR':
return { ...state, error: action.payload }
case 'SET_UNITY_READY':
return { ...state, unityReady: action.payload }
case 'SET_UNITY_PROGRESS':
return { ...state, unityProgress: action.payload }
case 'RESET':
return initialState
default:
return state
}
}
// Context types
interface GeoContextValue {
state: GeoLernweltState
dispatch: React.Dispatch<GeoAction>
// Convenience actions
setAOI: (aoi: AOIResponse | null) => void
setPolygon: (polygon: GeoJSONPolygon | null) => void
setTheme: (theme: AOITheme) => void
setQuality: (quality: AOIQuality) => void
setDifficulty: (difficulty: Difficulty) => void
setLearningNodes: (nodes: LearningNode[]) => void
addNode: (node: LearningNode) => void
updateNode: (node: LearningNode) => void
removeNode: (nodeId: string) => void
selectNode: (node: LearningNode | null) => void
setDrawing: (drawing: boolean) => void
setLoading: (loading: boolean) => void
setError: (error: string | null) => void
setUnityReady: (ready: boolean) => void
setUnityProgress: (progress: number) => void
reset: () => void
}
// Create context
const GeoContext = createContext<GeoContextValue | null>(null)
// Provider component
export function GeoProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(geoReducer, initialState)
// Convenience action creators
const setAOI = useCallback(
(aoi: AOIResponse | null) => dispatch({ type: 'SET_AOI', payload: aoi }),
[]
)
const setPolygon = useCallback(
(polygon: GeoJSONPolygon | null) =>
dispatch({ type: 'SET_POLYGON', payload: polygon }),
[]
)
const setTheme = useCallback(
(theme: AOITheme) => dispatch({ type: 'SET_THEME', payload: theme }),
[]
)
const setQuality = useCallback(
(quality: AOIQuality) => dispatch({ type: 'SET_QUALITY', payload: quality }),
[]
)
const setDifficulty = useCallback(
(difficulty: Difficulty) =>
dispatch({ type: 'SET_DIFFICULTY', payload: difficulty }),
[]
)
const setLearningNodes = useCallback(
(nodes: LearningNode[]) =>
dispatch({ type: 'SET_LEARNING_NODES', payload: nodes }),
[]
)
const addNode = useCallback(
(node: LearningNode) => dispatch({ type: 'ADD_LEARNING_NODE', payload: node }),
[]
)
const updateNode = useCallback(
(node: LearningNode) =>
dispatch({ type: 'UPDATE_LEARNING_NODE', payload: node }),
[]
)
const removeNode = useCallback(
(nodeId: string) =>
dispatch({ type: 'REMOVE_LEARNING_NODE', payload: nodeId }),
[]
)
const selectNode = useCallback(
(node: LearningNode | null) => dispatch({ type: 'SELECT_NODE', payload: node }),
[]
)
const setDrawing = useCallback(
(drawing: boolean) => dispatch({ type: 'SET_DRAWING', payload: drawing }),
[]
)
const setLoading = useCallback(
(loading: boolean) => dispatch({ type: 'SET_LOADING', payload: loading }),
[]
)
const setError = useCallback(
(error: string | null) => dispatch({ type: 'SET_ERROR', payload: error }),
[]
)
const setUnityReady = useCallback(
(ready: boolean) => dispatch({ type: 'SET_UNITY_READY', payload: ready }),
[]
)
const setUnityProgress = useCallback(
(progress: number) => dispatch({ type: 'SET_UNITY_PROGRESS', payload: progress }),
[]
)
const reset = useCallback(() => dispatch({ type: 'RESET' }), [])
const value: GeoContextValue = {
state,
dispatch,
setAOI,
setPolygon,
setTheme,
setQuality,
setDifficulty,
setLearningNodes,
addNode,
updateNode,
removeNode,
selectNode,
setDrawing,
setLoading,
setError,
setUnityReady,
setUnityProgress,
reset,
}
return <GeoContext.Provider value={value}>{children}</GeoContext.Provider>
}
// Hook to use context
export function useGeo() {
const context = useContext(GeoContext)
if (!context) {
throw new Error('useGeo must be used within a GeoProvider')
}
return context
}
// Hook for just the state (read-only)
export function useGeoState() {
const { state } = useGeo()
return state
}

View File

@@ -0,0 +1,29 @@
/**
* GeoEdu Service - Library Exports
*/
// Context
export { GeoProvider, useGeo, useGeoState } from './GeoContext'
// Map Styles
export {
createMapStyle,
createFallbackStyle,
createDarkStyle,
createTerrainStyle,
GERMANY_BOUNDS,
GERMANY_CENTER,
MAINAU_CENTER,
MAINAU_BOUNDS,
} from './mapStyles'
// Unity Bridge
export {
UnityBridge,
getUnityBridge,
createLoaderConfig,
type UnityInstance,
type UnityMessage,
type UnityMessageHandler,
type UnityLoaderConfig,
} from './unityBridge'

View File

@@ -0,0 +1,369 @@
/**
* MapLibre Style Configurations for GeoEdu Service
* Styles for displaying OSM data with terrain
*/
import { MapStyle, MapLayer } from '@/app/geo-lernwelt/types'
// Germany bounds
export const GERMANY_BOUNDS: [[number, number], [number, number]] = [
[5.87, 47.27],
[15.04, 55.06],
]
// Default center (Germany)
export const GERMANY_CENTER: [number, number] = [10.45, 51.16]
// Mainau island (demo location)
export const MAINAU_CENTER: [number, number] = [9.1925, 47.7085]
export const MAINAU_BOUNDS: [[number, number], [number, number]] = [
[9.185, 47.705],
[9.200, 47.712],
]
/**
* Create a MapLibre style for the self-hosted tile server
*/
export function createMapStyle(geoServiceUrl: string): MapStyle {
return {
version: 8,
name: 'GeoEdu Germany',
metadata: {
description: 'Self-hosted OSM tiles for DSGVO-compliant education',
attribution: '© OpenStreetMap contributors',
},
sources: {
osm: {
type: 'vector',
tiles: [`${geoServiceUrl}/api/v1/tiles/{z}/{x}/{y}.pbf`],
minzoom: 0,
maxzoom: 14,
attribution: '© OpenStreetMap contributors (ODbL)',
},
terrain: {
type: 'raster-dem',
tiles: [`${geoServiceUrl}/api/v1/terrain/{z}/{x}/{y}.png`],
tileSize: 256,
attribution: '© Copernicus DEM GLO-30',
},
hillshade: {
type: 'raster',
tiles: [`${geoServiceUrl}/api/v1/terrain/hillshade/{z}/{x}/{y}.png`],
tileSize: 256,
},
},
layers: [
// Background
{
id: 'background',
type: 'background',
paint: { 'background-color': '#f8f4f0' },
},
// Hillshade
{
id: 'hillshade',
type: 'raster',
source: 'hillshade',
paint: { 'raster-opacity': 0.3 },
},
// Water areas
{
id: 'water',
type: 'fill',
source: 'osm',
'source-layer': 'water',
paint: { 'fill-color': '#a0c8f0' },
},
// Parks
{
id: 'landuse-park',
type: 'fill',
source: 'osm',
'source-layer': 'landuse',
filter: ['==', 'class', 'park'],
paint: { 'fill-color': '#c8e6c8', 'fill-opacity': 0.5 },
},
// Forest
{
id: 'landuse-forest',
type: 'fill',
source: 'osm',
'source-layer': 'landuse',
filter: ['==', 'class', 'wood'],
paint: { 'fill-color': '#94d294', 'fill-opacity': 0.5 },
},
// Buildings
{
id: 'building',
type: 'fill',
source: 'osm',
'source-layer': 'building',
minzoom: 13,
paint: {
'fill-color': '#d9d0c9',
'fill-opacity': 0.8,
},
},
// Building outlines
{
id: 'building-outline',
type: 'line',
source: 'osm',
'source-layer': 'building',
minzoom: 13,
paint: {
'line-color': '#b8a89a',
'line-width': 1,
},
},
// Minor roads
{
id: 'road-minor',
type: 'line',
source: 'osm',
'source-layer': 'transportation',
filter: ['all', ['==', '$type', 'LineString'], ['in', 'class', 'minor', 'service']],
paint: {
'line-color': '#ffffff',
'line-width': ['interpolate', ['linear'], ['zoom'], 10, 0.5, 14, 2],
},
},
// Secondary roads
{
id: 'road-secondary',
type: 'line',
source: 'osm',
'source-layer': 'transportation',
filter: ['all', ['==', '$type', 'LineString'], ['in', 'class', 'secondary', 'tertiary']],
paint: {
'line-color': '#ffc107',
'line-width': ['interpolate', ['linear'], ['zoom'], 8, 1, 14, 4],
},
},
// Primary roads
{
id: 'road-primary',
type: 'line',
source: 'osm',
'source-layer': 'transportation',
filter: ['all', ['==', '$type', 'LineString'], ['==', 'class', 'primary']],
paint: {
'line-color': '#ff9800',
'line-width': ['interpolate', ['linear'], ['zoom'], 6, 1, 14, 6],
},
},
// Highways
{
id: 'road-highway',
type: 'line',
source: 'osm',
'source-layer': 'transportation',
filter: ['all', ['==', '$type', 'LineString'], ['==', 'class', 'motorway']],
paint: {
'line-color': '#ff6f00',
'line-width': ['interpolate', ['linear'], ['zoom'], 4, 1, 14, 8],
},
},
// Railways
{
id: 'railway',
type: 'line',
source: 'osm',
'source-layer': 'transportation',
filter: ['==', 'class', 'rail'],
paint: {
'line-color': '#555555',
'line-width': 2,
'line-dasharray': [3, 3],
},
},
// Water lines (rivers, streams)
{
id: 'waterway',
type: 'line',
source: 'osm',
'source-layer': 'waterway',
paint: {
'line-color': '#a0c8f0',
'line-width': ['interpolate', ['linear'], ['zoom'], 10, 1, 14, 3],
},
},
// Place labels
{
id: 'place-label-city',
type: 'symbol',
source: 'osm',
'source-layer': 'place',
filter: ['==', 'class', 'city'],
layout: {
'text-field': '{name}',
'text-font': ['Open Sans Bold'],
'text-size': 16,
},
paint: {
'text-color': '#333333',
'text-halo-color': '#ffffff',
'text-halo-width': 2,
},
},
{
id: 'place-label-town',
type: 'symbol',
source: 'osm',
'source-layer': 'place',
filter: ['==', 'class', 'town'],
minzoom: 8,
layout: {
'text-field': '{name}',
'text-font': ['Open Sans Semibold'],
'text-size': 14,
},
paint: {
'text-color': '#444444',
'text-halo-color': '#ffffff',
'text-halo-width': 1.5,
},
},
{
id: 'place-label-village',
type: 'symbol',
source: 'osm',
'source-layer': 'place',
filter: ['==', 'class', 'village'],
minzoom: 10,
layout: {
'text-field': '{name}',
'text-font': ['Open Sans Regular'],
'text-size': 12,
},
paint: {
'text-color': '#555555',
'text-halo-color': '#ffffff',
'text-halo-width': 1,
},
},
],
terrain: {
source: 'terrain',
exaggeration: 1.5,
},
}
}
/**
* Fallback style using OSM raster tiles (when self-hosted tiles not available)
*/
export function createFallbackStyle(): MapStyle {
return {
version: 8,
name: 'OSM Fallback',
metadata: {
description: 'Fallback style using public OSM raster tiles',
attribution: '© OpenStreetMap contributors',
},
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,
},
],
}
}
/**
* Dark mode variant of the map style
*/
export function createDarkStyle(geoServiceUrl: string): MapStyle {
const baseStyle = createMapStyle(geoServiceUrl)
return {
...baseStyle,
name: 'GeoEdu Germany (Dark)',
layers: baseStyle.layers.map((layer: MapLayer) => {
// Adjust colors for dark mode
if (layer.id === 'background') {
return { ...layer, paint: { 'background-color': '#1a1a2e' } }
}
if (layer.id === 'water') {
return { ...layer, paint: { 'fill-color': '#1e3a5f' } }
}
if (layer.type === 'symbol') {
return {
...layer,
paint: {
...layer.paint,
'text-color': '#e0e0e0',
'text-halo-color': '#1a1a2e',
},
}
}
return layer
}),
}
}
/**
* Terrain-focused style (for topography theme)
*/
export function createTerrainStyle(geoServiceUrl: string): MapStyle {
const baseStyle = createMapStyle(geoServiceUrl)
// Add contour lines source and layer
return {
...baseStyle,
name: 'GeoEdu Terrain',
sources: {
...baseStyle.sources,
contours: {
type: 'vector',
tiles: [`${geoServiceUrl}/api/v1/terrain/contours/{z}/{x}/{y}.pbf`],
maxzoom: 14,
},
},
layers: [
...baseStyle.layers,
{
id: 'contour-lines',
type: 'line',
source: 'contours',
'source-layer': 'contour',
minzoom: 10,
paint: {
'line-color': '#8b4513',
'line-width': ['match', ['get', 'index'], 5, 1.5, 0.5],
'line-opacity': 0.5,
},
},
{
id: 'contour-labels',
type: 'symbol',
source: 'contours',
'source-layer': 'contour',
minzoom: 12,
filter: ['==', ['%', ['get', 'ele'], 50], 0],
layout: {
'text-field': '{ele}m',
'text-font': ['Open Sans Regular'],
'text-size': 10,
'symbol-placement': 'line',
},
paint: {
'text-color': '#8b4513',
'text-halo-color': '#ffffff',
'text-halo-width': 1,
},
},
],
}
}

View File

@@ -0,0 +1,359 @@
/**
* Unity WebGL Bridge
* Communication layer between React and Unity WebGL
*/
import { LearningNode, AOIManifest } from '@/app/geo-lernwelt/types'
// Message types from Unity to React
export type UnityMessageType =
| 'nodeSelected'
| 'terrainLoaded'
| 'cameraPosition'
| 'error'
| 'ready'
| 'progress'
export interface UnityMessage {
type: UnityMessageType
nodeId?: string
position?: { x: number; y: number; z: number }
message?: string
progress?: number
}
// Message handler type
export type UnityMessageHandler = (message: UnityMessage) => void
// Unity instance interface
export interface UnityInstance {
SendMessage: (objectName: string, methodName: string, value?: string | number) => void
Quit: () => Promise<void>
}
// Unity loader configuration
export interface UnityLoaderConfig {
dataUrl: string
frameworkUrl: string
codeUrl: string
streamingAssetsUrl: string
companyName: string
productName: string
productVersion: string
}
/**
* Unity Bridge class for managing communication
*/
export class UnityBridge {
private instance: UnityInstance | null = null
private messageHandlers: UnityMessageHandler[] = []
private isReady: boolean = false
constructor() {
// Set up global message handler
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).handleUnityMessage = this.handleMessage.bind(this)
}
}
/**
* Set the Unity instance (called after Unity loads)
*/
setInstance(instance: UnityInstance) {
this.instance = instance
this.isReady = true
}
/**
* Check if Unity is ready
*/
ready(): boolean {
return this.isReady && this.instance !== null
}
/**
* Register a message handler
*/
onMessage(handler: UnityMessageHandler): () => void {
this.messageHandlers.push(handler)
// Return unsubscribe function
return () => {
const index = this.messageHandlers.indexOf(handler)
if (index > -1) {
this.messageHandlers.splice(index, 1)
}
}
}
/**
* Handle incoming message from Unity
*/
private handleMessage(messageJson: string) {
try {
const message: UnityMessage = JSON.parse(messageJson)
// Notify all handlers
this.messageHandlers.forEach((handler) => {
try {
handler(message)
} catch (e) {
console.error('Error in Unity message handler:', e)
}
})
} catch (e) {
console.error('Failed to parse Unity message:', e)
}
}
/**
* Send a message to Unity
*/
sendMessage(objectName: string, methodName: string, value?: string | number) {
if (!this.instance) {
console.warn('Unity instance not ready, message queued')
return false
}
try {
this.instance.SendMessage(objectName, methodName, value)
return true
} catch (e) {
console.error('Error sending message to Unity:', e)
return false
}
}
// ============================================
// Terrain Commands
// ============================================
/**
* Load terrain from manifest
*/
loadTerrain(manifestUrl: string) {
return this.sendMessage('TerrainManager', 'LoadManifest', manifestUrl)
}
/**
* Set terrain exaggeration (vertical scale)
*/
setTerrainExaggeration(exaggeration: number) {
return this.sendMessage('TerrainManager', 'SetExaggeration', exaggeration)
}
/**
* Toggle terrain wireframe mode
*/
toggleWireframe(enabled: boolean) {
return this.sendMessage('TerrainManager', 'SetWireframe', enabled ? 1 : 0)
}
// ============================================
// Camera Commands
// ============================================
/**
* Focus camera on a learning node
*/
focusOnNode(nodeId: string) {
return this.sendMessage('CameraController', 'FocusOnNode', nodeId)
}
/**
* Focus camera on a position
*/
focusOnPosition(x: number, y: number, z: number) {
return this.sendMessage(
'CameraController',
'FocusOnPosition',
JSON.stringify({ x, y, z })
)
}
/**
* Set camera mode
*/
setCameraMode(mode: 'orbit' | 'fly' | 'firstPerson') {
return this.sendMessage('CameraController', 'SetMode', mode)
}
/**
* Reset camera to default view
*/
resetCamera() {
return this.sendMessage('CameraController', 'Reset')
}
// ============================================
// Learning Node Commands
// ============================================
/**
* Load learning nodes
*/
loadNodes(nodes: LearningNode[]) {
return this.sendMessage(
'LearningNodeManager',
'LoadNodes',
JSON.stringify(nodes)
)
}
/**
* Add a single learning node
*/
addNode(node: LearningNode) {
return this.sendMessage(
'LearningNodeManager',
'AddNode',
JSON.stringify(node)
)
}
/**
* Remove a learning node
*/
removeNode(nodeId: string) {
return this.sendMessage('LearningNodeManager', 'RemoveNode', nodeId)
}
/**
* Update a learning node
*/
updateNode(node: LearningNode) {
return this.sendMessage(
'LearningNodeManager',
'UpdateNode',
JSON.stringify(node)
)
}
/**
* Highlight a learning node
*/
highlightNode(nodeId: string, highlight: boolean) {
return this.sendMessage(
'LearningNodeManager',
'HighlightNode',
JSON.stringify({ nodeId, highlight })
)
}
/**
* Show/hide all nodes
*/
setNodesVisible(visible: boolean) {
return this.sendMessage('LearningNodeManager', 'SetVisible', visible ? 1 : 0)
}
// ============================================
// UI Commands
// ============================================
/**
* Toggle UI visibility
*/
toggleUI(visible: boolean) {
return this.sendMessage('UIManager', 'SetVisible', visible ? 1 : 0)
}
/**
* Show toast message
*/
showToast(message: string) {
return this.sendMessage('UIManager', 'ShowToast', message)
}
/**
* Set language
*/
setLanguage(lang: 'de' | 'en') {
return this.sendMessage('UIManager', 'SetLanguage', lang)
}
// ============================================
// Screenshot & Recording
// ============================================
/**
* Take a screenshot
*/
takeScreenshot(): Promise<string> {
return new Promise((resolve, reject) => {
const handler = (message: UnityMessage) => {
if (message.type === 'error') {
reject(new Error(message.message))
return true
}
// Would need custom message type for screenshot result
return false
}
const unsubscribe = this.onMessage(handler)
this.sendMessage('ScreenshotManager', 'TakeScreenshot')
// Timeout after 5 seconds
setTimeout(() => {
unsubscribe()
reject(new Error('Screenshot timeout'))
}, 5000)
})
}
// ============================================
// Lifecycle
// ============================================
/**
* Quit Unity
*/
async quit() {
if (this.instance) {
await this.instance.Quit()
this.instance = null
this.isReady = false
}
}
/**
* Cleanup
*/
destroy() {
this.messageHandlers = []
if (typeof window !== 'undefined') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (window as any).handleUnityMessage
}
}
}
// Singleton instance
let bridgeInstance: UnityBridge | null = null
/**
* Get or create the Unity bridge instance
*/
export function getUnityBridge(): UnityBridge {
if (!bridgeInstance) {
bridgeInstance = new UnityBridge()
}
return bridgeInstance
}
/**
* Create Unity loader configuration
*/
export function createLoaderConfig(buildPath: string): UnityLoaderConfig {
return {
dataUrl: `${buildPath}/WebGL.data`,
frameworkUrl: `${buildPath}/WebGL.framework.js`,
codeUrl: `${buildPath}/WebGL.wasm`,
streamingAssetsUrl: `${buildPath}/StreamingAssets`,
companyName: 'BreakPilot',
productName: 'GeoEdu Lernwelt',
productVersion: '1.0.0',
}
}

399
studio-v2/lib/i18n.ts Normal file
View File

@@ -0,0 +1,399 @@
/**
* BreakPilot Studio v2 - i18n Module
*
* 7 Sprachen: de, en, tr, ar, ru, uk, pl
* localStorage-Persistenz mit Key 'bp_language'
* RTL-Support fuer Arabisch
*/
export type Language = 'de' | 'en' | 'tr' | 'ar' | 'ru' | 'uk' | 'pl'
export const availableLanguages: Record<Language, { name: string; flag: string; rtl?: boolean }> = {
de: { name: 'Deutsch', flag: '🇩🇪' },
en: { name: 'English', flag: '🇬🇧' },
tr: { name: 'Türkçe', flag: '🇹🇷' },
ar: { name: 'العربية', flag: '🇸🇦', rtl: true },
ru: { name: 'Русский', flag: '🇷🇺' },
uk: { name: 'Українська', flag: '🇺🇦' },
pl: { name: 'Polski', flag: '🇵🇱' },
}
export const defaultLanguage: Language = 'de'
// Uebersetzungen fuer Studio v2 UI
export const translations: Record<Language, Record<string, string>> = {
de: {
// Kopfleiste
dashboard: 'Dashboard',
dashboard_subtitle: 'Willkommen zurück! Hier ist Ihr Überblick.',
search_placeholder: 'Suchen...',
// Sidebar Navigation
nav_dashboard: 'Dashboard',
nav_dokumente: 'Dokumente',
nav_klausuren: 'Klausuren',
nav_analytics: 'Analysen',
nav_vokabeln: 'Vokabeln',
nav_worksheet_editor: 'Arbeitsblätter',
nav_worksheet_cleanup: 'Bereinigung',
nav_korrektur: 'Korrekturen',
nav_compliance_pipeline: 'Compliance Pipeline',
nav_meet: 'Meet',
nav_alerts: 'Alerts',
nav_alerts_b2b: 'B2B Alerts',
nav_messages: 'Nachrichten',
nav_settings: 'Einstellungen',
// Stats Kacheln
stat_open_corrections: 'Offene Korrekturen',
stat_completed_week: 'Erledigt (Woche)',
stat_average: 'Durchschnitt',
stat_time_saved: 'Zeitersparnis',
// Klausuren
recent_klausuren: 'Aktuelle Klausuren',
show_all: 'Alle anzeigen',
students: 'Schüler',
status_in_progress: 'In Bearbeitung',
status_completed: 'Abgeschlossen',
// Quick Actions
quick_actions: 'Schnellaktionen',
create_klausur: 'Neue Klausur erstellen',
upload_work: 'Arbeiten hochladen',
magic_help: 'Magic Help',
fairness_check: 'Fairness-Check',
// AI Tipp
ai_tip: 'KI-Tipp',
ai_tip_text: '3 Klausuren warten auf Fairness-Check. Möchten Sie diese jetzt prüfen?',
// Footer / Common
imprint: 'Impressum',
privacy: 'Datenschutz',
contact: 'Kontakt',
legal: 'AGB',
cookie_settings: 'Cookie-Einstellungen',
copyright: '© 2025 BreakPilot. Alle Rechte vorbehalten.',
back_to_selection: 'Zurück zur Design-Auswahl',
language: 'Sprache',
// Theme
theme_light: 'Hell',
theme_dark: 'Dunkel',
// User Menu
logout: 'Abmelden',
},
en: {
dashboard: 'Dashboard',
dashboard_subtitle: 'Welcome back! Here is your overview.',
search_placeholder: 'Search...',
nav_dashboard: 'Dashboard',
nav_dokumente: 'Documents',
nav_klausuren: 'Exams',
nav_analytics: 'Analytics',
nav_vokabeln: 'Vocabulary',
nav_worksheet_editor: 'Worksheets',
nav_worksheet_cleanup: 'Cleanup',
nav_korrektur: 'Corrections',
nav_compliance_pipeline: 'Compliance Pipeline',
nav_meet: 'Meet',
nav_alerts: 'Alerts',
nav_alerts_b2b: 'B2B Alerts',
nav_messages: 'Messages',
nav_settings: 'Settings',
stat_open_corrections: 'Open Corrections',
stat_completed_week: 'Completed (Week)',
stat_average: 'Average',
stat_time_saved: 'Time Saved',
recent_klausuren: 'Recent Exams',
show_all: 'Show all',
students: 'Students',
status_in_progress: 'In Progress',
status_completed: 'Completed',
quick_actions: 'Quick Actions',
create_klausur: 'Create New Exam',
upload_work: 'Upload Papers',
magic_help: 'Magic Help',
fairness_check: 'Fairness Check',
ai_tip: 'AI Tip',
ai_tip_text: '3 exams are waiting for fairness check. Would you like to review them now?',
imprint: 'Imprint',
privacy: 'Privacy',
contact: 'Contact',
legal: 'Terms',
cookie_settings: 'Cookie Settings',
copyright: '© 2025 BreakPilot. All rights reserved.',
back_to_selection: 'Back to Design Selection',
language: 'Language',
theme_light: 'Light',
theme_dark: 'Dark',
logout: 'Log out',
},
tr: {
dashboard: 'Kontrol Paneli',
dashboard_subtitle: 'Tekrar hoş geldiniz! İşte genel bakışınız.',
search_placeholder: 'Ara...',
nav_dashboard: 'Kontrol Paneli',
nav_dokumente: 'Belgeler',
nav_klausuren: 'Sınavlar',
nav_analytics: 'Analizler',
nav_vokabeln: 'Kelimeler',
nav_worksheet_editor: 'Çalışma Sayfaları',
nav_worksheet_cleanup: 'Temizleme',
nav_korrektur: 'Düzeltmeler',
nav_compliance_pipeline: 'Uyum Boru Hattı',
nav_meet: 'Meet',
nav_alerts: 'Uyarılar',
nav_alerts_b2b: 'B2B Uyarılar',
nav_messages: 'Mesajlar',
nav_settings: 'Ayarlar',
stat_open_corrections: 'Açık Düzeltmeler',
stat_completed_week: 'Tamamlanan (Hafta)',
stat_average: 'Ortalama',
stat_time_saved: 'Kazanılan Zaman',
recent_klausuren: 'Son Sınavlar',
show_all: 'Tümünü göster',
students: 'Öğrenciler',
status_in_progress: 'Devam Ediyor',
status_completed: 'Tamamlandı',
quick_actions: 'Hızlı İşlemler',
create_klausur: 'Yeni Sınav Oluştur',
upload_work: 'Kağıt Yükle',
magic_help: 'Magic Help',
fairness_check: 'Adillik Kontrolü',
ai_tip: 'Yapay Zeka İpucu',
ai_tip_text: '3 sınav adillik kontrolü bekliyor. Şimdi incelemek ister misiniz?',
imprint: 'Künye',
privacy: 'Gizlilik',
contact: 'İletişim',
legal: 'Kullanım Koşulları',
cookie_settings: 'Çerez Ayarları',
copyright: '© 2025 BreakPilot. Tüm hakları saklıdır.',
back_to_selection: 'Tasarım Seçimine Dön',
language: 'Dil',
theme_light: 'Açık',
theme_dark: 'Koyu',
logout: ıkış yap',
},
ar: {
dashboard: 'لوحة التحكم',
dashboard_subtitle: 'مرحباً بعودتك! إليك نظرة عامة.',
search_placeholder: 'بحث...',
nav_dashboard: 'لوحة التحكم',
nav_dokumente: 'المستندات',
nav_klausuren: 'الامتحانات',
nav_analytics: 'التحليلات',
nav_vokabeln: 'المفردات',
nav_worksheet_editor: 'أوراق العمل',
nav_worksheet_cleanup: 'تنظيف',
nav_korrektur: 'التصحيحات',
nav_compliance_pipeline: 'خط أنابيب الامتثال',
nav_meet: 'اجتماع',
nav_alerts: 'تنبيهات',
nav_alerts_b2b: 'تنبيهات الشركات',
nav_messages: 'الرسائل',
nav_settings: 'الإعدادات',
stat_open_corrections: 'التصحيحات المفتوحة',
stat_completed_week: 'المكتمل (أسبوع)',
stat_average: 'المتوسط',
stat_time_saved: 'الوقت الموفر',
recent_klausuren: 'الامتحانات الأخيرة',
show_all: 'عرض الكل',
students: 'الطلاب',
status_in_progress: 'قيد التنفيذ',
status_completed: 'مكتمل',
quick_actions: 'إجراءات سريعة',
create_klausur: 'إنشاء امتحان جديد',
upload_work: 'تحميل الأوراق',
magic_help: 'المساعدة السحرية',
fairness_check: 'فحص العدالة',
ai_tip: 'نصيحة الذكاء الاصطناعي',
ai_tip_text: '3 امتحانات تنتظر فحص العدالة. هل تريد مراجعتها الآن؟',
imprint: 'البصمة',
privacy: 'الخصوصية',
contact: 'اتصل بنا',
legal: 'الشروط والأحكام',
cookie_settings: 'إعدادات ملفات تعريف الارتباط',
copyright: '© 2025 BreakPilot. جميع الحقوق محفوظة.',
back_to_selection: 'العودة لاختيار التصميم',
language: 'اللغة',
theme_light: 'فاتح',
theme_dark: 'داكن',
logout: 'تسجيل الخروج',
},
ru: {
dashboard: 'Панель управления',
dashboard_subtitle: 'С возвращением! Вот ваш обзор.',
search_placeholder: 'Поиск...',
nav_dashboard: 'Панель управления',
nav_dokumente: 'Документы',
nav_klausuren: 'Экзамены',
nav_analytics: 'Аналитика',
nav_vokabeln: 'Словарь',
nav_worksheet_editor: 'Рабочие листы',
nav_worksheet_cleanup: 'Очистка',
nav_korrektur: 'Проверки',
nav_compliance_pipeline: 'Пайплайн соответствия',
nav_meet: 'Встреча',
nav_alerts: 'Оповещения',
nav_alerts_b2b: 'B2B оповещения',
nav_messages: 'Сообщения',
nav_settings: 'Настройки',
stat_open_corrections: 'Открытые проверки',
stat_completed_week: 'Завершено (неделя)',
stat_average: 'Средний балл',
stat_time_saved: 'Сэкономлено времени',
recent_klausuren: 'Последние экзамены',
show_all: 'Показать все',
students: 'Студенты',
status_in_progress: 'В работе',
status_completed: 'Завершено',
quick_actions: 'Быстрые действия',
create_klausur: 'Создать экзамен',
upload_work: 'Загрузить работы',
magic_help: 'Magic Help',
fairness_check: 'Проверка справедливости',
ai_tip: 'Совет ИИ',
ai_tip_text: '3 экзамена ждут проверки справедливости. Хотите проверить сейчас?',
imprint: 'Импрессум',
privacy: 'Конфиденциальность',
contact: 'Контакт',
legal: 'Условия использования',
cookie_settings: 'Настройки cookie',
copyright: '© 2025 BreakPilot. Все права защищены.',
back_to_selection: 'Вернуться к выбору дизайна',
language: 'Язык',
theme_light: 'Светлая',
theme_dark: 'Тёмная',
logout: 'Выйти',
},
uk: {
dashboard: 'Панель керування',
dashboard_subtitle: 'З поверненням! Ось ваш огляд.',
search_placeholder: 'Пошук...',
nav_dashboard: 'Панель керування',
nav_dokumente: 'Документи',
nav_klausuren: 'Іспити',
nav_analytics: 'Аналітика',
nav_vokabeln: 'Словник',
nav_worksheet_editor: 'Робочі аркуші',
nav_worksheet_cleanup: 'Очищення',
nav_korrektur: 'Перевірки',
nav_compliance_pipeline: 'Пайплайн відповідності',
nav_meet: 'Зустріч',
nav_alerts: 'Сповіщення',
nav_alerts_b2b: 'B2B сповіщення',
nav_messages: 'Повідомлення',
nav_settings: 'Налаштування',
stat_open_corrections: 'Відкриті перевірки',
stat_completed_week: 'Завершено (тиждень)',
stat_average: 'Середній бал',
stat_time_saved: 'Збережено часу',
recent_klausuren: 'Останні іспити',
show_all: 'Показати все',
students: 'Студенти',
status_in_progress: 'В роботі',
status_completed: 'Завершено',
quick_actions: 'Швидкі дії',
create_klausur: 'Створити іспит',
upload_work: 'Завантажити роботи',
magic_help: 'Magic Help',
fairness_check: 'Перевірка справедливості',
ai_tip: 'Порада ШІ',
ai_tip_text: '3 іспити чекають перевірки справедливості. Бажаєте перевірити зараз?',
imprint: 'Імпресум',
privacy: 'Конфіденційність',
contact: 'Контакт',
legal: 'Умови використання',
cookie_settings: 'Налаштування cookie',
copyright: '© 2025 BreakPilot. Всі права захищені.',
back_to_selection: 'Повернутися до вибору дизайну',
language: 'Мова',
theme_light: 'Світла',
theme_dark: 'Темна',
logout: 'Вийти',
},
pl: {
dashboard: 'Panel główny',
dashboard_subtitle: 'Witaj ponownie! Oto twój przegląd.',
search_placeholder: 'Szukaj...',
nav_dashboard: 'Panel główny',
nav_dokumente: 'Dokumenty',
nav_klausuren: 'Egzaminy',
nav_analytics: 'Analizy',
nav_vokabeln: 'Słownictwo',
nav_worksheet_editor: 'Arkusze robocze',
nav_worksheet_cleanup: 'Czyszczenie',
nav_korrektur: 'Korekty',
nav_compliance_pipeline: 'Pipeline zgodności',
nav_meet: 'Spotkanie',
nav_alerts: 'Powiadomienia',
nav_alerts_b2b: 'Powiadomienia B2B',
nav_messages: 'Wiadomości',
nav_settings: 'Ustawienia',
stat_open_corrections: 'Otwarte korekty',
stat_completed_week: 'Ukończone (tydzień)',
stat_average: 'Średnia',
stat_time_saved: 'Zaoszczędzony czas',
recent_klausuren: 'Ostatnie egzaminy',
show_all: 'Pokaż wszystkie',
students: 'Uczniowie',
status_in_progress: 'W trakcie',
status_completed: 'Ukończone',
quick_actions: 'Szybkie akcje',
create_klausur: 'Utwórz egzamin',
upload_work: 'Prześlij prace',
magic_help: 'Magic Help',
fairness_check: 'Kontrola sprawiedliwości',
ai_tip: 'Wskazówka AI',
ai_tip_text: '3 egzaminy czekają na kontrolę sprawiedliwości. Chcesz je teraz sprawdzić?',
imprint: 'Impressum',
privacy: 'Prywatność',
contact: 'Kontakt',
legal: 'Regulamin',
cookie_settings: 'Ustawienia plików cookie',
copyright: '© 2025 BreakPilot. Wszelkie prawa zastrzeżone.',
back_to_selection: 'Powrót do wyboru projektu',
language: 'Język',
theme_light: 'Jasny',
theme_dark: 'Ciemny',
logout: 'Wyloguj się',
},
}
// LocalStorage Key
const STORAGE_KEY = 'bp_language'
// Aktuelle Sprache aus localStorage oder default
export function getStoredLanguage(): Language {
if (typeof window === 'undefined') return defaultLanguage
const stored = localStorage.getItem(STORAGE_KEY)
if (stored && stored in availableLanguages) {
return stored as Language
}
return defaultLanguage
}
// Sprache speichern
export function setStoredLanguage(lang: Language): void {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEY, lang)
}
// Uebersetzung abrufen
export function t(key: string, lang: Language): string {
return translations[lang][key] || translations[defaultLanguage][key] || key
}
// Ist die Sprache RTL?
export function isRTL(lang: Language): boolean {
return availableLanguages[lang]?.rtl === true
}

View File

@@ -0,0 +1,506 @@
/**
* Korrekturplattform API Service Layer
*
* Connects to klausur-service (Port 8086) for all correction-related operations.
* Uses dynamic host detection for local network compatibility.
*/
import type {
Klausur,
StudentWork,
CriteriaScores,
Annotation,
AnnotationPosition,
AnnotationType,
FairnessAnalysis,
EHSuggestion,
GradeInfo,
CreateKlausurData,
} from '@/app/korrektur/types'
// Get API base URL dynamically
// On localhost: direct connection to port 8086
// On macmini: use nginx proxy /klausur-api/ to avoid certificate issues
const getApiBase = (): string => {
if (typeof window === 'undefined') return 'http://localhost:8086'
const { hostname } = window.location
// Use nginx proxy on macmini to avoid cross-origin certificate issues
return hostname === 'localhost' ? 'http://localhost:8086' : '/klausur-api'
}
// Generic fetch wrapper with error handling
async function apiFetch<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${getApiBase()}${endpoint}`
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(errorData.detail || `HTTP ${response.status}`)
}
return response.json()
}
// ============================================================================
// KLAUSUREN API
// ============================================================================
export async function getKlausuren(): Promise<Klausur[]> {
const data = await apiFetch<{ klausuren: Klausur[] }>('/api/v1/klausuren')
return data.klausuren || []
}
export async function getKlausur(id: string): Promise<Klausur> {
return apiFetch<Klausur>(`/api/v1/klausuren/${id}`)
}
export async function createKlausur(data: CreateKlausurData): Promise<Klausur> {
return apiFetch<Klausur>('/api/v1/klausuren', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function deleteKlausur(id: string): Promise<void> {
await apiFetch(`/api/v1/klausuren/${id}`, { method: 'DELETE' })
}
// ============================================================================
// STUDENTS API
// ============================================================================
export async function getStudents(klausurId: string): Promise<StudentWork[]> {
const data = await apiFetch<{ students: StudentWork[] }>(
`/api/v1/klausuren/${klausurId}/students`
)
return data.students || []
}
export async function getStudent(studentId: string): Promise<StudentWork> {
return apiFetch<StudentWork>(`/api/v1/students/${studentId}`)
}
export async function uploadStudentWork(
klausurId: string,
file: File,
anonymId: string
): Promise<StudentWork> {
const formData = new FormData()
formData.append('file', file)
formData.append('anonym_id', anonymId)
const response = await fetch(
`${getApiBase()}/api/v1/klausuren/${klausurId}/students`,
{
method: 'POST',
body: formData,
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'Upload failed' }))
throw new Error(errorData.detail || `HTTP ${response.status}`)
}
return response.json()
}
export async function deleteStudent(studentId: string): Promise<void> {
await apiFetch(`/api/v1/students/${studentId}`, { method: 'DELETE' })
}
// ============================================================================
// CRITERIA & GUTACHTEN API
// ============================================================================
export async function updateCriteria(
studentId: string,
criteria: CriteriaScores
): Promise<StudentWork> {
return apiFetch<StudentWork>(`/api/v1/students/${studentId}/criteria`, {
method: 'PUT',
body: JSON.stringify({ criteria_scores: criteria }),
})
}
export async function updateGutachten(
studentId: string,
gutachten: string
): Promise<StudentWork> {
return apiFetch<StudentWork>(`/api/v1/students/${studentId}/gutachten`, {
method: 'PUT',
body: JSON.stringify({ gutachten }),
})
}
export async function generateGutachten(
studentId: string
): Promise<{ gutachten: string }> {
return apiFetch<{ gutachten: string }>(
`/api/v1/students/${studentId}/gutachten/generate`,
{ method: 'POST' }
)
}
// ============================================================================
// ANNOTATIONS API
// ============================================================================
export async function getAnnotations(studentId: string): Promise<Annotation[]> {
const data = await apiFetch<{ annotations: Annotation[] }>(
`/api/v1/students/${studentId}/annotations`
)
return data.annotations || []
}
export async function createAnnotation(
studentId: string,
annotation: {
page: number
position: AnnotationPosition
type: AnnotationType
text: string
severity?: 'minor' | 'major' | 'critical'
suggestion?: string
linked_criterion?: string
}
): Promise<Annotation> {
return apiFetch<Annotation>(`/api/v1/students/${studentId}/annotations`, {
method: 'POST',
body: JSON.stringify(annotation),
})
}
export async function updateAnnotation(
annotationId: string,
updates: Partial<{
text: string
severity: 'minor' | 'major' | 'critical'
suggestion: string
}>
): Promise<Annotation> {
return apiFetch<Annotation>(`/api/v1/annotations/${annotationId}`, {
method: 'PUT',
body: JSON.stringify(updates),
})
}
export async function deleteAnnotation(annotationId: string): Promise<void> {
await apiFetch(`/api/v1/annotations/${annotationId}`, { method: 'DELETE' })
}
// ============================================================================
// EH/RAG API (500+ NiBiS Dokumente)
// ============================================================================
export async function getEHSuggestions(
studentId: string,
criterion?: string
): Promise<EHSuggestion[]> {
const data = await apiFetch<{ suggestions: EHSuggestion[] }>(
`/api/v1/students/${studentId}/eh-suggestions`,
{
method: 'POST',
body: JSON.stringify({ criterion }),
}
)
return data.suggestions || []
}
export async function queryRAG(
query: string,
topK: number = 5
): Promise<{ results: Array<{ text: string; score: number; metadata: any }> }> {
return apiFetch('/api/v1/eh/rag-query', {
method: 'POST',
body: JSON.stringify({ query, top_k: topK }),
})
}
export async function uploadEH(file: File): Promise<{ id: string; name: string }> {
const formData = new FormData()
formData.append('file', file)
const response = await fetch(
`${getApiBase()}/api/v1/eh/upload`,
{
method: 'POST',
body: formData,
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: 'EH Upload failed' }))
throw new Error(errorData.detail || `HTTP ${response.status}`)
}
return response.json()
}
// ============================================================================
// FAIRNESS & EXPORT API
// ============================================================================
export async function getFairnessAnalysis(
klausurId: string
): Promise<FairnessAnalysis> {
return apiFetch<FairnessAnalysis>(`/api/v1/klausuren/${klausurId}/fairness`)
}
export async function getGradeInfo(): Promise<GradeInfo> {
return apiFetch<GradeInfo>('/api/v1/grade-info')
}
// Export endpoints return file downloads
export function getGutachtenExportUrl(studentId: string): string {
return `${getApiBase()}/api/v1/students/${studentId}/export/gutachten`
}
export function getAnnotationsExportUrl(studentId: string): string {
return `${getApiBase()}/api/v1/students/${studentId}/export/annotations`
}
export function getOverviewExportUrl(klausurId: string): string {
return `${getApiBase()}/api/v1/klausuren/${klausurId}/export/overview`
}
export function getAllGutachtenExportUrl(klausurId: string): string {
return `${getApiBase()}/api/v1/klausuren/${klausurId}/export/all-gutachten`
}
// Get student file URL (PDF/Image)
export function getStudentFileUrl(studentId: string): string {
return `${getApiBase()}/api/v1/students/${studentId}/file`
}
// ============================================================================
// ARCHIV API (NiBiS Zentralabitur Documents)
// ============================================================================
export interface ArchivDokument {
id: string
title: string
subject: string
niveau: string
year: number
task_number?: number
doc_type: string
variant?: string
bundesland: string
minio_path?: string
preview_url?: string
}
export interface ArchivSearchResponse {
total: number
documents: ArchivDokument[]
filters: {
subjects: string[]
years: number[]
niveaus: string[]
doc_types: string[]
}
}
export interface ArchivFilters {
subject?: string
year?: number
bundesland?: string
niveau?: string
doc_type?: string
search?: string
limit?: number
offset?: number
}
export async function getArchivDocuments(filters: ArchivFilters = {}): Promise<ArchivSearchResponse> {
const params = new URLSearchParams()
if (filters.subject && filters.subject !== 'Alle') params.append('subject', filters.subject)
if (filters.year) params.append('year', filters.year.toString())
if (filters.bundesland && filters.bundesland !== 'Alle') params.append('bundesland', filters.bundesland)
if (filters.niveau && filters.niveau !== 'Alle') params.append('niveau', filters.niveau)
if (filters.doc_type && filters.doc_type !== 'Alle') params.append('doc_type', filters.doc_type)
if (filters.search) params.append('search', filters.search)
if (filters.limit) params.append('limit', filters.limit.toString())
if (filters.offset) params.append('offset', filters.offset.toString())
const queryString = params.toString()
const endpoint = `/api/v1/archiv${queryString ? `?${queryString}` : ''}`
return apiFetch<ArchivSearchResponse>(endpoint)
}
export async function getArchivDocument(docId: string): Promise<ArchivDokument & { text_preview?: string }> {
return apiFetch<ArchivDokument & { text_preview?: string }>(`/api/v1/archiv/${docId}`)
}
export async function getArchivDocumentUrl(docId: string, expires: number = 3600): Promise<{ url: string; expires_in: number; filename: string }> {
return apiFetch<{ url: string; expires_in: number; filename: string }>(`/api/v1/archiv/${docId}/url?expires=${expires}`)
}
export async function searchArchivSemantic(
query: string,
options: { year?: number; subject?: string; niveau?: string; limit?: number } = {}
): Promise<Array<{
id: string
score: number
text: string
year: number
subject: string
niveau: string
task_number?: number
doc_type: string
}>> {
const params = new URLSearchParams({ query })
if (options.year) params.append('year', options.year.toString())
if (options.subject) params.append('subject', options.subject)
if (options.niveau) params.append('niveau', options.niveau)
if (options.limit) params.append('limit', options.limit.toString())
return apiFetch(`/api/v1/archiv/search/semantic?${params.toString()}`)
}
export async function getArchivSuggestions(query: string): Promise<Array<{ label: string; type: string }>> {
return apiFetch<Array<{ label: string; type: string }>>(`/api/v1/archiv/suggest?query=${encodeURIComponent(query)}`)
}
export async function getArchivStats(): Promise<{
total_documents: number
total_chunks: number
by_year: Record<string, number>
by_subject: Record<string, number>
by_niveau: Record<string, number>
}> {
return apiFetch('/api/v1/archiv/stats')
}
// ============================================================================
// STATS API (for Dashboard)
// ============================================================================
export interface KorrekturStats {
totalKlausuren: number
totalStudents: number
openCorrections: number
completedThisWeek: number
averageGrade: number
timeSavedHours: number
}
export async function getKorrekturStats(): Promise<KorrekturStats> {
try {
const klausuren = await getKlausuren()
let totalStudents = 0
let openCorrections = 0
let completedCount = 0
let gradeSum = 0
let gradedCount = 0
for (const klausur of klausuren) {
totalStudents += klausur.student_count || 0
completedCount += klausur.completed_count || 0
openCorrections += (klausur.student_count || 0) - (klausur.completed_count || 0)
}
// Get average from all klausuren
for (const klausur of klausuren) {
if (klausur.status === 'completed' || klausur.status === 'in_progress') {
try {
const fairness = await getFairnessAnalysis(klausur.id)
if (fairness.average_grade > 0) {
gradeSum += fairness.average_grade
gradedCount++
}
} catch {
// Skip if fairness analysis not available
}
}
}
const averageGrade = gradedCount > 0 ? gradeSum / gradedCount : 0
// Estimate time saved: ~30 minutes per correction with AI assistance
const timeSavedHours = Math.round(completedCount * 0.5)
return {
totalKlausuren: klausuren.length,
totalStudents,
openCorrections,
completedThisWeek: completedCount, // Simplified, would need date filtering
averageGrade: Math.round(averageGrade * 10) / 10,
timeSavedHours,
}
} catch {
return {
totalKlausuren: 0,
totalStudents: 0,
openCorrections: 0,
completedThisWeek: 0,
averageGrade: 0,
timeSavedHours: 0,
}
}
}
// Export all functions as a namespace
export const korrekturApi = {
// Klausuren
getKlausuren,
getKlausur,
createKlausur,
deleteKlausur,
// Students
getStudents,
getStudent,
uploadStudentWork,
deleteStudent,
// Criteria & Gutachten
updateCriteria,
updateGutachten,
generateGutachten,
// Annotations
getAnnotations,
createAnnotation,
updateAnnotation,
deleteAnnotation,
// EH/RAG
getEHSuggestions,
queryRAG,
uploadEH,
// Fairness & Export
getFairnessAnalysis,
getGradeInfo,
getGutachtenExportUrl,
getAnnotationsExportUrl,
getOverviewExportUrl,
getAllGutachtenExportUrl,
getStudentFileUrl,
// Archiv (NiBiS)
getArchivDocuments,
getArchivDocument,
getArchivDocumentUrl,
searchArchivSemantic,
getArchivSuggestions,
getArchivStats,
// Stats
getKorrekturStats,
}

View File

@@ -0,0 +1 @@
export * from './api'

View File

@@ -0,0 +1,188 @@
'use client'
import React, { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react'
import { MOTION, LAYERS } from './depth-system'
import { usePerformance } from './PerformanceContext'
/**
* Focus Context - Manages focus mode for the UI
*
* When an element enters "focus mode", the rest of the UI dims and blurs,
* creating a spotlight effect that helps users concentrate on the task at hand.
*
* This is particularly useful for:
* - Replying to messages
* - Editing content
* - Modal-like interactions without actual modals
*/
interface FocusContextType {
/** Whether focus mode is active */
isFocused: boolean
/** The ID of the focused element (if any) */
focusedElementId: string | null
/** Enter focus mode */
enterFocus: (elementId: string) => void
/** Exit focus mode */
exitFocus: () => void
/** Toggle focus mode */
toggleFocus: (elementId: string) => void
}
const FocusContext = createContext<FocusContextType | null>(null)
interface FocusProviderProps {
children: React.ReactNode
/** Duration of the focus transition in ms */
transitionDuration?: number
/** Blur amount for unfocused elements (px) */
blurAmount?: number
/** Dim amount for unfocused elements (0-1) */
dimAmount?: number
}
export function FocusProvider({
children,
transitionDuration = 300,
blurAmount = 4,
dimAmount = 0.6,
}: FocusProviderProps) {
const [isFocused, setIsFocused] = useState(false)
const [focusedElementId, setFocusedElementId] = useState<string | null>(null)
const { settings } = usePerformance()
const enterFocus = useCallback((elementId: string) => {
setFocusedElementId(elementId)
setIsFocused(true)
}, [])
const exitFocus = useCallback(() => {
setIsFocused(false)
// Delay clearing the ID to allow exit animation
setTimeout(() => {
setFocusedElementId(null)
}, transitionDuration)
}, [transitionDuration])
const toggleFocus = useCallback(
(elementId: string) => {
if (isFocused && focusedElementId === elementId) {
exitFocus()
} else {
enterFocus(elementId)
}
},
[isFocused, focusedElementId, enterFocus, exitFocus]
)
// Keyboard handler for Escape
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFocused) {
exitFocus()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isFocused, exitFocus])
// Adaptive values based on performance
const adaptiveBlur = settings.enableBlur ? blurAmount * settings.blurIntensity : 0
const adaptiveDuration = Math.round(transitionDuration * settings.animationSpeed)
return (
<FocusContext.Provider
value={{
isFocused,
focusedElementId,
enterFocus,
exitFocus,
toggleFocus,
}}
>
{/* Focus backdrop - dims and blurs unfocused content */}
<div
style={{
position: 'fixed',
inset: 0,
zIndex: LAYERS.floating.zIndex,
pointerEvents: isFocused ? 'auto' : 'none',
opacity: isFocused ? 1 : 0,
backgroundColor: `rgba(0, 0, 0, ${isFocused ? dimAmount : 0})`,
backdropFilter: isFocused && adaptiveBlur > 0 ? `blur(${adaptiveBlur}px)` : 'none',
WebkitBackdropFilter: isFocused && adaptiveBlur > 0 ? `blur(${adaptiveBlur}px)` : 'none',
transition: `
opacity ${adaptiveDuration}ms ${MOTION.standard.easing},
backdrop-filter ${adaptiveDuration}ms ${MOTION.standard.easing}
`,
}}
onClick={exitFocus}
aria-hidden={!isFocused}
/>
{children}
</FocusContext.Provider>
)
}
export function useFocus() {
const context = useContext(FocusContext)
if (!context) {
return {
isFocused: false,
focusedElementId: null,
enterFocus: () => {},
exitFocus: () => {},
toggleFocus: () => {},
}
}
return context
}
/**
* Hook to check if a specific element is focused
*/
export function useIsFocused(elementId: string): boolean {
const { isFocused, focusedElementId } = useFocus()
return isFocused && focusedElementId === elementId
}
/**
* FocusTarget - Wrapper that makes children focusable
*/
interface FocusTargetProps {
children: React.ReactNode
/** Unique ID for this focus target */
id: string
/** Additional class names */
className?: string
/** Style overrides */
style?: React.CSSProperties
}
export function FocusTarget({ children, id, className = '', style }: FocusTargetProps) {
const { isFocused, focusedElementId } = useFocus()
const { settings } = usePerformance()
const isThisElement = focusedElementId === id
const shouldElevate = isFocused && isThisElement
const duration = Math.round(MOTION.emphasis.duration * settings.animationSpeed)
return (
<div
className={className}
style={{
...style,
position: 'relative',
zIndex: shouldElevate ? LAYERS.overlay.zIndex + 1 : 'auto',
transition: `z-index ${duration}ms ${MOTION.standard.easing}`,
}}
data-focus-target={id}
data-focused={shouldElevate}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,374 @@
'use client'
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'
/**
* Performance Context - Adaptive Quality System
*
* Monitors device capabilities and runtime performance to automatically
* adjust visual quality. This ensures smooth 60fps (or 120fps on capable devices)
* by degrading effects when needed.
*/
// =============================================================================
// TYPES
// =============================================================================
export type QualityLevel = 'high' | 'medium' | 'low' | 'minimal'
export interface PerformanceMetrics {
/** Frames per second (rolling average) */
fps: number
/** Dropped frames in last second */
droppedFrames: number
/** Device memory in GB (if available) */
deviceMemory: number | null
/** Number of logical CPU cores */
hardwareConcurrency: number
/** Whether device prefers reduced motion */
prefersReducedMotion: boolean
/** Whether device is on battery/power-save */
isLowPowerMode: boolean
/** Current quality level */
qualityLevel: QualityLevel
}
export interface QualitySettings {
/** Enable backdrop blur effects */
enableBlur: boolean
/** Blur intensity multiplier (0-1) */
blurIntensity: number
/** Enable shadow effects */
enableShadows: boolean
/** Shadow complexity (0-1) */
shadowComplexity: number
/** Enable parallax effects */
enableParallax: boolean
/** Parallax intensity multiplier (0-1) */
parallaxIntensity: number
/** Enable spring animations */
enableSpringAnimations: boolean
/** Animation duration multiplier (0.5-1.5) */
animationSpeed: number
/** Enable typewriter effects */
enableTypewriter: boolean
/** Max concurrent animations */
maxConcurrentAnimations: number
}
interface PerformanceContextType {
metrics: PerformanceMetrics
settings: QualitySettings
/** Force a specific quality level (null = auto) */
forceQuality: (level: QualityLevel | null) => void
/** Report that an animation started */
reportAnimationStart: () => void
/** Report that an animation ended */
reportAnimationEnd: () => void
/** Check if we can start another animation */
canStartAnimation: () => boolean
}
// =============================================================================
// DEFAULT VALUES
// =============================================================================
const DEFAULT_METRICS: PerformanceMetrics = {
fps: 60,
droppedFrames: 0,
deviceMemory: null,
hardwareConcurrency: 4,
prefersReducedMotion: false,
isLowPowerMode: false,
qualityLevel: 'high',
}
const QUALITY_PRESETS: Record<QualityLevel, QualitySettings> = {
high: {
enableBlur: true,
blurIntensity: 1,
enableShadows: true,
shadowComplexity: 1,
enableParallax: true,
parallaxIntensity: 1,
enableSpringAnimations: true,
animationSpeed: 1,
enableTypewriter: true,
maxConcurrentAnimations: 10,
},
medium: {
enableBlur: true,
blurIntensity: 0.7,
enableShadows: true,
shadowComplexity: 0.7,
enableParallax: true,
parallaxIntensity: 0.5,
enableSpringAnimations: true,
animationSpeed: 0.9,
enableTypewriter: true,
maxConcurrentAnimations: 6,
},
low: {
enableBlur: false,
blurIntensity: 0,
enableShadows: true,
shadowComplexity: 0.4,
enableParallax: false,
parallaxIntensity: 0,
enableSpringAnimations: false,
animationSpeed: 0.7,
enableTypewriter: true,
maxConcurrentAnimations: 3,
},
minimal: {
enableBlur: false,
blurIntensity: 0,
enableShadows: false,
shadowComplexity: 0,
enableParallax: false,
parallaxIntensity: 0,
enableSpringAnimations: false,
animationSpeed: 0.5,
enableTypewriter: false,
maxConcurrentAnimations: 1,
},
}
// =============================================================================
// CONTEXT
// =============================================================================
const PerformanceContext = createContext<PerformanceContextType | null>(null)
// =============================================================================
// PROVIDER
// =============================================================================
export function PerformanceProvider({ children }: { children: React.ReactNode }) {
const [metrics, setMetrics] = useState<PerformanceMetrics>(DEFAULT_METRICS)
const [forcedQuality, setForcedQuality] = useState<QualityLevel | null>(null)
const [activeAnimations, setActiveAnimations] = useState(0)
const frameTimesRef = useRef<number[]>([])
const lastFrameTimeRef = useRef<number>(performance.now())
const rafIdRef = useRef<number | null>(null)
// Detect device capabilities on mount
useEffect(() => {
if (typeof window === 'undefined') return
const detectCapabilities = () => {
// Hardware concurrency
const cores = navigator.hardwareConcurrency || 4
// Device memory (Chrome only)
const memory = (navigator as any).deviceMemory || null
// Reduced motion preference
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
// Battery/power save mode (approximation)
let lowPower = false
if ('getBattery' in navigator) {
(navigator as any).getBattery?.().then((battery: any) => {
lowPower = battery.level < 0.2 && !battery.charging
setMetrics(prev => ({ ...prev, isLowPowerMode: lowPower }))
})
}
// Determine initial quality level
let initialQuality: QualityLevel = 'high'
if (reducedMotion) {
initialQuality = 'minimal'
} else if (cores <= 2 || (memory && memory <= 2)) {
initialQuality = 'low'
} else if (cores <= 4 || (memory && memory <= 4)) {
initialQuality = 'medium'
}
setMetrics(prev => ({
...prev,
hardwareConcurrency: cores,
deviceMemory: memory,
prefersReducedMotion: reducedMotion,
qualityLevel: initialQuality,
}))
}
detectCapabilities()
// Listen for reduced motion changes
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)')
const handleChange = (e: MediaQueryListEvent) => {
setMetrics(prev => ({
...prev,
prefersReducedMotion: e.matches,
qualityLevel: e.matches ? 'minimal' : prev.qualityLevel,
}))
}
mediaQuery.addEventListener('change', handleChange)
return () => mediaQuery.removeEventListener('change', handleChange)
}, [])
// FPS monitoring loop
useEffect(() => {
if (typeof window === 'undefined') return
let frameCount = 0
let lastFpsUpdate = performance.now()
const measureFrame = (timestamp: number) => {
const delta = timestamp - lastFrameTimeRef.current
lastFrameTimeRef.current = timestamp
// Track frame times (keep last 60)
frameTimesRef.current.push(delta)
if (frameTimesRef.current.length > 60) {
frameTimesRef.current.shift()
}
frameCount++
// Update FPS every second
if (timestamp - lastFpsUpdate >= 1000) {
const avgFrameTime =
frameTimesRef.current.reduce((a, b) => a + b, 0) / frameTimesRef.current.length
const fps = Math.round(1000 / avgFrameTime)
// Count dropped frames (frames > 20ms = dropped at 60fps)
const dropped = frameTimesRef.current.filter(t => t > 20).length
setMetrics(prev => {
// Auto-adjust quality based on performance
let newQuality = prev.qualityLevel
if (!forcedQuality) {
if (dropped > 10 && prev.qualityLevel !== 'minimal') {
// Downgrade
const levels: QualityLevel[] = ['high', 'medium', 'low', 'minimal']
const currentIndex = levels.indexOf(prev.qualityLevel)
newQuality = levels[Math.min(currentIndex + 1, levels.length - 1)]
} else if (dropped === 0 && fps >= 58 && prev.qualityLevel !== 'high') {
// Consider upgrade (only if stable for a while)
// This is conservative - we don't want to oscillate
}
}
return {
...prev,
fps,
droppedFrames: dropped,
qualityLevel: forcedQuality || newQuality,
}
})
frameCount = 0
lastFpsUpdate = timestamp
}
rafIdRef.current = requestAnimationFrame(measureFrame)
}
rafIdRef.current = requestAnimationFrame(measureFrame)
return () => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current)
}
}
}, [forcedQuality])
// Get current settings based on quality level
const settings = QUALITY_PRESETS[forcedQuality || metrics.qualityLevel]
// Force quality level
const forceQuality = useCallback((level: QualityLevel | null) => {
setForcedQuality(level)
if (level) {
setMetrics(prev => ({ ...prev, qualityLevel: level }))
}
}, [])
// Animation tracking
const reportAnimationStart = useCallback(() => {
setActiveAnimations(prev => prev + 1)
}, [])
const reportAnimationEnd = useCallback(() => {
setActiveAnimations(prev => Math.max(0, prev - 1))
}, [])
const canStartAnimation = useCallback(() => {
return activeAnimations < settings.maxConcurrentAnimations
}, [activeAnimations, settings.maxConcurrentAnimations])
return (
<PerformanceContext.Provider
value={{
metrics,
settings,
forceQuality,
reportAnimationStart,
reportAnimationEnd,
canStartAnimation,
}}
>
{children}
</PerformanceContext.Provider>
)
}
// =============================================================================
// HOOKS
// =============================================================================
export function usePerformance() {
const context = useContext(PerformanceContext)
if (!context) {
// Return defaults if used outside provider
return {
metrics: DEFAULT_METRICS,
settings: QUALITY_PRESETS.high,
forceQuality: () => {},
reportAnimationStart: () => {},
reportAnimationEnd: () => {},
canStartAnimation: () => true,
}
}
return context
}
/**
* Hook to get adaptive blur value
*/
export function useAdaptiveBlur(baseBlur: number): number {
const { settings } = usePerformance()
if (!settings.enableBlur) return 0
return Math.round(baseBlur * settings.blurIntensity)
}
/**
* Hook to get adaptive shadow
*/
export function useAdaptiveShadow(baseShadow: string, fallbackShadow: string = 'none'): string {
const { settings } = usePerformance()
if (!settings.enableShadows) return fallbackShadow
// Could also reduce shadow complexity here
return baseShadow
}
/**
* Hook for reduced motion check
*/
export function usePrefersReducedMotion(): boolean {
const { metrics } = usePerformance()
return metrics.prefersReducedMotion
}
/**
* Hook to get animation duration with performance adjustment
*/
export function useAdaptiveAnimationDuration(baseDuration: number): number {
const { settings } = usePerformance()
return Math.round(baseDuration * settings.animationSpeed)
}

View File

@@ -0,0 +1,335 @@
/**
* Spatial UI - Depth System
*
* Design tokens for creating consistent depth and layering across the UI.
* Based on the "Fake-3D without 3D" principle - 2.5D composition using
* z-hierarchy, shadows, blur, and subtle motion.
*/
// =============================================================================
// LAYER DEFINITIONS
// =============================================================================
export const LAYERS = {
/** Base content layer - main page content */
base: {
name: 'base',
zIndex: 0,
elevation: 0,
},
/** Content cards, lists, primary UI elements */
content: {
name: 'content',
zIndex: 10,
elevation: 1,
},
/** Floating elements, dropdowns, popovers */
floating: {
name: 'floating',
zIndex: 20,
elevation: 2,
},
/** Sticky headers, navigation */
sticky: {
name: 'sticky',
zIndex: 30,
elevation: 2,
},
/** Overlays, notifications, chat bubbles */
overlay: {
name: 'overlay',
zIndex: 40,
elevation: 3,
},
/** Modal dialogs */
modal: {
name: 'modal',
zIndex: 50,
elevation: 4,
},
/** Tooltips, cursor assists */
tooltip: {
name: 'tooltip',
zIndex: 60,
elevation: 5,
},
} as const
export type LayerName = keyof typeof LAYERS
// =============================================================================
// SHADOW DEFINITIONS (Dynamic based on elevation)
// =============================================================================
export const SHADOWS = {
/** No shadow - flat on surface */
none: 'none',
/** Subtle lift - barely visible */
xs: '0 1px 2px rgba(0,0,0,0.05)',
/** Small lift - cards at rest */
sm: '0 2px 4px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04)',
/** Medium lift - cards on hover */
md: '0 4px 8px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04)',
/** Large lift - active/dragging */
lg: '0 8px 16px rgba(0,0,0,0.10), 0 4px 8px rgba(0,0,0,0.06)',
/** Extra large - floating overlays */
xl: '0 12px 24px rgba(0,0,0,0.12), 0 6px 12px rgba(0,0,0,0.08)',
/** 2XL - modals, maximum elevation */
'2xl': '0 20px 40px rgba(0,0,0,0.15), 0 10px 20px rgba(0,0,0,0.10)',
/** Glow effect for focus states */
glow: (color: string, intensity: number = 0.3) =>
`0 0 20px rgba(${color}, ${intensity}), 0 4px 12px rgba(0,0,0,0.1)`,
} as const
// Dark mode shadows (slightly more pronounced)
export const SHADOWS_DARK = {
none: 'none',
xs: '0 1px 3px rgba(0,0,0,0.2)',
sm: '0 2px 6px rgba(0,0,0,0.25), 0 1px 3px rgba(0,0,0,0.15)',
md: '0 4px 12px rgba(0,0,0,0.3), 0 2px 6px rgba(0,0,0,0.2)',
lg: '0 8px 20px rgba(0,0,0,0.35), 0 4px 10px rgba(0,0,0,0.25)',
xl: '0 12px 32px rgba(0,0,0,0.4), 0 6px 16px rgba(0,0,0,0.3)',
'2xl': '0 20px 50px rgba(0,0,0,0.5), 0 10px 25px rgba(0,0,0,0.35)',
glow: (color: string, intensity: number = 0.4) =>
`0 0 30px rgba(${color}, ${intensity}), 0 4px 16px rgba(0,0,0,0.3)`,
} as const
// =============================================================================
// BLUR DEFINITIONS (Material simulation)
// =============================================================================
export const BLUR = {
/** No blur */
none: 0,
/** Subtle - barely perceptible */
xs: 4,
/** Small - light frosted glass */
sm: 8,
/** Medium - standard glass effect */
md: 12,
/** Large - heavy frosted glass */
lg: 16,
/** Extra large - maximum blur */
xl: 24,
/** 2XL - extreme blur for backgrounds */
'2xl': 32,
} as const
// =============================================================================
// MOTION DEFINITIONS (Physics-based transitions)
// =============================================================================
export const MOTION = {
/** Micro-interactions (hover, focus) */
micro: {
duration: 150,
easing: 'cubic-bezier(0.25, 0.1, 0.25, 1)',
},
/** Standard transitions */
standard: {
duration: 220,
easing: 'cubic-bezier(0.4, 0, 0.2, 1)',
},
/** Emphasis (entering elements) */
emphasis: {
duration: 300,
easing: 'cubic-bezier(0.0, 0, 0.2, 1)',
},
/** Spring-like bounce */
spring: {
duration: 400,
easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)',
},
/** Deceleration (entering from off-screen) */
decelerate: {
duration: 350,
easing: 'cubic-bezier(0, 0, 0.2, 1)',
},
/** Acceleration (exiting to off-screen) */
accelerate: {
duration: 250,
easing: 'cubic-bezier(0.4, 0, 1, 1)',
},
} as const
// =============================================================================
// PARALLAX DEFINITIONS (Subtle depth cues)
// =============================================================================
export const PARALLAX = {
/** No parallax */
none: 0,
/** Minimal - barely perceptible (1-2px) */
subtle: 0.02,
/** Light - noticeable but not distracting (2-4px) */
light: 0.04,
/** Medium - clear depth separation (4-6px) */
medium: 0.06,
/** Strong - pronounced effect (use sparingly) */
strong: 0.1,
} as const
// =============================================================================
// MATERIAL DEFINITIONS (Surface types)
// =============================================================================
export interface Material {
background: string
backgroundDark: string
blur: number
opacity: number
border: string
borderDark: string
}
export const MATERIALS: Record<string, Material> = {
/** Solid surface - no transparency */
solid: {
background: 'rgb(255, 255, 255)',
backgroundDark: 'rgb(15, 23, 42)',
blur: 0,
opacity: 1,
border: 'rgba(0, 0, 0, 0.1)',
borderDark: 'rgba(255, 255, 255, 0.1)',
},
/** Frosted glass - standard glassmorphism */
glass: {
background: 'rgba(255, 255, 255, 0.7)',
backgroundDark: 'rgba(255, 255, 255, 0.1)',
blur: 12,
opacity: 0.7,
border: 'rgba(0, 0, 0, 0.1)',
borderDark: 'rgba(255, 255, 255, 0.2)',
},
/** Thin glass - more transparent */
thinGlass: {
background: 'rgba(255, 255, 255, 0.5)',
backgroundDark: 'rgba(255, 255, 255, 0.05)',
blur: 8,
opacity: 0.5,
border: 'rgba(0, 0, 0, 0.05)',
borderDark: 'rgba(255, 255, 255, 0.1)',
},
/** Thick glass - more opaque */
thickGlass: {
background: 'rgba(255, 255, 255, 0.85)',
backgroundDark: 'rgba(255, 255, 255, 0.15)',
blur: 16,
opacity: 0.85,
border: 'rgba(0, 0, 0, 0.15)',
borderDark: 'rgba(255, 255, 255, 0.25)',
},
/** Acrylic - Windows 11 style */
acrylic: {
background: 'rgba(255, 255, 255, 0.6)',
backgroundDark: 'rgba(30, 30, 30, 0.6)',
blur: 20,
opacity: 0.6,
border: 'rgba(255, 255, 255, 0.3)',
borderDark: 'rgba(255, 255, 255, 0.15)',
},
}
// =============================================================================
// UTILITY FUNCTIONS
// =============================================================================
/**
* Get shadow based on elevation level and theme
*/
export function getShadow(
elevation: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl',
isDark: boolean = false
): string {
return isDark ? SHADOWS_DARK[elevation] : SHADOWS[elevation]
}
/**
* Get CSS transition string for depth changes
*/
export function getDepthTransition(motion: keyof typeof MOTION = 'standard'): string {
const m = MOTION[motion]
return `box-shadow ${m.duration}ms ${m.easing}, transform ${m.duration}ms ${m.easing}`
}
/**
* Get transform for elevation effect (slight scale + Y offset)
*/
export function getElevationTransform(
elevation: 'rest' | 'hover' | 'active' | 'dragging'
): string {
switch (elevation) {
case 'rest':
return 'translateY(0) scale(1)'
case 'hover':
return 'translateY(-2px) scale(1.01)'
case 'active':
return 'translateY(0) scale(0.99)'
case 'dragging':
return 'translateY(-4px) scale(1.02)'
default:
return 'translateY(0) scale(1)'
}
}
/**
* Calculate parallax offset based on cursor position
*/
export function calculateParallax(
cursorX: number,
cursorY: number,
elementRect: DOMRect,
intensity: number = PARALLAX.subtle
): { x: number; y: number } {
const centerX = elementRect.left + elementRect.width / 2
const centerY = elementRect.top + elementRect.height / 2
const deltaX = (cursorX - centerX) * intensity
const deltaY = (cursorY - centerY) * intensity
// Clamp to reasonable values
const maxOffset = 6
return {
x: Math.max(-maxOffset, Math.min(maxOffset, deltaX)),
y: Math.max(-maxOffset, Math.min(maxOffset, deltaY)),
}
}
/**
* Get CSS variables for a material
*/
export function getMaterialCSS(material: keyof typeof MATERIALS, isDark: boolean): string {
const m = MATERIALS[material]
return `
background: ${isDark ? m.backgroundDark : m.background};
backdrop-filter: blur(${m.blur}px);
-webkit-backdrop-filter: blur(${m.blur}px);
border-color: ${isDark ? m.borderDark : m.border};
`
}
// =============================================================================
// CSS CLASS GENERATORS (for Tailwind-like usage)
// =============================================================================
export const depthClasses = {
// Layer z-index
'layer-base': 'z-0',
'layer-content': 'z-10',
'layer-floating': 'z-20',
'layer-sticky': 'z-30',
'layer-overlay': 'z-40',
'layer-modal': 'z-50',
'layer-tooltip': 'z-60',
// Transitions
'depth-transition': 'transition-all duration-200 ease-out',
'depth-transition-spring': 'transition-all duration-400',
} as const

View File

@@ -0,0 +1,16 @@
/**
* Spatial UI System
*
* A design system for creating cinematic, depth-aware user interfaces.
* Implements "Fake-3D without 3D" - 2.5D composition using layering,
* shadows, blur, and physics-based motion.
*/
// Core systems
export * from './depth-system'
export * from './PerformanceContext'
export * from './FocusContext'
// Re-export common types
export type { QualityLevel, QualitySettings, PerformanceMetrics } from './PerformanceContext'
export type { LayerName, Material } from './depth-system'

View File

@@ -0,0 +1,13 @@
/**
* Voice Service Library
* Client-side voice streaming and encryption
*/
export { VoiceAPI, type VoiceSession, type VoiceTask } from './voice-api'
export {
VoiceEncryption,
generateMasterKey,
generateKeyHash,
encryptContent,
decryptContent,
} from './voice-encryption'

View File

@@ -0,0 +1,400 @@
/**
* Voice API Client
* WebSocket-based voice streaming to voice-service
*/
import { VoiceEncryption } from './voice-encryption'
const VOICE_SERVICE_URL =
process.env.NEXT_PUBLIC_VOICE_SERVICE_URL || 'http://localhost:8091'
const WS_URL = VOICE_SERVICE_URL.replace('http', 'ws')
export interface VoiceSession {
id: string
namespace_id: string
status: string
created_at: string
websocket_url: string
}
export interface VoiceTask {
id: string
session_id: string
type: string
state: string
created_at: string
updated_at: string
result_available: boolean
error_message?: string
}
export interface TranscriptMessage {
type: 'transcript'
text: string
final: boolean
confidence: number
}
export interface IntentMessage {
type: 'intent'
intent: string
confidence: number
parameters: Record<string, unknown>
}
export interface ResponseMessage {
type: 'response'
text: string
}
export interface StatusMessage {
type: 'status'
status: string
}
export interface TaskCreatedMessage {
type: 'task_created'
task_id: string
task_type: string
state: string
}
export interface ErrorMessage {
type: 'error'
message: string
code: string
}
export type VoiceMessage =
| TranscriptMessage
| IntentMessage
| ResponseMessage
| StatusMessage
| TaskCreatedMessage
| ErrorMessage
export type VoiceEventHandler = (message: VoiceMessage) => void
export type AudioHandler = (audioData: ArrayBuffer) => void
export type ErrorHandler = (error: Error) => void
/**
* Voice API Client
* Handles session management and WebSocket streaming
*/
export class VoiceAPI {
private encryption: VoiceEncryption
private session: VoiceSession | null = null
private ws: WebSocket | null = null
private audioContext: AudioContext | null = null
private mediaStream: MediaStream | null = null
private processor: ScriptProcessorNode | null = null
private onMessage: VoiceEventHandler | null = null
private onAudio: AudioHandler | null = null
private onError: ErrorHandler | null = null
private onStatusChange: ((status: string) => void) | null = null
constructor() {
this.encryption = new VoiceEncryption()
}
/**
* Initialize the voice API
*/
async initialize(): Promise<void> {
await this.encryption.initialize()
}
/**
* Check if API is ready
*/
isReady(): boolean {
return this.encryption.isInitialized()
}
/**
* Create a new voice session
*/
async createSession(): Promise<VoiceSession> {
const namespaceId = this.encryption.getNamespaceId()
const keyHash = this.encryption.getKeyHash()
if (!namespaceId || !keyHash) {
throw new Error('Encryption not initialized')
}
const response = await fetch(`${VOICE_SERVICE_URL}/api/v1/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
namespace_id: namespaceId,
key_hash: keyHash,
device_type: 'pwa',
client_version: '1.0.0',
}),
})
if (!response.ok) {
throw new Error(`Failed to create session: ${response.statusText}`)
}
this.session = await response.json()
return this.session!
}
/**
* Connect to WebSocket for voice streaming
*/
async connect(): Promise<void> {
if (!this.session) {
await this.createSession()
}
return new Promise((resolve, reject) => {
const wsUrl = this.session!.websocket_url
this.ws = new WebSocket(wsUrl)
this.ws.onopen = () => {
console.log('WebSocket connected')
this.onStatusChange?.('connected')
resolve()
}
this.ws.onerror = (event) => {
console.error('WebSocket error:', event)
this.onError?.(new Error('WebSocket connection failed'))
reject(new Error('WebSocket connection failed'))
}
this.ws.onclose = () => {
console.log('WebSocket closed')
this.onStatusChange?.('disconnected')
}
this.ws.onmessage = (event) => {
if (event.data instanceof Blob) {
// Binary audio data
event.data.arrayBuffer().then((buffer) => {
this.onAudio?.(buffer)
})
} else {
// JSON message
try {
const message = JSON.parse(event.data) as VoiceMessage
this.onMessage?.(message)
if (message.type === 'status') {
this.onStatusChange?.(message.status)
}
} catch (e) {
console.warn('Failed to parse message:', event.data)
}
}
}
})
}
/**
* Start capturing audio from microphone
*/
async startCapture(): Promise<void> {
try {
// Request microphone access
this.mediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 24000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
},
})
// Create audio context
this.audioContext = new AudioContext({ sampleRate: 24000 })
const source = this.audioContext.createMediaStreamSource(this.mediaStream)
// Create processor for capturing audio
// Note: ScriptProcessorNode is deprecated but still widely supported
// In production, use AudioWorklet
this.processor = this.audioContext.createScriptProcessor(2048, 1, 1)
this.processor.onaudioprocess = (event) => {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return
const inputData = event.inputBuffer.getChannelData(0)
// Convert Float32 to Int16
const int16Data = new Int16Array(inputData.length)
for (let i = 0; i < inputData.length; i++) {
const s = Math.max(-1, Math.min(1, inputData[i]))
int16Data[i] = s < 0 ? s * 0x8000 : s * 0x7fff
}
// Send audio chunk
this.ws.send(int16Data.buffer)
}
source.connect(this.processor)
this.processor.connect(this.audioContext.destination)
this.onStatusChange?.('listening')
} catch (error) {
console.error('Failed to start capture:', error)
this.onError?.(error as Error)
throw error
}
}
/**
* Stop capturing audio
*/
stopCapture(): void {
if (this.processor) {
this.processor.disconnect()
this.processor = null
}
if (this.mediaStream) {
this.mediaStream.getTracks().forEach((track) => track.stop())
this.mediaStream = null
}
if (this.audioContext) {
this.audioContext.close()
this.audioContext = null
}
// Signal end of turn
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'end_turn' }))
}
this.onStatusChange?.('processing')
}
/**
* Send interrupt signal
*/
interrupt(): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'interrupt' }))
}
}
/**
* Disconnect from voice service
*/
async disconnect(): Promise<void> {
this.stopCapture()
if (this.ws) {
this.ws.close()
this.ws = null
}
if (this.session) {
try {
await fetch(
`${VOICE_SERVICE_URL}/api/v1/sessions/${this.session.id}`,
{ method: 'DELETE' }
)
} catch (e) {
console.warn('Failed to close session:', e)
}
this.session = null
}
}
/**
* Get pending tasks for current session
*/
async getTasks(): Promise<VoiceTask[]> {
if (!this.session) return []
const response = await fetch(
`${VOICE_SERVICE_URL}/api/v1/sessions/${this.session.id}/tasks`
)
if (!response.ok) {
throw new Error('Failed to get tasks')
}
return response.json()
}
/**
* Approve a task
*/
async approveTask(taskId: string): Promise<void> {
const response = await fetch(
`${VOICE_SERVICE_URL}/api/v1/tasks/${taskId}/transition`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
new_state: 'approved',
reason: 'user_approved',
}),
}
)
if (!response.ok) {
throw new Error('Failed to approve task')
}
}
/**
* Reject a task
*/
async rejectTask(taskId: string): Promise<void> {
const response = await fetch(
`${VOICE_SERVICE_URL}/api/v1/tasks/${taskId}/transition`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
new_state: 'rejected',
reason: 'user_rejected',
}),
}
)
if (!response.ok) {
throw new Error('Failed to reject task')
}
}
/**
* Set event handlers
*/
setOnMessage(handler: VoiceEventHandler): void {
this.onMessage = handler
}
setOnAudio(handler: AudioHandler): void {
this.onAudio = handler
}
setOnError(handler: ErrorHandler): void {
this.onError = handler
}
setOnStatusChange(handler: (status: string) => void): void {
this.onStatusChange = handler
}
/**
* Get current session
*/
getSession(): VoiceSession | null {
return this.session
}
/**
* Get connection status
*/
isConnected(): boolean {
return this.ws !== null && this.ws.readyState === WebSocket.OPEN
}
}

View File

@@ -0,0 +1,334 @@
/**
* Voice Encryption - Client-Side AES-256-GCM
* DSGVO-compliant: Encryption key NEVER leaves the device
*
* The master key is stored in IndexedDB (encrypted with device key)
* Server only receives:
* - Key hash (for verification)
* - Encrypted blobs
* - Namespace ID (pseudonym)
*
* NOTE: crypto.subtle is only available in secure contexts (HTTPS or localhost).
* In development over HTTP (e.g., http://macmini:3000), encryption is disabled.
*/
/**
* Check if we're in a secure context where crypto.subtle is available
*/
export function isSecureContext(): boolean {
if (typeof window === 'undefined') return false
if (typeof crypto === 'undefined') return false
return crypto.subtle !== undefined
}
/**
* Check if encryption is available
* Returns false in non-secure HTTP contexts
*/
export function isEncryptionAvailable(): boolean {
return isSecureContext()
}
const DB_NAME = 'breakpilot-voice'
const DB_VERSION = 1
const STORE_NAME = 'keys'
const MASTER_KEY_ID = 'master-key'
/**
* Open IndexedDB for key storage
*/
async function openDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve(request.result)
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME, { keyPath: 'id' })
}
}
})
}
/**
* Generate a new master key for encryption
* This is called once when the teacher first uses voice features
* Returns null if encryption is not available (non-secure context)
*/
export async function generateMasterKey(): Promise<CryptoKey | null> {
if (!isEncryptionAvailable()) {
console.warn('[VoiceEncryption] crypto.subtle nicht verfügbar - HTTP-Kontext erkannt. Verschlüsselung deaktiviert.')
return null
}
const key = await crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true, // extractable for storage
['encrypt', 'decrypt']
)
// Store in IndexedDB
await storeMasterKey(key)
return key
}
/**
* Store master key in IndexedDB
*/
async function storeMasterKey(key: CryptoKey): Promise<void> {
if (!isEncryptionAvailable()) return
const db = await openDatabase()
const exportedKey = await crypto.subtle.exportKey('raw', key)
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readwrite')
const store = transaction.objectStore(STORE_NAME)
const request = store.put({
id: MASTER_KEY_ID,
key: Array.from(new Uint8Array(exportedKey)),
createdAt: new Date().toISOString(),
})
request.onerror = () => reject(request.error)
request.onsuccess = () => resolve()
})
}
/**
* Get master key from IndexedDB
* Returns null if encryption is not available
*/
export async function getMasterKey(): Promise<CryptoKey | null> {
if (!isEncryptionAvailable()) {
return null
}
try {
const db = await openDatabase()
return new Promise((resolve, reject) => {
const transaction = db.transaction(STORE_NAME, 'readonly')
const store = transaction.objectStore(STORE_NAME)
const request = store.get(MASTER_KEY_ID)
request.onerror = () => reject(request.error)
request.onsuccess = async () => {
const record = request.result
if (!record) {
resolve(null)
return
}
// Import key
const keyData = new Uint8Array(record.key)
const key = await crypto.subtle.importKey(
'raw',
keyData,
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
)
resolve(key)
}
})
} catch {
return null
}
}
/**
* Generate key hash for server verification
* Format: "sha256:base64encodedHash"
* Returns "disabled" if encryption is not available
*/
export async function generateKeyHash(key: CryptoKey | null): Promise<string> {
if (!key || !isEncryptionAvailable()) {
return 'disabled'
}
const exportedKey = await crypto.subtle.exportKey('raw', key)
const hashBuffer = await crypto.subtle.digest('SHA-256', exportedKey)
const hashArray = Array.from(new Uint8Array(hashBuffer))
const hashBase64 = btoa(String.fromCharCode(...hashArray))
return `sha256:${hashBase64}`
}
/**
* Encrypt content before sending to server
* @param content - Plaintext content
* @param key - Master key
* @returns Base64 encoded encrypted content
*/
export async function encryptContent(
content: string,
key: CryptoKey
): Promise<string> {
const iv = crypto.getRandomValues(new Uint8Array(12))
const encoded = new TextEncoder().encode(content)
const ciphertext = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoded
)
// Combine IV + ciphertext
const combined = new Uint8Array(iv.length + ciphertext.byteLength)
combined.set(iv)
combined.set(new Uint8Array(ciphertext), iv.length)
return btoa(String.fromCharCode(...combined))
}
/**
* Decrypt content received from server
* @param encrypted - Base64 encoded encrypted content
* @param key - Master key
* @returns Decrypted plaintext
*/
export async function decryptContent(
encrypted: string,
key: CryptoKey
): Promise<string> {
const data = Uint8Array.from(atob(encrypted), (c) => c.charCodeAt(0))
const iv = data.slice(0, 12)
const ciphertext = data.slice(12)
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
)
return new TextDecoder().decode(decrypted)
}
/**
* Generate a namespace ID for the teacher
* This is a pseudonym that doesn't contain PII
*/
export function generateNamespaceId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(16))
const hex = Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
return `ns-${hex}`
}
/**
* Get or create namespace ID
*/
export async function getNamespaceId(): Promise<string> {
const stored = localStorage.getItem('breakpilot-namespace-id')
if (stored) {
return stored
}
const newId = generateNamespaceId()
localStorage.setItem('breakpilot-namespace-id', newId)
return newId
}
/**
* VoiceEncryption class for managing encryption state
*/
export class VoiceEncryption {
private masterKey: CryptoKey | null = null
private keyHash: string | null = null
private namespaceId: string | null = null
private encryptionEnabled: boolean = false
/**
* Initialize encryption
* Creates master key if not exists
* In non-secure contexts (HTTP), encryption is disabled but the class still works
*/
async initialize(): Promise<void> {
// Check if encryption is available
this.encryptionEnabled = isEncryptionAvailable()
if (!this.encryptionEnabled) {
console.warn('[VoiceEncryption] Verschlüsselung deaktiviert - kein sicherer Kontext (HTTPS erforderlich)')
console.warn('[VoiceEncryption] Für Produktion bitte HTTPS verwenden!')
this.keyHash = 'disabled'
this.namespaceId = await getNamespaceId()
return
}
// Get or create master key
this.masterKey = await getMasterKey()
if (!this.masterKey) {
this.masterKey = await generateMasterKey()
}
// Generate key hash
this.keyHash = await generateKeyHash(this.masterKey)
// Get namespace ID
this.namespaceId = await getNamespaceId()
}
/**
* Check if encryption is initialized
*/
isInitialized(): boolean {
// Consider initialized even if encryption is disabled
return this.keyHash !== null
}
/**
* Check if encryption is actually enabled
*/
isEncryptionEnabled(): boolean {
return this.encryptionEnabled && this.masterKey !== null
}
/**
* Get key hash for server authentication
*/
getKeyHash(): string | null {
return this.keyHash
}
/**
* Get namespace ID
*/
getNamespaceId(): string | null {
return this.namespaceId
}
/**
* Encrypt content
* Returns plaintext if encryption is disabled
*/
async encrypt(content: string): Promise<string> {
if (!this.encryptionEnabled || !this.masterKey) {
// In development without HTTPS, return content as-is (base64 encoded for consistency)
return btoa(unescape(encodeURIComponent(content)))
}
return encryptContent(content, this.masterKey)
}
/**
* Decrypt content
* Returns content as-is if encryption is disabled
*/
async decrypt(encrypted: string): Promise<string> {
if (!this.encryptionEnabled || !this.masterKey) {
// In development without HTTPS, decode base64
try {
return decodeURIComponent(escape(atob(encrypted)))
} catch {
return encrypted
}
}
return decryptContent(encrypted, this.masterKey)
}
}

View File

@@ -0,0 +1,419 @@
'use client'
import React, { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react'
import type { Canvas, Object as FabricObject } from 'fabric'
import type {
EditorTool,
WorksheetDocument,
WorksheetPage,
PageFormat,
HistoryEntry,
DEFAULT_PAGE_FORMAT
} from '@/app/worksheet-editor/types'
// Context Types
interface WorksheetContextType {
// Canvas
canvas: Canvas | null
setCanvas: (canvas: Canvas | null) => void
// Document
document: WorksheetDocument | null
setDocument: (doc: WorksheetDocument | null) => void
// Tool State
activeTool: EditorTool
setActiveTool: (tool: EditorTool) => void
// Selection
selectedObjects: FabricObject[]
setSelectedObjects: (objects: FabricObject[]) => void
// Zoom
zoom: number
setZoom: (zoom: number) => void
zoomIn: () => void
zoomOut: () => void
zoomToFit: () => void
// Grid
showGrid: boolean
setShowGrid: (show: boolean) => void
snapToGrid: boolean
setSnapToGrid: (snap: boolean) => void
gridSize: number
setGridSize: (size: number) => void
// Pages
currentPageIndex: number
setCurrentPageIndex: (index: number) => void
addPage: () => void
deletePage: (index: number) => void
// History
canUndo: boolean
canRedo: boolean
undo: () => void
redo: () => void
saveToHistory: (action: string) => void
// Save/Load
saveDocument: () => Promise<void>
loadDocument: (id: string) => Promise<void>
// Export
exportToPDF: () => Promise<Blob>
exportToImage: (format: 'png' | 'jpg') => Promise<Blob>
// Dirty State
isDirty: boolean
setIsDirty: (dirty: boolean) => void
}
const WorksheetContext = createContext<WorksheetContextType | null>(null)
// Provider Props
interface WorksheetProviderProps {
children: ReactNode
}
// Generate unique ID
const generateId = () => `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
// Default Page Format
const defaultPageFormat: PageFormat = {
width: 210,
height: 297,
orientation: 'portrait',
margins: { top: 15, right: 15, bottom: 15, left: 15 }
}
export function WorksheetProvider({ children }: WorksheetProviderProps) {
// Canvas State
const [canvas, setCanvas] = useState<Canvas | null>(null)
// Document State
const [document, setDocument] = useState<WorksheetDocument | null>(null)
// Editor State
const [activeTool, setActiveTool] = useState<EditorTool>('select')
const [selectedObjects, setSelectedObjects] = useState<FabricObject[]>([])
const [zoom, setZoom] = useState(1)
const [showGrid, setShowGrid] = useState(true)
const [snapToGrid, setSnapToGrid] = useState(true)
const [gridSize, setGridSize] = useState(10)
const [currentPageIndex, setCurrentPageIndex] = useState(0)
const [isDirty, setIsDirty] = useState(false)
// History State
const historyRef = useRef<HistoryEntry[]>([])
const historyIndexRef = useRef(-1)
const [canUndo, setCanUndo] = useState(false)
const [canRedo, setCanRedo] = useState(false)
// Initialize empty document
useEffect(() => {
if (!document) {
const newDoc: WorksheetDocument = {
id: generateId(),
title: 'Neues Arbeitsblatt',
pages: [{
id: generateId(),
index: 0,
canvasJSON: ''
}],
pageFormat: defaultPageFormat,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
}
setDocument(newDoc)
}
}, [document])
// Zoom functions
const zoomIn = useCallback(() => {
setZoom(prev => Math.min(prev * 1.2, 4))
}, [])
const zoomOut = useCallback(() => {
setZoom(prev => Math.max(prev / 1.2, 0.25))
}, [])
const zoomToFit = useCallback(() => {
setZoom(1)
}, [])
// Page functions
const addPage = useCallback(() => {
if (!document) return
const newPage: WorksheetPage = {
id: generateId(),
index: document.pages.length,
canvasJSON: ''
}
setDocument({
...document,
pages: [...document.pages, newPage],
updatedAt: new Date().toISOString()
})
setCurrentPageIndex(document.pages.length)
setIsDirty(true)
}, [document])
const deletePage = useCallback((index: number) => {
if (!document || document.pages.length <= 1) return
const newPages = document.pages.filter((_, i) => i !== index)
.map((page, i) => ({ ...page, index: i }))
setDocument({
...document,
pages: newPages,
updatedAt: new Date().toISOString()
})
if (currentPageIndex >= newPages.length) {
setCurrentPageIndex(newPages.length - 1)
}
setIsDirty(true)
}, [document, currentPageIndex])
// History functions
const saveToHistory = useCallback((action: string) => {
if (!canvas) return
try {
const canvasData = canvas.toJSON()
if (!canvasData) return
const entry: HistoryEntry = {
canvasJSON: JSON.stringify(canvasData),
timestamp: Date.now(),
action
}
// Remove any future history if we're not at the end
if (historyIndexRef.current < historyRef.current.length - 1) {
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1)
}
historyRef.current.push(entry)
historyIndexRef.current = historyRef.current.length - 1
// Limit history size
if (historyRef.current.length > 50) {
historyRef.current.shift()
historyIndexRef.current--
}
setCanUndo(historyIndexRef.current > 0)
setCanRedo(false)
setIsDirty(true)
} catch (error) {
console.error('Failed to save history:', error)
}
}, [canvas])
const undo = useCallback(() => {
if (!canvas || historyIndexRef.current <= 0) return
historyIndexRef.current--
const entry = historyRef.current[historyIndexRef.current]
canvas.loadFromJSON(JSON.parse(entry.canvasJSON), () => {
canvas.renderAll()
})
setCanUndo(historyIndexRef.current > 0)
setCanRedo(true)
}, [canvas])
const redo = useCallback(() => {
if (!canvas || historyIndexRef.current >= historyRef.current.length - 1) return
historyIndexRef.current++
const entry = historyRef.current[historyIndexRef.current]
canvas.loadFromJSON(JSON.parse(entry.canvasJSON), () => {
canvas.renderAll()
})
setCanUndo(true)
setCanRedo(historyIndexRef.current < historyRef.current.length - 1)
}, [canvas])
// Save/Load functions
const saveDocument = useCallback(async () => {
if (!canvas || !document) return
// Save current page state
const currentPage = document.pages[currentPageIndex]
currentPage.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`
try {
const response = await fetch(`${apiBase}/api/v1/worksheet/save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(document)
})
if (!response.ok) throw new Error('Failed to save')
const result = await response.json()
setDocument({ ...document, id: result.id })
setIsDirty(false)
} catch (error) {
console.error('Save failed:', error)
// Fallback to localStorage
localStorage.setItem(`worksheet_${document.id}`, JSON.stringify(document))
setIsDirty(false)
}
}, [canvas, document, currentPageIndex])
const loadDocument = useCallback(async (id: string) => {
const { hostname, protocol } = window.location
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
try {
const response = await fetch(`${apiBase}/api/v1/worksheet/${id}`)
if (!response.ok) throw new Error('Failed to load')
const doc = await response.json()
setDocument(doc)
setCurrentPageIndex(0)
if (canvas && doc.pages[0]?.canvasJSON) {
canvas.loadFromJSON(JSON.parse(doc.pages[0].canvasJSON), () => {
canvas.renderAll()
})
}
} catch (error) {
console.error('Load failed:', error)
// Try localStorage fallback
const stored = localStorage.getItem(`worksheet_${id}`)
if (stored) {
const doc = JSON.parse(stored)
setDocument(doc)
setCurrentPageIndex(0)
}
}
}, [canvas])
// Export functions
const exportToPDF = useCallback(async (): Promise<Blob> => {
if (!canvas || !document) throw new Error('No canvas or document')
const { PDFDocument, rgb } = await import('pdf-lib')
const pdfDoc = await PDFDocument.create()
for (const page of document.pages) {
const pdfPage = pdfDoc.addPage([
document.pageFormat.width * 2.83465, // mm to points
document.pageFormat.height * 2.83465
])
// Export canvas as PNG and embed
if (page.canvasJSON) {
// Load the page content
if (currentPageIndex !== page.index) {
await new Promise<void>((resolve) => {
canvas.loadFromJSON(JSON.parse(page.canvasJSON), () => {
canvas.renderAll()
resolve()
})
})
}
const dataUrl = canvas.toDataURL({ format: 'png', multiplier: 2 })
const imageBytes = await fetch(dataUrl).then(res => res.arrayBuffer())
const image = await pdfDoc.embedPng(imageBytes)
pdfPage.drawImage(image, {
x: 0,
y: 0,
width: pdfPage.getWidth(),
height: pdfPage.getHeight()
})
}
}
const pdfBytes = await pdfDoc.save()
// Convert Uint8Array to ArrayBuffer for Blob compatibility
const arrayBuffer = pdfBytes.slice().buffer as ArrayBuffer
return new Blob([arrayBuffer], { type: 'application/pdf' })
}, [canvas, document, currentPageIndex])
const exportToImage = useCallback(async (format: 'png' | 'jpg'): Promise<Blob> => {
if (!canvas) throw new Error('No canvas')
// Fabric.js uses 'jpeg' not 'jpg'
const fabricFormat = format === 'jpg' ? 'jpeg' : format
const dataUrl = canvas.toDataURL({
format: fabricFormat as 'png' | 'jpeg',
quality: format === 'jpg' ? 0.9 : undefined,
multiplier: 2
})
const response = await fetch(dataUrl)
return response.blob()
}, [canvas])
const value: WorksheetContextType = {
canvas,
setCanvas,
document,
setDocument,
activeTool,
setActiveTool,
selectedObjects,
setSelectedObjects,
zoom,
setZoom,
zoomIn,
zoomOut,
zoomToFit,
showGrid,
setShowGrid,
snapToGrid,
setSnapToGrid,
gridSize,
setGridSize,
currentPageIndex,
setCurrentPageIndex,
addPage,
deletePage,
canUndo,
canRedo,
undo,
redo,
saveToHistory,
saveDocument,
loadDocument,
exportToPDF,
exportToImage,
isDirty,
setIsDirty
}
return (
<WorksheetContext.Provider value={value}>
{children}
</WorksheetContext.Provider>
)
}
export function useWorksheet() {
const context = useContext(WorksheetContext)
if (!context) {
throw new Error('useWorksheet must be used within a WorksheetProvider')
}
return context
}

View File

@@ -0,0 +1,272 @@
/**
* Worksheet Cleanup Service
*
* API client for the worksheet cleanup endpoints:
* - Handwriting detection
* - Handwriting removal (inpainting)
* - Layout reconstruction
*
* All processing happens on the local Mac Mini server.
*/
export interface CleanupCapabilities {
opencv_available: boolean
lama_available: boolean
paddleocr_available: boolean
}
export interface DetectionResult {
has_handwriting: boolean
confidence: number
handwriting_ratio: number
detection_method: string
mask_base64?: string
}
export 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
}
}
export interface PipelineResult {
success: boolean
handwriting_detected: boolean
handwriting_removed: boolean
layout_reconstructed: boolean
cleaned_image_base64?: string
fabric_json?: any
metadata: {
detection?: {
confidence: number
handwriting_ratio: number
method: string
}
inpainting?: {
method_used: string
processing_time_ms: number
}
layout?: {
element_count: number
table_count: number
page_width: number
page_height: number
}
}
}
export type InpaintingMethod = 'auto' | 'opencv_telea' | 'opencv_ns' | 'lama'
export interface CleanupOptions {
removeHandwriting: boolean
reconstructLayout: boolean
inpaintingMethod: InpaintingMethod
}
/**
* Get the API base URL for the klausur-service
*/
function getApiUrl(): string {
if (typeof window === 'undefined') return 'http://localhost:8086'
const { hostname, protocol } = window.location
return hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
}
/**
* Get available cleanup capabilities on the server
*/
export async function getCapabilities(): Promise<CleanupCapabilities> {
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/capabilities`)
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
return response.json()
}
/**
* Quick preview of cleanup without full processing
*/
export async function previewCleanup(file: File): Promise<PreviewResult> {
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) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
return response.json()
}
/**
* Detect handwriting in an image
*/
export async function detectHandwriting(
file: File,
options: { returnMask?: boolean; minConfidence?: number } = {}
): Promise<DetectionResult> {
const formData = new FormData()
formData.append('image', file)
formData.append('return_mask', String(options.returnMask ?? true))
formData.append('min_confidence', String(options.minConfidence ?? 0.3))
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting`, {
method: 'POST',
body: formData
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
return response.json()
}
/**
* Get the handwriting detection mask as an image blob
*/
export async function getHandwritingMask(file: File): Promise<Blob> {
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}`)
}
return response.blob()
}
/**
* Remove handwriting from an image
*/
export async function removeHandwriting(
file: File,
options: {
mask?: File
method?: InpaintingMethod
returnBase64?: boolean
} = {}
): Promise<{ imageBlob?: Blob; imageBase64?: string; metadata: any }> {
const formData = new FormData()
formData.append('image', file)
formData.append('method', options.method ?? 'auto')
formData.append('return_base64', String(options.returnBase64 ?? false))
if (options.mask) {
formData.append('mask', options.mask)
}
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/remove-handwriting`, {
method: 'POST',
body: formData
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
if (options.returnBase64) {
const data = await response.json()
return {
imageBase64: data.image_base64,
metadata: data.metadata
}
} else {
const imageBlob = await response.blob()
const methodUsed = response.headers.get('X-Method-Used') || 'unknown'
const processingTime = parseFloat(response.headers.get('X-Processing-Time-Ms') || '0')
return {
imageBlob,
metadata: {
method_used: methodUsed,
processing_time_ms: processingTime
}
}
}
}
/**
* Run the full cleanup pipeline
*/
export async function runCleanupPipeline(
file: File,
options: CleanupOptions = {
removeHandwriting: true,
reconstructLayout: true,
inpaintingMethod: 'auto'
}
): Promise<PipelineResult> {
const formData = new FormData()
formData.append('image', file)
formData.append('remove_handwriting', String(options.removeHandwriting))
formData.append('reconstruct', String(options.reconstructLayout))
formData.append('inpainting_method', options.inpaintingMethod)
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, {
method: 'POST',
body: formData
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Unknown error' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
return response.json()
}
/**
* Helper to convert base64 to blob
*/
export function base64ToBlob(base64: string, mimeType: string = 'image/png'): Blob {
const byteCharacters = atob(base64)
const byteArrays = []
for (let offset = 0; offset < byteCharacters.length; offset += 512) {
const slice = byteCharacters.slice(offset, offset + 512)
const byteNumbers = new Array(slice.length)
for (let i = 0; i < slice.length; i++) {
byteNumbers[i] = slice.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
byteArrays.push(byteArray)
}
return new Blob(byteArrays, { type: mimeType })
}
/**
* Helper to create object URL from base64
*/
export function base64ToObjectUrl(base64: string, mimeType: string = 'image/png'): string {
const blob = base64ToBlob(base64, mimeType)
return URL.createObjectURL(blob)
}

View File

@@ -0,0 +1,10 @@
/**
* Worksheet Editor Library
*
* Export context and utilities
*/
export { WorksheetProvider, useWorksheet } from './WorksheetContext'
// Cleanup Service for handwriting removal
export * from './cleanup-service'