feat(historique): refonte pixel-perfect avec stats + filtres + tendance 30j (Sprint 4.7)
Inclut le retrait du padding de AppLayout et le wrapper standardisé (mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9) ajouté sur 11 pages (Dashboard, Progression, 9 pages Simulation EE/EO/T1) pour laisser chaque page gérer son max-width. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d8bae9520c
commit
3ce91aaa7b
20 changed files with 1417 additions and 874 deletions
|
|
@ -91,81 +91,85 @@ export function EnregistrementEOPage() {
|
|||
const lockControls = submitting || isCorrecting
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">{formatTache(production.tache)}</h2>
|
||||
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
|
||||
<Timer className="size-3.5" aria-hidden="true" />
|
||||
Durée recommandée : {formatTimer(dureeRecommandee)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">
|
||||
{formatTache(production.tache)}
|
||||
</h2>
|
||||
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
|
||||
<Timer className="size-3.5" aria-hidden="true" />
|
||||
Durée recommandée : {formatTimer(dureeRecommandee)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* T1 affiche la présentation générée comme texte de référence à lire.
|
||||
{/* T1 affiche la présentation générée comme texte de référence à lire.
|
||||
T3 affiche le sujet sélectionné. Les deux sont mutuellement exclusifs. */}
|
||||
{production.tache === 'EO_T1' && presentationT1 && (
|
||||
<section
|
||||
className="mb-6 rounded-lg border border-border bg-surface-solid p-4"
|
||||
aria-label="Texte de présentation de référence"
|
||||
>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
|
||||
Ta présentation (référence)
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-ink-secondary">
|
||||
{presentationT1}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{production.tache !== 'EO_T1' && sujet && (
|
||||
<div className="mb-6">
|
||||
<SujetDisplay sujet={sujet} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AudioRecorder
|
||||
minSeconds={minSeconds}
|
||||
maxSeconds={dureeRecommandee || undefined}
|
||||
downloadFilename={`expria-${production.tache.toLowerCase()}-${production.id.slice(0, 8)}`}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
autoStart
|
||||
disabled={lockControls}
|
||||
/>
|
||||
|
||||
{lockControls && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="mt-4 flex items-start gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text"
|
||||
>
|
||||
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-medium">Transcription et correction en cours…</p>
|
||||
<p className="mt-0.5 text-xs">
|
||||
Cela peut prendre jusqu'à 60 secondes. Tu seras redirigé vers le rapport
|
||||
automatiquement.
|
||||
{production.tache === 'EO_T1' && presentationT1 && (
|
||||
<section
|
||||
className="mb-6 rounded-lg border border-border bg-surface-solid p-4"
|
||||
aria-label="Texte de présentation de référence"
|
||||
>
|
||||
<p className="mb-2 text-[11px] font-semibold uppercase tracking-widest text-ink-tertiary">
|
||||
Ta présentation (référence)
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto whitespace-pre-wrap text-sm leading-relaxed text-ink-secondary">
|
||||
{presentationT1}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{production.tache !== 'EO_T1' && sujet && (
|
||||
<div className="mb-6">
|
||||
<SujetDisplay sujet={sujet} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{encodingError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{encodingError}
|
||||
</div>
|
||||
)}
|
||||
<AudioRecorder
|
||||
minSeconds={minSeconds}
|
||||
maxSeconds={dureeRecommandee || undefined}
|
||||
downloadFilename={`expria-${production.tache.toLowerCase()}-${production.id.slice(0, 8)}`}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={handleCancel}
|
||||
autoStart
|
||||
disabled={lockControls}
|
||||
/>
|
||||
|
||||
{correctError && !lockControls && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
La correction a échoué. Réessayez dans quelques instants.
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{lockControls && (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className="mt-4 flex items-start gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text"
|
||||
>
|
||||
<Loader2 className="mt-0.5 size-4 shrink-0 animate-spin" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-medium">Transcription et correction en cours…</p>
|
||||
<p className="mt-0.5 text-xs">
|
||||
Cela peut prendre jusqu'à 60 secondes. Tu seras redirigé vers le rapport
|
||||
automatiquement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{encodingError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{encodingError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{correctError && !lockControls && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
La correction a échoué. Réessayez dans quelques instants.
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,50 +29,54 @@ export function ModeChoixT1Page() {
|
|||
if (shouldRedirect) return null
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
reset()
|
||||
navigate('/simulation/eo')
|
||||
}}
|
||||
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
reset()
|
||||
navigate('/simulation/eo')
|
||||
}}
|
||||
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 — Présentation personnelle</h2>
|
||||
<p className="mt-1 mb-6 text-sm text-ink-secondary">Choisis ton mode d'entraînement.</p>
|
||||
<h2 className="text-lg font-semibold text-ink-primary">
|
||||
Tâche 1 — Présentation personnelle
|
||||
</h2>
|
||||
<p className="mt-1 mb-6 text-sm text-ink-secondary">Choisis ton mode d'entraînement.</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Card
|
||||
variant="interactive"
|
||||
className="flex flex-col gap-3 p-5"
|
||||
onClick={() => navigate('/simulation/eo/t1/questionnaire')}
|
||||
>
|
||||
<Sparkles className="size-6 text-brand-text" aria-hidden="true" />
|
||||
<h3 className="text-base font-semibold text-ink-primary">Générer ma présentation</h3>
|
||||
<p className="text-sm leading-relaxed text-ink-secondary">
|
||||
Réponds à 5 questions — Expria génère ton texte personnalisé que tu lis avant
|
||||
d'enregistrer.
|
||||
</p>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<Card
|
||||
variant="interactive"
|
||||
className="flex flex-col gap-3 p-5"
|
||||
onClick={() => navigate('/simulation/eo/t1/questionnaire')}
|
||||
>
|
||||
<Sparkles className="size-6 text-brand-text" aria-hidden="true" />
|
||||
<h3 className="text-base font-semibold text-ink-primary">Générer ma présentation</h3>
|
||||
<p className="text-sm leading-relaxed text-ink-secondary">
|
||||
Réponds à 5 questions — Expria génère ton texte personnalisé que tu lis avant
|
||||
d'enregistrer.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="interactive"
|
||||
className="flex flex-col gap-3 p-5"
|
||||
onClick={() => navigate('/simulation/eo/pre-enregistrement')}
|
||||
>
|
||||
<Mic className="size-6 text-brand-text" aria-hidden="true" />
|
||||
<h3 className="text-base font-semibold text-ink-primary">Enregistrer directement</h3>
|
||||
<p className="text-sm leading-relaxed text-ink-secondary">
|
||||
Tu as déjà préparé ta présentation — enregistre-toi directement sans passer par le
|
||||
formulaire.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
<Card
|
||||
variant="interactive"
|
||||
className="flex flex-col gap-3 p-5"
|
||||
onClick={() => navigate('/simulation/eo/pre-enregistrement')}
|
||||
>
|
||||
<Mic className="size-6 text-brand-text" aria-hidden="true" />
|
||||
<h3 className="text-base font-semibold text-ink-primary">Enregistrer directement</h3>
|
||||
<p className="text-sm leading-relaxed text-ink-secondary">
|
||||
Tu as déjà préparé ta présentation — enregistre-toi directement sans passer par le
|
||||
formulaire.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,55 +51,59 @@ export function PreEnregistrementEOPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">{heading}</h2>
|
||||
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
|
||||
<Timer className="size-3.5" aria-hidden="true" />
|
||||
Durée recommandée : {formatTimer(dureeRecommandee)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{sujet && !isT1 && (
|
||||
<div className="mb-6">
|
||||
<SujetDisplay sujet={sujet} />
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h2 className="text-lg font-semibold text-ink-primary">{heading}</h2>
|
||||
<Badge variant="neutral" className="inline-flex items-center gap-1.5">
|
||||
<Timer className="size-3.5" aria-hidden="true" />
|
||||
Durée recommandée : {formatTimer(dureeRecommandee)}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-6 rounded-lg border border-border bg-surface p-4 text-sm text-ink-secondary">
|
||||
<p className="font-medium text-ink-primary">Avant de commencer</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>Trouvez un endroit calme : la transcription est plus précise sans bruit de fond.</li>
|
||||
<li>Autorisez l'accès au micro dans votre navigateur lorsque demandé.</li>
|
||||
<li>Parlez de manière naturelle. La durée est indicative, pas un cap.</li>
|
||||
{isT1 && (
|
||||
<li>
|
||||
Présentez-vous à l'examinateur : prénom, parcours, situation familiale, loisirs,
|
||||
projet d'immigration au Canada.
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
Vous pourrez télécharger votre enregistrement à la fin — il n'est pas conservé sur nos
|
||||
serveurs.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<Mic className="size-4" aria-hidden="true" />}
|
||||
onClick={handleStart}
|
||||
>
|
||||
Démarrer l'enregistrement
|
||||
</Button>
|
||||
{isT3 && (
|
||||
<Button variant="secondary" size="md" onClick={handleChangeSujet}>
|
||||
Changer de sujet
|
||||
</Button>
|
||||
{sujet && !isT1 && (
|
||||
<div className="mb-6">
|
||||
<SujetDisplay sujet={sujet} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div className="mb-6 rounded-lg border border-border bg-surface p-4 text-sm text-ink-secondary">
|
||||
<p className="font-medium text-ink-primary">Avant de commencer</p>
|
||||
<ul className="mt-2 list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
Trouvez un endroit calme : la transcription est plus précise sans bruit de fond.
|
||||
</li>
|
||||
<li>Autorisez l'accès au micro dans votre navigateur lorsque demandé.</li>
|
||||
<li>Parlez de manière naturelle. La durée est indicative, pas un cap.</li>
|
||||
{isT1 && (
|
||||
<li>
|
||||
Présentez-vous à l'examinateur : prénom, parcours, situation familiale, loisirs,
|
||||
projet d'immigration au Canada.
|
||||
</li>
|
||||
)}
|
||||
<li>
|
||||
Vous pourrez télécharger votre enregistrement à la fin — il n'est pas conservé sur nos
|
||||
serveurs.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<Mic className="size-4" aria-hidden="true" />}
|
||||
onClick={handleStart}
|
||||
>
|
||||
Démarrer l'enregistrement
|
||||
</Button>
|
||||
{isT3 && (
|
||||
<Button variant="secondary" size="md" onClick={handleChangeSujet}>
|
||||
Changer de sujet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,91 +108,93 @@ export function PresentationGenereeT1Page() {
|
|||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Ta présentation générée</h2>
|
||||
<p className="mt-1 text-sm text-ink-secondary">
|
||||
Lis-la, modifie-la si nécessaire, puis enregistre-toi.
|
||||
</p>
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl px-4 py-6">
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Ta présentation générée</h2>
|
||||
<p className="mt-1 text-sm text-ink-secondary">
|
||||
Lis-la, modifie-la si nécessaire, puis enregistre-toi.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={
|
||||
copied ? (
|
||||
<Check className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="size-4" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? 'Copié' : 'Copier'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Download className="size-4" aria-hidden="true" />}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
.txt
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={
|
||||
isEditing ? (
|
||||
<Save className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Pencil className="size-4" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
onClick={handleToggleEdit}
|
||||
>
|
||||
{isEditing ? 'Enregistrer les modifications' : 'Modifier'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={
|
||||
copied ? (
|
||||
<Check className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Copy className="size-4" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? 'Copié' : 'Copier'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Download className="size-4" aria-hidden="true" />}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
.txt
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={
|
||||
isEditing ? (
|
||||
<Save className="size-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Pencil className="size-4" aria-hidden="true" />
|
||||
)
|
||||
}
|
||||
onClick={handleToggleEdit}
|
||||
>
|
||||
{isEditing ? 'Enregistrer les modifications' : 'Modifier'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
readOnly={!isEditing}
|
||||
rows={12}
|
||||
className="w-full rounded-lg border border-border bg-surface p-4 text-sm leading-relaxed text-ink-primary focus:border-brand focus:outline-none focus:shadow-focus disabled:cursor-not-allowed read-only:bg-surface-solid"
|
||||
/>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
readOnly={!isEditing}
|
||||
rows={12}
|
||||
className="w-full rounded-lg border border-border bg-surface p-4 text-sm leading-relaxed text-ink-primary focus:border-brand focus:outline-none focus:shadow-focus disabled:cursor-not-allowed read-only:bg-surface-solid"
|
||||
/>
|
||||
|
||||
<div
|
||||
role="note"
|
||||
className="mt-4 rounded-md border border-warning/40 bg-warning-soft px-4 py-3 text-sm text-ink-primary"
|
||||
>
|
||||
<strong className="text-warning">Conseil :</strong> Lis ce texte à voix haute 2-3 fois avant
|
||||
d'enregistrer. Vise un débit naturel, ni trop rapide ni trop lent.
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-col gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>Présentation sauvegardée — retrouvée automatiquement à ta prochaine visite.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefaire}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium underline underline-offset-4 hover:text-brand-text"
|
||||
<div
|
||||
role="note"
|
||||
className="mt-4 rounded-md border border-warning/40 bg-warning-soft px-4 py-3 text-sm text-ink-primary"
|
||||
>
|
||||
<RotateCcw className="size-3.5" aria-hidden="true" />
|
||||
Refaire
|
||||
</button>
|
||||
</div>
|
||||
<strong className="text-warning">Conseil :</strong> Lis ce texte à voix haute 2-3 fois
|
||||
avant d'enregistrer. Vise un débit naturel, ni trop rapide ni trop lent.
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<Mic className="size-4" aria-hidden="true" />}
|
||||
onClick={handleStartRecording}
|
||||
className="mt-6 w-full"
|
||||
>
|
||||
Je suis prêt — Enregistrer
|
||||
</Button>
|
||||
</main>
|
||||
<div className="mt-3 flex flex-col gap-2 rounded-md border border-brand/30 bg-brand/10 px-4 py-3 text-sm text-brand-text sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>Présentation sauvegardée — retrouvée automatiquement à ta prochaine visite.</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRefaire}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium underline underline-offset-4 hover:text-brand-text"
|
||||
>
|
||||
<RotateCcw className="size-3.5" aria-hidden="true" />
|
||||
Refaire
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<Mic className="size-4" aria-hidden="true" />}
|
||||
onClick={handleStartRecording}
|
||||
className="mt-6 w-full"
|
||||
>
|
||||
Je suis prêt — Enregistrer
|
||||
</Button>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -158,86 +158,88 @@ export function QuestionnaireT1Page() {
|
|||
if (shouldRedirect) return null
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/simulation/eo/t1/mode')}
|
||||
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 — Questionnaire</h2>
|
||||
<p className="mt-1 mb-6 text-sm text-ink-secondary">
|
||||
Réponds brièvement à ces 5 questions. Ta présentation personnalisée sera générée
|
||||
automatiquement.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
|
||||
{FIELDS.map((field) => {
|
||||
const value = reponses[field.key]
|
||||
const showError = touched[field.key] && fieldErrors[field.key]
|
||||
const id = `q-${field.key}`
|
||||
return (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<label htmlFor={id} className="block text-sm font-medium text-ink-primary">
|
||||
{field.label}
|
||||
</label>
|
||||
{field.multiline ? (
|
||||
<textarea
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
onBlur={() => handleBlur(field.key)}
|
||||
placeholder={field.placeholder}
|
||||
maxLength={FIELD_MAX}
|
||||
rows={2}
|
||||
className={inputBase}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
onBlur={() => handleBlur(field.key)}
|
||||
placeholder={field.placeholder}
|
||||
maxLength={FIELD_MAX}
|
||||
className={inputBase}
|
||||
/>
|
||||
)}
|
||||
{showError && (
|
||||
<p className="text-xs text-danger" role="alert">
|
||||
{fieldErrors[field.key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{apiErrorMessage && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
<div className="mb-2 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/simulation/eo/t1/mode')}
|
||||
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{apiErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
← Retour
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<Sparkles className="size-4" aria-hidden="true" />}
|
||||
loading={mutation.isPending}
|
||||
disabled={!formValid || mutation.isPending}
|
||||
>
|
||||
Générer ma présentation
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
<h2 className="text-lg font-semibold text-ink-primary">Tâche 1 — Questionnaire</h2>
|
||||
<p className="mt-1 mb-6 text-sm text-ink-secondary">
|
||||
Réponds brièvement à ces 5 questions. Ta présentation personnalisée sera générée
|
||||
automatiquement.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5" noValidate>
|
||||
{FIELDS.map((field) => {
|
||||
const value = reponses[field.key]
|
||||
const showError = touched[field.key] && fieldErrors[field.key]
|
||||
const id = `q-${field.key}`
|
||||
return (
|
||||
<div key={field.key} className="space-y-1.5">
|
||||
<label htmlFor={id} className="block text-sm font-medium text-ink-primary">
|
||||
{field.label}
|
||||
</label>
|
||||
{field.multiline ? (
|
||||
<textarea
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
onBlur={() => handleBlur(field.key)}
|
||||
placeholder={field.placeholder}
|
||||
maxLength={FIELD_MAX}
|
||||
rows={2}
|
||||
className={inputBase}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={id}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => handleChange(field.key, e.target.value)}
|
||||
onBlur={() => handleBlur(field.key)}
|
||||
placeholder={field.placeholder}
|
||||
maxLength={FIELD_MAX}
|
||||
className={inputBase}
|
||||
/>
|
||||
)}
|
||||
{showError && (
|
||||
<p className="text-xs text-danger" role="alert">
|
||||
{fieldErrors[field.key]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{apiErrorMessage && (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
{apiErrorMessage}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
icon={<Sparkles className="size-4" aria-hidden="true" />}
|
||||
loading={mutation.isPending}
|
||||
disabled={!formValid || mutation.isPending}
|
||||
>
|
||||
Générer ma présentation
|
||||
</Button>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,94 +213,99 @@ export function RapportPage() {
|
|||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
{/* Breadcrumb */}
|
||||
<nav
|
||||
aria-label="Fil d'Ariane"
|
||||
className="flex items-center gap-1.5 text-sm text-ink-secondary"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSimulations}
|
||||
className="transition-colors duration-150 hover:text-ink-primary"
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-3xl space-y-6 px-4 py-6">
|
||||
{/* Breadcrumb */}
|
||||
<nav
|
||||
aria-label="Fil d'Ariane"
|
||||
className="flex items-center gap-1.5 text-sm text-ink-secondary"
|
||||
>
|
||||
Simulations
|
||||
</button>
|
||||
<span aria-hidden="true">›</span>
|
||||
<span aria-current="page" className="text-ink-primary">
|
||||
Rapport
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
{(isLoading || isPlanLoading) && <RapportSkeleton />}
|
||||
|
||||
{isInProgress && (
|
||||
<p className="text-center text-sm text-ink-secondary" aria-live="polite">
|
||||
Votre simulation est en cours.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(isError || isPlanError) && !isInProgress && !isLoading && !isPlanLoading && (
|
||||
<Card variant="default" className="border-l-4 border-l-danger p-4">
|
||||
<div role="alert" className="space-y-3">
|
||||
<p className="text-sm text-danger">
|
||||
Impossible de charger ce rapport. Réessayez dans quelques instants.
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/simulation/ee')}>
|
||||
Retour aux simulations
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{rapport && planData && (
|
||||
<>
|
||||
<ScoreHero score={rapport.score} nclc={rapport.nclc} nclcCible={rapport.nclc_cible} />
|
||||
|
||||
<RevelationCards revelation={rapport.revelation} />
|
||||
|
||||
<DiagnosticCallout diagnostic={rapport.diagnostic} />
|
||||
|
||||
<BlurredSection
|
||||
visible={isSectionVisible(planData.plan, 'criteres')}
|
||||
onUpgrade={onUpgrade}
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToSimulations}
|
||||
className="transition-colors duration-150 hover:text-ink-primary"
|
||||
>
|
||||
<CriteresSection rapport={rapport} />
|
||||
</BlurredSection>
|
||||
Simulations
|
||||
</button>
|
||||
<span aria-hidden="true">›</span>
|
||||
<span aria-current="page" className="text-ink-primary">
|
||||
Rapport
|
||||
</span>
|
||||
</nav>
|
||||
|
||||
<ConseilNclcCallout
|
||||
conseil={rapport.conseil_nclc}
|
||||
nclc={rapport.nclc}
|
||||
nclcCible={rapport.nclc_cible}
|
||||
/>
|
||||
{(isLoading || isPlanLoading) && <RapportSkeleton />}
|
||||
|
||||
<BlurredSection
|
||||
visible={isSectionVisible(planData.plan, 'exercices')}
|
||||
onUpgrade={onUpgrade}
|
||||
>
|
||||
<ExercicesSection
|
||||
rapport={rapport}
|
||||
hasTimedOut={hasTimedOut}
|
||||
onRetry={() => void refetch()}
|
||||
{isInProgress && (
|
||||
<p className="text-center text-sm text-ink-secondary" aria-live="polite">
|
||||
Votre simulation est en cours.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{(isError || isPlanError) && !isInProgress && !isLoading && !isPlanLoading && (
|
||||
<Card variant="default" className="border-l-4 border-l-danger p-4">
|
||||
<div role="alert" className="space-y-3">
|
||||
<p className="text-sm text-danger">
|
||||
Impossible de charger ce rapport. Réessayez dans quelques instants.
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate('/simulation/ee')}>
|
||||
Retour aux simulations
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{rapport && planData && (
|
||||
<>
|
||||
<ScoreHero score={rapport.score} nclc={rapport.nclc} nclcCible={rapport.nclc_cible} />
|
||||
|
||||
<RevelationCards revelation={rapport.revelation} />
|
||||
|
||||
<DiagnosticCallout diagnostic={rapport.diagnostic} />
|
||||
|
||||
<BlurredSection
|
||||
visible={isSectionVisible(planData.plan, 'criteres')}
|
||||
onUpgrade={onUpgrade}
|
||||
>
|
||||
<CriteresSection rapport={rapport} />
|
||||
</BlurredSection>
|
||||
|
||||
<ConseilNclcCallout
|
||||
conseil={rapport.conseil_nclc}
|
||||
nclc={rapport.nclc}
|
||||
nclcCible={rapport.nclc_cible}
|
||||
/>
|
||||
</BlurredSection>
|
||||
|
||||
<BlurredSection visible={isSectionVisible(planData.plan, 'modele')} onUpgrade={onUpgrade}>
|
||||
<ModeleSection
|
||||
rapport={rapport}
|
||||
hasTimedOut={hasTimedOut}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
</BlurredSection>
|
||||
<BlurredSection
|
||||
visible={isSectionVisible(planData.plan, 'exercices')}
|
||||
onUpgrade={onUpgrade}
|
||||
>
|
||||
<ExercicesSection
|
||||
rapport={rapport}
|
||||
hasTimedOut={hasTimedOut}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
</BlurredSection>
|
||||
|
||||
{/* Action de sortie — reset + nouvelle simulation */}
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button variant="primary" onClick={goToSimulations}>
|
||||
Nouvelle simulation
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
<BlurredSection
|
||||
visible={isSectionVisible(planData.plan, 'modele')}
|
||||
onUpgrade={onUpgrade}
|
||||
>
|
||||
<ModeleSection
|
||||
rapport={rapport}
|
||||
hasTimedOut={hasTimedOut}
|
||||
onRetry={() => void refetch()}
|
||||
/>
|
||||
</BlurredSection>
|
||||
|
||||
{/* Action de sortie — reset + nouvelle simulation */}
|
||||
<div className="flex justify-center pt-4">
|
||||
<Button variant="primary" onClick={goToSimulations}>
|
||||
Nouvelle simulation
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,40 +38,42 @@ export function SimulationEOPage() {
|
|||
const { isCreating, taskUnavailableMessage, selectTask } = useSimulationFlow()
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
{isPlanLoading && <SimulationEOSkeleton />}
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
{isPlanLoading && <SimulationEOSkeleton />}
|
||||
|
||||
{isPlanError && (
|
||||
<div className="space-y-3 text-center">
|
||||
<p className="text-sm text-danger">
|
||||
Impossible de charger vos informations. Réessayez dans quelques instants.
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isPlanError && (
|
||||
<div className="space-y-3 text-center">
|
||||
<p className="text-sm text-danger">
|
||||
Impossible de charger vos informations. Réessayez dans quelques instants.
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{planData && (
|
||||
<div className="space-y-4">
|
||||
<TaskSelector
|
||||
type="EO"
|
||||
plan={planData.plan}
|
||||
simulationsUsed={planData.simulations_used}
|
||||
isLoading={isCreating}
|
||||
onSelect={selectTask}
|
||||
/>
|
||||
{planData && (
|
||||
<div className="space-y-4">
|
||||
<TaskSelector
|
||||
type="EO"
|
||||
plan={planData.plan}
|
||||
simulationsUsed={planData.simulations_used}
|
||||
isLoading={isCreating}
|
||||
onSelect={selectTask}
|
||||
/>
|
||||
|
||||
{taskUnavailableMessage && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-lg border border-border bg-surface px-4 py-3 text-sm text-ink-secondary"
|
||||
>
|
||||
{taskUnavailableMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
{taskUnavailableMessage && (
|
||||
<div
|
||||
role="status"
|
||||
className="rounded-lg border border-border bg-surface px-4 py-3 text-sm text-ink-secondary"
|
||||
>
|
||||
{taskUnavailableMessage}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,45 +56,47 @@ export function SimulationPage() {
|
|||
// createMutation.onSuccess (idle → choosing-subject → navigate /sujets).
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
{isPlanLoading && <SimulationSkeleton />}
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-2xl px-4 py-6">
|
||||
{isPlanLoading && <SimulationSkeleton />}
|
||||
|
||||
{isPlanError && (
|
||||
<div className="space-y-3 text-center">
|
||||
<p className="text-sm text-danger">
|
||||
Impossible de charger vos informations. Réessayez dans quelques instants.
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isPlanError && (
|
||||
<div className="space-y-3 text-center">
|
||||
<p className="text-sm text-danger">
|
||||
Impossible de charger vos informations. Réessayez dans quelques instants.
|
||||
</p>
|
||||
<Button variant="secondary" size="sm" onClick={() => refetchPlan()}>
|
||||
Réessayer
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{planData && step === 'idle' && (
|
||||
<TaskSelector
|
||||
type="EE"
|
||||
plan={planData.plan}
|
||||
simulationsUsed={planData.simulations_used}
|
||||
isLoading={isCreating}
|
||||
onSelect={selectTask}
|
||||
/>
|
||||
)}
|
||||
{planData && step === 'idle' && (
|
||||
<TaskSelector
|
||||
type="EE"
|
||||
plan={planData.plan}
|
||||
simulationsUsed={planData.simulations_used}
|
||||
isLoading={isCreating}
|
||||
onSelect={selectTask}
|
||||
/>
|
||||
)}
|
||||
|
||||
{planData && (step === 'task-selected' || step === 'correcting') && production && (
|
||||
<SimulationForm
|
||||
tache={production.tache}
|
||||
sujet={sujet}
|
||||
plan={planData.plan}
|
||||
simulationId={production.id}
|
||||
initialContenu={production.contenu}
|
||||
step={step}
|
||||
isSubmitting={isCorrecting}
|
||||
error={correctError}
|
||||
onSubmit={submitText}
|
||||
onBack={reset}
|
||||
onChangeSujet={goToSubjectPicker}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
{planData && (step === 'task-selected' || step === 'correcting') && production && (
|
||||
<SimulationForm
|
||||
tache={production.tache}
|
||||
sujet={sujet}
|
||||
plan={planData.plan}
|
||||
simulationId={production.id}
|
||||
initialContenu={production.contenu}
|
||||
step={step}
|
||||
isSubmitting={isCorrecting}
|
||||
error={correctError}
|
||||
onSubmit={submitText}
|
||||
onBack={reset}
|
||||
onChangeSujet={goToSubjectPicker}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,63 +64,69 @@ export function SujetsEOPage() {
|
|||
const hasSujets = (sujets?.length ?? 0) > 0
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
reset()
|
||||
navigate('/simulation/eo')
|
||||
}}
|
||||
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<h2 className="flex-1 text-lg font-semibold text-ink-primary">
|
||||
Choisir un sujet — {formatTache(production.tache)}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-ink-secondary">
|
||||
{isLoading
|
||||
? 'Chargement des sujets…'
|
||||
: hasSujets
|
||||
? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
|
||||
: 'Aucun sujet disponible pour cette tâche.'}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Shuffle className="size-4" aria-hidden="true" />}
|
||||
onClick={handleRandom}
|
||||
disabled={!hasSujets}
|
||||
>
|
||||
Sujet aléatoire
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
Impossible de charger les sujets.{' '}
|
||||
<button type="button" onClick={() => refetch()} className="underline underline-offset-2">
|
||||
Réessayer
|
||||
<div className="mx-auto w-full max-w-[1100px] px-5 py-6 lg:px-9 lg:py-9">
|
||||
<main className="mx-auto max-w-4xl px-4 py-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
reset()
|
||||
navigate('/simulation/eo')
|
||||
}}
|
||||
className="text-sm text-ink-secondary underline-offset-4 hover:text-ink-primary hover:underline"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<h2 className="flex-1 text-lg font-semibold text-ink-primary">
|
||||
Choisir un sujet — {formatTache(production.tache)}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<SujetsSkeleton />
|
||||
) : hasSujets ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sujets!.map((sujet) => (
|
||||
<SujetCard key={sujet.id} sujet={sujet} onSelect={handleSelect} />
|
||||
))}
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-ink-secondary">
|
||||
{isLoading
|
||||
? 'Chargement des sujets…'
|
||||
: hasSujets
|
||||
? `${sujets!.length} sujet${sujets!.length > 1 ? 's' : ''} disponible${sujets!.length > 1 ? 's' : ''}.`
|
||||
: 'Aucun sujet disponible pour cette tâche.'}
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
icon={<Shuffle className="size-4" aria-hidden="true" />}
|
||||
onClick={handleRandom}
|
||||
disabled={!hasSujets}
|
||||
>
|
||||
Sujet aléatoire
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
|
||||
{isError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mb-4 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm text-danger"
|
||||
>
|
||||
Impossible de charger les sujets.{' '}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => refetch()}
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
Réessayer
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<SujetsSkeleton />
|
||||
) : hasSujets ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{sujets!.map((sujet) => (
|
||||
<SujetCard key={sujet.id} sujet={sujet} onSelect={handleSelect} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue