Python 64.6%
TypeScript 33.7%
CSS 0.8%
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';7import { useUI } from '@/stores/ui';89interface SpeechRecognitionLike { start(): void; stop(): void; lang: string; interimResults: boolean; continuous: boolean; onresult: ((e: { results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null; onend: (() => void) | null }10declare global { interface Window { webkitSpeechRecognition?: new () => SpeechRecognitionLike; SpeechRecognition?: new () => SpeechRecognitionLike } }1112export function Composer({ conversationId, streaming, onSend, onStop, deep, onToggleDeep, course, onCourseChange, courses, disabled }: {13 conversationId: string | null;14 streaming: boolean;15 onSend: (text: string, attachments: string[]) => void;16 onStop: () => void;17 deep: boolean;18 onToggleDeep: () => void;19 course: string;20 onCourseChange: (c: string) => void;21 courses: string[];22 disabled?: boolean;23}) {24 const [text, setText] = useState('');25 const [files, setFiles] = useState<UploadedFile[]>([]);26 const [uploading, setUploading] = useState(false);27 const [drag, setDrag] = useState(false);28 const [error, setError] = useState<string | null>(null);29 const [listening, setListening] = useState(false);30 const taRef = useRef<HTMLTextAreaElement>(null);31 const fileRef = useRef<HTMLInputElement>(null);32 const recRef = useRef<SpeechRecognitionLike | null>(null);33 const speechOk = typeof window !== 'undefined' && !!(window.SpeechRecognition || window.webkitSpeechRecognition);34 const draft = useUI((st) => st.draft);35 const setDraft = useUI((st) => st.setDraft);36 useEffect(() => {37 if (draft) {38 setText(draft);39 setDraft('');40 setTimeout(() => { taRef.current?.focus(); taRef.current?.setSelectionRange(taRef.current.value.length, taRef.current.value.length); }, 50);41 }42 }, [draft, setDraft]);4344 useEffect(() => {45 const ta = taRef.current;46 if (!ta) return;47 ta.style.height = 'auto';48 ta.style.height = Math.min(ta.scrollHeight, 200) + 'px';49 }, [text]);5051 const upload = async (list: FileList | File[]) => {52 setError(null);53 setUploading(true);54 try {55 for (const f of Array.from(list)) {56 const fd = new FormData();57 fd.append('file', f);58 if (conversationId) fd.append('conversation_id', conversationId);59 const up = await api<UploadedFile>('/files', { method: 'POST', body: fd });60 setFiles((prev) => [...prev, up]);61 }62 } catch (e) {63 setError(e instanceof Error ? e.message : 'Téléversement impossible.');64 } finally {65 setUploading(false);66 }67 };6869 const send = () => {70 const t = text.trim();71 if (!t || streaming || disabled) return;72 onSend(t, files.map((f) => f.file_id));73 setText('');74 setFiles([]);75 };7677 const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {78 if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {79 e.preventDefault();80 send();81 }82 };8384 const onDrop = (e: DragEvent) => {85 e.preventDefault();86 setDrag(false);87 if (e.dataTransfer.files?.length) void upload(e.dataTransfer.files);88 };8990 const toggleMic = () => {91 if (listening) {92 recRef.current?.stop();93 setListening(false);94 return;95 }96 const Ctor = window.SpeechRecognition || window.webkitSpeechRecognition;97 if (!Ctor) return;98 const rec = new Ctor();99 rec.lang = 'fr-CA';100 rec.interimResults = false;101 rec.continuous = true;102 rec.onresult = (ev) => {103 const parts: string[] = [];104 for (let i = 0; i < ev.results.length; i++) parts.push(ev.results[i][0].transcript);105 setText((t) => (t ? t + ' ' : '') + parts.join(' ').trim());106 };107 rec.onend = () => setListening(false);108 recRef.current = rec;109 rec.start();110 setListening(true);111 };112113 return (114 <div className="px-2.5 sm:px-6 pb-[calc(6px+var(--safe-bottom))] pt-2 bg-gradient-to-t from-[#f4f7f9] via-[#f4f7f9]/95 to-transparent">115 <div116 onDragOver={(e) => { e.preventDefault(); setDrag(true); }}117 onDragLeave={() => setDrag(false)}118 onDrop={onDrop}119 className={cn('mx-auto max-w-[900px] rounded-[22px] border bg-white shadow-float transition-colors focus-within:border-uqo-blue/60', drag ? 'border-uqo-green ring-2 ring-uqo-green/30' : 'border-neutral-line')}120 >121 {(files.length > 0 || error) && (122 <div className="flex flex-wrap gap-2 px-3 pt-3">123 {files.map((f) => (124 <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">125 <FileText size={13} /> <span className="max-w-[160px] truncate">{f.filename}</span> <span className="text-neutral-muted">{fmtBytes(f.size)}</span>126 <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>127 </span>128 ))}129 {error && <span className="text-xs text-semantic-error self-center">{error}</span>}130 </div>131 )}132 <textarea133 ref={taRef}134 value={text}135 onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}136 onKeyDown={onKey}137 rows={1}138 placeholder={disabled ? 'Connexion requise' : window.innerWidth < 640 ? 'Pose ta question au tuteur…' : 'Pose ta question… (Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne)'}139 aria-label="Message"140 disabled={disabled}141 className="w-full resize-none bg-transparent px-4 pt-3 pb-1 text-[16px] leading-relaxed outline-none placeholder:text-neutral-muted/70 max-h-[200px] scroll-thin"142 />143 <div className="flex items-center gap-1 px-2 pb-2">144 <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)} />145 <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)">146 <Paperclip size={19} className={uploading ? 'animate-pulse' : ''} />147 </button>148 {speechOk && (149 <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">150 {listening ? <MicOff size={19} /> : <Mic size={19} />}151 </button>152 )}153 <select value={course} onChange={(e) => onCourseChange(e.target.value)} aria-label="Cours" disabled={!!conversationId}154 className="h-9 rounded-full border border-neutral-line bg-neutral-surface px-2.5 text-xs font-semibold text-uqo-blue-dark disabled:opacity-70">155 {courses.map((c) => <option key={c} value={c}>{c}</option>)}156 </select>157 <button onClick={onToggleDeep} className={cn('h-9 px-2.5 rounded-full text-xs font-semibold inline-flex items-center gap-1 border transition', deep ? 'bg-uqo-blue text-white border-uqo-blue' : 'border-neutral-line bg-neutral-surface text-neutral-muted hover:bg-white')} title="Réflexion approfondie : modèle plus puissant, plus lent" aria-pressed={deep}>158 <Brain size={14} /> <span className="hidden sm:inline">Réflexion approfondie</span><span className="sm:hidden">Approfondi</span>159 </button>160 <div className="flex-1" />161 {streaming ? (162 <button onClick={onStop} className="h-11 w-11 inline-flex items-center justify-center rounded-full bg-neutral-text text-white shadow-soft active:scale-95 transition" aria-label="Arrêter"><Square size={16} /></button>163 ) : (164 <button onClick={send} disabled={!text.trim() || disabled} className="h-11 w-11 inline-flex items-center justify-center rounded-full bg-uqo-gradient text-white shadow-soft active:scale-95 transition disabled:bg-none disabled:bg-neutral-line disabled:text-neutral-muted disabled:shadow-none" aria-label="Envoyer"><ArrowUp size={19} /></button>165 )}166 </div>167 </div>168 <p className="mx-auto max-w-[900px] mt-1.5 text-center text-[10.5px] leading-tight text-neutral-muted px-2"><span className="sm:hidden">Outil pédagogique — vérifie les calculs importants.</span><span className="hidden sm:inline">Outil pédagogique. UQO-Chat peut se tromper : vérifie les calculs importants. Ne constitue pas une évaluation professionnelle.</span></p>169 </div>170 );171}172