- 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>
390 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|