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:
361
studio-v2/lib/ActivityContext.tsx
Normal file
361
studio-v2/lib/ActivityContext.tsx
Normal 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
|
||||
}
|
||||
1165
studio-v2/lib/AlertsB2BContext.tsx
Normal file
1165
studio-v2/lib/AlertsB2BContext.tsx
Normal file
File diff suppressed because it is too large
Load Diff
334
studio-v2/lib/AlertsContext.tsx
Normal file
334
studio-v2/lib/AlertsContext.tsx
Normal 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' })
|
||||
}
|
||||
90
studio-v2/lib/LanguageContext.tsx
Normal file
90
studio-v2/lib/LanguageContext.tsx
Normal 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
|
||||
}
|
||||
925
studio-v2/lib/MessagesContext.tsx
Normal file
925
studio-v2/lib/MessagesContext.tsx
Normal 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': ['📚', '📖', '📝', '✏️', '📓', '📕', '📗', '📘', '🎓', '🏫', '📅', '⏰', '🔔']
|
||||
}
|
||||
71
studio-v2/lib/ThemeContext.tsx
Normal file
71
studio-v2/lib/ThemeContext.tsx
Normal 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
|
||||
}
|
||||
276
studio-v2/lib/geo-lernwelt/GeoContext.tsx
Normal file
276
studio-v2/lib/geo-lernwelt/GeoContext.tsx
Normal 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
|
||||
}
|
||||
29
studio-v2/lib/geo-lernwelt/index.ts
Normal file
29
studio-v2/lib/geo-lernwelt/index.ts
Normal 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'
|
||||
369
studio-v2/lib/geo-lernwelt/mapStyles.ts
Normal file
369
studio-v2/lib/geo-lernwelt/mapStyles.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
359
studio-v2/lib/geo-lernwelt/unityBridge.ts
Normal file
359
studio-v2/lib/geo-lernwelt/unityBridge.ts
Normal 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
399
studio-v2/lib/i18n.ts
Normal 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
|
||||
}
|
||||
506
studio-v2/lib/korrektur/api.ts
Normal file
506
studio-v2/lib/korrektur/api.ts
Normal 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,
|
||||
}
|
||||
1
studio-v2/lib/korrektur/index.ts
Normal file
1
studio-v2/lib/korrektur/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './api'
|
||||
188
studio-v2/lib/spatial-ui/FocusContext.tsx
Normal file
188
studio-v2/lib/spatial-ui/FocusContext.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
374
studio-v2/lib/spatial-ui/PerformanceContext.tsx
Normal file
374
studio-v2/lib/spatial-ui/PerformanceContext.tsx
Normal 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)
|
||||
}
|
||||
335
studio-v2/lib/spatial-ui/depth-system.ts
Normal file
335
studio-v2/lib/spatial-ui/depth-system.ts
Normal 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
|
||||
16
studio-v2/lib/spatial-ui/index.ts
Normal file
16
studio-v2/lib/spatial-ui/index.ts
Normal 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'
|
||||
13
studio-v2/lib/voice/index.ts
Normal file
13
studio-v2/lib/voice/index.ts
Normal 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'
|
||||
400
studio-v2/lib/voice/voice-api.ts
Normal file
400
studio-v2/lib/voice/voice-api.ts
Normal 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
|
||||
}
|
||||
}
|
||||
334
studio-v2/lib/voice/voice-encryption.ts
Normal file
334
studio-v2/lib/voice/voice-encryption.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
419
studio-v2/lib/worksheet-editor/WorksheetContext.tsx
Normal file
419
studio-v2/lib/worksheet-editor/WorksheetContext.tsx
Normal 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
|
||||
}
|
||||
272
studio-v2/lib/worksheet-editor/cleanup-service.ts
Normal file
272
studio-v2/lib/worksheet-editor/cleanup-service.ts
Normal 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)
|
||||
}
|
||||
10
studio-v2/lib/worksheet-editor/index.ts
Normal file
10
studio-v2/lib/worksheet-editor/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user