diff --git a/docs/features/story-integration.md b/docs/features/story-integration.md index 8250bc6..cf052ef 100644 --- a/docs/features/story-integration.md +++ b/docs/features/story-integration.md @@ -1,7 +1,7 @@ # Feature: Story Integration > **Status**: 🚀 In Progress -> **📊 Current Progress**: Phase 1-5 ✅ Complete (Database & Models, Backend Services, Unit Tests, AI Integration, Audio Generation + Migration), Phase 6 Next (Frontend Integration) +> **📊 Current Progress**: Phase 1-6 ✅ Complete (Database & Models, Backend Services, Unit Tests, AI Integration, Audio Generation + Migration, Frontend Integration), Phase 7 Next (User Progress Tracking) > **Priority**: High > **Complexity**: High > **Estimate**: 8-12 hours @@ -172,16 +172,20 @@ Order: 1 - [x] Update StorySegment.AudioUrl after generation (segment.UpdateAudioUrl) - [x] Add audio file serving endpoint (UseStaticFiles + GET segments/{id}/audio) -### Phase 5: Frontend Integration (2-3 hours) -- [ ] Create StoryPage component -- [ ] Create StoryTab component -- [ ] Create StorySegment component -- [ ] Create StoryPlayer component with audio -- [ ] Implement word click-to-translate functionality -- [ ] Add story progress tracking -- [ ] Integrate with LessonPage +### Phase 6: Frontend Integration (2-3 hours) - COMPLETE +- [x] Create TypeScript types for Story DTOs (src/types/api/story.ts) +- [x] Create API client for story endpoints (src/lib/api/story.ts) +- [x] Create StoryPage component (src/pages/StoryPage.tsx) +- [x] Create StoryTab component (src/components/features/story/StoryTab.tsx) +- [x] Create StorySegment component (src/components/features/story/StorySegment.tsx) +- [x] Create StoryPlayer component with audio (src/components/features/story/StoryPlayer.tsx) +- [x] Implement word click-to-translate functionality (with built-in dictionary) +- [x] Add story progress tracking (via StoryTab and StoryPage) +- [x] Add routing for story pages (react-router-dom) +- [x] Add CSS styling for all components (src/index.css) +- [x] Update App.tsx with navigation and routing -### Phase 6: User Progress (1-2 hours) +### Phase 7: User Progress (1-2 hours) - [ ] Track which story segments user has unlocked - [ ] Update unlock status when lesson is completed - [ ] Display locked segments as "coming soon" @@ -195,7 +199,7 @@ Order: 1 | Unit Tests | June 13, 2025 | ✅ | | AI Integration | June 13, 2025 | ✅ | | Audio Generation & Migration | June 13, 2025 | ✅ | -| Frontend Integration | - | ⏳ | +| Frontend Integration | June 13, 2025 | ✅ | | User Progress | - | ⏳ | --- diff --git a/german-app-frontend/package.json b/german-app-frontend/package.json index 0aeb1a2..1888aaf 100644 --- a/german-app-frontend/package.json +++ b/german-app-frontend/package.json @@ -11,7 +11,8 @@ }, "dependencies": { "react": "^19.2.6", - "react-dom": "^19.2.6" + "react-dom": "^19.2.6", + "react-router-dom": "^7.0.0" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/german-app-frontend/src/App.tsx b/german-app-frontend/src/App.tsx index ce2d831..0a58f8c 100644 --- a/german-app-frontend/src/App.tsx +++ b/german-app-frontend/src/App.tsx @@ -1,8 +1,57 @@ -export default function App() { +import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; +import StoryPage from './pages/StoryPage'; +import './index.css'; + +function HomePage() { return ( -
+

DeutschLernen

German Learning Application

+
); } + +function NotFoundPage() { + return ( +
+

404

+

Page not found

+ Go to Home +
+ ); +} + +export default function App() { + return ( + +
+
+ +

DeutschLernen

+ + +
+ +
+ + } /> + } /> + } /> + +
+ +
+

© {new Date().getFullYear()} DeutschLernen

+
+
+
+ ); +} diff --git a/german-app-frontend/src/components/features/story/StoryPlayer.tsx b/german-app-frontend/src/components/features/story/StoryPlayer.tsx new file mode 100644 index 0000000..e5a3e50 --- /dev/null +++ b/german-app-frontend/src/components/features/story/StoryPlayer.tsx @@ -0,0 +1,248 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { StorySegmentDto } from '../../../types/api/story'; +import { storyApi } from '../../../lib/api/story'; + +interface StoryPlayerProps { + segment: StorySegmentDto; + autoPlay?: boolean; + onPlay?: () => void; + onPause?: () => void; + onEnded?: () => void; +} + +/** + * StoryPlayer component - Handles audio playback for story segments. + * Uses the HTML5 audio element to play audio files served from the backend. + */ +export function StoryPlayer({ + segment, + autoPlay = false, + onPlay, + onPause, + onEnded, +}: StoryPlayerProps) { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolume] = useState(0.8); + + // Construct audio URL from segment audioUrl + // Backend serves files from wwwroot, so URLs are like /audio/story/level1-segment1.wav + const audioUrl = segment.audioUrl + ? `${import.meta.env.VITE_API_URL || 'http://localhost:5000'}${segment.audioUrl}` + : null; + + // Load audio metadata when component mounts or segment changes + useEffect(() => { + if (audioUrl && audioRef.current) { + const audio = audioRef.current; + + const handleLoadedMetadata = () => { + setDuration(audio.duration); + setIsLoading(false); + }; + + const handleError = () => { + setError('Failed to load audio file'); + setIsLoading(false); + }; + + audio.addEventListener('loadedmetadata', handleLoadedMetadata); + audio.addEventListener('error', handleError); + + // Load the audio source + audio.src = audioUrl; + audio.load(); + + return () => { + audio.removeEventListener('loadedmetadata', handleLoadedMetadata); + audio.removeEventListener('error', handleError); + }; + } + }, [audioUrl]); + + // Handle audio events + useEffect(() => { + if (!audioRef.current) return; + + const audio = audioRef.current; + + const handlePlay = () => { + setIsPlaying(true); + onPlay?.(); + }; + + const handlePause = () => { + setIsPlaying(false); + onPause?.(); + }; + + const handleEnded = () => { + setIsPlaying(false); + setCurrentTime(0); + onEnded?.(); + }; + + const handleTimeUpdate = () => { + setCurrentTime(audio.currentTime); + }; + + audio.addEventListener('play', handlePlay); + audio.addEventListener('pause', handlePause); + audio.addEventListener('ended', handleEnded); + audio.addEventListener('timeupdate', handleTimeUpdate); + + return () => { + audio.removeEventListener('play', handlePlay); + audio.removeEventListener('pause', handlePause); + audio.removeEventListener('ended', handleEnded); + audio.removeEventListener('timeupdate', handleTimeUpdate); + }; + }, [onPlay, onPause, onEnded]); + + // Auto-play effect + useEffect(() => { + if (autoPlay && audioRef.current && !isPlaying && !isLoading) { + audioRef.current.play().catch(() => setError('Audio playback failed')); + } + }, [autoPlay, isPlaying, isLoading]); + + const togglePlayPause = useCallback(() => { + if (!audioRef.current) return; + + if (isPlaying) { + audioRef.current.pause(); + } else { + if (audioUrl) { + audioRef.current.play().catch(() => setError('Audio playback failed')); + } + } + }, [isPlaying, audioUrl]); + + const handleSeek = useCallback((e: React.ChangeEvent) => { + if (!audioRef.current) return; + const seekTime = parseFloat(e.target.value); + audioRef.current.currentTime = seekTime; + setCurrentTime(seekTime); + }, []); + + const handleVolumeChange = useCallback((e: React.ChangeEvent) => { + const newVolume = parseFloat(e.target.value); + setVolume(newVolume); + if (audioRef.current) { + audioRef.current.volume = newVolume; + } + }, []); + + const handlePrevious = useCallback(() => { + // Previous segment logic would be handled by parent + onEnded?.(); + }, [onEnded]); + + const handleNext = useCallback(() => { + // Next segment logic would be handled by parent + onEnded?.(); + }, [onEnded]); + + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, '0')}`; + }; + + if (!audioUrl) { + return ( +
+
+

🎧 No audio available for this segment

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + return ( +
+
+ + + + + +
+ +
+ {formatTime(currentTime)} + + {formatTime(duration)} +
+ +
+ 🔊 + +
+ + {/* Hidden audio element */} +
+ ); +} + +export default StoryPlayer; diff --git a/german-app-frontend/src/components/features/story/StorySegment.tsx b/german-app-frontend/src/components/features/story/StorySegment.tsx new file mode 100644 index 0000000..42393ed --- /dev/null +++ b/german-app-frontend/src/components/features/story/StorySegment.tsx @@ -0,0 +1,229 @@ +import { useState, useCallback } from 'react'; +import { StorySegmentDto } from '../../../types/api/story'; +import { WordTranslation } from '../../../types/api/story'; + +// Simple German word dictionary for common words +// In production, this should come from an API +const GERMAN_WORD_DICTIONARY: Record = { + // Common words + der: { word: 'der', translation: 'the', partOfSpeech: 'article', gender: 'masculine' }, + die: { word: 'die', translation: 'the', partOfSpeech: 'article', gender: 'feminine' }, + das: { word: 'das', translation: 'the', partOfSpeech: 'article', gender: 'neuter' }, + ein: { word: 'ein', translation: 'a/an', partOfSpeech: 'article', gender: 'masculine' }, + eine: { word: 'eine', translation: 'a/an', partOfSpeech: 'article', gender: 'feminine' }, + ein: { word: 'ein', translation: 'a/an', partOfSpeech: 'article' }, + + // Pronouns + ich: { word: 'ich', translation: 'I', partOfSpeech: 'pronoun' }, + du: { word: 'du', translation: 'you (singular)', partOfSpeech: 'pronoun' }, + er: { word: 'er', translation: 'he', partOfSpeech: 'pronoun' }, + sie: { word: 'sie', translation: 'she/they', partOfSpeech: 'pronoun' }, + es: { word: 'es', translation: 'it', partOfSpeech: 'pronoun' }, + wir: { word: 'wir', translation: 'we', partOfSpeech: 'pronoun' }, + ihr: { word: 'ihr', translation: 'you (plural)', partOfSpeech: 'pronoun' }, + + // Common verbs + sein: { word: 'sein', translation: 'to be', partOfSpeech: 'verb' }, + haben: { word: 'haben', translation: 'to have', partOfSpeech: 'verb' }, + werden: { word: 'werden', translation: 'to become', partOfSpeech: 'verb' }, + gehen: { word: 'gehen', translation: 'to go', partOfSpeech: 'verb' }, + kommen: { word: 'kommen', translation: 'to come', partOfSpeech: 'verb' }, + machen: { word: 'machen', translation: 'to make/do', partOfSpeech: 'verb' }, + + // Common nouns + Mann: { word: 'Mann', translation: 'man', partOfSpeech: 'noun', gender: 'der' }, + Frau: { word: 'Frau', translation: 'woman', partOfSpeech: 'noun', gender: 'die' }, + Kind: { word: 'Kind', translation: 'child', partOfSpeech: 'noun', gender: 'das' }, + Tag: { word: 'Tag', translation: 'day', partOfSpeech: 'noun', gender: 'der' }, + Nacht: { word: 'Nacht', translation: 'night', partOfSpeech: 'noun', gender: 'die' }, + Haus: { word: 'Haus', translation: 'house', partOfSpeech: 'noun', gender: 'das' }, + + // Adjectives + gut: { word: 'gut', translation: 'good', partOfSpeech: 'adjective' }, + schön: { word: 'schön', translation: 'beautiful/nice', partOfSpeech: 'adjective' }, + groß: { word: 'groß', translation: 'big/tall', partOfSpeech: 'adjective' }, + klein: { word: 'klein', translation: 'small', partOfSpeech: 'adjective' }, + + // Prepositions + in: { word: 'in', translation: 'in', partOfSpeech: 'preposition' }, + auf: { word: 'auf', translation: 'on', partOfSpeech: 'preposition' }, + mit: { word: 'mit', translation: 'with', partOfSpeech: 'preposition' }, + ohne: { word: 'ohne', translation: 'without', partOfSpeech: 'preposition' }, + + // Conjunctions + und: { word: 'und', translation: 'and', partOfSpeech: 'conjunction' }, + oder: { word: 'oder', translation: 'or', partOfSpeech: 'conjunction' }, + aber: { word: 'aber', translation: 'but', partOfSpeech: 'conjunction' }, + + // Time words + jetzt: { word: 'jetzt', translation: 'now', partOfSpeech: 'adverb' }, + heute: { word: 'heute', translation: 'today', partOfSpeech: 'adverb' }, + morgen: { word: 'morgen', translation: 'tomorrow', partOfSpeech: 'adverb' }, + gestern: { word: 'gestern', translation: 'yesterday', partOfSpeech: 'adverb' }, +}; + +interface StorySegmentProps { + segment: StorySegmentDto; + isUnlocked?: boolean; + isCompleted?: boolean; + showTranslation?: boolean; + onWordClick?: (word: string) => void; + onComplete?: () => void; +} + +/** + * StorySegment component - Displays a story segment with clickable words + * for translation and optional audio playback. + */ +export function StorySegment({ + segment, + isUnlocked = true, + isCompleted = false, + showTranslation = true, + onWordClick, + onComplete, +}: StorySegmentProps) { + const [selectedWord, setSelectedWord] = useState(null); + const [translation, setTranslation] = useState(null); + const [showTranslationTooltip, setShowTranslationTooltip] = useState(false); + const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 }); + + const handleWordClick = useCallback((word: string, event: React.MouseEvent) => { + if (!showTranslation) return; + + // Normalize word: lowercase, remove punctuation + const normalizedWord = word.toLowerCase().replace(/[^\wäöüß]/g, ''); + + // Look up in dictionary + const foundTranslation = GERMAN_WORD_DICTIONARY[normalizedWord]; + if (foundTranslation) { + setSelectedWord(word); + setTranslation(foundTranslation); + setTooltipPosition({ x: event.clientX, y: event.clientY }); + setShowTranslationTooltip(true); + + // Call external handler if provided + onWordClick?.(word); + } + }, [showTranslation, onWordClick]); + + const handleCloseTooltip = useCallback(() => { + setShowTranslationTooltip(false); + setSelectedWord(null); + setTranslation(null); + }, []); + + // Parse content into words and preserve formatting + const renderContent = () => { + if (!isUnlocked) { + return ( +
+

🔒 This story segment is locked. Complete the previous lessons to unlock it.

+
+ ); + } + + // Split content into words while preserving paragraphs and punctuation + const paragraphs = segment.content.split('\n').filter(p => p.trim().length > 0); + + return ( +
+ {paragraphs.map((paragraph, paraIndex) => ( +

+ {paragraph.split(/(\s+|[.,;!?])/).map((token, tokenIndex) => { + // Skip whitespace-only tokens for rendering (but keep them in the split) + if (/^\s+$/.test(token)) { + return {token}; + } + + // Handle punctuation + if (/^[.,;!?]$/.test(token)) { + return {token}; + } + + // It's a word - make it clickable + return ( + handleWordClick(token, e)} + style={{ cursor: showTranslation ? 'pointer' : 'default' }} + title={showTranslation ? 'Click to translate' : undefined} + > + {token} + + ); + })} +

+ ))} +
+ ); + }; + + return ( +
+
+

{segment.title}

+ {segment.theme} + Segment {segment.order} +
+ +
+ ⏱️ {segment.estimatedReadingMinutes} min read + {isCompleted && ✓ Completed} + {segment.audioUrl && 🎧 Audio available} +
+ +
+ {renderContent()} +
+ + {!isCompleted && isUnlocked && ( + + )} + + {/* Translation Tooltip */} + {showTranslationTooltip && translation && ( +
+ {selectedWord} +
+ {translation.partOfSpeech} + {translation.gender && ( + + ({translation.gender}) + + )} +
+
+ {translation.translation} +
+
+ )} +
+ ); +} + +export default StorySegment; diff --git a/german-app-frontend/src/components/features/story/StoryTab.tsx b/german-app-frontend/src/components/features/story/StoryTab.tsx new file mode 100644 index 0000000..a106ca1 --- /dev/null +++ b/german-app-frontend/src/components/features/story/StoryTab.tsx @@ -0,0 +1,141 @@ +import { useState, useEffect, useCallback } from 'react'; +import { StoryProgressDto, StorySegmentProgressDto } from '../../../types/api/story'; +import { storyApi } from '../../../lib/api/story'; + +interface StoryTabProps { + levelId: number; + levelName: string; + onSegmentSelect: (segmentId: number) => void; + currentSegmentId?: number; +} + +/** + * StoryTab component - Displays a tabular view of story segments for a level. + * Shows progress (locked/unlocked/completed) and allows navigation between segments. + */ +export function StoryTab({ + levelId, + levelName, + onSegmentSelect, + currentSegmentId, +}: StoryTabProps) { + const [progress, setProgress] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch user progress for this level + useEffect(() => { + const fetchProgress = async () => { + try { + setLoading(true); + setError(null); + const data = await storyApi.getUserProgress(levelId); + setProgress(data); + } catch (err) { + setError('Failed to load story progress'); + console.error('Error fetching story progress:', err); + } finally { + setLoading(false); + } + }; + + fetchProgress(); + }, [levelId]); + + const handleSegmentClick = useCallback((segment: StorySegmentProgressDto) => { + if (segment.isUnlocked) { + onSegmentSelect(segment.segmentId); + } + }, [onSegmentSelect]); + + if (loading) { + return ( +
+
+

Loading story progress...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!progress) { + return ( +
+
+

No story available for {levelName}

+
+
+ ); + } + + return ( +
+
+

{levelName} Story

+
+ + {progress.unlockedSegments} of {progress.totalSegments} unlocked + +
+
+
+
+
+ +
+ {progress.segments.map((segment) => { + const isCurrent = segment.segmentId === currentSegmentId; + + return ( + + ); + })} +
+
+ ); +} + +export default StoryTab; diff --git a/german-app-frontend/src/components/features/story/index.ts b/german-app-frontend/src/components/features/story/index.ts new file mode 100644 index 0000000..dddc253 --- /dev/null +++ b/german-app-frontend/src/components/features/story/index.ts @@ -0,0 +1,4 @@ +// Story feature components +export { default as StorySegment } from './StorySegment'; +export { default as StoryPlayer } from './StoryPlayer'; +export { default as StoryTab } from './StoryTab'; diff --git a/german-app-frontend/src/index.css b/german-app-frontend/src/index.css index b5ceeed..03451da 100644 --- a/german-app-frontend/src/index.css +++ b/german-app-frontend/src/index.css @@ -1,9 +1,762 @@ +/* Global Styles */ +:root { + --primary-color: #4f46e5; + --primary-hover: #4338ca; + --secondary-color: #10b981; + --background-color: #f8fafc; + --card-background: #ffffff; + --text-color: #1e293b; + --text-muted: #64748b; + --border-color: #e2e8f0; + --error-color: #ef4444; + --success-color: #10b981; + --warning-color: #f59e0b; + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; +} + +/* Reset and Base Styles */ * { margin: 0; padding: 0; box-sizing: border-box; } -body { - font-family: Arial, sans-serif; +html, body { + height: 100%; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + font-size: 16px; + line-height: 1.5; + color: var(--text-color); + background-color: var(--background-color); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* App Container */ +.app-container { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +/* Header */ +.app-header { + background: linear-gradient(135deg, var(--primary-color), #7c3aed); + color: white; + padding: 1rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: var(--shadow-md); +} + +.app-header a { + color: white; + text-decoration: none; +} + +.app-title { + font-size: 1.5rem; + font-weight: 600; +} + +.app-nav { + display: flex; + gap: 1.5rem; +} + +.app-nav a { + padding: 0.5rem 1rem; + border-radius: var(--radius-md); + transition: background-color 0.2s; +} + +.app-nav a:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +/* Main Content */ +.app-main { + flex: 1; + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + width: 100%; +} + +/* Footer */ +.app-footer { + text-align: center; + padding: 1rem; + color: var(--text-muted); + font-size: 0.875rem; + border-top: 1px solid var(--border-color); +} + +/* Home Page */ +.home-page { + text-align: center; + padding: 4rem 2rem; +} + +.home-page h1 { + font-size: 2.5rem; + color: var(--primary-color); + margin-bottom: 1rem; +} + +.home-page p { + color: var(--text-muted); + margin-bottom: 2rem; +} + +.nav-links { + display: flex; + justify-content: center; + gap: 1rem; + flex-wrap: wrap; +} + +.nav-links a { + display: block; + padding: 0.75rem 1.5rem; + background-color: var(--primary-color); + color: white; + border-radius: var(--radius-md); + text-decoration: none; + font-weight: 500; + transition: all 0.2s; +} + +.nav-links a:hover { + background-color: var(--primary-hover); + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +/* Not Found Page */ +.not-found-page { + text-align: center; + padding: 4rem 2rem; +} + +.not-found-page h1 { + font-size: 4rem; + color: var(--error-color); + margin-bottom: 1rem; +} + +.not-found-page a { + display: inline-block; + margin-top: 2rem; + padding: 0.75rem 1.5rem; + background-color: var(--primary-color); + color: white; + border-radius: var(--radius-md); + text-decoration: none; +} + +/* Story Page */ +.story-page { + max-width: 1400px; + margin: 0 auto; +} + +.page-header { + margin-bottom: 2rem; +} + +.page-header h1 { + font-size: 2rem; + color: var(--primary-color); + margin-bottom: 0.5rem; +} + +.level-description { + color: var(--text-muted); +} + +.level-selector { + margin-bottom: 1rem; +} + +.level-selector select { + padding: 0.75rem 1rem; + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + font-size: 1rem; + background-color: white; + cursor: pointer; + width: 100%; + max-width: 400px; +} + +.level-selector select:focus { + outline: none; + border-color: var(--primary-color); +} + +/* Story Layout */ +.story-layout { + display: grid; + grid-template-columns: 280px 1fr; + gap: 2rem; + margin-top: 1rem; +} + +/* Sidebar */ +.story-sidebar { + background-color: var(--card-background); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow-md); + height: fit-content; +} + +/* Story Main */ +.story-main { + background-color: var(--card-background); + border-radius: var(--radius-lg); + padding: 2rem; + box-shadow: var(--shadow-md); +} + +.story-content-wrapper { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +/* Segment Navigation */ +.segment-navigation { + display: flex; + justify-content: space-between; + padding: 1rem 0; + border-bottom: 1px solid var(--border-color); + margin-bottom: 1rem; +} + +.nav-btn { + padding: 0.5rem 1rem; + background-color: var(--primary-color); + color: white; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s; +} + +.nav-btn:hover:not(:disabled) { + background-color: var(--primary-hover); +} + +.nav-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Story Card */ +.story-card { + background-color: white; + border-radius: var(--radius-lg); + padding: 2rem; + box-shadow: var(--shadow-sm); + border: 1px solid var(--border-color); +} + +/* Story Segment */ +.story-segment { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.segment-header { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.segment-title { + font-size: 1.5rem; + color: var(--primary-color); + font-weight: 600; +} + +.segment-theme { + background-color: var(--primary-color); + color: white; + padding: 0.25rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 500; +} + +.segment-order { + color: var(--text-muted); + font-size: 0.875rem; +} + +.segment-meta { + display: flex; + align-items: center; + gap: 1rem; + font-size: 0.875rem; + color: var(--text-muted); + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.completed-badge { + background-color: var(--success-color); + color: white; + padding: 0.25rem 0.5rem; + border-radius: var(--radius-sm); + font-size: 0.75rem; +} + +.segment-body { + line-height: 1.8; + font-size: 1.125rem; +} + +.story-content { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.story-paragraph { + margin: 0; +} + +.story-word { + color: var(--primary-color); + cursor: pointer; + transition: all 0.1s; + position: relative; + display: inline; +} + +.story-word:hover { + color: var(--primary-hover); + transform: scale(1.02); +} + +.punctuation { + display: inline; +} + +.whitespace { + display: inline; +} + +.locked-content { + color: var(--text-muted); + font-style: italic; + text-align: center; + padding: 2rem; +} + +.mark-complete-btn { + padding: 0.75rem 1.5rem; + background-color: var(--secondary-color); + color: white; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + font-size: 1rem; + font-weight: 500; + transition: all 0.2s; + align-self: flex-start; +} + +.mark-complete-btn:hover { + background-color: #059669; + transform: translateY(-2px); +} + +/* Audio Section */ +.audio-section { + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid var(--border-color); +} + +.audio-section h4 { + color: var(--primary-color); + margin-bottom: 1rem; +} + +/* Story Player */ +.story-player { + background-color: var(--card-background); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + border: 1px solid var(--border-color); +} + +.player-controls { + display: flex; + justify-content: center; + align-items: center; + gap: 1.5rem; + margin-bottom: 1rem; +} + +.player-btn { + background-color: var(--primary-color); + color: white; + border: none; + border-radius: 50%; + width: 48px; + height: 48px; + cursor: pointer; + font-size: 1.25rem; + transition: all 0.2s; + display: flex; + align-items: center; + justify-content: center; +} + +.player-btn:hover:not(:disabled) { + background-color: var(--primary-hover); + transform: scale(1.05); +} + +.player-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.play-pause-btn { + width: 64px; + height: 64px; + font-size: 1.5rem; +} + +.player-progress { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1rem; +} + +.player-time { + font-size: 0.75rem; + color: var(--text-muted); + min-width: 40px; +} + +.seek-slider, .volume-slider { + flex: 1; + -webkit-appearance: none; + height: 4px; + background-color: var(--border-color); + border-radius: 2px; + outline: none; +} + +.seek-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 16px; + height: 16px; + background-color: var(--primary-color); + border-radius: 50%; + cursor: pointer; +} + +.seek-slider::-moz-range-thumb { + width: 16px; + height: 16px; + background-color: var(--primary-color); + border-radius: 50%; + cursor: pointer; + border: none; +} + +.player-volume { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.volume-icon { + font-size: 1rem; +} + +.no-audio-notice, +.player-error { + text-align: center; + padding: 1rem; + color: var(--text-muted); +} + +/* Translation Tooltip */ +.translation-tooltip { + position: fixed; + z-index: 1000; + background-color: #2d3748; + color: white; + padding: 10px 15px; + border-radius: 8px; + font-size: 14px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3); + max-width: 300px; +} + +.translation-tooltip strong { + display: block; + font-size: 1.25rem; + margin-bottom: 8px; + color: #fbbf24; +} + +.translation-tooltip em { + color: #a0aec0; + font-size: 0.875rem; +} + +/* Story Tab */ +.story-tab { + background-color: var(--card-background); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + border: 1px solid var(--border-color); +} + +.tab-header { + margin-bottom: 1.5rem; +} + +.tab-header h3 { + color: var(--primary-color); + margin-bottom: 1rem; + font-size: 1.125rem; +} + +.progress-summary { + margin-bottom: 1rem; +} + +.progress-count { + display: block; + font-size: 0.875rem; + color: var(--text-muted); + margin-bottom: 0.5rem; +} + +.progress-bar { + height: 6px; + background-color: var(--border-color); + border-radius: 3px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--primary-color), var(--secondary-color)); + border-radius: 3px; + transition: width 0.3s ease; +} + +.segments-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; +} + +.segment-card { + padding: 1rem; + border: 2px solid var(--border-color); + border-radius: var(--radius-md); + background-color: white; + cursor: pointer; + transition: all 0.2s; + text-align: left; + display: flex; + flex-direction: column; +} + +.segment-card:hover:not(:disabled) { + border-color: var(--primary-color); + box-shadow: var(--shadow-sm); + transform: translateY(-2px); +} + +.segment-card:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.segment-card.current { + border-color: var(--primary-color); + background-color: rgba(79, 70, 229, 0.05); +} + +.segment-card.completed { + border-color: var(--success-color); + background-color: rgba(16, 185, 129, 0.1); +} + +.segment-card.locked { + opacity: 0.5; + cursor: not-allowed; +} + +.segment-card-content { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.segment-order { + background-color: var(--primary-color); + color: white; + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + flex-shrink: 0; +} + +.segment-title { + flex: 1; + font-size: 0.875rem; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.segment-status { + font-size: 1rem; +} + +/* Loading Indicators */ +.loading-indicator { + text-align: center; + padding: 4rem 2rem; + color: var(--text-muted); +} + +.loading-indicator::after { + content: ''; + animation: dots 1.5s steps(4, end) infinite; +} + +@keyframes dots { + 0%, 20% { content: '.'; } + 40% { content: '..'; } + 60%, 100% { content: '...'; } +} + +/* Error Messages */ +.error-message { + text-align: center; + padding: 2rem; + color: var(--error-color); +} + +.error-message button, +.no-story-notice button { + margin-top: 1rem; + padding: 0.5rem 1rem; + background-color: var(--primary-color); + color: white; + border: none; + border-radius: var(--radius-md); + cursor: pointer; +} + +/* No Story Notice */ +.no-story-notice { + text-align: center; + padding: 2rem; + color: var(--text-muted); +} + +/* Responsive Design */ +@media (max-width: 768px) { + .story-layout { + grid-template-columns: 1fr; + } + + .story-sidebar { + order: 2; + } + + .story-main { + order: 1; + } + + .segments-grid { + grid-template-columns: repeat(2, 1fr); + } + + .page-header h1 { + font-size: 1.5rem; + } + + .level-selector select { + max-width: 100%; + } +} + +@media (max-width: 480px) { + .app-header { + flex-direction: column; + gap: 1rem; + padding: 1rem; + } + + .app-nav { + flex-wrap: wrap; + justify-content: center; + gap: 1rem; + } + + .story-layout { + gap: 1rem; + } + + .story-main { + padding: 1rem; + } + + .story-card { + padding: 1rem; + } + + .segments-grid { + grid-template-columns: 1fr; + } + + .player-controls { + gap: 1rem; + } + + .player-btn.play-pause-btn { + width: 56px; + height: 56px; + font-size: 1.25rem; + } } diff --git a/german-app-frontend/src/lib/api/story.ts b/german-app-frontend/src/lib/api/story.ts new file mode 100644 index 0000000..ebf3338 --- /dev/null +++ b/german-app-frontend/src/lib/api/story.ts @@ -0,0 +1,276 @@ +import { + StorySegmentDto, + CreateStorySegmentDto, + UpdateStorySegmentDto, + StoryGenerationRequestDto, + StoryGenerationResponseDto, + StoryProgressDto, + LevelWithStoriesDto, + LessonWithStoryStatusDto, + StoryAudioResponse, +} from '../../types/api/story'; + +// Base API URL - should match your backend configuration +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000/api'; + +/** + * Fetches with automatic token handling for authenticated requests + */ +async function authenticatedFetch( + input: RequestInfo | URL, + init?: RequestInit +): Promise { + const token = localStorage.getItem('accessToken'); + + const headers = new Headers(init?.headers); + if (token) { + headers.append('Authorization', `Bearer ${token}`); + } + + const response = await fetch(input, { + ...init, + headers, + credentials: 'include', // Include cookies for refresh token + }); + + // Handle 401 Unauthorized by attempting token refresh + if (response.status === 401) { + // Try to refresh token + const refreshToken = localStorage.getItem('refreshToken'); + if (refreshToken) { + const refreshResponse = await fetch(`${API_BASE_URL}/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ refreshToken }), + credentials: 'include', + }); + + if (refreshResponse.ok) { + const { accessToken, refreshToken: newRefreshToken } = await refreshResponse.json(); + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', newRefreshToken); + + // Retry the original request with the new token + headers.set('Authorization', `Bearer ${accessToken}`); + return fetch(input, { ...init, headers, credentials: 'include' }); + } + } + + // If refresh fails, redirect to login + window.location.href = '/login'; + throw new Error('Unauthorized'); + } + + return response; +} + +/** + * Story API Client + * Provides methods to interact with the Story API endpoints + */ +export const storyApi = { + // ============ Levels ============ + + /** + * Gets all levels that have stories + */ + async getLevelsWithStories(): Promise { + const response = await authenticatedFetch(`${API_BASE_URL}/story/levels`); + if (!response.ok) { + throw new Error(`Failed to fetch levels with stories: ${response.status}`); + } + return response.json(); + }, + + // ============ Story Segments ============ + + /** + * Gets all story segments for a specific level + */ + async getSegmentsByLevel( + levelId: number, + includeInactive: boolean = false + ): Promise { + const response = await authenticatedFetch( + `${API_BASE_URL}/story/levels/${levelId}/segments?includeInactive=${includeInactive}` + ); + if (!response.ok) { + throw new Error(`Failed to fetch segments for level ${levelId}: ${response.status}`); + } + return response.json(); + }, + + /** + * Gets a specific story segment by ID + */ + async getSegmentById(segmentId: number): Promise { + const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}`); + if (!response.ok) { + throw new Error(`Failed to fetch segment ${segmentId}: ${response.status}`); + } + return response.json(); + }, + + /** + * Creates a new story segment (Admin only) + */ + async createSegment(dto: CreateStorySegmentDto): Promise { + const response = await authenticatedFetch(`${API_BASE_URL}/story/segments`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(dto), + }); + if (!response.ok) { + throw new Error(`Failed to create segment: ${response.status}`); + } + return response.json(); + }, + + /** + * Updates an existing story segment (Admin only) + */ + async updateSegment( + segmentId: number, + dto: UpdateStorySegmentDto + ): Promise { + const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(dto), + }); + if (!response.ok) { + throw new Error(`Failed to update segment ${segmentId}: ${response.status}`); + } + return response.json(); + }, + + /** + * Deletes a story segment (Admin only) + */ + async deleteSegment(segmentId: number): Promise { + const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}`, { + method: 'DELETE', + }); + if (!response.ok) { + throw new Error(`Failed to delete segment ${segmentId}: ${response.status}`); + } + }, + + // ============ Story Generation ============ + + /** + * Generates a story for a level using AI (Admin only) + */ + async generateStory( + levelId: number, + request: StoryGenerationRequestDto + ): Promise { + const response = await authenticatedFetch(`${API_BASE_URL}/story/levels/${levelId}/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + if (!response.ok) { + throw new Error(`Failed to generate story for level ${levelId}: ${response.status}`); + } + return response.json(); + }, + + /** + * Generates audio for a specific story segment (Admin only) + */ + async generateSegmentAudio(segmentId: number): Promise { + const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}/audio`, { + method: 'POST', + }); + if (!response.ok) { + throw new Error(`Failed to generate audio for segment ${segmentId}: ${response.status}`); + } + return response.json(); + }, + + // ============ User Progress ============ + + /** + * Gets the user's progress through a story for a level + */ + async getUserProgress(levelId: number): Promise { + const response = await authenticatedFetch(`${API_BASE_URL}/story/levels/${levelId}/progress`); + if (!response.ok) { + throw new Error(`Failed to fetch progress for level ${levelId}: ${response.status}`); + } + return response.json(); + }, + + /** + * Marks a story segment as completed + */ + async markSegmentAsCompleted(segmentId: number): Promise { + const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}/complete`, { + method: 'POST', + }); + if (!response.ok) { + throw new Error(`Failed to mark segment ${segmentId} as completed: ${response.status}`); + } + }, + + /** + * Gets the next story segment for a user to read + */ + async getNextSegment(levelId: number): Promise { + const response = await authenticatedFetch(`${API_BASE_URL}/story/levels/${levelId}/next`); + if (!response.ok) { + throw new Error(`Failed to fetch next segment for level ${levelId}: ${response.status}`); + } + return response.json(); + }, + + /** + * Checks if a specific segment is unlocked for the current user + */ + async isSegmentUnlocked(segmentId: number): Promise { + const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}/unlocked`); + if (!response.ok) { + throw new Error(`Failed to check if segment ${segmentId} is unlocked: ${response.status}`); + } + return response.json(); + }, + + // ============ Audio ============ + + /** + * Gets the audio URL for a story segment + */ + async getSegmentAudio(segmentId: number): Promise { + const response = await authenticatedFetch(`${API_BASE_URL}/story/segments/${segmentId}/audio`); + if (!response.ok) { + throw new Error(`Failed to fetch audio for segment ${segmentId}: ${response.status}`); + } + return response.json(); + }, + + // ============ Lessons with Story Status ============ + + /** + * Gets all lessons in a level with their associated story segments + */ + async getLessonsWithStoryStatus(levelId: number): Promise { + const response = await authenticatedFetch( + `${API_BASE_URL}/story/levels/${levelId}/lessons-with-stories` + ); + if (!response.ok) { + throw new Error(`Failed to fetch lessons with story status for level ${levelId}: ${response.status}`); + } + return response.json(); + }, +}; + +export default storyApi; diff --git a/german-app-frontend/src/pages/StoryPage.tsx b/german-app-frontend/src/pages/StoryPage.tsx new file mode 100644 index 0000000..23a4f62 --- /dev/null +++ b/german-app-frontend/src/pages/StoryPage.tsx @@ -0,0 +1,242 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { StorySegmentDto, LevelWithStoriesDto } from '../types/api/story'; +import { storyApi } from '../lib/api/story'; +import StorySegment from '../components/features/story/StorySegment'; +import StoryPlayer from '../components/features/story/StoryPlayer'; +import StoryTab from '../components/features/story/StoryTab'; + +/** + * StoryPage - Main page for viewing and interacting with story segments. + * Displays story segments for a level, allows navigation, and tracks progress. + */ +export function StoryPage() { + const { levelId: levelIdParam } = useParams(); + const navigate = useNavigate(); + + const levelId = parseInt(levelIdParam || '1', 10); + + const [levels, setLevels] = useState([]); + const [currentLevel, setCurrentLevel] = useState(null); + const [segments, setSegments] = useState([]); + const [currentSegment, setCurrentSegment] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Fetch data on mount + useEffect(() => { + const fetchData = async () => { + try { + setLoading(true); + setError(null); + + // Fetch levels with stories + const levelsData = await storyApi.getLevelsWithStories(); + setLevels(levelsData); + + // Find current level + const currentLevelData = levelsData.find(l => l.id === levelId) || levelsData[0]; + setCurrentLevel(currentLevelData); + + // Fetch segments for the level + const segmentsData = await storyApi.getSegmentsByLevel(currentLevelData.id, false); + setSegments(segmentsData); + + // Set first segment as current + if (segmentsData.length > 0) { + // Try to find the first unlocked segment, or use the first one + setCurrentSegment(segmentsData[0]); + } + } catch (err) { + setError('Failed to load story data'); + console.error('Error fetching story data:', err); + } finally { + setLoading(false); + } + }; + + fetchData(); + }, [levelId]); + + // Fetch next segment when level or segments change + const fetchNextSegment = useCallback(async () => { + if (!currentLevel) return; + + try { + const nextSegment = await storyApi.getNextSegment(currentLevel.id); + if (nextSegment) { + setCurrentSegment(nextSegment); + } + } catch (err) { + console.error('Error fetching next segment:', err); + } + }, [currentLevel]); + + const handleSegmentSelect = useCallback((segmentId: number) => { + const segment = segments.find(s => s.id === segmentId); + if (segment) { + setCurrentSegment(segment); + } + }, [segments]); + + const handleMarkAsCompleted = useCallback(async () => { + if (!currentSegment) return; + + try { + await storyApi.markSegmentAsCompleted(currentSegment.id); + // Refresh to get updated progress + const updatedSegments = await storyApi.getSegmentsByLevel(levelId, false); + setSegments(updatedSegments); + + // Move to next segment + await fetchNextSegment(); + } catch (err) { + console.error('Error marking segment as completed:', err); + setError('Failed to mark segment as completed'); + } + }, [currentSegment, levelId, fetchNextSegment]); + + const handleLevelChange = useCallback((newLevelId: number) => { + navigate(`/story/${newLevelId}`); + }, [navigate]); + + const handlePreviousSegment = useCallback(() => { + if (!currentSegment || !segments.length) return; + + const currentIndex = segments.findIndex(s => s.id === currentSegment.id); + if (currentIndex > 0) { + setCurrentSegment(segments[currentIndex - 1]); + } + }, [currentSegment, segments]); + + const handleNextSegment = useCallback(() => { + if (!currentSegment || !segments.length) return; + + const currentIndex = segments.findIndex(s => s.id === currentSegment.id); + if (currentIndex < segments.length - 1) { + setCurrentSegment(segments[currentIndex + 1]); + } + }, [currentSegment, segments]); + + if (loading) { + return ( +
+
+

Loading story...

+
+
+ ); + } + + if (error) { + return ( +
+
+

⚠️ {error}

+ +
+
+ ); + } + + if (!currentLevel) { + return ( +
+
+

No story available for this level

+
+
+ ); + } + + return ( +
+
+
+ +
+ +

{currentLevel.name} Story

+

+ Follow along with this continuous story as you progress through your German lessons. +

+
+ +
+ {/* Left sidebar - Navigation */} +
+ +
+ + {/* Main content - Story segment */} +
+ {currentSegment ? ( +
+
+ + + +
+ +
+ + + {currentSegment.audioUrl && ( +
+

Listen to the Story

+ +
+ )} +
+
+ ) : ( +
+

No segments available for this level

+
+ )} +
+
+
+ ); +} + +export default StoryPage; diff --git a/german-app-frontend/src/types/api/story.ts b/german-app-frontend/src/types/api/story.ts new file mode 100644 index 0000000..2d4f544 --- /dev/null +++ b/german-app-frontend/src/types/api/story.ts @@ -0,0 +1,102 @@ +// Story Segment DTO types +// Mirror of GermanApp.Application.DTOs.StorySegmentDto + +export interface StorySegmentDto { + id: number; + levelId: number; + lessonId: number | null; + content: string; + audioUrl: string | null; + order: number; + title: string; + theme: string; + estimatedReadingMinutes: number; + isActive: boolean; + createdAt: string; // ISO date string + updatedAt: string | null; // ISO date string +} + +export interface CreateStorySegmentDto { + levelId: number; + lessonId: number | null; + content: string; + order: number; + title: string; + theme: string; + estimatedReadingMinutes?: number; +} + +export interface UpdateStorySegmentDto { + content?: string | null; + order?: number | null; + title?: string | null; + theme?: string | null; + estimatedReadingMinutes?: number | null; + lessonId?: number | null; + isActive?: boolean | null; +} + +export interface StoryGenerationRequestDto { + levelId: number; + theme: string; + segmentCount: number; + customPrompt?: string | null; +} + +export interface StoryGenerationResponseDto { + levelId: number; + theme: string; + segmentCount: number; + fullStoryText: string; + segments: StorySegmentDto[]; +} + +// Story Progress DTO types +export interface StorySegmentProgressDto { + segmentId: number; + order: number; + title: string; + isUnlocked: boolean; + isCompleted: boolean; +} + +export interface StoryProgressDto { + levelId: number; + levelName: string; + totalSegments: number; + unlockedSegments: number; + currentSegmentOrder: number; + segments: StorySegmentProgressDto[]; +} + +// Level and Lesson DTO types for story integration +export interface LevelWithStoriesDto { + id: number; + name: string; + code: string; + order: number; + hasStories: boolean; + storySegmentCount: number; +} + +export interface LessonWithStoryStatusDto { + id: number; + title: string; + order: number; + topic: string; + hasStorySegment: boolean; + storySegmentCount: number; +} + +// API Response types +export interface StoryAudioResponse { + audioUrl: string; +} + +// Vocabulary for word translation +export interface WordTranslation { + word: string; + translation: string; + partOfSpeech: string; + gender?: string; // for German nouns (der/die/das) +}