Python 64.6%
TypeScript 33.7%
CSS 0.8%
1import { useEffect, useMemo, useState } from 'react';2import { Link, useNavigate, useParams } from 'react-router-dom';3import { Archive, BarChart3, LogOut, MoreHorizontal, Pencil, Pin, PinOff, Plus, Search, Settings, ShieldCheck, Trash2, X } from 'lucide-react';4import * as DM from '@radix-ui/react-dropdown-menu';5import { useChat } from '@/stores/chat';6import { useAuth } from '@/stores/auth';7import { useUI } from '@/stores/ui';8import { Logo } from '@/components/ui/logo';9import { cn } from '@/lib/cn';10import { fmtDate } from '@/lib/format';11import type { Conversation } from '@/lib/types';1213function groupLabel(iso: string): string {14 const d = new Date(iso.endsWith('Z') ? iso : iso + 'Z');15 const now = new Date();16 const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();17 const diffDays = Math.floor((startToday - new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()) / 86400000);18 if (diffDays <= 0) return "Aujourd'hui";19 if (diffDays === 1) return 'Hier';20 if (diffDays < 7) return '7 derniers jours';21 if (diffDays < 30) return '30 derniers jours';22 return 'Plus ancien';23}2425export function Sidebar({ onClose }: { onClose?: () => void }) {26 const { id } = useParams();27 const nav = useNavigate();28 const { conversations, loadConversations, updateConversation, deleteConversation } = useChat();29 const user = useAuth((s) => s.user);30 const logout = useAuth((s) => s.logout);31 const { course, setSettings } = useUI();32 const [q, setQ] = useState('');33 const [renaming, setRenaming] = useState<string | null>(null);34 const [draft, setDraft] = useState('');3536 useEffect(() => {37 const t = setTimeout(() => loadConversations(q || undefined).catch(() => undefined), q ? 250 : 0);38 return () => clearTimeout(t);39 }, [q, loadConversations]);4041 const groups = useMemo(() => {42 const pinned = conversations.filter((c) => c.pinned);43 const rest = conversations.filter((c) => !c.pinned);44 const out: { label: string; items: Conversation[] }[] = [];45 if (pinned.length) out.push({ label: 'Épinglées', items: pinned });46 for (const c of rest) {47 const label = groupLabel(c.updated_at);48 const g = out.find((x) => x.label === label);49 if (g) g.items.push(c); else out.push({ label, items: [c] });50 }51 return out;52 }, [conversations]);5354 const go = (path: string) => { nav(path); onClose?.(); };55 const rename = async (cid: string) => { const title = draft.trim(); setRenaming(null); if (title) await updateConversation(cid, { title }); };56 const remove = async (cid: string) => {57 if (!confirm('Supprimer définitivement cette conversation ?')) return;58 await deleteConversation(cid);59 if (id === cid) go('/');60 };61 const roleLabel = user?.role === 'professor' ? 'Professeur' : user?.role === 'admin' ? 'Administrateur' : 'Étudiant·e';6263 return (64 <aside className="flex h-full w-[304px] max-w-[86vw] flex-col bg-white border-r border-neutral-line">65 <div className="bg-uqo-gradient text-white px-4 pt-[calc(14px+var(--safe-top))] pb-4">66 <div className="flex items-center justify-between">67 <Link to="/" onClick={onClose}><Logo inverted /></Link>68 {onClose && <button onClick={onClose} className="h-10 w-10 inline-flex items-center justify-center rounded-xl hover:bg-white/10" aria-label="Fermer le menu"><X size={20} /></button>}69 </div>70 <button onClick={() => go('/')} className="mt-4 w-full h-11 rounded-xl bg-white text-uqo-blue-dark font-semibold inline-flex items-center justify-center gap-2 shadow-soft hover:bg-uqo-blue-light active:scale-[0.99] transition">71 <Plus size={18} /> Nouvelle conversation <span className="text-neutral-muted text-xs font-medium">· {course}</span>72 </button>73 </div>74 <div className="px-3 pt-3">75 <label className="relative block">76 <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-muted" />77 <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Rechercher une conversation…" className="w-full h-11 rounded-xl border border-neutral-line bg-neutral-surface pl-9 pr-3 text-sm focus:bg-white" aria-label="Rechercher dans l'historique" />78 </label>79 </div>80 <nav className="mt-2 flex-1 overflow-y-auto scroll-thin px-2 pb-2" aria-label="Conversations">81 {conversations.length === 0 && <p className="px-3 py-8 text-center text-sm text-neutral-muted">Aucune conversation pour l'instant.<br />Pose ta première question !</p>}82 {groups.map((g) => (83 <div key={g.label} className="mt-2">84 <div className="px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-muted">{g.label}</div>85 <ul className="space-y-0.5">86 {g.items.map((c) => (87 <li key={c.id} className={cn('group flex items-center rounded-xl transition', id === c.id ? 'bg-uqo-blue-light/70' : 'hover:bg-neutral-surface')}>88 {renaming === c.id ? (89 <input autoFocus value={draft} onChange={(e) => setDraft(e.target.value)} onBlur={() => rename(c.id)} onKeyDown={(e) => e.key === 'Enter' && rename(c.id)} className="flex-1 h-11 bg-transparent px-3 text-sm outline-none" />90 ) : (91 <button onClick={() => go(`/c/${c.id}`)} className="flex-1 min-w-0 text-left px-3 py-2.5">92 <span className={cn('flex items-center gap-1.5 text-[13.5px] truncate', id === c.id ? 'font-semibold text-uqo-blue-dark' : 'font-medium')}>{c.pinned && <Pin size={12} className="text-uqo-blue shrink-0" />}<span className="truncate">{c.title}</span></span>93 <span className="block text-[11px] text-neutral-muted">{c.course} · {fmtDate(c.updated_at)}</span>94 </button>95 )}96 <DM.Root>97 <DM.Trigger asChild>98 <button className="h-11 w-10 inline-flex items-center justify-center rounded-lg text-neutral-muted opacity-70 sm:opacity-0 group-hover:opacity-100 focus:opacity-100 data-[state=open]:opacity-100" aria-label="Options"><MoreHorizontal size={16} /></button>99 </DM.Trigger>100 <DM.Portal>101 <DM.Content align="end" className="z-50 min-w-[190px] rounded-xl border border-neutral-line bg-white p-1 shadow-float text-sm">102 <DM.Item onSelect={() => { setDraft(c.title); setRenaming(c.id); }} className="flex items-center gap-2 rounded-lg px-3 py-2.5 outline-none hover:bg-neutral-surface cursor-pointer"><Pencil size={14} /> Renommer</DM.Item>103 <DM.Item onSelect={() => updateConversation(c.id, { pinned: !c.pinned })} className="flex items-center gap-2 rounded-lg px-3 py-2.5 outline-none hover:bg-neutral-surface cursor-pointer">{c.pinned ? <PinOff size={14} /> : <Pin size={14} />} {c.pinned ? 'Désépingler' : 'Épingler'}</DM.Item>104 <DM.Item onSelect={() => updateConversation(c.id, { archived: true })} className="flex items-center gap-2 rounded-lg px-3 py-2.5 outline-none hover:bg-neutral-surface cursor-pointer"><Archive size={14} /> Archiver</DM.Item>105 <DM.Separator className="my-1 h-px bg-neutral-line" />106 <DM.Item onSelect={() => remove(c.id)} className="flex items-center gap-2 rounded-lg px-3 py-2.5 outline-none text-semantic-error hover:bg-red-50 cursor-pointer"><Trash2 size={14} /> Supprimer</DM.Item>107 </DM.Content>108 </DM.Portal>109 </DM.Root>110 </li>111 ))}112 </ul>113 </div>114 ))}115 </nav>116 <div className="border-t border-neutral-line p-3 pb-[calc(12px+var(--safe-bottom))] space-y-1 bg-neutral-surface/60">117 {user && (user.role === 'professor' || user.role === 'admin') && (118 <button onClick={() => go('/professeur')} className="w-full h-11 rounded-xl px-3 inline-flex items-center gap-2 text-sm font-medium hover:bg-white"><BarChart3 size={16} className="text-uqo-blue" /> Tableau de bord professeur</button>119 )}120 {user?.role === 'admin' && (121 <button onClick={() => go('/admin')} className="w-full h-11 rounded-xl px-3 inline-flex items-center gap-2 text-sm font-medium hover:bg-white"><ShieldCheck size={16} className="text-uqo-blue" /> Coûts et système</button>122 )}123 <div className="flex items-center gap-2 pt-1">124 <span className="h-9 w-9 rounded-full bg-uqo-gradient text-white inline-flex items-center justify-center text-xs font-bold">{(user?.display_name || user?.email || '?').slice(0, 2).toUpperCase()}</span>125 <span className="min-w-0 flex-1"><span className="block text-sm font-semibold truncate">{user?.display_name || user?.email}</span><span className="block text-[11px] text-neutral-muted">{roleLabel}</span></span>126 <button onClick={() => setSettings(true)} className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white" aria-label="Préférences"><Settings size={18} /></button>127 <button onClick={() => logout().then(() => go('/connexion'))} className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white" aria-label="Se déconnecter"><LogOut size={18} /></button>128 </div>129 </div>130 </aside>131 );132}133