feat(companion): Migrate Companion from admin-v2 to studio-v2 as pure lesson tool

Remove Companion module entirely from admin-v2. Rebuild in studio-v2 as a
focused lesson timer (no dashboard mode). Direct flow: start → active → ended.
Fix timer bug where lastTickRef reset prevented countdown. Add companion link
to Sidebar and i18n translations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
BreakPilot Dev
2026-02-09 23:50:20 +01:00
parent 2f8ffb7352
commit 0f7be76e41
35 changed files with 327 additions and 1435 deletions

View File

@@ -1,39 +0,0 @@
'use client'
import { PagePurpose } from '@/components/common/PagePurpose'
import { getModuleByHref } from '@/lib/navigation'
import { GraduationCap, Construction } from 'lucide-react'
export default function CompanionPage() {
const moduleInfo = getModuleByHref('/development/companion')
return (
<div className="space-y-6">
{moduleInfo && (
<PagePurpose
title={moduleInfo.module.name}
purpose={moduleInfo.module.purpose}
audience={moduleInfo.module.audience}
collapsible={true}
defaultCollapsed={true}
/>
)}
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
<div className="flex justify-center mb-4">
<div className="p-4 bg-slate-100 rounded-full">
<GraduationCap className="w-12 h-12 text-slate-400" />
</div>
</div>
<h2 className="text-xl font-semibold text-slate-800 mb-2">Companion Dev</h2>
<p className="text-slate-600 mb-4">
Lesson-Modus Entwicklung fuer strukturiertes Lernen.
</p>
<div className="inline-flex items-center gap-2 px-4 py-2 bg-amber-50 border border-amber-200 rounded-lg text-amber-700">
<Construction className="w-4 h-4" />
<span className="text-sm font-medium">In Entwicklung</span>
</div>
</div>
</div>
)
}

View File

@@ -1,76 +0,0 @@
'use client'
import { Suspense } from 'react'
import { PagePurpose } from '@/components/common/PagePurpose'
import { getModuleByHref } from '@/lib/navigation'
import { CompanionDashboard } from '@/components/companion/CompanionDashboard'
import { GraduationCap } from 'lucide-react'
function LoadingFallback() {
return (
<div className="space-y-6">
{/* Header Skeleton */}
<div className="flex items-center justify-between">
<div className="h-12 w-80 bg-slate-200 rounded-xl animate-pulse" />
<div className="flex gap-2">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="h-10 w-10 bg-slate-200 rounded-lg animate-pulse" />
))}
</div>
</div>
{/* Phase Timeline Skeleton */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="h-4 w-24 bg-slate-200 rounded mb-4 animate-pulse" />
<div className="flex gap-4">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-center gap-2">
<div className="w-10 h-10 bg-slate-200 rounded-full animate-pulse" />
{i < 5 && <div className="w-8 h-1 bg-slate-200 animate-pulse" />}
</div>
))}
</div>
</div>
{/* Stats Skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="bg-white border border-slate-200 rounded-xl p-4">
<div className="h-4 w-16 bg-slate-200 rounded mb-2 animate-pulse" />
<div className="h-8 w-12 bg-slate-200 rounded animate-pulse" />
</div>
))}
</div>
{/* Content Skeleton */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white border border-slate-200 rounded-xl p-6 h-64 animate-pulse" />
<div className="bg-white border border-slate-200 rounded-xl p-6 h-64 animate-pulse" />
</div>
</div>
)
}
export default function CompanionPage() {
const moduleInfo = getModuleByHref('/education/companion')
return (
<div className="space-y-6">
{/* Page Purpose Header */}
{moduleInfo && (
<PagePurpose
title={moduleInfo.module.name}
purpose={moduleInfo.module.purpose}
audience={moduleInfo.module.audience}
collapsible={true}
defaultCollapsed={true}
/>
)}
{/* Main Companion Dashboard */}
<Suspense fallback={<LoadingFallback />}>
<CompanionDashboard />
</Suspense>
</div>
)
}

View File

@@ -1,102 +0,0 @@
import { NextResponse } from 'next/server'
/**
* GET /api/admin/companion
* Proxy to backend /api/state/dashboard for companion dashboard data
*/
export async function GET() {
try {
const backendUrl = process.env.BACKEND_URL || 'http://localhost:8000'
// TODO: Replace with actual backend call when endpoint is available
// const response = await fetch(`${backendUrl}/api/state/dashboard`, {
// method: 'GET',
// headers: {
// 'Content-Type': 'application/json',
// },
// })
//
// if (!response.ok) {
// throw new Error(`Backend responded with ${response.status}`)
// }
//
// const data = await response.json()
// return NextResponse.json(data)
// Mock response for development
const mockData = {
success: true,
data: {
context: {
currentPhase: 'erarbeitung',
phaseDisplayName: 'Erarbeitung',
},
stats: {
classesCount: 4,
studentsCount: 96,
learningUnitsCreated: 23,
gradesEntered: 156,
},
phases: [
{ id: 'einstieg', shortName: 'E', displayName: 'Einstieg', duration: 8, status: 'completed', color: '#4A90E2' },
{ id: 'erarbeitung', shortName: 'A', displayName: 'Erarbeitung', duration: 20, status: 'active', color: '#F5A623' },
{ id: 'sicherung', shortName: 'S', displayName: 'Sicherung', duration: 10, status: 'planned', color: '#7ED321' },
{ id: 'transfer', shortName: 'T', displayName: 'Transfer', duration: 7, status: 'planned', color: '#9013FE' },
{ id: 'reflexion', shortName: 'R', displayName: 'Reflexion', duration: 5, status: 'planned', color: '#6B7280' },
],
progress: {
percentage: 65,
completed: 13,
total: 20,
},
suggestions: [
{
id: '1',
title: 'Klausuren korrigieren',
description: 'Deutsch LK - 12 unkorrigierte Arbeiten warten',
priority: 'urgent',
icon: 'ClipboardCheck',
actionTarget: '/ai/klausur-korrektur',
estimatedTime: 120,
},
{
id: '2',
title: 'Elternsprechtag vorbereiten',
description: 'Notenuebersicht fuer 8b erstellen',
priority: 'high',
icon: 'Users',
actionTarget: '/education/grades',
estimatedTime: 30,
},
],
upcomingEvents: [
{
id: 'e1',
title: 'Mathe-Test 9b',
date: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
type: 'exam',
inDays: 2,
},
{
id: 'e2',
title: 'Elternsprechtag',
date: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
type: 'parent_meeting',
inDays: 5,
},
],
},
}
return NextResponse.json(mockData)
} catch (error) {
console.error('Companion dashboard error:', error)
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
)
}
}

View File

@@ -1,312 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Settings, MessageSquare, HelpCircle, RefreshCw } from 'lucide-react'
import { CompanionMode, TeacherSettings, FeedbackType } from '@/lib/companion/types'
import { DEFAULT_TEACHER_SETTINGS, STORAGE_KEYS } from '@/lib/companion/constants'
// Components
import { ModeToggle } from './ModeToggle'
import { PhaseTimeline } from './companion-mode/PhaseTimeline'
import { StatsGrid } from './companion-mode/StatsGrid'
import { SuggestionList } from './companion-mode/SuggestionList'
import { EventsCard } from './companion-mode/EventsCard'
import { LessonContainer } from './lesson-mode/LessonContainer'
import { SettingsModal } from './modals/SettingsModal'
import { FeedbackModal } from './modals/FeedbackModal'
import { OnboardingModal } from './modals/OnboardingModal'
// Hooks
import { useCompanionData } from '@/hooks/companion/useCompanionData'
import { useLessonSession } from '@/hooks/companion/useLessonSession'
import { useKeyboardShortcuts } from '@/hooks/companion/useKeyboardShortcuts'
export function CompanionDashboard() {
// Mode state
const [mode, setMode] = useState<CompanionMode>('companion')
// Modal states
const [showSettings, setShowSettings] = useState(false)
const [showFeedback, setShowFeedback] = useState(false)
const [showOnboarding, setShowOnboarding] = useState(false)
// Settings
const [settings, setSettings] = useState<TeacherSettings>(DEFAULT_TEACHER_SETTINGS)
// Load settings from localStorage
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.SETTINGS)
if (stored) {
try {
const parsed = JSON.parse(stored)
setSettings({ ...DEFAULT_TEACHER_SETTINGS, ...parsed })
} catch {
// Invalid stored settings
}
}
// Check if onboarding needed
const onboardingStored = localStorage.getItem(STORAGE_KEYS.ONBOARDING_STATE)
if (!onboardingStored) {
setShowOnboarding(true)
}
// Restore last mode
const lastMode = localStorage.getItem(STORAGE_KEYS.LAST_MODE) as CompanionMode
if (lastMode && ['companion', 'lesson', 'classic'].includes(lastMode)) {
setMode(lastMode)
}
}, [])
// Save mode to localStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.LAST_MODE, mode)
}, [mode])
// Companion data hook
const { data: companionData, loading: companionLoading, refresh } = useCompanionData()
// Lesson session hook
const {
session,
startLesson,
endLesson,
pauseLesson,
resumeLesson,
extendTime,
skipPhase,
saveReflection,
addHomework,
removeHomework,
isPaused,
} = useLessonSession({
onOvertimeStart: () => {
// Play sound if enabled
if (settings.soundNotifications) {
// TODO: Play notification sound
}
},
})
// Handle pause/resume toggle
const handlePauseToggle = useCallback(() => {
if (isPaused) {
resumeLesson()
} else {
pauseLesson()
}
}, [isPaused, pauseLesson, resumeLesson])
// Keyboard shortcuts
useKeyboardShortcuts({
onPauseResume: mode === 'lesson' && session ? handlePauseToggle : undefined,
onExtend: mode === 'lesson' && session && !isPaused ? () => extendTime(5) : undefined,
onNextPhase: mode === 'lesson' && session && !isPaused ? skipPhase : undefined,
onCloseModal: () => {
setShowSettings(false)
setShowFeedback(false)
setShowOnboarding(false)
},
enabled: settings.showKeyboardShortcuts,
})
// Handle settings save
const handleSaveSettings = (newSettings: TeacherSettings) => {
setSettings(newSettings)
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(newSettings))
}
// Handle feedback submit
const handleFeedbackSubmit = async (type: FeedbackType, title: string, description: string) => {
const response = await fetch('/api/admin/companion/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
title,
description,
sessionId: session?.sessionId,
}),
})
if (!response.ok) {
throw new Error('Failed to submit feedback')
}
}
// Handle onboarding complete
const handleOnboardingComplete = (data: { state?: string; schoolType?: string }) => {
localStorage.setItem(STORAGE_KEYS.ONBOARDING_STATE, JSON.stringify({
...data,
completed: true,
completedAt: new Date().toISOString(),
}))
setShowOnboarding(false)
setSettings({ ...settings, onboardingCompleted: true })
}
// Handle lesson start
const handleStartLesson = (data: { classId: string; subject: string; topic?: string; templateId?: string }) => {
startLesson(data)
setMode('lesson')
}
return (
<div className={`min-h-[calc(100vh-200px)] ${settings.highContrastMode ? 'high-contrast' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<ModeToggle
currentMode={mode}
onModeChange={setMode}
disabled={!!session && session.status === 'in_progress'}
/>
<div className="flex items-center gap-2">
{/* Refresh Button */}
{mode === 'companion' && (
<button
onClick={refresh}
disabled={companionLoading}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Aktualisieren"
>
<RefreshCw className={`w-5 h-5 ${companionLoading ? 'animate-spin' : ''}`} />
</button>
)}
{/* Feedback Button */}
<button
onClick={() => setShowFeedback(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Feedback"
>
<MessageSquare className="w-5 h-5" />
</button>
{/* Settings Button */}
<button
onClick={() => setShowSettings(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Einstellungen"
>
<Settings className="w-5 h-5" />
</button>
{/* Help Button */}
<button
onClick={() => setShowOnboarding(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Hilfe"
>
<HelpCircle className="w-5 h-5" />
</button>
</div>
</div>
{/* Main Content */}
{mode === 'companion' && (
<div className="space-y-6">
{/* Phase Timeline */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Aktuelle Phase</h3>
{companionData ? (
<PhaseTimeline
phases={companionData.phases}
currentPhaseIndex={companionData.phases.findIndex(p => p.status === 'active')}
/>
) : (
<div className="h-10 bg-slate-100 rounded animate-pulse" />
)}
</div>
{/* Stats */}
<StatsGrid
stats={companionData?.stats || { classesCount: 0, studentsCount: 0, learningUnitsCreated: 0, gradesEntered: 0 }}
loading={companionLoading}
/>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Suggestions */}
<SuggestionList
suggestions={companionData?.suggestions || []}
loading={companionLoading}
onSuggestionClick={(suggestion) => {
// Navigate to action target
window.location.href = suggestion.actionTarget
}}
/>
{/* Events */}
<EventsCard
events={companionData?.upcomingEvents || []}
loading={companionLoading}
/>
</div>
{/* Quick Start Lesson Button */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl p-6 text-white">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold mb-1">Bereit fuer die naechste Stunde?</h3>
<p className="text-blue-100">Starten Sie den Lesson-Modus fuer strukturierten Unterricht.</p>
</div>
<button
onClick={() => setMode('lesson')}
className="px-6 py-3 bg-white text-blue-600 rounded-xl font-semibold hover:bg-blue-50 transition-colors"
>
Stunde starten
</button>
</div>
</div>
</div>
)}
{mode === 'lesson' && (
<LessonContainer
session={session}
onStartLesson={handleStartLesson}
onEndLesson={endLesson}
onPauseToggle={handlePauseToggle}
onExtendTime={extendTime}
onSkipPhase={skipPhase}
onSaveReflection={saveReflection}
onAddHomework={addHomework}
onRemoveHomework={removeHomework}
/>
)}
{mode === 'classic' && (
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
<h2 className="text-xl font-semibold text-slate-800 mb-2">Classic Mode</h2>
<p className="text-slate-600 mb-4">
Die klassische Ansicht ohne Timer und Phasenstruktur.
</p>
<p className="text-sm text-slate-500">
Dieser Modus ist fuer flexible Unterrichtsgestaltung gedacht.
</p>
</div>
)}
{/* Modals */}
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
settings={settings}
onSave={handleSaveSettings}
/>
<FeedbackModal
isOpen={showFeedback}
onClose={() => setShowFeedback(false)}
onSubmit={handleFeedbackSubmit}
/>
<OnboardingModal
isOpen={showOnboarding}
onClose={() => setShowOnboarding(false)}
onComplete={handleOnboardingComplete}
/>
</div>
)
}

View File

@@ -1,61 +0,0 @@
'use client'
import { GraduationCap, Timer, Layout } from 'lucide-react'
import { CompanionMode } from '@/lib/companion/types'
interface ModeToggleProps {
currentMode: CompanionMode
onModeChange: (mode: CompanionMode) => void
disabled?: boolean
}
const modes: { id: CompanionMode; label: string; icon: React.ReactNode; description: string }[] = [
{
id: 'companion',
label: 'Companion',
icon: <GraduationCap className="w-4 h-4" />,
description: 'Dashboard mit Vorschlaegen',
},
{
id: 'lesson',
label: 'Lesson',
icon: <Timer className="w-4 h-4" />,
description: 'Timer und Phasen',
},
{
id: 'classic',
label: 'Classic',
icon: <Layout className="w-4 h-4" />,
description: 'Klassische Ansicht',
},
]
export function ModeToggle({ currentMode, onModeChange, disabled }: ModeToggleProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-1 inline-flex gap-1">
{modes.map((mode) => {
const isActive = currentMode === mode.id
return (
<button
key={mode.id}
onClick={() => onModeChange(mode.id)}
disabled={disabled}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium
transition-all duration-200
${isActive
? 'bg-slate-900 text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
title={mode.description}
>
{mode.icon}
<span>{mode.label}</span>
</button>
)
})}
</div>
)
}

View File

@@ -1,173 +0,0 @@
'use client'
import { Calendar, FileQuestion, Users, Clock, ChevronRight } from 'lucide-react'
import { UpcomingEvent, EventType } from '@/lib/companion/types'
import { EVENT_TYPE_CONFIG } from '@/lib/companion/constants'
interface EventsCardProps {
events: UpcomingEvent[]
onEventClick?: (event: UpcomingEvent) => void
loading?: boolean
maxItems?: number
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
FileQuestion,
Users,
Clock,
Calendar,
}
function getEventIcon(type: EventType) {
const config = EVENT_TYPE_CONFIG[type]
const Icon = iconMap[config.icon] || Calendar
return { Icon, ...config }
}
function formatEventDate(dateStr: string, inDays: number): string {
if (inDays === 0) return 'Heute'
if (inDays === 1) return 'Morgen'
if (inDays < 7) return `In ${inDays} Tagen`
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
interface EventItemProps {
event: UpcomingEvent
onClick?: () => void
}
function EventItem({ event, onClick }: EventItemProps) {
const { Icon, color, bg } = getEventIcon(event.type)
const isUrgent = event.inDays <= 2
return (
<button
onClick={onClick}
className={`
w-full flex items-center gap-3 p-3 rounded-lg
transition-all duration-200
hover:bg-slate-50
${isUrgent ? 'bg-red-50/50' : ''}
`}
>
<div className={`p-2 rounded-lg ${bg}`}>
<Icon className={`w-4 h-4 ${color}`} />
</div>
<div className="flex-1 text-left min-w-0">
<p className="font-medium text-slate-900 truncate">{event.title}</p>
<p className={`text-sm ${isUrgent ? 'text-red-600 font-medium' : 'text-slate-500'}`}>
{formatEventDate(event.date, event.inDays)}
</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-400 flex-shrink-0" />
</button>
)
}
export function EventsCard({
events,
onEventClick,
loading,
maxItems = 5,
}: EventsCardProps) {
const displayEvents = events.slice(0, maxItems)
if (loading) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-14 bg-slate-100 rounded-lg animate-pulse" />
))}
</div>
</div>
)
}
if (events.length === 0) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<div className="text-center py-6">
<Calendar className="w-10 h-10 text-slate-300 mx-auto mb-2" />
<p className="text-sm text-slate-500">Keine anstehenden Termine</p>
</div>
</div>
)
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<span className="text-sm text-slate-500">
{events.length} Termin{events.length !== 1 ? 'e' : ''}
</span>
</div>
<div className="space-y-1">
{displayEvents.map((event) => (
<EventItem
key={event.id}
event={event}
onClick={() => onEventClick?.(event)}
/>
))}
</div>
{events.length > maxItems && (
<button className="w-full mt-3 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors">
Alle {events.length} anzeigen
</button>
)}
</div>
)
}
/**
* Compact inline version for header/toolbar
*/
export function EventsInline({ events }: { events: UpcomingEvent[] }) {
const nextEvent = events[0]
if (!nextEvent) {
return (
<div className="flex items-center gap-2 text-sm text-slate-500">
<Calendar className="w-4 h-4" />
<span>Keine Termine</span>
</div>
)
}
const { Icon, color } = getEventIcon(nextEvent.type)
const isUrgent = nextEvent.inDays <= 2
return (
<div className={`flex items-center gap-2 text-sm ${isUrgent ? 'text-red-600' : 'text-slate-600'}`}>
<Icon className={`w-4 h-4 ${color}`} />
<span className="truncate max-w-[150px]">{nextEvent.title}</span>
<span className="text-slate-400">-</span>
<span className={isUrgent ? 'font-medium' : ''}>
{formatEventDate(nextEvent.date, nextEvent.inDays)}
</span>
</div>
)
}

View File

@@ -1,114 +0,0 @@
'use client'
import { Users, GraduationCap, BookOpen, FileCheck } from 'lucide-react'
import { CompanionStats } from '@/lib/companion/types'
interface StatsGridProps {
stats: CompanionStats
loading?: boolean
}
interface StatCardProps {
label: string
value: number
icon: React.ReactNode
color: string
loading?: boolean
}
function StatCard({ label, value, icon, color, loading }: StatCardProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500 mb-1">{label}</p>
{loading ? (
<div className="h-8 w-16 bg-slate-200 rounded animate-pulse" />
) : (
<p className="text-2xl font-bold text-slate-900">{value}</p>
)}
</div>
<div className={`p-2 rounded-lg ${color}`}>
{icon}
</div>
</div>
</div>
)
}
export function StatsGrid({ stats, loading }: StatsGridProps) {
const statCards = [
{
label: 'Klassen',
value: stats.classesCount,
icon: <Users className="w-5 h-5 text-blue-600" />,
color: 'bg-blue-100',
},
{
label: 'Schueler',
value: stats.studentsCount,
icon: <GraduationCap className="w-5 h-5 text-green-600" />,
color: 'bg-green-100',
},
{
label: 'Lerneinheiten',
value: stats.learningUnitsCreated,
icon: <BookOpen className="w-5 h-5 text-purple-600" />,
color: 'bg-purple-100',
},
{
label: 'Noten',
value: stats.gradesEntered,
icon: <FileCheck className="w-5 h-5 text-amber-600" />,
color: 'bg-amber-100',
},
]
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{statCards.map((card) => (
<StatCard
key={card.label}
label={card.label}
value={card.value}
icon={card.icon}
color={card.color}
loading={loading}
/>
))}
</div>
)
}
/**
* Compact version of StatsGrid for sidebar or smaller spaces
*/
export function StatsGridCompact({ stats, loading }: StatsGridProps) {
const items = [
{ label: 'Klassen', value: stats.classesCount, icon: <Users className="w-4 h-4" /> },
{ label: 'Schueler', value: stats.studentsCount, icon: <GraduationCap className="w-4 h-4" /> },
{ label: 'Einheiten', value: stats.learningUnitsCreated, icon: <BookOpen className="w-4 h-4" /> },
{ label: 'Noten', value: stats.gradesEntered, icon: <FileCheck className="w-4 h-4" /> },
]
return (
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-medium text-slate-500 mb-3">Statistiken</h3>
<div className="space-y-3">
{items.map((item) => (
<div key={item.label} className="flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-600">
{item.icon}
<span className="text-sm">{item.label}</span>
</div>
{loading ? (
<div className="h-5 w-8 bg-slate-200 rounded animate-pulse" />
) : (
<span className="font-semibold text-slate-900">{item.value}</span>
)}
</div>
))}
</div>
</div>
)
}

View File

@@ -1,170 +0,0 @@
'use client'
import { ChevronRight, Clock, Lightbulb, ClipboardCheck, BookOpen, Calendar, Users, MessageSquare, FileText } from 'lucide-react'
import { Suggestion, SuggestionPriority } from '@/lib/companion/types'
import { PRIORITY_COLORS } from '@/lib/companion/constants'
interface SuggestionListProps {
suggestions: Suggestion[]
onSuggestionClick?: (suggestion: Suggestion) => void
loading?: boolean
maxItems?: number
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
ClipboardCheck,
BookOpen,
Calendar,
Users,
Clock,
MessageSquare,
FileText,
Lightbulb,
}
function getIcon(iconName: string) {
const Icon = iconMap[iconName] || Lightbulb
return Icon
}
interface SuggestionCardProps {
suggestion: Suggestion
onClick?: () => void
}
function SuggestionCard({ suggestion, onClick }: SuggestionCardProps) {
const priorityStyles = PRIORITY_COLORS[suggestion.priority]
const Icon = getIcon(suggestion.icon)
return (
<button
onClick={onClick}
className={`
w-full p-4 rounded-xl border text-left
transition-all duration-200
hover:shadow-md hover:scale-[1.01]
${priorityStyles.bg} ${priorityStyles.border}
`}
>
<div className="flex items-start gap-3">
{/* Priority Dot & Icon */}
<div className="flex-shrink-0 relative">
<div className={`p-2 rounded-lg bg-white shadow-sm`}>
<Icon className={`w-5 h-5 ${priorityStyles.text}`} />
</div>
<span
className={`absolute -top-1 -right-1 w-3 h-3 rounded-full ${priorityStyles.dot}`}
title={`Prioritaet: ${suggestion.priority}`}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h4 className={`font-medium ${priorityStyles.text} mb-1`}>
{suggestion.title}
</h4>
<p className="text-sm text-slate-600 line-clamp-2">
{suggestion.description}
</p>
{/* Meta */}
<div className="flex items-center gap-3 mt-2">
<span className="inline-flex items-center gap-1 text-xs text-slate-500">
<Clock className="w-3 h-3" />
~{suggestion.estimatedTime} Min
</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${priorityStyles.bg} ${priorityStyles.text}`}>
{suggestion.priority}
</span>
</div>
</div>
{/* Arrow */}
<ChevronRight className="w-5 h-5 text-slate-400 flex-shrink-0" />
</div>
</button>
)
}
export function SuggestionList({
suggestions,
onSuggestionClick,
loading,
maxItems = 5,
}: SuggestionListProps) {
// Sort by priority: urgent > high > medium > low
const priorityOrder: Record<SuggestionPriority, number> = {
urgent: 0,
high: 1,
medium: 2,
low: 3,
}
const sortedSuggestions = [...suggestions]
.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
.slice(0, maxItems)
if (loading) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-24 bg-slate-100 rounded-xl animate-pulse" />
))}
</div>
</div>
)
}
if (suggestions.length === 0) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<div className="text-center py-8">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
<ClipboardCheck className="w-6 h-6 text-green-600" />
</div>
<p className="text-slate-600">Alles erledigt!</p>
<p className="text-sm text-slate-500 mt-1">Keine offenen Aufgaben</p>
</div>
</div>
)
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<span className="text-sm text-slate-500">
{suggestions.length} Aufgabe{suggestions.length !== 1 ? 'n' : ''}
</span>
</div>
<div className="space-y-3">
{sortedSuggestions.map((suggestion) => (
<SuggestionCard
key={suggestion.id}
suggestion={suggestion}
onClick={() => onSuggestionClick?.(suggestion)}
/>
))}
</div>
{suggestions.length > maxItems && (
<button className="w-full mt-4 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors">
Alle {suggestions.length} anzeigen
</button>
)}
</div>
)
}

View File

@@ -1,24 +0,0 @@
// Main components
export { CompanionDashboard } from './CompanionDashboard'
export { ModeToggle } from './ModeToggle'
// Companion Mode components
export { PhaseTimeline, PhaseTimelineDetailed } from './companion-mode/PhaseTimeline'
export { StatsGrid, StatsGridCompact } from './companion-mode/StatsGrid'
export { SuggestionList } from './companion-mode/SuggestionList'
export { EventsCard, EventsInline } from './companion-mode/EventsCard'
// Lesson Mode components
export { LessonContainer } from './lesson-mode/LessonContainer'
export { LessonStartForm } from './lesson-mode/LessonStartForm'
export { LessonActiveView } from './lesson-mode/LessonActiveView'
export { LessonEndedView } from './lesson-mode/LessonEndedView'
export { VisualPieTimer, CompactTimer } from './lesson-mode/VisualPieTimer'
export { QuickActionsBar, QuickActionsCompact } from './lesson-mode/QuickActionsBar'
export { HomeworkSection } from './lesson-mode/HomeworkSection'
export { ReflectionSection } from './lesson-mode/ReflectionSection'
// Modals
export { SettingsModal } from './modals/SettingsModal'
export { FeedbackModal } from './modals/FeedbackModal'
export { OnboardingModal } from './modals/OnboardingModal'

View File

@@ -1,80 +0,0 @@
'use client'
import { LessonSession, LessonStatus } from '@/lib/companion/types'
import { LessonStartForm } from './LessonStartForm'
import { LessonActiveView } from './LessonActiveView'
import { LessonEndedView } from './LessonEndedView'
interface LessonContainerProps {
session: LessonSession | null
onStartLesson: (data: { classId: string; subject: string; topic?: string; templateId?: string }) => void
onEndLesson: () => void
onPauseToggle: () => void
onExtendTime: (minutes: number) => void
onSkipPhase: () => void
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
onAddHomework: (title: string, dueDate: string) => void
onRemoveHomework: (id: string) => void
loading?: boolean
}
export function LessonContainer({
session,
onStartLesson,
onEndLesson,
onPauseToggle,
onExtendTime,
onSkipPhase,
onSaveReflection,
onAddHomework,
onRemoveHomework,
loading,
}: LessonContainerProps) {
// Determine which view to show based on session state
const getView = (): 'start' | 'active' | 'ended' => {
if (!session) return 'start'
const status = session.status
if (status === 'completed') return 'ended'
if (status === 'not_started') return 'start'
return 'active'
}
const view = getView()
if (view === 'start') {
return (
<LessonStartForm
onStart={onStartLesson}
loading={loading}
/>
)
}
if (view === 'ended' && session) {
return (
<LessonEndedView
session={session}
onSaveReflection={onSaveReflection}
onAddHomework={onAddHomework}
onRemoveHomework={onRemoveHomework}
onStartNew={() => onEndLesson()} // This will clear the session and show start form
/>
)
}
if (session) {
return (
<LessonActiveView
session={session}
onPauseToggle={onPauseToggle}
onExtendTime={onExtendTime}
onSkipPhase={onSkipPhase}
onEndLesson={onEndLesson}
/>
)
}
return null
}

View File

@@ -1,156 +0,0 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { CompanionData } from '@/lib/companion/types'
import { createDefaultPhases } from '@/lib/companion/constants'
interface UseCompanionDataOptions {
pollingInterval?: number // ms, default 30000
autoRefresh?: boolean
}
interface UseCompanionDataReturn {
data: CompanionData | null
loading: boolean
error: string | null
refresh: () => Promise<void>
lastUpdated: Date | null
}
// Mock data for development - will be replaced with actual API calls
function getMockData(): CompanionData {
return {
context: {
currentPhase: 'erarbeitung',
phaseDisplayName: 'Erarbeitung',
},
stats: {
classesCount: 4,
studentsCount: 96,
learningUnitsCreated: 23,
gradesEntered: 156,
},
phases: createDefaultPhases(),
progress: {
percentage: 65,
completed: 13,
total: 20,
},
suggestions: [
{
id: '1',
title: 'Klausuren korrigieren',
description: 'Deutsch LK - 12 unkorrigierte Arbeiten warten',
priority: 'urgent',
icon: 'ClipboardCheck',
actionTarget: '/ai/klausur-korrektur',
estimatedTime: 120,
},
{
id: '2',
title: 'Elternsprechtag vorbereiten',
description: 'Notenuebersicht fuer 8b erstellen',
priority: 'high',
icon: 'Users',
actionTarget: '/education/grades',
estimatedTime: 30,
},
{
id: '3',
title: 'Material hochladen',
description: 'Arbeitsblatt fuer naechste Woche bereitstellen',
priority: 'medium',
icon: 'FileText',
actionTarget: '/development/content',
estimatedTime: 15,
},
{
id: '4',
title: 'Lernstandserhebung planen',
description: 'Mathe 7a - Naechster Test in 2 Wochen',
priority: 'low',
icon: 'Calendar',
actionTarget: '/education/planning',
estimatedTime: 45,
},
],
upcomingEvents: [
{
id: 'e1',
title: 'Mathe-Test 9b',
date: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000).toISOString(),
type: 'exam',
inDays: 2,
},
{
id: 'e2',
title: 'Elternsprechtag',
date: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
type: 'parent_meeting',
inDays: 5,
},
{
id: 'e3',
title: 'Notenschluss Q1',
date: new Date(Date.now() + 14 * 24 * 60 * 60 * 1000).toISOString(),
type: 'deadline',
inDays: 14,
},
],
}
}
export function useCompanionData(options: UseCompanionDataOptions = {}): UseCompanionDataReturn {
const { pollingInterval = 30000, autoRefresh = true } = options
const [data, setData] = useState<CompanionData | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const fetchData = useCallback(async () => {
try {
// TODO: Replace with actual API call
// const response = await fetch('/api/admin/companion')
// if (!response.ok) throw new Error('Failed to fetch companion data')
// const result = await response.json()
// setData(result.data)
// For now, use mock data with a small delay to simulate network
await new Promise((resolve) => setTimeout(resolve, 300))
setData(getMockData())
setLastUpdated(new Date())
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}, [])
const refresh = useCallback(async () => {
setLoading(true)
await fetchData()
}, [fetchData])
// Initial fetch
useEffect(() => {
fetchData()
}, [fetchData])
// Polling
useEffect(() => {
if (!autoRefresh || pollingInterval <= 0) return
const interval = setInterval(fetchData, pollingInterval)
return () => clearInterval(interval)
}, [autoRefresh, pollingInterval, fetchData])
return {
data,
loading,
error,
refresh,
lastUpdated,
}
}

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* POST /api/admin/companion/feedback
* POST /api/companion/feedback
* Submit feedback (bug report, feature request, general feedback)
* Proxy to backend /api/feedback
*/
@@ -95,7 +95,7 @@ export async function POST(request: NextRequest) {
}
/**
* GET /api/admin/companion/feedback
* GET /api/companion/feedback
* Get feedback history (admin only)
*/
export async function GET(request: NextRequest) {

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
/**
* POST /api/admin/companion/lesson
* POST /api/companion/lesson
* Start a new lesson session
* Proxy to backend /api/classroom/sessions
*/
@@ -71,7 +71,7 @@ export async function POST(request: NextRequest) {
}
/**
* GET /api/admin/companion/lesson
* GET /api/companion/lesson
* Get current lesson session or list of recent sessions
*/
export async function GET(request: NextRequest) {
@@ -116,7 +116,7 @@ export async function GET(request: NextRequest) {
}
/**
* PATCH /api/admin/companion/lesson
* PATCH /api/companion/lesson
* Update lesson session (timer state, phase changes, etc.)
*/
export async function PATCH(request: NextRequest) {
@@ -160,7 +160,7 @@ export async function PATCH(request: NextRequest) {
}
/**
* DELETE /api/admin/companion/lesson
* DELETE /api/companion/lesson
* End/delete a lesson session
*/
export async function DELETE(request: NextRequest) {

View File

@@ -17,7 +17,7 @@ const DEFAULT_SETTINGS = {
}
/**
* GET /api/admin/companion/settings
* GET /api/companion/settings
* Get teacher settings
* Proxy to backend /api/teacher/settings
*/
@@ -58,7 +58,7 @@ export async function GET() {
}
/**
* PUT /api/admin/companion/settings
* PUT /api/companion/settings
* Update teacher settings
*/
export async function PUT(request: NextRequest) {
@@ -110,7 +110,7 @@ export async function PUT(request: NextRequest) {
}
/**
* PATCH /api/admin/companion/settings
* PATCH /api/companion/settings
* Partially update teacher settings
*/
export async function PATCH(request: NextRequest) {

View File

@@ -0,0 +1,51 @@
'use client'
import { useTheme } from '@/lib/ThemeContext'
import { Sidebar } from '@/components/Sidebar'
import { CompanionDashboard } from '@/components/companion/CompanionDashboard'
import { ThemeToggle } from '@/components/ThemeToggle'
import { LanguageDropdown } from '@/components/LanguageDropdown'
export default function CompanionPage() {
const { isDark } = useTheme()
return (
<div className={`min-h-screen flex relative overflow-hidden ${
isDark
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
}`}>
{/* Animated Background Blobs */}
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'
}`} />
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'
}`} />
{/* Sidebar */}
<div className="relative z-10 p-4">
<Sidebar />
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col relative z-10 p-4 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<h1 className={`text-2xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Companion
</h1>
<div className="flex items-center gap-2">
<ThemeToggle />
<LanguageDropdown />
</div>
</div>
{/* Companion Dashboard */}
<div className="flex-1 overflow-auto">
<CompanionDashboard />
</div>
</div>
</div>
)
}

View File

@@ -81,6 +81,12 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)},
{ id: 'companion', labelKey: 'nav_companion', href: '/companion', icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 6v6l4 2" />
</svg>
)},
{ id: 'meet', labelKey: 'nav_meet', href: '/meet', icon: (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
@@ -111,6 +117,7 @@ export function Sidebar({ selectedTab = 'dashboard', onTabChange }: SidebarProps
// Determine active item based on pathname or selectedTab
const getActiveItem = () => {
if (pathname === '/companion') return 'companion'
if (pathname === '/meet') return 'meet'
if (pathname === '/vocab-worksheet') return 'vokabeln'
if (pathname === '/worksheet-editor') return 'worksheet-editor'

View File

@@ -0,0 +1,222 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Settings, MessageSquare, HelpCircle, Timer } from 'lucide-react'
import { TeacherSettings, FeedbackType } from '@/lib/companion/types'
import { DEFAULT_TEACHER_SETTINGS, STORAGE_KEYS } from '@/lib/companion/constants'
// Components
import { LessonStartForm } from './lesson-mode/LessonStartForm'
import { LessonActiveView } from './lesson-mode/LessonActiveView'
import { LessonEndedView } from './lesson-mode/LessonEndedView'
import { SettingsModal } from './modals/SettingsModal'
import { FeedbackModal } from './modals/FeedbackModal'
import { OnboardingModal } from './modals/OnboardingModal'
// Hooks
import { useLessonSession } from '@/hooks/companion/useLessonSession'
import { useKeyboardShortcuts } from '@/hooks/companion/useKeyboardShortcuts'
export function CompanionDashboard() {
// Modal states
const [showSettings, setShowSettings] = useState(false)
const [showFeedback, setShowFeedback] = useState(false)
const [showOnboarding, setShowOnboarding] = useState(false)
// Settings
const [settings, setSettings] = useState<TeacherSettings>(DEFAULT_TEACHER_SETTINGS)
// Load settings from localStorage
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.SETTINGS)
if (stored) {
try {
const parsed = JSON.parse(stored)
setSettings({ ...DEFAULT_TEACHER_SETTINGS, ...parsed })
} catch {
// Invalid stored settings
}
}
// Check if onboarding needed
const onboardingStored = localStorage.getItem(STORAGE_KEYS.ONBOARDING_STATE)
if (!onboardingStored) {
setShowOnboarding(true)
}
}, [])
// Lesson session hook
const {
session,
startLesson,
endLesson,
clearSession,
pauseLesson,
resumeLesson,
extendTime,
skipPhase,
saveReflection,
addHomework,
removeHomework,
isPaused,
} = useLessonSession({
onOvertimeStart: () => {
if (settings.soundNotifications) {
// TODO: Play notification sound
}
},
})
// Handle pause/resume toggle
const handlePauseToggle = useCallback(() => {
if (isPaused) {
resumeLesson()
} else {
pauseLesson()
}
}, [isPaused, pauseLesson, resumeLesson])
// Keyboard shortcuts
useKeyboardShortcuts({
onPauseResume: session ? handlePauseToggle : undefined,
onExtend: session && !isPaused ? () => extendTime(5) : undefined,
onNextPhase: session && !isPaused ? skipPhase : undefined,
onCloseModal: () => {
setShowSettings(false)
setShowFeedback(false)
setShowOnboarding(false)
},
enabled: settings.showKeyboardShortcuts,
})
// Handle settings save
const handleSaveSettings = (newSettings: TeacherSettings) => {
setSettings(newSettings)
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(newSettings))
}
// Handle feedback submit
const handleFeedbackSubmit = async (type: FeedbackType, title: string, description: string) => {
const response = await fetch('/api/companion/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
title,
description,
sessionId: session?.sessionId,
}),
})
if (!response.ok) {
throw new Error('Failed to submit feedback')
}
}
// Handle onboarding complete
const handleOnboardingComplete = (data: { state?: string; schoolType?: string }) => {
localStorage.setItem(STORAGE_KEYS.ONBOARDING_STATE, JSON.stringify({
...data,
completed: true,
completedAt: new Date().toISOString(),
}))
setShowOnboarding(false)
setSettings({ ...settings, onboardingCompleted: true })
}
// Determine current view based on session status
const renderContent = () => {
if (!session) {
return <LessonStartForm onStart={startLesson} />
}
if (session.status === 'completed') {
return (
<LessonEndedView
session={session}
onSaveReflection={saveReflection}
onAddHomework={addHomework}
onRemoveHomework={removeHomework}
onStartNew={clearSession}
/>
)
}
// in_progress or paused
return (
<LessonActiveView
session={session}
onPauseToggle={handlePauseToggle}
onExtendTime={extendTime}
onSkipPhase={skipPhase}
onEndLesson={endLesson}
/>
)
}
return (
<div className={`min-h-[calc(100vh-200px)] ${settings.highContrastMode ? 'high-contrast' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<Timer className="w-5 h-5 text-blue-600" />
</div>
<h2 className="text-lg font-semibold text-slate-900">Unterrichtsstunde</h2>
</div>
<div className="flex items-center gap-2">
{/* Feedback Button */}
<button
onClick={() => setShowFeedback(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Feedback"
>
<MessageSquare className="w-5 h-5" />
</button>
{/* Settings Button */}
<button
onClick={() => setShowSettings(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Einstellungen"
>
<Settings className="w-5 h-5" />
</button>
{/* Help Button */}
<button
onClick={() => setShowOnboarding(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Hilfe"
>
<HelpCircle className="w-5 h-5" />
</button>
</div>
</div>
{/* Main Content */}
{renderContent()}
{/* Modals */}
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
settings={settings}
onSave={handleSaveSettings}
/>
<FeedbackModal
isOpen={showFeedback}
onClose={() => setShowFeedback(false)}
onSubmit={handleFeedbackSubmit}
/>
<OnboardingModal
isOpen={showOnboarding}
onClose={() => setShowOnboarding(false)}
onComplete={handleOnboardingComplete}
/>
</div>
)
}

View File

@@ -4,7 +4,7 @@ import { BookOpen, Clock, Users } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { VisualPieTimer } from './VisualPieTimer'
import { QuickActionsBar } from './QuickActionsBar'
import { PhaseTimelineDetailed } from '../companion-mode/PhaseTimeline'
import { PhaseTimelineDetailed } from './PhaseTimeline'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,

View File

@@ -1,103 +1,29 @@
'use client'
import { Check } from 'lucide-react'
import { Phase } from '@/lib/companion/types'
import { PHASE_COLORS, formatMinutes } from '@/lib/companion/constants'
import { formatMinutes } from '@/lib/companion/constants'
interface PhaseTimelineProps {
phases: Phase[]
interface PhaseTimelinePhase {
id: string
shortName: string
displayName: string
duration: number
status: string
actualTime?: number
color: string
}
interface PhaseTimelineDetailedProps {
phases: PhaseTimelinePhase[]
currentPhaseIndex: number
onPhaseClick?: (index: number) => void
compact?: boolean
}
export function PhaseTimeline({
phases,
currentPhaseIndex,
onPhaseClick,
compact = false,
}: PhaseTimelineProps) {
return (
<div className={`flex items-center ${compact ? 'gap-2' : 'gap-3'}`}>
{phases.map((phase, index) => {
const isActive = index === currentPhaseIndex
const isCompleted = phase.status === 'completed'
const isPast = index < currentPhaseIndex
const colors = PHASE_COLORS[phase.id]
return (
<div key={phase.id} className="flex items-center">
{/* Phase Dot/Circle */}
<button
onClick={() => onPhaseClick?.(index)}
disabled={!onPhaseClick}
className={`
relative flex items-center justify-center
${compact ? 'w-8 h-8' : 'w-10 h-10'}
rounded-full font-semibold text-sm
transition-all duration-300
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
${isActive
? `ring-4 ring-offset-2 ${colors.tailwind} text-white`
: isCompleted || isPast
? `${colors.tailwind} text-white opacity-80`
: 'bg-slate-200 text-slate-500'
}
`}
style={{
backgroundColor: isActive || isCompleted || isPast ? colors.hex : undefined,
// Use CSS custom property for ring color with Tailwind
'--tw-ring-color': isActive ? colors.hex : undefined,
} as React.CSSProperties}
title={`${phase.displayName} (${formatMinutes(phase.duration)})`}
>
{isCompleted ? (
<Check className={compact ? 'w-4 h-4' : 'w-5 h-5'} />
) : (
phase.shortName
)}
{/* Active indicator pulse */}
{isActive && (
<span
className="absolute inset-0 rounded-full animate-ping opacity-30"
style={{ backgroundColor: colors.hex }}
/>
)}
</button>
{/* Connector Line */}
{index < phases.length - 1 && (
<div
className={`
${compact ? 'w-4' : 'w-8'} h-1 mx-1
${isPast || isCompleted
? 'bg-gradient-to-r'
: 'bg-slate-200'
}
`}
style={{
background: isPast || isCompleted
? `linear-gradient(to right, ${colors.hex}, ${PHASE_COLORS[phases[index + 1].id].hex})`
: undefined,
}}
/>
)}
</div>
)
})}
</div>
)
}
/**
* Detailed Phase Timeline with labels and durations
*/
export function PhaseTimelineDetailed({
phases,
currentPhaseIndex,
onPhaseClick,
}: PhaseTimelineProps) {
}: PhaseTimelineDetailedProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Unterrichtsphasen</h3>
@@ -107,7 +33,6 @@ export function PhaseTimelineDetailed({
const isActive = index === currentPhaseIndex
const isCompleted = phase.status === 'completed'
const isPast = index < currentPhaseIndex
const colors = PHASE_COLORS[phase.id]
return (
<div key={phase.id} className="flex flex-col items-center flex-1">
@@ -118,7 +43,7 @@ export function PhaseTimelineDetailed({
className="flex-1 h-1"
style={{
background: isPast || isCompleted
? PHASE_COLORS[phases[index - 1].id].hex
? phases[index - 1].color
: '#e2e8f0',
}}
/>
@@ -138,9 +63,9 @@ export function PhaseTimelineDetailed({
${isActive ? 'ring-4 ring-offset-2 shadow-lg' : ''}
`}
style={{
backgroundColor: isActive || isCompleted || isPast ? colors.hex : '#e2e8f0',
backgroundColor: isActive || isCompleted || isPast ? phase.color : '#e2e8f0',
color: isActive || isCompleted || isPast ? 'white' : '#64748b',
'--tw-ring-color': isActive ? `${colors.hex}40` : undefined,
'--tw-ring-color': isActive ? `${phase.color}40` : undefined,
} as React.CSSProperties}
>
{isCompleted ? (
@@ -152,7 +77,7 @@ export function PhaseTimelineDetailed({
{isActive && (
<span
className="absolute inset-0 rounded-full animate-ping opacity-20"
style={{ backgroundColor: colors.hex }}
style={{ backgroundColor: phase.color }}
/>
)}
</button>
@@ -161,7 +86,7 @@ export function PhaseTimelineDetailed({
<div
className="flex-1 h-1"
style={{
background: isCompleted ? colors.hex : '#e2e8f0',
background: isCompleted ? phase.color : '#e2e8f0',
}}
/>
)}

View File

@@ -1,3 +1,2 @@
export { useCompanionData } from './useCompanionData'
export { useLessonSession } from './useLessonSession'
export { useKeyboardShortcuts, useKeyboardShortcutHints } from './useKeyboardShortcuts'

View File

@@ -29,6 +29,7 @@ interface UseLessonSessionReturn {
templateId?: string
}) => void
endLesson: () => void
clearSession: () => void
pauseLesson: () => void
resumeLesson: () => void
extendTime: (minutes: number) => void
@@ -96,10 +97,11 @@ export function useLessonSession(
const now = Date.now()
const delta = Math.floor((now - lastTickRef.current) / 1000)
lastTickRef.current = now
if (delta <= 0) return
lastTickRef.current = now
setSession((prev) => {
if (!prev) return null
@@ -138,7 +140,7 @@ export function useLessonSession(
useEffect(() => {
if (session?.status === 'in_progress' && !session.isPaused) {
lastTickRef.current = Date.now()
timerRef.current = setInterval(tick, 100) // Update every 100ms for smooth animation
timerRef.current = setInterval(tick, 100)
} else {
if (timerRef.current) {
clearInterval(timerRef.current)
@@ -175,12 +177,10 @@ export function useLessonSession(
if (stored) {
try {
const parsed = JSON.parse(stored) as LessonSession
// Only restore if session is not completed and not too old (< 24h)
const sessionTime = new Date(parsed.startTime).getTime()
const isRecent = Date.now() - sessionTime < 24 * 60 * 60 * 1000
if (parsed.status !== 'completed' && isRecent) {
// Pause the restored session
setSession({ ...parsed, isPaused: true })
}
} catch {
@@ -197,7 +197,6 @@ export function useLessonSession(
topic?: string
templateId?: string
}) => {
// Find template durations
let durations = DEFAULT_PHASE_DURATIONS
if (data.templateId) {
const template = SYSTEM_TEMPLATES.find((t) => t.templateId === data.templateId)
@@ -253,6 +252,11 @@ export function useLessonSession(
onLessonComplete?.(completedSession)
}, [session, onLessonComplete])
const clearSession = useCallback(() => {
setSession(null)
localStorage.removeItem(STORAGE_KEYS.CURRENT_SESSION)
}, [])
const pauseLesson = useCallback(() => {
if (!session || session.isPaused) return
@@ -302,7 +306,6 @@ export function useLessonSession(
currentPhase.duration += minutes
// Reset overtime trigger if we've added time
if (hasTriggeredOvertimeRef.current) {
const phaseDurationSeconds = currentPhase.duration * 60
if (currentPhase.actualTime < phaseDurationSeconds) {
@@ -325,7 +328,6 @@ export function useLessonSession(
const nextPhaseIndex = session.currentPhaseIndex + 1
// Check if this was the last phase
if (nextPhaseIndex >= session.phases.length) {
endLesson()
return
@@ -336,14 +338,12 @@ export function useLessonSession(
const updatedPhases = [...prev.phases]
// Complete current phase
updatedPhases[prev.currentPhaseIndex] = {
...updatedPhases[prev.currentPhaseIndex],
status: 'completed',
completedAt: new Date().toISOString(),
}
// Start next phase
updatedPhases[nextPhaseIndex] = {
...updatedPhases[nextPhaseIndex],
status: 'active',
@@ -422,17 +422,12 @@ export function useLessonSession(
[session]
)
// Clear session (for starting new)
const clearSession = useCallback(() => {
setSession(null)
localStorage.removeItem(STORAGE_KEYS.CURRENT_SESSION)
}, [])
return {
session,
timerState,
startLesson,
endLesson: session?.status === 'completed' ? clearSession : endLesson,
endLesson,
clearSession,
pauseLesson,
resumeLesson,
extendTime,

View File

@@ -37,7 +37,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'Arbeitsblätter',
nav_worksheet_cleanup: 'Bereinigung',
nav_korrektur: 'Korrekturen',
nav_compliance_pipeline: 'Compliance Pipeline',
nav_companion: 'Companion', nav_compliance_pipeline: 'Compliance Pipeline',
nav_meet: 'Meet',
nav_alerts: 'Alerts',
nav_alerts_b2b: 'B2B Alerts',
@@ -98,7 +98,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'Worksheets',
nav_worksheet_cleanup: 'Cleanup',
nav_korrektur: 'Corrections',
nav_compliance_pipeline: 'Compliance Pipeline',
nav_companion: 'Companion', nav_compliance_pipeline: 'Compliance Pipeline',
nav_meet: 'Meet',
nav_alerts: 'Alerts',
nav_alerts_b2b: 'B2B Alerts',
@@ -145,7 +145,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'Çalışma Sayfaları',
nav_worksheet_cleanup: 'Temizleme',
nav_korrektur: 'Düzeltmeler',
nav_compliance_pipeline: 'Uyum Boru Hattı',
nav_companion: 'Companion', nav_compliance_pipeline: 'Uyum Boru Hattı',
nav_meet: 'Meet',
nav_alerts: 'Uyarılar',
nav_alerts_b2b: 'B2B Uyarılar',
@@ -192,7 +192,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'أوراق العمل',
nav_worksheet_cleanup: 'تنظيف',
nav_korrektur: 'التصحيحات',
nav_compliance_pipeline: 'خط أنابيب الامتثال',
nav_companion: 'المرافق', nav_compliance_pipeline: 'خط أنابيب الامتثال',
nav_meet: 'اجتماع',
nav_alerts: 'تنبيهات',
nav_alerts_b2b: 'تنبيهات الشركات',
@@ -239,7 +239,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'Рабочие листы',
nav_worksheet_cleanup: 'Очистка',
nav_korrektur: 'Проверки',
nav_compliance_pipeline: 'Пайплайн соответствия',
nav_companion: 'Компаньон', nav_compliance_pipeline: 'Пайплайн соответствия',
nav_meet: 'Встреча',
nav_alerts: 'Оповещения',
nav_alerts_b2b: 'B2B оповещения',
@@ -286,7 +286,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'Робочі аркуші',
nav_worksheet_cleanup: 'Очищення',
nav_korrektur: 'Перевірки',
nav_compliance_pipeline: 'Пайплайн відповідності',
nav_companion: 'Компаньйон', nav_compliance_pipeline: 'Пайплайн відповідності',
nav_meet: 'Зустріч',
nav_alerts: 'Сповіщення',
nav_alerts_b2b: 'B2B сповіщення',
@@ -333,7 +333,7 @@ export const translations: Record<Language, Record<string, string>> = {
nav_worksheet_editor: 'Arkusze robocze',
nav_worksheet_cleanup: 'Czyszczenie',
nav_korrektur: 'Korekty',
nav_compliance_pipeline: 'Pipeline zgodności',
nav_companion: 'Companion', nav_compliance_pipeline: 'Pipeline zgodności',
nav_meet: 'Spotkanie',
nav_alerts: 'Powiadomienia',
nav_alerts_b2b: 'Powiadomienia B2B',