edtech / apps /admin /src /pages /TrackListPage.tsx
CognxSafeTrack
feat(i18n): complete admin app internationalization across all pages
d80fec4
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);
// AI curriculum generator state
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>
);
}