| import { useEffect, useState } from 'react'; |
| import { useTranslation } from 'react-i18next'; |
| import { useNavigate } from 'react-router-dom'; |
| import { BookOpen, Plus, Edit2, Trash2, ChevronRight, Building2, Sparkles, Loader2, X } from 'lucide-react'; |
| import { useAuth } from '../lib/auth'; |
| import { useTenant } from '../lib/tenant'; |
| import { api } from '../lib/api'; |
| import { useToast } from '../hooks/useToast'; |
| import { logError } from '../lib/logger'; |
|
|
| export default function TrackListPage() { |
| const { t } = useTranslation(); |
| const toast = useToast(); |
| const { token } = useAuth(); |
| const { selectedOrgId } = useTenant(); |
| const navigate = useNavigate(); |
| const [tracks, setTracks] = useState<any[]>([]); |
| const [loading, setLoading] = useState(true); |
|
|
| |
| const [aiModalOpen, setAiModalOpen] = useState(false); |
| const [generating, setGenerating] = useState(false); |
| const [aiForm, setAiForm] = useState({ description: '', numDays: 5, language: 'FR', targetAudience: '' }); |
|
|
| const load = async () => { |
| if (!token || !selectedOrgId) return; |
| try { |
| const data = await api.get('/v1/admin/tracks', token, selectedOrgId); |
| setTracks(data); |
| } catch (err) { |
| logError(err); |
| } finally { |
| setLoading(false); |
| } |
| }; |
|
|
| useEffect(() => { |
| if (selectedOrgId && token) load(); |
| }, [selectedOrgId, token]); |
|
|
| const del = async (id: string) => { |
| if (!confirm(t('tracks.confirm_delete'))) return; |
| try { |
| await api.delete(`/v1/admin/tracks/${id}`, token, selectedOrgId); |
| load(); |
| } catch (err) { |
| logError(err); |
| } |
| }; |
|
|
| const handleAiGenerate = async (e: React.FormEvent) => { |
| e.preventDefault(); |
| if (!token || !selectedOrgId || !aiForm.description.trim()) return; |
| setGenerating(true); |
| try { |
| const res = await api.post( |
| `/v1/organizations/${selectedOrgId}/content/ai-generate`, |
| { ...aiForm, numDays: Number(aiForm.numDays) }, |
| token, |
| selectedOrgId |
| ); |
| setAiModalOpen(false); |
| toast.success(`${res.track.title} — ${res.track.days.length} ${t('tracks.days')}`); |
| load(); |
| navigate(`/content/${res.track.id}/days`); |
| } catch (err: any) { |
| toast.error(err?.message ?? t('tracks.ai_error')); |
| } finally { |
| setGenerating(false); |
| } |
| }; |
|
|
| if (!selectedOrgId) { |
| return ( |
| <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400"> |
| <Building2 className="w-12 h-12 mb-4 opacity-20" /> |
| <p className="max-w-xs text-center mt-2">{t('common.select_org')}</p> |
| </div> |
| ); |
| } |
|
|
| if (loading) { |
| return ( |
| <div className="flex flex-col items-center justify-center min-h-[60vh] text-slate-400"> |
| <div className="w-8 h-8 border-4 border-slate-200 border-t-slate-900 rounded-full animate-spin mb-4"></div> |
| <p>{t('common.loading')}</p> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div className="p-8"> |
| <div className="flex justify-between items-center mb-6"> |
| <h1 className="text-3xl font-bold text-slate-800">{t('tracks.title')}</h1> |
| <div className="flex items-center gap-3"> |
| <button |
| onClick={() => setAiModalOpen(true)} |
| className="flex items-center gap-2 bg-indigo-600 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-indigo-700 shadow-lg shadow-indigo-100" |
| > |
| <Sparkles className="w-4 h-4" /> {t('tracks.ai_generate_btn')} |
| </button> |
| <button onClick={() => navigate('/content/new')} className="flex items-center gap-2 bg-slate-900 text-white px-4 py-2 rounded-xl text-sm font-bold hover:bg-slate-700"> |
| <Plus className="w-4 h-4" /> {t('tracks.new')} |
| </button> |
| </div> |
| </div> |
| |
| <div className="grid gap-4"> |
| {tracks.map((track: any) => ( |
| <div key={track.id} className="bg-white rounded-xl border border-slate-100 p-5 flex items-center justify-between shadow-sm hover:shadow-md transition"> |
| <div className="flex items-center gap-4"> |
| <div className="bg-purple-100 p-3 rounded-xl"><BookOpen className="w-5 h-5 text-purple-600" /></div> |
| <div> |
| <div className="flex items-center gap-2"> |
| <h3 className="font-bold text-slate-800">{track.title}</h3> |
| {track.isPremium && <span className="bg-amber-100 text-amber-700 text-xs px-2 py-0.5 rounded-full font-medium">Premium</span>} |
| <span className="bg-slate-100 text-slate-600 text-xs px-2 py-0.5 rounded-full">{track.language}</span> |
| </div> |
| <p className="text-sm text-slate-500 mt-0.5">{track._count?.days || 0} {t('tracks.days')} · {track._count?.enrollments || 0} {t('tracks.enrolled')} · {track.duration}j</p> |
| </div> |
| </div> |
| <div className="flex items-center gap-2"> |
| <button onClick={() => navigate(`/content/${track.id}`)} className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg"><Edit2 className="w-4 h-4" /></button> |
| <button onClick={() => navigate(`/content/${track.id}/days`)} className="flex items-center gap-1 text-sm text-slate-600 hover:text-slate-900 px-3 py-2 rounded-lg hover:bg-slate-50">{t('tracks.days_label')} <ChevronRight className="w-4 h-4" /></button> |
| <button onClick={() => del(track.id)} className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg"><Trash2 className="w-4 h-4" /></button> |
| </div> |
| </div> |
| ))} |
| {!tracks.length && ( |
| <div className="text-center py-16 text-slate-400"> |
| <BookOpen className="w-12 h-12 mx-auto mb-3 opacity-30" /> |
| <p>{t('tracks.no_tracks')}</p> |
| <button |
| onClick={() => setAiModalOpen(true)} |
| className="mt-4 inline-flex items-center gap-2 text-indigo-600 hover:text-indigo-700 font-semibold text-sm" |
| > |
| <Sparkles className="w-4 h-4" /> {t('tracks.ai_generate_first')} |
| </button> |
| </div> |
| )} |
| </div> |
| |
| {/* AI Curriculum Generator Modal */} |
| {aiModalOpen && ( |
| <div className="fixed inset-0 z-50 flex items-center justify-center p-4"> |
| <div className="absolute inset-0 bg-slate-900/40 backdrop-blur-sm" onClick={() => !generating && setAiModalOpen(false)} /> |
| <div className="bg-white rounded-3xl w-full max-w-lg shadow-2xl relative z-10 overflow-hidden"> |
| <div className="bg-gradient-to-br from-indigo-600 to-purple-600 px-8 pt-8 pb-6"> |
| <div className="flex justify-between items-start"> |
| <div> |
| <div className="flex items-center gap-2 text-white/80 text-sm font-medium mb-1"> |
| <Sparkles className="w-4 h-4" /> {t('tracks.ai_modal_badge')} |
| </div> |
| <h2 className="text-2xl font-bold text-white">{t('tracks.ai_modal_title')}</h2> |
| <p className="text-indigo-200 text-sm mt-1">{t('tracks.ai_modal_subtitle')}</p> |
| </div> |
| {!generating && ( |
| <button onClick={() => setAiModalOpen(false)} className="text-white/60 hover:text-white"> |
| <X className="w-5 h-5" /> |
| </button> |
| )} |
| </div> |
| </div> |
| |
| <form onSubmit={handleAiGenerate} className="px-8 py-6 space-y-5"> |
| <div> |
| <label className="block text-sm font-bold text-slate-700 mb-2">{t('tracks.ai_description_label')} *</label> |
| <textarea |
| required |
| rows={3} |
| value={aiForm.description} |
| onChange={e => setAiForm(f => ({ ...f, description: e.target.value }))} |
| disabled={generating} |
| placeholder={t('tracks.ai_description_placeholder')} |
| className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all text-sm resize-none" |
| /> |
| </div> |
| |
| <div className="grid grid-cols-2 gap-4"> |
| <div> |
| <label className="block text-sm font-bold text-slate-700 mb-2">{t('tracks.ai_num_days')}</label> |
| <input |
| type="number" |
| min={1} |
| max={30} |
| value={aiForm.numDays} |
| onChange={e => setAiForm(f => ({ ...f, numDays: parseInt(e.target.value) || 5 }))} |
| disabled={generating} |
| className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all text-sm" |
| /> |
| </div> |
| <div> |
| <label className="block text-sm font-bold text-slate-700 mb-2">{t('tracks.ai_language')}</label> |
| <select |
| value={aiForm.language} |
| onChange={e => setAiForm(f => ({ ...f, language: e.target.value }))} |
| disabled={generating} |
| className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all text-sm" |
| > |
| <option value="FR">{t('tracks.ai_lang_fr')}</option> |
| <option value="EN">{t('tracks.ai_lang_en')}</option> |
| <option value="WOL">{t('tracks.ai_lang_wol')}</option> |
| </select> |
| </div> |
| </div> |
| |
| <div> |
| <label className="block text-sm font-bold text-slate-700 mb-2">{t('tracks.ai_audience')} <span className="text-slate-400 font-normal">({t('tracks.ai_audience_optional')})</span></label> |
| <input |
| type="text" |
| value={aiForm.targetAudience} |
| onChange={e => setAiForm(f => ({ ...f, targetAudience: e.target.value }))} |
| disabled={generating} |
| placeholder={t('tracks.ai_audience_placeholder')} |
| className="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-2xl outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all text-sm" |
| /> |
| </div> |
| |
| <button |
| type="submit" |
| disabled={generating || !aiForm.description.trim()} |
| className="w-full py-4 bg-indigo-600 text-white rounded-2xl font-bold hover:bg-indigo-700 disabled:opacity-50 transition-all shadow-lg shadow-indigo-100 flex items-center justify-center gap-2" |
| > |
| {generating ? ( |
| <> |
| <Loader2 className="w-4 h-4 animate-spin" /> |
| {t('tracks.ai_generating')} |
| </> |
| ) : ( |
| <> |
| <Sparkles className="w-4 h-4" /> |
| {t('tracks.ai_generate_submit')} |
| </> |
| )} |
| </button> |
| </form> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|