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
+
+
+
+
+
+
+ } />
+ } />
+ } />
+
+
+
+
+
+
+ );
+}
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 (
+
+ );
+ }
+
+ 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)
+}