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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -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) {
|
||||
@@ -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) {
|
||||
51
studio-v2/app/companion/page.tsx
Normal file
51
studio-v2/app/companion/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
222
studio-v2/components/companion/CompanionDashboard.tsx
Normal file
222
studio-v2/components/companion/CompanionDashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1,3 +1,2 @@
|
||||
export { useCompanionData } from './useCompanionData'
|
||||
export { useLessonSession } from './useLessonSession'
|
||||
export { useKeyboardShortcuts, useKeyboardShortcutHints } from './useKeyboardShortcuts'
|
||||
@@ -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,
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user