1import { useEffect, useRef, useState, type ChangeEvent, type DragEvent, type KeyboardEvent } from 'react';2import { ArrowUp, Brain, Mic, MicOff, Paperclip, Square, X, FileText } from 'lucide-react';3import { api } from '@/lib/api';4import type { UploadedFile } from '@/lib/types';5import { cn } from '@/lib/cn';6import { fmtBytes } from '@/lib/format';78interface SpeechRecognitionLike { start(): void; stop(): void; lang: string; interimResults: boolean; continuous: boolean; onresult: ((e: { results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null; onend: (() => void) | null }9declare global { interface Window { webkitSpeechRecognition?: new () => SpeechRecognitionLike; SpeechRecognition?: new () => SpeechRecognitionLike } }1011export function Composer({ conversationId, streaming, onSend, onStop, deep, onToggleDeep, course, onCourseChange, courses, disabled }: {12 conversationId: string | null;13 streaming: boolean;14 onSend: (text: string, attachments: string[]) => void;15 onStop: () => void;16 deep: boolean;17 onToggleDeep: () => void;18 course: string;19 onCourseChange: (c: string) => void;20 courses: string[];21 disabled?: boolean;22}) {23 const [text, setText] = useState('');24 const [files, setFiles] = useState<UploadedFile[]>([]);25 const [uploading, setUploading] = useState(false);26 const [drag, setDrag] = useState(false);27 const [error, setError] = useState<string | null>(null);28 const [listening, setListening] = useState(false);29 const taRef = useRef<HTMLTextAreaElement>(null);30 const fileRef = useRef<HTMLInputElement>(null);31 const recRef = useRef<SpeechRecognitionLike | null>(null);32 const speechOk = typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition);3334 useEffect(() => {35 const ta = taRef.current;36 if (!ta) return;37 ta.style.height = 'auto';38 ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';39 }, [text]);4041 const upload = async (list: FileList | File[]) => {42 setError(null);43 setUploading(true);44 try {45 for (const f of Array.from(list)) {46 const fd = new FormData();47 fd.append('file', f);48 if (conversationId) fd.append('conversation_id', conversationId);49 const up = await api<UploadedFile>('/files', { method: 'POST', body: fd });50 setFiles((prev) => [...prev, up]);51 }52 } catch (e) {53 setError(e instanceof Error ? e.message : 'Téléversement impossible.');54 } finally {55 setUploading(false);56 }57 };5859 const send = () => {60 const t = text.trim();61 if (!t || streaming || disabled) return;62 onSend(t, files.map((f) => f.file_id));63 setText('');64 setFiles([]);65 };6667 const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {68 if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {69 e.preventDefault();70 send();71 }72 };7374 const onDrop = (e: DragEvent) => {75 e.preventDefault();76 setDrag(false);77 if (e.dataTransfer.files?.length) void upload(e.dataTransfer.files);78 };7980 const toggleMic = () => {81 if (listening) {82 recRef.current?.stop();83 setListening(false);84 return;85 }86 const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;87 if (!Ctor) return;88 const rec = new Ctor();89 rec.lang = 'fr-CA';90 rec.interimResults = false;91 rec.continuous = true;92 rec.onresult = (ev) => {93 const parts: string[] = [];94 for (let i = 0; i < ev.results.length; i++) parts.push(ev.results[i][0].transcript);95 setText((t) => (t ? t + ' ' : '') + parts.join(' ').trim());96 };97 rec.onend = () => setListening(false);98 recRef.current = rec;99 rec.start();100 setListening(true);101 };102103 return (104 <div className="px-3 sm:px-6 pb-[calc(8px+var(--safe-bottom))] pt-2 bg-gradient-to-t from-neutral-bg via-neutral-bg to-transparent">105 <div106 onDragOver={(e) => { e.preventDefault(); setDrag(true); }}107 onDragLeave={() => setDrag(false)}108 onDrop={onDrop}109 className={cn('mx-auto max-w-[900px] rounded-2xl border bg-white shadow-card transition-colors', drag ? 'border-uqo-green ring-2 ring-uqo-green/30' : 'border-neutral-line')}110 >111 {(files.length > 0 || error) && (112 <div className="flex flex-wrap gap-2 px-3 pt-3">113 {files.map((f) => (114 <span key={f.file_id} className="inline-flex items-center gap-1.5 rounded-lg bg-uqo-blue-light text-uqo-blue-dark text-xs px-2 py-1.5">115 <FileText size={13} /> <span className="max-w-[160px] truncate">{f.filename}</span> <span className="text-neutral-muted">{fmtBytes(f.size)}</span>116 <button onClick={() => setFiles((p) => p.filter((x) => x.file_id !== f.file_id))} aria-label="Retirer" className="h-6 w-6 inline-flex items-center justify-center rounded hover:bg-white/60"><X size={12} /></button>117 </span>118 ))}119 {error && <span className="text-xs text-semantic-error self-center">{error}</span>}120 </div>121 )}122 <textarea123 ref={taRef}124 value={text}125 onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}126 onKeyDown={onKey}127 rows={1}128 placeholder={disabled ? 'Connexion requise' : 'Pose ta question… (Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne)'}129 aria-label="Message"130 disabled={disabled}131 className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-[16px] outline-none placeholder:text-neutral-muted/70 max-h-[200px] scroll-thin"132 />133 <div className="flex items-center gap-1 px-2 pb-2">134 <input ref={fileRef} type="file" multiple hidden accept=".xlsx,.xls,.csv,.pdf,.png,.jpg,.jpeg,.webp,.txt,.md,.docx,.json" onChange={(e) => e.target.files && upload(e.target.files)} />135 <button onClick={() => fileRef.current?.click()} disabled={uploading || disabled} className="h-11 w-11 inline-flex items-center justify-center rounded-xl text-neutral-muted hover:bg-neutral-surface" aria-label="Joindre un fichier" title="Joindre un fichier (xlsx, csv, pdf, image)">136 <Paperclip size={19} className={uploading ? 'animate-pulse' : ''} />137 </button>138 {speechOk && (139 <button onClick={toggleMic} disabled={disabled} className={cn('h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface', listening ? 'text-semantic-error' : 'text-neutral-muted')} aria-label="Dictée vocale">140 {listening ? <MicOff size={19} /> : <Mic size={19} />}141 </button>142 )}143 <select value={course} onChange={(e) => onCourseChange(e.target.value)} aria-label="Cours" disabled={!!conversationId}144 className="h-9 rounded-lg border border-neutral-line bg-white px-2 text-xs font-medium text-uqo-blue-dark disabled:opacity-70">145 {courses.map((c) => <option key={c} value={c}>{c}</option>)}146 </select>147 <button onClick={onToggleDeep} className={cn('h-9 px-2.5 rounded-lg text-xs font-medium inline-flex items-center gap-1 border', deep ? 'bg-uqo-blue text-white border-uqo-blue' : 'border-neutral-line text-neutral-muted hover:bg-neutral-surface')} title="Réflexion approfondie : modèle plus puissant, plus lent" aria-pressed={deep}>148 <Brain size={14} /> <span className="hidden sm:inline">Réflexion approfondie</span><span className="sm:hidden">Approfondi</span>149 </button>150 <div className="flex-1" />151 {streaming ? (152 <button onClick={onStop} className="h-11 w-11 inline-flex items-center justify-center rounded-xl bg-neutral-text text-white" aria-label="Arrêter"><Square size={16} /></button>153 ) : (154 <button onClick={send} disabled={!text.trim() || disabled} className="h-11 w-11 inline-flex items-center justify-center rounded-xl bg-uqo-blue text-white disabled:bg-neutral-line disabled:text-neutral-muted" aria-label="Envoyer"><ArrowUp size={19} /></button>155 )}156 </div>157 </div>158 <p className="mx-auto max-w-[900px] mt-1.5 text-center text-[11px] text-neutral-muted px-2">Outil pédagogique. UQO-Chat peut se tromper : vérifie les calculs importants. Ne constitue pas une évaluation professionnelle.</p>159 </div>160 );161}162