feat(simulations): minuteur + limites mots + clavier sticky + bouton Soumettre
This commit is contained in:
parent
41d2eec3f7
commit
c5b433749d
1 changed files with 80 additions and 19 deletions
|
|
@ -3,18 +3,26 @@
|
||||||
*
|
*
|
||||||
* SEC-04 : validation Zod avant envoi (texte non vide, max 5 000 caractères).
|
* SEC-04 : validation Zod avant envoi (texte non vide, max 5 000 caractères).
|
||||||
* SEC-05 : aucun dangerouslySetInnerHTML — le texte utilisateur est rendu comme texte.
|
* SEC-05 : aucun dangerouslySetInnerHTML — le texte utilisateur est rendu comme texte.
|
||||||
* Règle H : aucune logique métier — le composant reçoit tache, handlers et états par props.
|
* Règle H : aucune logique métier — délègue à simulationConfig + useTimer.
|
||||||
|
*
|
||||||
|
* Minuteur et cibles de mots : cf. getSimulationConfig(tache).
|
||||||
|
* À l'expiration du timer, soumission automatique si mots ≥ motsMin,
|
||||||
|
* sinon message explicite demandant d'atteindre le seuil.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
import { useEffect, useRef, useState, type FormEvent } from 'react'
|
||||||
import { Loader2 } from 'lucide-react'
|
import { Clock, Loader2 } from 'lucide-react'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { Button } from '@/shared/components/ui/button'
|
import { Button } from '@/shared/components/ui/button'
|
||||||
import { formatTache } from '@/entities/production/lib'
|
import { formatTache } from '@/entities/production/lib'
|
||||||
import type { SujetData, Tache } from '@/entities/production/types'
|
import type { SujetData, Tache } from '@/entities/production/types'
|
||||||
import type { ApiError } from '@/shared/types/api'
|
import type { ApiError } from '@/shared/types/api'
|
||||||
|
import { countWords, getSimulationConfig } from '../lib/simulationConfig'
|
||||||
|
import { useTimer } from '../hooks/useTimer'
|
||||||
import { SujetDisplay } from './SujetDisplay'
|
import { SujetDisplay } from './SujetDisplay'
|
||||||
import { SpecialCharsKeyboard } from './SpecialCharsKeyboard'
|
import { SpecialCharsKeyboard } from './SpecialCharsKeyboard'
|
||||||
|
import { TimerDisplay } from './TimerDisplay'
|
||||||
|
import { WordCountBar } from './WordCountBar'
|
||||||
|
|
||||||
const textSchema = z.object({
|
const textSchema = z.object({
|
||||||
texte: z
|
texte: z
|
||||||
|
|
@ -49,9 +57,16 @@ interface Props {
|
||||||
|
|
||||||
export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, onBack }: Props) {
|
export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, onBack }: Props) {
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
const hasAutoSubmittedRef = useRef(false)
|
||||||
const [texte, setTexte] = useState('')
|
const [texte, setTexte] = useState('')
|
||||||
const [fieldError, setFieldError] = useState<string | null>(null)
|
const [fieldError, setFieldError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const config = getSimulationConfig(tache)
|
||||||
|
const wordCount = countWords(texte)
|
||||||
|
const canSubmit = wordCount >= config.motsMin
|
||||||
|
|
||||||
|
const timer = useTimer(config.dureeMinutes, !isSubmitting)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
|
|
@ -59,6 +74,16 @@ export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, on
|
||||||
el.style.height = `${el.scrollHeight}px`
|
el.style.height = `${el.scrollHeight}px`
|
||||||
}, [texte])
|
}, [texte])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!timer.isExpired) return
|
||||||
|
if (hasAutoSubmittedRef.current) return
|
||||||
|
if (isSubmitting) return
|
||||||
|
if (wordCount < config.motsMin) return
|
||||||
|
|
||||||
|
hasAutoSubmittedRef.current = true
|
||||||
|
onSubmit(texte)
|
||||||
|
}, [timer.isExpired, wordCount, config.motsMin, isSubmitting, texte, onSubmit])
|
||||||
|
|
||||||
function handleInsert(char: string) {
|
function handleInsert(char: string) {
|
||||||
const el = textareaRef.current
|
const el = textareaRef.current
|
||||||
if (!el) {
|
if (!el) {
|
||||||
|
|
@ -86,11 +111,17 @@ export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, on
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wordCount < config.motsMin) {
|
||||||
|
setFieldError(`Écrivez au moins ${config.motsMin} mots pour soumettre.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
onSubmit(parsed.data.texte)
|
onSubmit(parsed.data.texte)
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiError = mapCorrectError(error)
|
const apiError = mapCorrectError(error)
|
||||||
const charCount = texte.length
|
const expiredBelowMin = timer.isExpired && wordCount < config.motsMin
|
||||||
|
const submitDisabled = isSubmitting || !canSubmit
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -103,7 +134,7 @@ export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, on
|
||||||
>
|
>
|
||||||
← Retour
|
← Retour
|
||||||
</button>
|
</button>
|
||||||
<h2 className="text-lg font-semibold text-ink-1">{formatTache(tache)}</h2>
|
<h2 className="flex-1 text-lg font-semibold text-ink-1">{formatTache(tache)}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SujetDisplay sujet={sujet} />
|
<SujetDisplay sujet={sujet} />
|
||||||
|
|
@ -117,12 +148,48 @@ export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, on
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{expiredBelowMin && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="rounded-md border border-warning/40 bg-warning-bg px-3 py-2 text-sm text-warning"
|
||||||
|
>
|
||||||
|
Temps écoulé. Écrivez au moins {config.motsMin} mots pour soumettre.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
<form onSubmit={handleSubmit} className="space-y-3" noValidate>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="texte" className="text-sm font-medium text-ink-2">
|
<label htmlFor="texte" className="text-sm font-medium text-ink-2">
|
||||||
Votre production
|
Votre production
|
||||||
</label>
|
</label>
|
||||||
<div className="sticky top-0 z-10 bg-canvas pb-1">
|
<div className="sticky top-14 z-20 bg-canvas pb-1 lg:top-0">
|
||||||
|
<div
|
||||||
|
className={`mb-2 flex items-center gap-2 rounded-md border px-3 py-2 ${
|
||||||
|
timer.isExpired || timer.secondesRestantes < 120
|
||||||
|
? 'border-danger bg-danger-bg'
|
||||||
|
: 'border-line bg-surface'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Clock
|
||||||
|
className={`size-4 ${
|
||||||
|
timer.isExpired || timer.secondesRestantes < 120 ? 'text-danger' : 'text-ink-3'
|
||||||
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-medium uppercase tracking-wide ${
|
||||||
|
timer.isExpired || timer.secondesRestantes < 120 ? 'text-danger' : 'text-ink-3'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Temps restant
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto">
|
||||||
|
<TimerDisplay
|
||||||
|
secondesRestantes={timer.secondesRestantes}
|
||||||
|
isExpired={timer.isExpired}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<SpecialCharsKeyboard onInsert={handleInsert} disabled={isSubmitting} />
|
<SpecialCharsKeyboard onInsert={handleInsert} disabled={isSubmitting} />
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -137,28 +204,22 @@ export function SimulationForm({ tache, sujet, isSubmitting, error, onSubmit, on
|
||||||
aria-describedby={fieldError ? 'texte-error' : undefined}
|
aria-describedby={fieldError ? 'texte-error' : undefined}
|
||||||
className="w-full resize-none overflow-y-hidden rounded-md border border-line bg-surface p-3 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50"
|
className="w-full resize-none overflow-y-hidden rounded-md border border-line bg-surface p-3 text-sm text-ink-1 placeholder:text-ink-5 focus:border-expria focus:outline-none focus:ring-2 focus:ring-expria/20 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-start justify-between gap-2">
|
<WordCountBar count={wordCount} config={config} />
|
||||||
{fieldError ? (
|
{fieldError && (
|
||||||
<p id="texte-error" className="text-sm text-danger">
|
<p id="texte-error" className="text-sm text-danger">
|
||||||
{fieldError}
|
{fieldError}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
)}
|
||||||
<span />
|
|
||||||
)}
|
|
||||||
<span className={`text-xs tabular-nums ${charCount > 5000 ? 'text-danger' : 'text-ink-5'}`}>
|
|
||||||
{charCount.toLocaleString('fr-FR')} / 5 000
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
<Button type="submit" className="w-full" disabled={submitDisabled}>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="animate-spin" aria-hidden="true" />
|
<Loader2 className="animate-spin" aria-hidden="true" />
|
||||||
Correction en cours…
|
Correction en cours…
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'Envoyer pour correction'
|
'Soumettre ma production'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue