SPB Git

spb/uqo-chat

Public
7.5 KB · 108 lines tsx
1import { useEffect, 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';1112export function Sidebar({ onClose }: { onClose?: () => void }) {13  const { id } = useParams();14  const nav = useNavigate();15  const { conversations, loadConversations, updateConversation, deleteConversation } = useChat();16  const user = useAuth((s) => s.user);17  const logout = useAuth((s) => s.logout);18  const { course, setSettings } = useUI();19  const [q, setQ] = useState('');20  const [renaming, setRenaming] = useState<string | null>(null);21  const [draft, setDraft] = useState('');2223  useEffect(() => {24    const t = setTimeout(() => loadConversations(q || undefined).catch(() => undefined), q ? 250 : 0);25    return () => clearTimeout(t);26  }, [q, loadConversations]);2728  const go = (path: string) => {29    nav(path);30    onClose?.();31  };3233  const rename = async (cid: string) => {34    const title = draft.trim();35    setRenaming(null);36    if (title) await updateConversation(cid, { title });37  };3839  const remove = async (cid: string) => {40    if (!confirm('Supprimer définitivement cette conversation ?')) return;41    await deleteConversation(cid);42    if (id === cid) go('/');43  };4445  return (46    <aside className="flex h-full w-[300px] max-w-[85vw] flex-col bg-neutral-surface border-r border-neutral-line">47      <div className="flex items-center justify-between px-4 pt-[calc(12px+var(--safe-top))] pb-3">48        <Link to="/" onClick={onClose}><Logo /></Link>49        {onClose && <button onClick={onClose} className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white" aria-label="Fermer le menu"><X size={20} /></button>}50      </div>51      <div className="px-3 space-y-2">52        <button onClick={() => go('/')} className="w-full h-11 rounded-xl bg-uqo-blue text-white font-medium inline-flex items-center justify-center gap-2 hover:bg-uqo-blue-dark">53          <Plus size={18} /> Nouvelle conversation <span className="text-white/70 text-xs">· {course}</span>54        </button>55        <label className="relative block">56          <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-neutral-muted" />57          <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Rechercher…" className="w-full h-11 rounded-xl border border-neutral-line bg-white pl-9 pr-3 text-sm" aria-label="Rechercher dans l'historique" />58        </label>59      </div>60      <nav className="mt-3 flex-1 overflow-y-auto scroll-thin px-2" aria-label="Conversations">61        {conversations.length === 0 && <p className="px-3 py-6 text-center text-sm text-neutral-muted">Aucune conversation pour l'instant.</p>}62        <ul className="space-y-0.5">63          {conversations.map((c) => (64            <li key={c.id} className={cn('group flex items-center rounded-xl', id === c.id ? 'bg-white shadow-card' : 'hover:bg-white/70')}>65              {renaming === c.id ? (66                <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" />67              ) : (68                <button onClick={() => go(`/c/${c.id}`)} className="flex-1 min-w-0 text-left px-3 py-2.5">69                  <span className="flex items-center gap-1.5 text-sm font-medium truncate">{c.pinned && <Pin size={12} className="text-uqo-blue shrink-0" />}<span className="truncate">{c.title}</span></span>70                  <span className="block text-[11px] text-neutral-muted">{c.course} · {fmtDate(c.updated_at)}</span>71                </button>72              )}73              <DM.Root>74                <DM.Trigger asChild>75                  <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>76                </DM.Trigger>77                <DM.Portal>78                  <DM.Content align="end" className="z-50 min-w-[190px] rounded-xl border border-neutral-line bg-white p-1 shadow-card text-sm">79                    <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>80                    <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>81                    <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>82                    <DM.Separator className="my-1 h-px bg-neutral-line" />83                    <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>84                  </DM.Content>85                </DM.Portal>86              </DM.Root>87            </li>88          ))}89        </ul>90      </nav>91      <div className="border-t border-neutral-line p-3 pb-[calc(12px+var(--safe-bottom))] space-y-1">92        {user && (user.role === 'professor' || user.role === 'admin') && (93          <button onClick={() => go('/professeur')} className="w-full h-11 rounded-xl px-3 inline-flex items-center gap-2 text-sm hover:bg-white"><BarChart3 size={16} className="text-uqo-blue" /> Tableau de bord professeur</button>94        )}95        {user?.role === 'admin' && (96          <button onClick={() => go('/admin')} className="w-full h-11 rounded-xl px-3 inline-flex items-center gap-2 text-sm hover:bg-white"><ShieldCheck size={16} className="text-uqo-blue" /> Coûts et système</button>97        )}98        <div className="flex items-center gap-2 pt-1">99          <span className="h-9 w-9 rounded-full bg-uqo-blue-light text-uqo-blue-dark inline-flex items-center justify-center text-xs font-semibold">{(user?.display_name || user?.email || '?').slice(0, 2).toUpperCase()}</span>100          <span className="min-w-0 flex-1"><span className="block text-sm font-medium truncate">{user?.display_name || user?.email}</span><span className="block text-[11px] text-neutral-muted capitalize">{user?.role === 'professor' ? 'professeur' : user?.role === 'admin' ? 'administrateur' : 'étudiant·e'}</span></span>101          <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>102          <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>103        </div>104      </div>105    </aside>106  );107}108
Files · cd08d441f7c277e8fedd6f3d3be82f02b44553ba
Menu

Theme