SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
9.2 KB · 133 lines tsx
Raw Blame History
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