DeutschLernen/german-app-frontend/src/pages/AdminStoryGeneratorPage.tsx
Lasse Rune Hansen 70af326ca7 feat(frontend/admin): Complete Admin Module implementation
- Add AdminLayout component for consistent admin UI
- Create AdminDashboardPage with statistics overview
- Create AdminUsersPage with user management (CRUD, role changes)
- Create AdminReportsPage with user progress reports and CSV export
- Create AdminStoryGeneratorPage for AI-powered story generation
- Create AdminUserDetailPage for detailed user progress tracking
- Add comprehensive admin API client with all endpoints
- Add TypeScript types for admin DTOs (users, reports, stories)
- Add extensive CSS styles for all admin pages
- Update App.tsx with proper admin sub-routes
- Update admin-module.md feature documentation with completion status

Admin features:
- Dashboard with stats (users, lessons, quizzes, stories, activity)
- User management (list, view details, change roles, delete)
- Progress reports (view all, detailed user reports, CSV export)
- Story generator (AI story creation for levels with audio)
- Role-based authentication (AdminRoute protects all admin pages)
- Responsive design for all admin pages

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
2026-06-14 15:29:20 +02:00

390 lines
13 KiB
TypeScript

/**
* Admin Story Generator page
* Allows admin to generate stories for levels using AI
*/
import { useEffect, useState, useCallback } from 'react';
import { useAuth } from '@/stores/authStore';
import { AdminLayout } from '@/components/features/admin/AdminLayout';
import {
getLevelsWithStories,
generateStory,
getStorySegmentsByLevel,
deleteStorySegment,
generateSegmentAudio,
} from '@/lib/api/admin';
import type { LevelWithStories, StorySegment, StoryGenerationRequest, StoryGenerationResponse } from '@/types/api/admin';
export function AdminStoryGeneratorPage() {
const { token } = useAuth();
const [levels, setLevels] = useState<LevelWithStories[]>([]);
const [selectedLevel, setSelectedLevel] = useState<number | null>(null);
const [theme, setTheme] = useState('');
const [segmentCount, setSegmentCount] = useState(5);
const [customPrompt, setCustomPrompt] = useState('');
const [segments, setSegments] = useState<StorySegment[]>([]);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [generationProgress, setGenerationProgress] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<number | null>(null);
const fetchLevels = useCallback(async () => {
if (!token) return;
try {
setLoading(true);
setError(null);
const data = await getLevelsWithStories(token);
setLevels(data);
if (data.length > 0) {
setSelectedLevel(data[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load levels');
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
fetchLevels();
}, [fetchLevels]);
const fetchSegments = useCallback(async (levelId: number) => {
if (!token) return;
try {
setLoading(true);
setError(null);
const data = await getStorySegmentsByLevel(token, levelId);
setSegments(data);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load story segments');
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
if (selectedLevel) {
fetchSegments(selectedLevel);
}
}, [selectedLevel, fetchSegments]);
const handleGenerateStory = async () => {
if (!token || !selectedLevel) return;
if (!theme.trim()) {
setError('Please enter a theme');
return;
}
if (segmentCount < 1 || segmentCount > 20) {
setError('Segment count must be between 1 and 20');
return;
}
try {
setGenerating(true);
setError(null);
setSuccess(null);
setGenerationProgress('Starting story generation...');
const request: StoryGenerationRequest = {
levelId: selectedLevel,
theme,
segmentCount,
customPrompt: customPrompt.trim() || undefined,
};
setGenerationProgress('Calling AI service to generate story...');
const response: StoryGenerationResponse = await generateStory(token, selectedLevel, request);
setGenerationProgress(`Story generated! ${response.segments.length} segments created.`);
setSuccess(`Successfully generated story with ${response.segments.length} segments for ${response.theme}`);
// Refresh segments
await fetchSegments(selectedLevel);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate story');
} finally {
setGenerating(false);
setGenerationProgress('');
}
};
const handleGenerateAudio = async (segmentId: number) => {
if (!token) return;
try {
setError(null);
setSuccess(null);
setSegments(segments.map(s =>
s.id === segmentId ? { ...s, audioUrl: 'Generating...' } : s
));
const updatedSegment = await generateSegmentAudio(token, segmentId);
setSegments(segments.map(s => s.id === segmentId ? updatedSegment : s));
setSuccess(`Audio generated for segment ${segmentId}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to generate audio');
}
};
const handleDeleteSegment = async (segmentId: number) => {
if (!token) return;
try {
setError(null);
await deleteStorySegment(token, segmentId);
setSegments(segments.filter(s => s.id !== segmentId));
setConfirmDelete(null);
setSuccess(`Segment ${segmentId} deleted successfully`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete segment');
}
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
};
if (loading && levels.length === 0) {
return (
<AdminLayout>
<div className="loading-overlay">
<div className="spinner"></div>
<p>Loading levels...</p>
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<div className="admin-story-generator">
<header className="story-generator-header">
<h1>Story Generator</h1>
<p className="story-generator-subtitle">
Generate AI-powered stories for German language learning
</p>
</header>
{error && (
<div className="error-message admin-error">{error}</div>
)}
{success && (
<div className="success-message admin-success">{success}</div>
)}
{/* Story Generation Form */}
<section className="story-generator-form">
<h2>Generate New Story</h2>
<div className="form-group">
<label htmlFor="level-select">Level:</label>
<select
id="level-select"
value={selectedLevel || ''}
onChange={(e) => setSelectedLevel(Number(e.target.value))}
disabled={loading || generating}
className="form-select"
>
{levels.map((level) => (
<option key={level.id} value={level.id}>
{level.code} - {level.name} {level.hasStories && `(Has Stories)`}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="theme-input">Theme:</label>
<input
id="theme-input"
type="text"
value={theme}
onChange={(e) => setTheme(e.target.value)}
placeholder="e.g., A Week in Berlin, Summer Vacation, Starting a New Job"
disabled={generating}
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="segment-count">Number of Segments:</label>
<input
id="segment-count"
type="number"
value={segmentCount}
onChange={(e) => setSegmentCount(Math.max(1, Math.min(20, Number(e.target.value))))}
min="1"
max="20"
disabled={generating}
className="form-input"
/>
</div>
<div className="form-group">
<label htmlFor="custom-prompt">
Custom Prompt (Optional):
<span className="hint">
Custom instructions for the AI (e.g., "Use only A1 vocabulary")
</span>
</label>
<textarea
id="custom-prompt"
value={customPrompt}
onChange={(e) => setCustomPrompt(e.target.value)}
placeholder="Enter custom AI prompt..."
disabled={generating}
className="form-textarea"
rows={3}
/>
</div>
<button
onClick={handleGenerateStory}
className="btn btn-primary btn-large"
disabled={generating || !theme.trim()}
>
{generating ? 'Generating...' : 'Generate Story'}
</button>
{generating && generationProgress && (
<div className="generation-progress">
<p>{generationProgress}</p>
</div>
)}
</section>
{/* Existing Segments */}
<section className="story-segments-list">
<h2>Existing Story Segments</h2>
<p className="segments-subtitle">
For level: {levels.find(l => l.id === selectedLevel)?.name || 'Select a level'}
</p>
{segments.length === 0 ? (
<div className="no-segments">
<p>No story segments found for this level. Generate a story to create segments.</p>
</div>
) : (
<div className="segments-table-container">
<table className="segments-table">
<thead>
<tr>
<th>Order</th>
<th>Title</th>
<th>Theme</th>
<th>Content Preview</th>
<th>Audio</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{segments.map((segment) => (
<tr key={segment.id} className="segment-row">
<td>{segment.order}</td>
<td className="segment-title">{segment.title}</td>
<td>{segment.theme}</td>
<td className="segment-content-preview">
{segment.content.substring(0, 100)}{segment.content.length > 100 ? '...' : ''}
</td>
<td>
{segment.audioUrl ? (
<audio controls className="audio-player-small">
<source src={segment.audioUrl} type="audio/mpeg" />
Your browser does not support the audio element.
</audio>
) : (
<button
onClick={() => handleGenerateAudio(segment.id)}
className="btn btn-small btn-secondary"
title="Generate Audio"
>
Generate Audio
</button>
)}
</td>
<td>{formatDate(segment.createdAt)}</td>
<td className="segment-actions">
<button
onClick={() => setConfirmDelete(segment.id)}
className="btn btn-danger btn-small"
title="Delete Segment"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{segments.length > 0 && (
<div className="segments-summary">
<p>
Showing {segments.length} segment{segments.length !== 1 ? 's' : ''}
for this level
</p>
</div>
)}
</section>
{/* Delete Confirmation Modal */}
{confirmDelete && (
<div className="modal-overlay" onClick={() => setConfirmDelete(null)}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h2>Confirm Delete</h2>
<p>
Are you sure you want to delete this story segment? This action cannot be undone.
</p>
<p className="warning-text">
All associated data will be permanently removed.
</p>
<div className="modal-actions">
<button
onClick={() => setConfirmDelete(null)}
className="btn btn-secondary"
>
Cancel
</button>
<button
onClick={() => handleDeleteSegment(confirmDelete)}
className="btn btn-danger"
>
Delete
</button>
</div>
</div>
</div>
)}
{/* Tips Section */}
<section className="story-generator-tips">
<h2>Tips for Story Generation</h2>
<ul>
<li><strong>Theme:</strong> Be specific. "A Week in Berlin" works better than "Travel".</li>
<li><strong>Segments:</strong> Each segment should be 1-2 paragraphs (150-300 words).</li>
<li><strong>Custom Prompt:</strong> Use this to specify CEFR level, vocabulary constraints, or style.</li>
<li><strong>Audio:</strong> Generated audio uses Coqui TTS with a German voice.</li>
<li><strong>Preview:</strong> Generated stories will use vocabulary from the level's lessons.</li>
</ul>
</section>
</div>
</AdminLayout>
);
}