feat(ui): official UQO wordmark + brand blue #0F6180, premium mobile pass (gradient hero, drawer grouped by date, refined bubbles, floating composer, skeletons, PWA icons/OG)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
31 changed files +265 −182
modified
backend/app/tools/create_docx.py
+1 −1
@@ -13,7 +13,7 @@ from app.llm.schemas import Artifact, ToolResult | ||
| 13 | 13 | from app.services import files as file_service |
| 14 | 14 | from app.tools.registry import ToolContext, registry |
| 15 | 15 | |
| 16 | −UQO_BLUE = "00467F" | |
| 16 | +UQO_BLUE = "0F6180" | |
| 17 | 17 | |
| 18 | 18 | |
| 19 | 19 | class Args(BaseModel): |
modified
backend/app/tools/create_excel.py
+3 −3
@@ -21,9 +21,9 @@ from app.tools.excel_spec import normalise_spec | ||
| 21 | 21 | from app.tools.excel_templates import TEMPLATES |
| 22 | 22 | from app.tools.registry import ToolContext, registry |
| 23 | 23 | |
| 24 | −UQO_BLUE = "00467F" | |
| 25 | −UQO_BLUE_DARK = "003057" | |
| 26 | −UQO_BLUE_LIGHT = "E6EEF5" | |
| 24 | +UQO_BLUE = "0F6180" | |
| 25 | +UQO_BLUE_DARK = "0A4A63" | |
| 26 | +UQO_BLUE_LIGHT = "E6F0F4" | |
| 27 | 27 | UQO_GREEN = "78BE20" |
| 28 | 28 | INPUT_FILL = PatternFill("solid", fgColor="DDEBF7") |
| 29 | 29 | INPUT_FONT = Font(color="1F4E79", bold=False) |
modified
backend/app/tools/excel_ops.py
+1 −1
@@ -24,7 +24,7 @@ from app.tools.create_excel import ( | ||
| 24 | 24 | _readme, |
| 25 | 25 | ) |
| 26 | 26 | |
| 27 | −UQO_BLUE_DARK = "003057" | |
| 27 | +UQO_BLUE_DARK = "0A4A63" | |
| 28 | 28 | TOTAL_BORDER = Border(top=Side(style="medium", color=UQO_BLUE_DARK)) |
| 29 | 29 | |
| 30 | 30 | |
modified
backend/app/tools/make_chart.py
+2 −2
@@ -12,7 +12,7 @@ from app.services import files as file_service | ||
| 12 | 12 | from app.tools.coerce import num |
| 13 | 13 | from app.tools.registry import ToolContext, registry |
| 14 | 14 | |
| 15 | −PALETTE = ["#00467F", "#78BE20", "#C6A300", "#5B6B7B", "#7FA7C9", "#C8102E", "#2E8B57", "#8B5CF6"] | |
| 15 | +PALETTE = ["#0F6180", "#78BE20", "#C6A300", "#5B6B7B", "#7FA7C9", "#C8102E", "#2E8B57", "#8B5CF6"] | |
| 16 | 16 | |
| 17 | 17 | |
| 18 | 18 | class Series(BaseModel): |
@@ -113,7 +113,7 @@ def render(args: dict[str, Any]) -> bytes: | ||
| 113 | 113 | if n > 1 or any(s[0] for s in series): |
| 114 | 114 | ax.legend(frameon=False, fontsize=9) |
| 115 | 115 | if args.get("title"): |
| 116 | − ax.set_title(args["title"], fontsize=12, fontweight="bold", color="#003057", loc="left") | |
| 116 | + ax.set_title(args["title"], fontsize=12, fontweight="bold", color="#0A4A63", loc="left") | |
| 117 | 117 | fig.text(0.99, 0.01, "UQO-Chat · outil pédagogique", ha="right", va="bottom", fontsize=7, color="#5B6B7B") |
| 118 | 118 | fig.tight_layout() |
| 119 | 119 | buf = io.BytesIO() |
modified
frontend/index.html
+3 −2
@@ -3,7 +3,7 @@ | ||
| 3 | 3 | <head> |
| 4 | 4 | <meta charset="UTF-8" /> |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> |
| 6 | − <meta name="theme-color" content="#00467F" /> | |
| 6 | + <meta name="theme-color" content="#0F6180" /> | |
| 7 | 7 | <meta name="description" content="UQO-Chat — tuteur IA en évaluation immobilière pour les cours IMM1003 et IMM1033 de l'UQO." /> |
| 8 | 8 | <meta name="apple-mobile-web-app-capable" content="yes" /> |
| 9 | 9 | <meta name="apple-mobile-web-app-status-bar-style" content="default" /> |
@@ -12,7 +12,8 @@ | ||
| 12 | 12 | <meta property="og:description" content="Comprendre, pratiquer et calculer en évaluation immobilière, 24/7." /> |
| 13 | 13 | <meta property="og:image" content="/icons/og.png" /> |
| 14 | 14 | <link rel="icon" href="/icons/favicon.svg" type="image/svg+xml" /> |
| 15 | − <link rel="apple-touch-icon" href="/icons/icon-192.png" /> | |
| 15 | + <link rel="icon" href="/icons/favicon-32.png" sizes="32x32" type="image/png" /> | |
| 16 | + <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" /> | |
| 16 | 17 | <link rel="preconnect" href="https://fonts.googleapis.com" /> |
| 17 | 18 | <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> |
| 18 | 19 | <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" /> |
modified
frontend/public/icons/apple-touch-icon.png
+0 −0
Binary file not shown.
added
frontend/public/icons/favicon-32.png
+0 −0
Binary file not shown.
added
frontend/public/icons/favicon-64.png
+0 −0
Binary file not shown.
modified
frontend/public/icons/favicon.svg
+1 −1
@@ -1 +1 @@ | ||
| 1 | −<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="1" y="1" width="30" height="30" rx="8" fill="#00467F"/><path d="M8 24V13l8-6 8 6v11h-5v-6h-6v6H8z" fill="#fff"/><rect x="21" y="8" width="3" height="4" rx=".6" fill="#78BE20"/></svg> | |
| 1 | +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 64 64"><image width="64" height="64" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAHG0lEQVR4nO1be1BUVRj/3bOLPOWhooCg4gtlFHyBCkmOL3zrTKZik+9MqzGzmqz+kKayMnXSGTNnCkxnxEpr8lG+GhU11DA1Hyj4FlIUwQUEdmF3m+9b7vWyArIiItx+zJ0997x/v/Od7ztnAeB/aBvS4zTymhFvxTMKQ2K8Q5ykxkC6NmKIxkq+pvOWattBQ7cGoQXy1fERjlRu6KiMl4DGIbSy+lXxE9UVNlaoeQpoHAIah9Ca+dvzFdA4BDQOAY1DQOMQ0DgENA4BjUNA49A/rYF0QoLZYjtrNfNwhZ93U0iShOx7BcgpKOJ8IUmw0o+16rZebi4Iau4FJ50O2YZC/JuXz/lS+dcd9m2fqAD9O7eBp4szp6/cyUP6zZwKAwf4eKJ7UCt+L7NYcPD8VZjKzEyMCAzo0g6vDI5EZMcgNPdw47Z5hcU4fiULCftSsfufDIWMTERu2ys4APOG9sNzIe3QwtOdRSkoNuJsZjbWJ5/Apj9PPdS2JpAcOQqf+Hw+glv6cHrNniN4P2kX9EIohKc93wsrp41R6oe8tRy38wt5Qp9OGobXY/tX2//65L+xcP0OWKxsBwr5BSOjsfiFIYrYlYHEm712CwpKjPxeExEMifGSQz6AOqcJ0VNkLH2onFZbLjcUlbC6NJElk2OZPIlUFUrNFkyN6YVVM8awACQs9TN3aF/ETxjCglC+DHlLEIxlZgwL64Sk+XHcTnLg2369IwLQipDpcbr8Uw0aWC5votfBUGxEZIdAvDasH4tDebmFxVi2PRkH0q7AYrEiqnMbvDM6Bv4+TZnIlOge+P1kOrYdT0OIfwt8PHEYC0djG0vLsGLHIew8lY4SUynC2vrj7VEDEBrYkttGh7TFnCF9sXpXSt0I4Cho1d4YHsVWQILl3S/GqC/WIS3rtlKH0ntOX8TuD2bC19OD6745IpoFmDUoAk46wdZBIkxamYTktCtK24xbd7HrVDp+WzQD3YP82ELmDI7Ed/v+QomprH7DoLHUjABvT0S0D+S9S6a5du9RJuys1/GK0uPipMf1nHtYvv2gYj3dAluhpac7IjoEsiAkws/HzjJ5dVtKF5aY8NHmvYp/aNvCG2FBfjWep3iSpNVOilbM19Md3u4uSl5KxnWeOJXRatEjR4ljlzKVei5N9Ojo1wLebq5Kn4cuXFWcotLWbObyszeyecU5jFqBwOZedSOAVeWE2KxVjCmpdlJUt6S0rEKeu3MTW12Vk5L7cHN2qtB3kclUwWlS/KctpRaZ+mFLcNJDpxPcjspp3DoRoLDEpKQpjhM5Mlt9+eCerrYzAsFsteJS9l1k5eYrISkuKlwRhCYqk6e8yf3Dlba594uQlnkbWbkGbkvPi3278yf5A9oStKXkA9KEft05jwQiZ3g+607dCHAtJ48nQYOO7BmCTn7NeUBS3MfdFdNieivmecdQyIJRfCaeZOpjenflmE4rS/1QPUrPHNiHQyDVobp7T1/kfskRcluzGT3a+WPF1FHsM2SnSJ/j+oTi3dExnCZBU9Kv4fLt3LqJAj+mnMbkqHCYzbb9vfvDWdiamsYCDO/RmR0QTZycU1L5yWz17hQObXT8JYIU03u2C8D8ddvQO7g1lsTFokuAb/nK6lBsKsXSrQeYzMbDpzB7UAS6traFORIqpksw/jhzCfeNJm7/fGiwIjphyS/7HKGEGp8EaUI0yNezxjEhWgH5FChDjvVHMq5j7JfrYbZY2FoGhrbHDwumsDCyQLRKi3/ayyTIKgjU/4w1m/Fr6jkl/NFZYNt70zkqyOavhjpvUdJOfLPnaIW7wxM7CVrLj6bzE7chYX/qQ+QJRH7nyXTErdrEYsg+Yv+5yxi79HtcvHVXEaF9y2ZImDsBmbkGTF6VxHUmfrWRyVMb2aQv3MzB8CUJXG5PnkB5mXcNmLV2i0PkH+suIKkuGn07BmF8RCibp04IXM7OxY4T5yu90MiTIk++9KURmNQ/zOZLrDYrItEWbtjBNzvZ0mSo38mSYsM7obO/L4tNDpbC49bUc8gvNjpM3pAY/yCoOPJ7AftJ2otEqO5KSw7vs7hYDouyCdPdgbZE0uGTnGcvQmXX5Kr6rykMjl6GZCjhTwjlVCaHJTls2YMmR+JQHbr1Df7kWzZreSt5uDjj1SGR6Bbkp2w39XjUp/2Y8jtVdZR8re8CZh7wwaBVWYQaNrO33fQoVo9ftgH9OrXhLzjO3MiucEcgER41pi1pbRjfCKmhPuFRxDhicxv1AgGNQ0DjENA4BDQOAY1DQOMQ0DhEfQ4+7+V1/GhSgHkq4vUpgoDGIepr4DUbpleaftqQ1C9a+lM5+c/nBTQOAY1D1OYfjhoq1DxFdYWNEfb8BDQOUVlmY7WCyngJRyo3ZFTFR6pJ44Z8PnjUQoon0cmziprMW3qcjp9li2ioi4X6wn8m6KGMj1O2WgAAAABJRU5ErkJggg=="/></svg> | |
| \ No newline at end of file | ||
modified
frontend/public/icons/icon-192.png
+0 −0
Binary file not shown.
modified
frontend/public/icons/icon-512.png
+0 −0
Binary file not shown.
modified
frontend/public/icons/icon-maskable-512.png
+0 −0
Binary file not shown.
modified
frontend/public/icons/og.png
+0 −0
Binary file not shown.
deleted
frontend/public/uqo-chat-logo.svg
+0 −1
@@ -1 +0,0 @@ | ||
| 1 | −<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect x="1" y="1" width="30" height="30" rx="8" fill="#00467F"/><path d="M8 24V13l8-6 8 6v11h-5v-6h-6v6H8z" fill="#fff"/><rect x="21" y="8" width="3" height="4" rx=".6" fill="#78BE20"/></svg> | |
added
frontend/public/uqo-logo-white.png
+0 −0
Binary file not shown.
added
frontend/public/uqo-logo.png
+0 −0
Binary file not shown.
modified
frontend/src/components/chat/chat-view.tsx
+9 −2
@@ -61,7 +61,14 @@ export function ChatView() { | ||
| 61 | 61 | {isStreaming && <div className="progress-bar" aria-hidden="true" />} |
| 62 | 62 | {id && warnings[id] && <div className="bg-[#fff8dc] text-[#7a6300] text-sm px-4 py-2 text-center">{warnings[id]}</div>} |
| 63 | 63 | <div ref={scrollRef} onScroll={onScroll} className="flex-1 overflow-y-auto scroll-thin"> |
| 64 | − {!id || list.length === 0 ? ( | |
| 64 | + {id && !messages[id] ? ( | |
| 65 | + <div className="mx-auto max-w-[900px] px-3 sm:px-6 py-6 space-y-5" aria-busy="true"> | |
| 66 | + <div className="flex justify-end"><div className="skeleton h-12 w-[60%]" /></div> | |
| 67 | + <div className="space-y-2"><div className="skeleton h-14 w-full" /><div className="skeleton h-28 w-[92%]" /></div> | |
| 68 | + <div className="flex justify-end"><div className="skeleton h-10 w-[45%]" /></div> | |
| 69 | + <div className="skeleton h-36 w-[88%]" /> | |
| 70 | + </div> | |
| 71 | + ) : !id || list.length === 0 ? ( | |
| 65 | 72 | <EmptyState course={courseObj} onPick={(t) => handleSend(t, [])} term={courseObj?.term || ''} /> |
| 66 | 73 | ) : ( |
| 67 | 74 | <div className="mx-auto max-w-[900px] px-3 sm:px-6 py-4 space-y-5"> |
@@ -81,7 +88,7 @@ export function ChatView() { | ||
| 81 | 88 | </div> |
| 82 | 89 | {!atBottom && list.length > 0 && ( |
| 83 | 90 | <button onClick={() => { setAtBottom(true); scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }); }} |
| 84 | − className="absolute right-4 bottom-[196px] sm:bottom-[150px] h-10 w-10 rounded-full bg-white border border-neutral-line shadow-card inline-flex items-center justify-center text-uqo-blue" aria-label="Aller en bas"> | |
| 91 | + className="absolute right-4 bottom-[172px] sm:bottom-[150px] h-10 w-10 rounded-full bg-white border border-neutral-line shadow-float inline-flex items-center justify-center text-uqo-blue active:scale-95 transition" aria-label="Aller en bas"> | |
| 85 | 92 | <ArrowDown size={18} /> |
| 86 | 93 | </button> |
| 87 | 94 | )} |
modified
frontend/src/components/chat/composer.tsx
+9 −9
@@ -111,12 +111,12 @@ export function Composer({ conversationId, streaming, onSend, onStop, deep, onTo | ||
| 111 | 111 | }; |
| 112 | 112 | |
| 113 | 113 | return ( |
| 114 | − <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"> | |
| 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 | 115 | <div |
| 116 | 116 | onDragOver={(e) => { e.preventDefault(); setDrag(true); }} |
| 117 | 117 | onDragLeave={() => setDrag(false)} |
| 118 | 118 | onDrop={onDrop} |
| 119 | − 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')} | |
| 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 | 120 | > |
| 121 | 121 | {(files.length > 0 || error) && ( |
| 122 | 122 | <div className="flex flex-wrap gap-2 px-3 pt-3"> |
@@ -135,10 +135,10 @@ export function Composer({ conversationId, streaming, onSend, onStop, deep, onTo | ||
| 135 | 135 | onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)} |
| 136 | 136 | onKeyDown={onKey} |
| 137 | 137 | rows={1} |
| 138 | − placeholder={disabled ? 'Connexion requise' : 'Pose ta question… (Entrée pour envoyer, Maj+Entrée pour une nouvelle ligne)'} | |
| 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 | 139 | aria-label="Message" |
| 140 | 140 | disabled={disabled} |
| 141 | − 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" | |
| 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 | 142 | /> |
| 143 | 143 | <div className="flex items-center gap-1 px-2 pb-2"> |
| 144 | 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)} /> |
@@ -151,21 +151,21 @@ export function Composer({ conversationId, streaming, onSend, onStop, deep, onTo | ||
| 151 | 151 | </button> |
| 152 | 152 | )} |
| 153 | 153 | <select value={course} onChange={(e) => onCourseChange(e.target.value)} aria-label="Cours" disabled={!!conversationId} |
| 154 | − className="h-9 rounded-lg border border-neutral-line bg-white px-2 text-xs font-medium text-uqo-blue-dark disabled:opacity-70"> | |
| 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 | 155 | {courses.map((c) => <option key={c} value={c}>{c}</option>)} |
| 156 | 156 | </select> |
| 157 | − <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}> | |
| 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 | 158 | <Brain size={14} /> <span className="hidden sm:inline">Réflexion approfondie</span><span className="sm:hidden">Approfondi</span> |
| 159 | 159 | </button> |
| 160 | 160 | <div className="flex-1" /> |
| 161 | 161 | {streaming ? ( |
| 162 | − <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> | |
| 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 | 163 | ) : ( |
| 164 | − <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> | |
| 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 | 165 | )} |
| 166 | 166 | </div> |
| 167 | 167 | </div> |
| 168 | − <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> | |
| 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 | 169 | </div> |
| 170 | 170 | ); |
| 171 | 171 | } |
modified
frontend/src/components/chat/empty-state.tsx
+37 −18
@@ -1,39 +1,58 @@ | ||
| 1 | −import { Calculator, FileSpreadsheet, ListChecks, Sparkles } from 'lucide-react'; | |
| 1 | +import { BookOpen, Calculator, FileSpreadsheet, ListChecks, MessageSquareText, Sparkles } from 'lucide-react'; | |
| 2 | 2 | import type { Course } from '@/lib/types'; |
| 3 | −import { Logo } from '@/components/ui/logo'; | |
| 4 | 3 | |
| 5 | −const ICONS = [Sparkles, Calculator, ListChecks, FileSpreadsheet, Sparkles]; | |
| 4 | +const ICONS = [Sparkles, Calculator, ListChecks, FileSpreadsheet, MessageSquareText]; | |
| 6 | 5 | |
| 7 | 6 | export function EmptyState({ course, onPick, term }: { course?: Course; onPick: (t: string) => void; term: string }) { |
| 8 | 7 | return ( |
| 9 | − <div className="mx-auto max-w-[760px] px-4 pt-8 sm:pt-16 pb-6 text-center animate-fadein"> | |
| 10 | − <Logo className="justify-center" withText={false} /> | |
| 11 | − <h1 className="mt-4 text-xl sm:text-2xl font-bold text-uqo-blue-dark">Bonjour ! Sur quoi travaille-t-on aujourd'hui ?</h1> | |
| 12 | − <p className="mt-2 text-sm sm:text-base text-neutral-muted"> | |
| 13 | − {course ? <><b className="text-uqo-blue">{course.code}</b> — {course.title}</> : 'Tuteur IA en évaluation immobilière'} · {term} | |
| 14 | − </p> | |
| 8 | + <div className="mx-auto max-w-[780px] px-3 sm:px-4 pt-3 sm:pt-10 pb-6 animate-fadein"> | |
| 9 | + <section className="relative overflow-hidden rounded-[24px] bg-uqo-gradient text-white px-5 py-6 sm:px-8 sm:py-8 shadow-float"> | |
| 10 | + <svg className="absolute -right-10 -top-10 h-56 w-56 opacity-15" viewBox="0 0 200 200" aria-hidden="true"><circle cx="100" cy="100" r="100" fill="white" /></svg> | |
| 11 | + <svg className="absolute right-16 bottom-[-60px] h-40 w-40 opacity-10" viewBox="0 0 200 200" aria-hidden="true"><circle cx="100" cy="100" r="100" fill="#78BE20" /></svg> | |
| 12 | + <img src="/uqo-logo-white.png" alt="UQO" className="h-9 sm:h-11 w-auto" /> | |
| 13 | + <h1 className="mt-4 text-[22px] sm:text-[28px] font-bold leading-tight tracking-tight">Bonjour ! Sur quoi travaille-t-on aujourd'hui ?</h1> | |
| 14 | + <p className="mt-2 text-sm sm:text-base text-white/85"> | |
| 15 | + {course ? <><span className="inline-block rounded-md bg-white/15 px-1.5 py-0.5 font-semibold text-white">{course.code}</span> {course.title}</> : 'Tuteur IA en évaluation immobilière'} | |
| 16 | + {term ? <span className="text-white/70"> · {term}</span> : null} | |
| 17 | + </p> | |
| 18 | + <ul className="mt-4 flex flex-wrap gap-1.5 text-[11.5px] text-white/85"> | |
| 19 | + {['Explique et cite tes notes de cours', 'Calcule en Python vérifié', 'Crée et modifie des Excel', 'Quiz et fiches Word'].map((t) => ( | |
| 20 | + <li key={t} className="rounded-full bg-white/12 px-2.5 py-1 backdrop-blur">{t}</li> | |
| 21 | + ))} | |
| 22 | + </ul> | |
| 23 | + </section> | |
| 24 | + | |
| 15 | 25 | {course?.announcement && ( |
| 16 | − <div className="mt-4 rounded-xl border border-uqo-gold/50 bg-[#fff8dc] text-left px-4 py-3 text-sm"> | |
| 26 | + <div className="mt-3 rounded-2xl border border-uqo-gold/40 bg-[#fff8dc] px-4 py-3 text-sm shadow-soft"> | |
| 17 | 27 | <b className="text-[#7a6300]">Annonce du professeur</b> |
| 18 | 28 | <p className="mt-0.5">{course.announcement}</p> |
| 19 | 29 | </div> |
| 20 | 30 | )} |
| 21 | − <div className="mt-6 grid gap-2 sm:grid-cols-2"> | |
| 31 | + | |
| 32 | + <h2 className="mt-5 mb-2 px-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-muted">Pour commencer</h2> | |
| 33 | + <div className="grid gap-2 sm:grid-cols-2"> | |
| 22 | 34 | {(course?.suggestions || []).map((s, i) => { |
| 23 | 35 | const Icon = ICONS[i % ICONS.length]; |
| 24 | 36 | return ( |
| 25 | − <button key={s} onClick={() => onPick(s)} className="flex items-center gap-3 rounded-xl border border-neutral-line bg-white px-4 py-3 text-left text-sm hover:border-uqo-blue hover:bg-uqo-blue-light/40 min-h-[56px] shadow-card"> | |
| 26 | − <Icon size={18} className="text-uqo-blue shrink-0" /> | |
| 27 | − <span>{s}</span> | |
| 37 | + <button key={s} onClick={() => onPick(s)} className="group flex items-center gap-3 rounded-2xl border border-neutral-line bg-white px-4 py-3.5 text-left text-[14.5px] hover:border-uqo-blue/50 hover:shadow-soft active:scale-[0.99] transition min-h-[58px]"> | |
| 38 | + <span className="h-9 w-9 rounded-xl bg-uqo-blue-light text-uqo-blue inline-flex items-center justify-center shrink-0 group-hover:bg-uqo-blue group-hover:text-white transition"><Icon size={17} /></span> | |
| 39 | + <span className="text-neutral-text">{s}</span> | |
| 28 | 40 | </button> |
| 29 | 41 | ); |
| 30 | 42 | })} |
| 31 | 43 | </div> |
| 44 | + | |
| 45 | + {course?.site && ( | |
| 46 | + <a href={course.site} target="_blank" rel="noopener noreferrer" className="mt-3 flex items-center gap-3 rounded-2xl border border-dashed border-neutral-line px-4 py-3 text-sm text-neutral-muted hover:border-uqo-blue/50 hover:text-uqo-blue-dark"> | |
| 47 | + <BookOpen size={16} className="text-uqo-blue" /> Notes de cours interactives : {course.site.replace('https://', '')} | |
| 48 | + </a> | |
| 49 | + )} | |
| 50 | + | |
| 32 | 51 | {course?.deadlines?.length ? ( |
| 33 | − <div className="mt-6 text-left"> | |
| 34 | − <div className="text-xs font-semibold text-neutral-muted uppercase tracking-wide">Échéances</div> | |
| 35 | − <ul className="mt-1 text-sm space-y-1"> | |
| 36 | − {course.deadlines.map((d) => <li key={d.label} className="flex justify-between border-b border-neutral-line/70 py-1"><span>{d.label}</span><span className="text-neutral-muted">{d.date}</span></li>)} | |
| 52 | + <div className="mt-5"> | |
| 53 | + <h2 className="mb-2 px-1 text-[11px] font-semibold uppercase tracking-wider text-neutral-muted">Échéances</h2> | |
| 54 | + <ul className="rounded-2xl border border-neutral-line bg-white divide-y divide-neutral-line/70"> | |
| 55 | + {course.deadlines.map((d) => <li key={d.label} className="flex justify-between px-4 py-2.5 text-sm"><span>{d.label}</span><span className="text-neutral-muted tabular-nums">{d.date}</span></li>)} | |
| 37 | 56 | </ul> |
| 38 | 57 | </div> |
| 39 | 58 | ) : null} |
modified
frontend/src/components/chat/message-bubble.tsx
+22 −13
@@ -6,6 +6,14 @@ import { ToolCallTimeline } from './tool-call-timeline'; | ||
| 6 | 6 | import { cn } from '@/lib/cn'; |
| 7 | 7 | import { fmtDate } from '@/lib/format'; |
| 8 | 8 | |
| 9 | +function TutorAvatar() { | |
| 10 | + return ( | |
| 11 | + <span className="h-8 w-8 rounded-full bg-uqo-gradient items-center justify-center shrink-0 mt-1 hidden sm:inline-flex shadow-soft" aria-hidden="true"> | |
| 12 | + <img src="/uqo-logo-white.png" alt="" className="h-3 w-auto" /> | |
| 13 | + </span> | |
| 14 | + ); | |
| 15 | +} | |
| 16 | + | |
| 9 | 17 | export function MessageBubble({ msg, conversationId, initials, onRegenerate, onFeedback, isLast }: { |
| 10 | 18 | msg: Message; |
| 11 | 19 | conversationId: string; |
@@ -24,12 +32,12 @@ export function MessageBubble({ msg, conversationId, initials, onRegenerate, onF | ||
| 24 | 32 | if (msg.role === 'user') { |
| 25 | 33 | return ( |
| 26 | 34 | <div className="flex justify-end gap-2 animate-fadein"> |
| 27 | − <div className="max-w-[88%] sm:max-w-[75%]"> | |
| 28 | − <div className="rounded-2xl rounded-br-md bg-uqo-blue text-white px-4 py-3 text-[15px] whitespace-pre-wrap break-words">{msg.content}</div> | |
| 29 | − {msg.attachments && msg.attachments.length > 0 && ( | |
| 30 | − <div className="mt-1 text-xs text-neutral-muted inline-flex items-center gap-1 justify-end w-full"><Paperclip size={12} /> {msg.attachments.length} fichier(s)</div> | |
| 31 | − )} | |
| 32 | − <div className="mt-1 text-[11px] text-neutral-muted text-right">{fmtDate(msg.created_at)}</div> | |
| 35 | + <div className="max-w-[86%] sm:max-w-[72%]"> | |
| 36 | + <div className="rounded-[20px] rounded-br-lg bg-uqo-gradient text-white px-4 py-3 text-[15px] leading-relaxed whitespace-pre-wrap break-words shadow-soft">{msg.content}</div> | |
| 37 | + <div className="mt-1 flex items-center justify-end gap-2 text-[11px] text-neutral-muted"> | |
| 38 | + {msg.attachments && msg.attachments.length > 0 && <span className="inline-flex items-center gap-1"><Paperclip size={11} /> {msg.attachments.length} fichier(s)</span>} | |
| 39 | + <span>{fmtDate(msg.created_at)}</span> | |
| 40 | + </div> | |
| 33 | 41 | </div> |
| 34 | 42 | <span className="hidden sm:inline-flex h-8 w-8 rounded-full bg-uqo-blue-light text-uqo-blue-dark items-center justify-center text-xs font-semibold shrink-0 mt-1">{initials}</span> |
| 35 | 43 | </div> |
@@ -39,10 +47,11 @@ export function MessageBubble({ msg, conversationId, initials, onRegenerate, onF | ||
| 39 | 47 | const empty = !msg.content && !(msg.tool_calls && msg.tool_calls.length); |
| 40 | 48 | return ( |
| 41 | 49 | <div className="flex gap-2 sm:gap-3 animate-fadein group"> |
| 42 | − <span className="h-8 w-8 rounded-full bg-uqo-blue text-white items-center justify-center text-[11px] font-bold shrink-0 mt-1 hidden sm:inline-flex">UQ</span> | |
| 50 | + <TutorAvatar /> | |
| 43 | 51 | <div className="min-w-0 flex-1 max-w-[900px]"> |
| 52 | + <div className="sm:hidden mb-1 flex items-center gap-1.5 text-[11px] font-semibold text-uqo-blue-dark"><img src="/uqo-logo.png" alt="" className="h-2.5 w-auto" /> Tuteur</div> | |
| 44 | 53 | {msg.tool_calls && msg.tool_calls.length > 0 && <ToolCallTimeline calls={msg.tool_calls} conversationId={conversationId} />} |
| 45 | − <div className={cn('rounded-2xl rounded-tl-md bg-white border border-neutral-line shadow-card px-4 py-3 text-[15px]', empty && msg.streaming && 'inline-block')}> | |
| 54 | + <div className={cn('rounded-[20px] rounded-tl-lg bg-white border border-neutral-line/80 shadow-soft px-4 py-3.5 text-[15px]', empty && msg.streaming && 'inline-block')}> | |
| 46 | 55 | {empty && msg.streaming ? ( |
| 47 | 56 | <span className="inline-flex gap-1 items-center h-5" aria-label="Le tuteur réfléchit"> |
| 48 | 57 | <i className="h-2 w-2 rounded-full bg-uqo-blue/60 animate-bounce [animation-delay:-0.2s]" /> |
@@ -58,17 +67,17 @@ export function MessageBubble({ msg, conversationId, initials, onRegenerate, onF | ||
| 58 | 67 | </div> |
| 59 | 68 | {!msg.streaming && ( |
| 60 | 69 | <div className="mt-1 flex items-center gap-0.5 text-neutral-muted"> |
| 61 | − <button onClick={copy} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface" aria-label="Copier">{copied ? <Check size={15} className="text-uqo-green" /> : <Copy size={15} />}</button> | |
| 70 | + <button onClick={copy} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-white active:scale-95 transition" aria-label="Copier">{copied ? <Check size={15} className="text-uqo-green" /> : <Copy size={15} />}</button> | |
| 62 | 71 | {onFeedback && ( |
| 63 | 72 | <> |
| 64 | − <button onClick={() => onFeedback(msg.feedback === 'up' ? null : 'up')} className={cn('h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface', msg.feedback === 'up' && 'text-uqo-green')} aria-label="Réponse utile"><ThumbsUp size={15} /></button> | |
| 65 | − <button onClick={() => onFeedback(msg.feedback === 'down' ? null : 'down')} className={cn('h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface', msg.feedback === 'down' && 'text-semantic-error')} aria-label="Réponse à améliorer"><ThumbsDown size={15} /></button> | |
| 73 | + <button onClick={() => onFeedback(msg.feedback === 'up' ? null : 'up')} className={cn('h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-white active:scale-95 transition', msg.feedback === 'up' && 'text-uqo-green')} aria-label="Réponse utile"><ThumbsUp size={15} /></button> | |
| 74 | + <button onClick={() => onFeedback(msg.feedback === 'down' ? null : 'down')} className={cn('h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-white active:scale-95 transition', msg.feedback === 'down' && 'text-semantic-error')} aria-label="Réponse à améliorer"><ThumbsDown size={15} /></button> | |
| 66 | 75 | </> |
| 67 | 76 | )} |
| 68 | 77 | {isLast && onRegenerate && ( |
| 69 | − <button onClick={onRegenerate} className="h-9 px-2 inline-flex items-center gap-1 rounded-lg hover:bg-neutral-surface text-xs" aria-label="Régénérer"><RefreshCw size={14} /> Régénérer</button> | |
| 78 | + <button onClick={onRegenerate} className="h-9 px-2 inline-flex items-center gap-1 rounded-lg hover:bg-white text-xs" aria-label="Régénérer"><RefreshCw size={14} /> Régénérer</button> | |
| 70 | 79 | )} |
| 71 | − <span className="ml-auto text-[11px]">{msg.model ? msg.model.split('/').pop() : ''} · {fmtDate(msg.created_at)}</span> | |
| 80 | + <span className="ml-auto text-[11px] truncate">{msg.model ? msg.model.split('/').pop() : ''} · {fmtDate(msg.created_at)}</span> | |
| 72 | 81 | </div> |
| 73 | 82 | )} |
| 74 | 83 | </div> |
modified
frontend/src/components/layout/app-shell.tsx
+15 −13
@@ -28,8 +28,8 @@ export function AppShell() { | ||
| 28 | 28 | <div className="hidden lg:block shrink-0"><Sidebar /></div> |
| 29 | 29 | <RD.Root open={sidebarOpen} onOpenChange={setSidebar}> |
| 30 | 30 | <RD.Portal> |
| 31 | − <RD.Overlay className="fixed inset-0 z-40 bg-uqo-blue-dark/40 lg:hidden" /> | |
| 32 | − <RD.Content className="fixed inset-y-0 left-0 z-50 lg:hidden outline-none animate-fadein" aria-describedby={undefined}> | |
| 31 | + <RD.Overlay className="fixed inset-0 z-40 bg-uqo-blue-dark/50 backdrop-blur-[2px] lg:hidden animate-fadein" /> | |
| 32 | + <RD.Content className="fixed inset-y-0 left-0 z-50 lg:hidden outline-none data-[state=open]:animate-[slidein_.22s_ease-out]" aria-describedby={undefined}> | |
| 33 | 33 | <RD.Title className="sr-only">Menu</RD.Title> |
| 34 | 34 | <Sidebar onClose={() => setSidebar(false)} /> |
| 35 | 35 | </RD.Content> |
@@ -37,29 +37,31 @@ export function AppShell() { | ||
| 37 | 37 | </RD.Root> |
| 38 | 38 | |
| 39 | 39 | <main className="relative flex min-w-0 flex-1 flex-col"> |
| 40 | − <header className="flex items-center gap-1 border-b border-neutral-line bg-white px-2 sm:px-4 pt-[var(--safe-top)] h-[calc(56px+var(--safe-top))] shrink-0"> | |
| 41 | − <button onClick={() => setSidebar(true)} className="lg:hidden h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface" aria-label="Ouvrir le menu"><Menu size={22} /></button> | |
| 42 | − <div className="lg:hidden"><Logo withText={false} /></div> | |
| 40 | + <header className="relative z-10 flex items-center gap-1 bg-white/90 backdrop-blur border-b border-neutral-line px-2 sm:px-4 pt-[var(--safe-top)] h-[calc(58px+var(--safe-top))] shrink-0"> | |
| 41 | + <span className="absolute inset-x-0 top-0 h-[3px] bg-gradient-to-r from-uqo-blue via-uqo-blue-dark to-uqo-green lg:hidden" aria-hidden="true" /> | |
| 42 | + <button onClick={() => setSidebar(true)} className="lg:hidden h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface active:scale-95 transition" aria-label="Ouvrir le menu"><Menu size={22} /></button> | |
| 43 | + <div className="lg:hidden pl-1"><Logo withText={false} size="sm" /></div> | |
| 43 | 44 | <div className="min-w-0 flex-1 px-2"> |
| 44 | − <div className="truncate text-sm sm:text-base font-semibold text-uqo-blue-dark">{conv?.title || 'Nouvelle conversation'}</div> | |
| 45 | − <div className="truncate text-[11px] text-neutral-muted">{conv ? `${conv.course}` : 'Tuteur IA — IMM1003 · IMM1033'}</div> | |
| 45 | + <div className="truncate text-[15px] sm:text-base font-semibold text-uqo-blue-dark tracking-tight">{conv?.title || 'Nouvelle conversation'}</div> | |
| 46 | + <div className="truncate text-[11px] text-neutral-muted">{conv ? <><span className="inline-block h-1.5 w-1.5 rounded-full bg-uqo-green mr-1 align-middle" />{conv.course}</> : 'Tuteur IA · IMM1003 · IMM1033'}</div> | |
| 46 | 47 | </div> |
| 47 | − <button onClick={() => nav('/')} className="lg:hidden h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface" aria-label="Nouvelle conversation"><Plus size={22} /></button> | |
| 48 | + <button onClick={() => nav('/')} className="lg:hidden h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface active:scale-95 transition text-uqo-blue" aria-label="Nouvelle conversation"><Plus size={22} /></button> | |
| 48 | 49 | {id && ( |
| 49 | − <button onClick={() => setPanel(!panelOpen)} className={cn('h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface', panelOpen && 'bg-uqo-blue-light text-uqo-blue')} aria-label="Fichiers et sources de la conversation" aria-pressed={panelOpen}><PanelRight size={20} /></button> | |
| 50 | + <button onClick={() => setPanel(!panelOpen)} className={cn('h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface active:scale-95 transition', panelOpen && 'bg-uqo-blue-light text-uqo-blue')} aria-label="Fichiers et sources de la conversation" aria-pressed={panelOpen}><PanelRight size={20} /></button> | |
| 50 | 51 | )} |
| 51 | 52 | </header> |
| 52 | − <div className="flex min-h-0 flex-1"> | |
| 53 | + <div className="flex min-h-0 flex-1 bg-uqo-soft"> | |
| 53 | 54 | <div className="relative min-w-0 flex-1"><Outlet /></div> |
| 54 | 55 | {id && panelOpen && ( |
| 55 | − <div className="hidden md:block w-[320px] shrink-0 border-l border-neutral-line bg-white"><ArtifactsPanel conversationId={id} /></div> | |
| 56 | + <div className="hidden md:block w-[330px] shrink-0 border-l border-neutral-line bg-white"><ArtifactsPanel conversationId={id} /></div> | |
| 56 | 57 | )} |
| 57 | 58 | </div> |
| 58 | 59 | {id && panelOpen && ( |
| 59 | 60 | <RD.Root open onOpenChange={setPanel}> |
| 60 | 61 | <RD.Portal> |
| 61 | − <RD.Overlay className="fixed inset-0 z-40 bg-uqo-blue-dark/40 md:hidden" /> | |
| 62 | − <RD.Content className="fixed inset-x-0 bottom-0 z-50 md:hidden max-h-[80dvh] rounded-t-2xl bg-white outline-none" aria-describedby={undefined}> | |
| 62 | + <RD.Overlay className="fixed inset-0 z-40 bg-uqo-blue-dark/50 backdrop-blur-[2px] md:hidden" /> | |
| 63 | + <RD.Content className="fixed inset-x-0 bottom-0 z-50 md:hidden max-h-[82dvh] rounded-t-3xl bg-white outline-none shadow-float" aria-describedby={undefined}> | |
| 64 | + <div className="mx-auto mt-2 h-1.5 w-10 rounded-full bg-neutral-line" aria-hidden="true" /> | |
| 63 | 65 | <RD.Title className="sr-only">Fichiers et sources</RD.Title> |
| 64 | 66 | <ArtifactsPanel conversationId={id} onClose={() => setPanel(false)} /> |
| 65 | 67 | </RD.Content> |
modified
frontend/src/components/layout/sidebar.tsx
+80 −55
@@ -1,4 +1,4 @@ | ||
| 1 | −import { useEffect, useState } from 'react'; | |
| 1 | +import { useEffect, useMemo, useState } from 'react'; | |
| 2 | 2 | import { Link, useNavigate, useParams } from 'react-router-dom'; |
| 3 | 3 | import { Archive, BarChart3, LogOut, MoreHorizontal, Pencil, Pin, PinOff, Plus, Search, Settings, ShieldCheck, Trash2, X } from 'lucide-react'; |
| 4 | 4 | import * as DM from '@radix-ui/react-dropdown-menu'; |
@@ -8,6 +8,19 @@ import { useUI } from '@/stores/ui'; | ||
| 8 | 8 | import { Logo } from '@/components/ui/logo'; |
| 9 | 9 | import { cn } from '@/lib/cn'; |
| 10 | 10 | import { fmtDate } from '@/lib/format'; |
| 11 | +import type { Conversation } from '@/lib/types'; | |
| 12 | + | |
| 13 | +function 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 | +} | |
| 11 | 24 | |
| 12 | 25 | export function Sidebar({ onClose }: { onClose?: () => void }) { |
| 13 | 26 | const { id } = useParams(); |
@@ -25,79 +38,91 @@ export function Sidebar({ onClose }: { onClose?: () => void }) { | ||
| 25 | 38 | return () => clearTimeout(t); |
| 26 | 39 | }, [q, loadConversations]); |
| 27 | 40 | |
| 28 | − const go = (path: string) => { | |
| 29 | − nav(path); | |
| 30 | − onClose?.(); | |
| 31 | − }; | |
| 32 | − | |
| 33 | − const rename = async (cid: string) => { | |
| 34 | − const title = draft.trim(); | |
| 35 | − setRenaming(null); | |
| 36 | − if (title) await updateConversation(cid, { title }); | |
| 37 | − }; | |
| 41 | + 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]); | |
| 38 | 53 | |
| 54 | + 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 }); }; | |
| 39 | 56 | const remove = async (cid: string) => { |
| 40 | 57 | if (!confirm('Supprimer définitivement cette conversation ?')) return; |
| 41 | 58 | await deleteConversation(cid); |
| 42 | 59 | if (id === cid) go('/'); |
| 43 | 60 | }; |
| 61 | + const roleLabel = user?.role === 'professor' ? 'Professeur' : user?.role === 'admin' ? 'Administrateur' : 'Étudiant·e'; | |
| 44 | 62 | |
| 45 | 63 | 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> | |
| 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> | |
| 54 | 72 | </button> |
| 73 | + </div> | |
| 74 | + <div className="px-3 pt-3"> | |
| 55 | 75 | <label className="relative block"> |
| 56 | 76 | <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" /> | |
| 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" /> | |
| 58 | 78 | </label> |
| 59 | 79 | </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> | |
| 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 | + ))} | |
| 90 | 115 | </nav> |
| 91 | − <div className="border-t border-neutral-line p-3 pb-[calc(12px+var(--safe-bottom))] space-y-1"> | |
| 116 | + <div className="border-t border-neutral-line p-3 pb-[calc(12px+var(--safe-bottom))] space-y-1 bg-neutral-surface/60"> | |
| 92 | 117 | {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> | |
| 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> | |
| 94 | 119 | )} |
| 95 | 120 | {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> | |
| 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> | |
| 97 | 122 | )} |
| 98 | 123 | <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> | |
| 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> | |
| 101 | 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> |
| 102 | 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> |
| 103 | 128 | </div> |
modified
frontend/src/components/ui/logo.tsx
+11 −19
@@ -1,26 +1,18 @@ | ||
| 1 | 1 | import { cn } from '@/lib/cn'; |
| 2 | 2 | |
| 3 | −const USE_OFFICIAL = import.meta.env.VITE_USE_OFFICIAL_LOGO === 'true'; | |
| 4 | − | |
| 5 | −/** Fallback logotype (official UQO logo requires authorisation from Service des communications). */ | |
| 6 | −export function Logo({ className, inverted = false, withText = true }: { className?: string; inverted?: boolean; withText?: boolean }) { | |
| 7 | − if (USE_OFFICIAL) { | |
| 8 | − return <img src={inverted ? '/uqo-logo-white.svg' : '/uqo-logo.svg'} alt="UQO" className={cn('h-8', className)} />; | |
| 9 | − } | |
| 10 | − const fg = inverted ? '#FFFFFF' : '#00467F'; | |
| 3 | +/** | |
| 4 | + * Official UQO wordmark (PNG @2x, recoloured) + product name. | |
| 5 | + * `inverted` for the blue header; `withText=false` for compact spots. | |
| 6 | + */ | |
| 7 | +export function Logo({ className, inverted = false, withText = true, size = 'md' }: { className?: string; inverted?: boolean; withText?: boolean; size?: 'sm' | 'md' | 'lg' }) { | |
| 8 | + const h = size === 'lg' ? 'h-12' : size === 'sm' ? 'h-6' : 'h-8'; | |
| 11 | 9 | return ( |
| 12 | − <span className={cn('inline-flex items-center gap-2', className)} aria-label="UQO-Chat"> | |
| 13 | − <svg width="30" height="30" viewBox="0 0 32 32" aria-hidden="true"> | |
| 14 | − <rect x="1" y="1" width="30" height="30" rx="8" fill={inverted ? 'rgba(255,255,255,.14)' : '#00467F'} /> | |
| 15 | − <path d="M8 24V13l8-6 8 6v11h-5v-6h-6v6H8z" fill={inverted ? '#FFFFFF' : '#FFFFFF'} /> | |
| 16 | − <rect x="21" y="8" width="3" height="4" rx=".6" fill="#78BE20" /> | |
| 17 | − </svg> | |
| 10 | + <span className={cn('inline-flex items-center gap-2.5', className)} aria-label="UQO-Chat"> | |
| 11 | + <img src={inverted ? '/uqo-logo-white.png' : '/uqo-logo.png'} alt="UQO" className={cn(h, 'w-auto select-none')} draggable={false} /> | |
| 18 | 12 | {withText && ( |
| 19 | − <span className="leading-none"> | |
| 20 | − <span className="block font-bold tracking-tight" style={{ color: fg, fontSize: 17 }}>UQO-Chat</span> | |
| 21 | − <span className="block text-[10.5px] font-medium tracking-wide" style={{ color: inverted ? 'rgba(255,255,255,.8)' : '#5B6B7B' }}> | |
| 22 | − Tuteur IA — IMM1003 · IMM1033 | |
| 23 | − </span> | |
| 13 | + <span className={cn('leading-none border-l pl-2.5', inverted ? 'border-white/30' : 'border-neutral-line')}> | |
| 14 | + <span className={cn('block font-bold tracking-tight', size === 'lg' ? 'text-[22px]' : 'text-[16px]', inverted ? 'text-white' : 'text-uqo-blue-dark')}>UQO-Chat</span> | |
| 15 | + <span className={cn('block font-medium tracking-wide whitespace-nowrap', size === 'lg' ? 'text-[12px]' : 'text-[10.5px]', inverted ? 'text-white/75' : 'text-neutral-muted')}>Tuteur IA · IMM1003 · IMM1033</span> | |
| 24 | 16 | </span> |
| 25 | 17 | )} |
| 26 | 18 | </span> |
modified
frontend/src/features/auth/login-page.tsx
+9 −5
@@ -80,14 +80,18 @@ export function LoginPage() { | ||
| 80 | 80 | |
| 81 | 81 | return ( |
| 82 | 82 | <div className="min-h-[100dvh] flex flex-col bg-neutral-surface"> |
| 83 | − <div className="bg-uqo-blue text-white px-6 pt-[calc(24px+var(--safe-top))] pb-10"> | |
| 84 | − <div className="mx-auto max-w-[440px]"><Logo inverted /></div> | |
| 83 | + <div className="relative overflow-hidden bg-uqo-gradient text-white px-6 pt-[calc(28px+var(--safe-top))] pb-16"> | |
| 84 | + <svg className="absolute -right-16 -top-16 h-72 w-72 opacity-10" viewBox="0 0 200 200" aria-hidden="true"><circle cx="100" cy="100" r="100" fill="white" /></svg> | |
| 85 | + <div className="mx-auto max-w-[440px]"> | |
| 86 | + <Logo inverted size="lg" /> | |
| 87 | + <p className="mt-5 text-[15px] text-white/85 max-w-[380px]">Comprendre, pratiquer et calculer en évaluation immobilière — avec un tuteur qui cite tes notes de cours.</p> | |
| 88 | + </div> | |
| 85 | 89 | </div> |
| 86 | − <div className="flex-1 px-4 -mt-6 pb-[calc(24px+var(--safe-bottom))]"> | |
| 87 | − <form onSubmit={submit} className="mx-auto max-w-[440px] rounded-2xl bg-white shadow-card p-6 space-y-4 animate-fadein"> | |
| 90 | + <div className="flex-1 px-4 -mt-9 pb-[calc(24px+var(--safe-bottom))]"> | |
| 91 | + <form onSubmit={submit} className="relative z-10 mx-auto max-w-[440px] rounded-[24px] bg-white shadow-float p-6 space-y-4 animate-fadein"> | |
| 88 | 92 | <div> |
| 89 | 93 | <h1 className="text-xl font-bold text-uqo-blue-dark">Connexion</h1> |
| 90 | − <p className="text-sm text-neutral-muted mt-1">Tuteur IA des cours IMM1003 et IMM1033 · {cfg?.term || ''}. Réservé aux adresses <b>@{cfg?.domains?.[0] || 'uqo.ca'}</b>.</p> | |
| 94 | + <p className="text-sm text-neutral-muted mt-1">IMM1003 · IMM1033 · {cfg?.term || ''}. Réservé aux adresses <b>@{cfg?.domains?.[0] || 'uqo.ca'}</b> et aux comptes créés par le professeur.</p> | |
| 91 | 95 | </div> |
| 92 | 96 | {token && busy && <div className="flex items-center gap-2 text-sm"><Spinner /> Vérification du lien…</div>} |
| 93 | 97 | <label className="block"> |
modified
frontend/src/features/auth/privacy-page.tsx
+1 −1
@@ -4,7 +4,7 @@ import { Logo } from '@/components/ui/logo'; | ||
| 4 | 4 | export function PrivacyPage() { |
| 5 | 5 | return ( |
| 6 | 6 | <div className="min-h-[100dvh] bg-neutral-surface"> |
| 7 | − <div className="bg-uqo-blue text-white px-6 py-5"><div className="mx-auto max-w-[760px]"><Link to="/"><Logo inverted /></Link></div></div> | |
| 7 | + <div className="bg-uqo-gradient text-white px-6 py-5"><div className="mx-auto max-w-[760px]"><Link to="/"><Logo inverted /></Link></div></div> | |
| 8 | 8 | <article className="mx-auto max-w-[760px] px-5 py-8 md bg-white my-6 rounded-2xl shadow-card text-[15px]"> |
| 9 | 9 | <h1>Politique de confidentialité — UQO-Chat</h1> |
| 10 | 10 | <p><i>Dernière mise à jour : septembre 2026. Conforme à la Loi 25 (Loi modernisant des dispositions législatives en matière de protection des renseignements personnels, Québec).</i></p> |
modified
frontend/src/features/professor/admin-costs-page.tsx
+1 −1
@@ -23,7 +23,7 @@ export function AdminCostsPage() { | ||
| 23 | 23 | const projected = daysElapsed ? (data.budget.spent_usd / daysElapsed) * 30 : 0; |
| 24 | 24 | return ( |
| 25 | 25 | <div className="min-h-[100dvh] bg-neutral-surface"> |
| 26 | − <header className="bg-uqo-blue text-white"><div className="mx-auto max-w-[1100px] px-4 pt-[calc(12px+var(--safe-top))] pb-3 flex items-center gap-3"><Link to="/" className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white/10" aria-label="Retour"><ArrowLeft size={20} /></Link><h1 className="text-lg font-bold">Coûts OpenRouter</h1></div></header> | |
| 26 | + <header className="bg-uqo-gradient text-white"><div className="mx-auto max-w-[1100px] px-4 pt-[calc(12px+var(--safe-top))] pb-3 flex items-center gap-3"><Link to="/" className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white/10" aria-label="Retour"><ArrowLeft size={20} /></Link><h1 className="text-lg font-bold">Coûts OpenRouter</h1></div></header> | |
| 27 | 27 | <main className="mx-auto max-w-[1100px] px-4 py-5 space-y-4"> |
| 28 | 28 | <div className="grid grid-cols-2 md:grid-cols-4 gap-3"> |
| 29 | 29 | {[['Dépensé ce mois', `${data.budget.spent_usd.toFixed(2)} $`], ['Budget', `${data.budget.budget_usd} $`], ['Utilisation', `${Math.round(data.budget.ratio * 100)} %`], ['Projection mensuelle', `${projected.toFixed(2)} $`]].map(([l, v]) => ( |
modified
frontend/src/features/professor/professor-page.tsx
+1 −1
@@ -25,7 +25,7 @@ export function ProfessorPage() { | ||
| 25 | 25 | const [tab, setTab] = useState<(typeof TABS)[number]>('Activité'); |
| 26 | 26 | return ( |
| 27 | 27 | <div className="min-h-[100dvh] bg-neutral-surface"> |
| 28 | − <header className="bg-uqo-blue text-white"> | |
| 28 | + <header className="bg-uqo-gradient text-white"> | |
| 29 | 29 | <div className="mx-auto max-w-[1100px] px-4 pt-[calc(12px+var(--safe-top))] pb-3 flex items-center gap-3"> |
| 30 | 30 | <Link to="/" className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-white/10" aria-label="Retour"><ArrowLeft size={20} /></Link> |
| 31 | 31 | <div><h1 className="text-lg font-bold">Tableau de bord professeur</h1><p className="text-xs text-white/80">IMM1003 · IMM1033 — données anonymisées</p></div> |
modified
frontend/src/index.css
+47 −22
@@ -7,52 +7,77 @@ | ||
| 7 | 7 | --font-scale: 1; |
| 8 | 8 | --safe-bottom: env(safe-area-inset-bottom, 0px); |
| 9 | 9 | --safe-top: env(safe-area-inset-top, 0px); |
| 10 | + --uqo: #0f6180; | |
| 11 | + --uqo-dark: #0a4a63; | |
| 12 | + --uqo-light: #e6f0f4; | |
| 10 | 13 | } |
| 11 | 14 | |
| 12 | 15 | html, body, #root { height: 100%; } |
| 16 | +html { -webkit-text-size-adjust: 100%; } | |
| 13 | 17 | body { |
| 14 | 18 | @apply bg-neutral-bg text-neutral-text font-sans antialiased; |
| 15 | 19 | font-size: calc(16px * var(--font-scale)); |
| 16 | 20 | overscroll-behavior-y: none; |
| 21 | + -webkit-tap-highlight-color: transparent; | |
| 22 | + text-rendering: optimizeLegibility; | |
| 17 | 23 | } |
| 24 | +::selection { background: rgba(15, 97, 128, 0.18); } | |
| 18 | 25 | input, textarea, select, button { font: inherit; } |
| 19 | 26 | /* Anti-zoom iOS: never below 16 px in inputs */ |
| 20 | 27 | input, textarea { font-size: max(16px, 1em); } |
| 28 | +button, a { touch-action: manipulation; } | |
| 21 | 29 | |
| 22 | −*:focus-visible { outline: 2px solid theme('colors.uqo.blue'); outline-offset: 2px; border-radius: 4px; } | |
| 30 | +*:focus-visible { outline: 2px solid var(--uqo); outline-offset: 2px; border-radius: 6px; } | |
| 23 | 31 | |
| 24 | −.scroll-thin { scrollbar-width: thin; scrollbar-color: #C5D0DB transparent; } | |
| 32 | +.scroll-thin { scrollbar-width: thin; scrollbar-color: #c5d0db transparent; } | |
| 25 | 33 | .scroll-thin::-webkit-scrollbar { width: 6px; height: 6px; } |
| 26 | −.scroll-thin::-webkit-scrollbar-thumb { background: #C5D0DB; border-radius: 3px; } | |
| 34 | +.scroll-thin::-webkit-scrollbar-thumb { background: #c5d0db; border-radius: 3px; } | |
| 35 | +.no-scrollbar { scrollbar-width: none; } | |
| 36 | +.no-scrollbar::-webkit-scrollbar { display: none; } | |
| 37 | + | |
| 38 | +/* Brand gradients */ | |
| 39 | +.bg-uqo-gradient { background: linear-gradient(135deg, #0f6180 0%, #0a4a63 60%, #083a4f 100%); } | |
| 40 | +.bg-uqo-soft { background: radial-gradient(1200px 400px at 20% -20%, rgba(15, 97, 128, 0.10), transparent 60%), #f4f7f9; } | |
| 41 | +.shadow-soft { box-shadow: 0 1px 2px rgba(10, 74, 99, 0.05), 0 6px 20px rgba(10, 74, 99, 0.07); } | |
| 42 | +.shadow-float { box-shadow: 0 2px 6px rgba(10, 74, 99, 0.08), 0 14px 34px rgba(10, 74, 99, 0.14); } | |
| 27 | 43 | |
| 28 | 44 | /* Markdown (prose-like, mobile first) */ |
| 29 | −.md { line-height: 1.6; word-wrap: break-word; } | |
| 30 | −.md > * + * { margin-top: 0.7em; } | |
| 31 | −.md h1, .md h2, .md h3, .md h4 { @apply font-semibold text-uqo-blue-dark; line-height: 1.3; margin-top: 1.1em; } | |
| 32 | −.md h1 { font-size: 1.35em; } .md h2 { font-size: 1.2em; } .md h3 { font-size: 1.08em; } .md h4 { font-size: 1em; } | |
| 45 | +.md { line-height: 1.62; word-wrap: break-word; } | |
| 46 | +.md > * + * { margin-top: 0.75em; } | |
| 47 | +.md h1, .md h2, .md h3, .md h4 { @apply font-semibold text-uqo-blue-dark; line-height: 1.3; margin-top: 1.15em; letter-spacing: -0.01em; } | |
| 48 | +.md h1 { font-size: 1.32em; } .md h2 { font-size: 1.18em; } .md h3 { font-size: 1.06em; } .md h4 { font-size: 1em; } | |
| 33 | 49 | .md p { margin: 0; } |
| 34 | −.md ul { list-style: disc; padding-left: 1.4em; } .md ol { list-style: decimal; padding-left: 1.4em; } | |
| 35 | −.md li + li { margin-top: 0.25em; } | |
| 36 | −.md a { @apply text-uqo-blue underline underline-offset-2; } | |
| 37 | −.md blockquote { @apply border-l-4 border-uqo-blue-light pl-3 text-neutral-muted; } | |
| 38 | −.md code:not(pre code) { @apply font-mono text-[0.9em] bg-neutral-surface px-1 py-0.5 rounded; } | |
| 39 | −.md pre { @apply font-mono text-[0.86em] rounded-lg overflow-x-auto; } | |
| 40 | −.md table { display: block; overflow-x: auto; max-width: 100%; border-collapse: collapse; font-size: 0.93em; } | |
| 41 | −.md thead th { @apply bg-uqo-blue-light text-uqo-blue-dark font-semibold text-left; padding: 6px 10px; } | |
| 42 | −.md td { padding: 6px 10px; border-top: 1px solid theme('colors.neutral.line'); white-space: nowrap; } | |
| 50 | +.md ul { list-style: disc; padding-left: 1.35em; } .md ol { list-style: decimal; padding-left: 1.35em; } | |
| 51 | +.md li + li { margin-top: 0.3em; } | |
| 52 | +.md li::marker { color: var(--uqo); } | |
| 53 | +.md a { @apply text-uqo-blue underline underline-offset-2 decoration-uqo-blue/40; } | |
| 54 | +.md blockquote { @apply border-l-4 border-uqo-blue/30 pl-3 text-neutral-muted bg-uqo-blue-light/40 rounded-r-lg py-1; } | |
| 55 | +.md code:not(pre code) { @apply font-mono text-[0.88em] bg-neutral-surface px-1.5 py-0.5 rounded-md text-uqo-blue-dark; } | |
| 56 | +.md pre { @apply font-mono text-[0.86em] rounded-xl overflow-x-auto; } | |
| 57 | +.md table { display: block; overflow-x: auto; max-width: 100%; border-collapse: separate; border-spacing: 0; font-size: 0.93em; border: 1px solid theme('colors.neutral.line'); border-radius: 10px; } | |
| 58 | +.md thead th { @apply bg-uqo-blue-light text-uqo-blue-dark font-semibold text-left; padding: 7px 10px; white-space: nowrap; } | |
| 59 | +.md td { padding: 7px 10px; border-top: 1px solid theme('colors.neutral.line'); white-space: nowrap; } | |
| 60 | +.md tbody tr:nth-child(even) td { background: #fafbfc; } | |
| 43 | 61 | .md hr { @apply border-neutral-line my-3; } |
| 44 | −.md .katex-display { overflow-x: auto; overflow-y: hidden; padding: 4px 0; margin: 0.5em 0; } | |
| 62 | +.md .katex-display { overflow-x: auto; overflow-y: hidden; padding: 6px 2px; margin: 0.5em 0; } | |
| 45 | 63 | .md .katex { font-size: 1.05em; } |
| 46 | −.md img { max-width: 100%; border-radius: 8px; } | |
| 64 | +.md img { max-width: 100%; border-radius: 10px; } | |
| 65 | +.md strong { color: theme('colors.uqo.blue-dark'); } | |
| 47 | 66 | |
| 48 | −.shiki { padding: 12px 14px; margin: 0; background: #0f1b2a !important; color: #e6edf3; } | |
| 67 | +.shiki { padding: 12px 14px; margin: 0; background: #0e1f2b !important; color: #e6edf3; } | |
| 49 | 68 | .shiki code { counter-reset: line; } |
| 50 | 69 | |
| 51 | 70 | .streaming-cursor::after { content: '▍'; @apply text-uqo-blue; animation: blink 1s steps(2) infinite; margin-left: 1px; } |
| 52 | 71 | @keyframes blink { 50% { opacity: 0; } } |
| 53 | 72 | |
| 54 | −.progress-bar { position: relative; height: 2px; overflow: hidden; background: theme('colors.uqo.blue-light'); } | |
| 55 | −.progress-bar::after { content: ''; position: absolute; left: -40%; width: 40%; height: 100%; background: theme('colors.uqo.blue'); animation: slide 1.2s ease-in-out infinite; } | |
| 73 | +.progress-bar { position: relative; height: 3px; overflow: hidden; background: var(--uqo-light); } | |
| 74 | +.progress-bar::after { content: ''; position: absolute; left: -40%; width: 40%; height: 100%; background: linear-gradient(90deg, transparent, var(--uqo), #78be20); animation: slide 1.2s ease-in-out infinite; } | |
| 56 | 75 | @keyframes slide { to { left: 100%; } } |
| 57 | 76 | |
| 58 | −.skeleton { @apply bg-neutral-surface rounded animate-pulse; } | |
| 77 | +.skeleton { @apply bg-neutral-surface rounded-xl; position: relative; overflow: hidden; } | |
| 78 | +.skeleton::after { content: ''; position: absolute; inset: 0; transform: translateX(-100%); background: linear-gradient(90deg, transparent, rgba(255,255,255,.7), transparent); animation: shimmer 1.4s infinite; } | |
| 79 | +@keyframes shimmer { to { transform: translateX(100%); } } | |
| 80 | + | |
| 81 | +@media (prefers-reduced-motion: reduce) { | |
| 82 | + *, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; } | |
| 83 | +} | |
modified
frontend/src/theme/uqo.ts
+8 −8
@@ -1,17 +1,17 @@ | ||
| 1 | −/** UQO design tokens — verify against the official graphic standards before launch. */ | |
| 1 | +/** UQO design tokens — aligned on the official wordmark blue (#0F6180). */ | |
| 2 | 2 | export const uqo = { |
| 3 | − blue: '#00467F', | |
| 4 | − blueDark: '#003057', | |
| 5 | − blueLight: '#E6EEF5', | |
| 3 | + blue: '#0F6180', | |
| 4 | + blueDark: '#0A4A63', | |
| 5 | + blueLight: '#E6F0F4', | |
| 6 | 6 | green: '#78BE20', |
| 7 | 7 | gold: '#C6A300', |
| 8 | 8 | bg: '#FFFFFF', |
| 9 | − surface: '#F5F7FA', | |
| 10 | − text: '#1A2B3C', | |
| 9 | + surface: '#F4F7F9', | |
| 10 | + text: '#16232D', | |
| 11 | 11 | muted: '#5B6B7B', |
| 12 | − line: '#D9E1EA', | |
| 12 | + line: '#DCE4EA', | |
| 13 | 13 | warning: '#F2A900', |
| 14 | 14 | error: '#C8102E', |
| 15 | 15 | } as const; |
| 16 | 16 | |
| 17 | −export const chartPalette = ['#00467F', '#78BE20', '#C6A300', '#5B6B7B', '#7FA7C9', '#C8102E']; | |
| 17 | +export const chartPalette = ['#0F6180', '#78BE20', '#C6A300', '#5B6B7B', '#7FB0C4', '#C8102E']; | |
modified
frontend/tailwind.config.ts
+2 −2
@@ -24,8 +24,8 @@ export default { | ||
| 24 | 24 | xs: ['12px', '1.4'], sm: ['14px', '1.55'], base: ['16px', '1.55'], lg: ['18px', '1.5'], |
| 25 | 25 | xl: ['22px', '1.35'], '2xl': ['28px', '1.25'], |
| 26 | 26 | }, |
| 27 | − boxShadow: { card: '0 1px 2px rgba(0,48,87,.06), 0 4px 16px rgba(0,48,87,.06)' }, | |
| 28 | − keyframes: { fadein: { from: { opacity: '0', transform: 'translateY(4px)' }, to: { opacity: '1', transform: 'none' } } }, | |
| 27 | + boxShadow: { card: '0 1px 2px rgba(10,74,99,.05), 0 4px 16px rgba(10,74,99,.06)' }, | |
| 28 | + keyframes: { fadein: { from: { opacity: '0', transform: 'translateY(4px)' }, to: { opacity: '1', transform: 'none' } }, slidein: { from: { transform: 'translateX(-100%)' }, to: { transform: 'none' } } }, | |
| 29 | 29 | animation: { fadein: 'fadein 150ms ease-out' }, |
| 30 | 30 | }, |
| 31 | 31 | }, |
modified
frontend/vite.config.ts
+2 −2
@@ -9,7 +9,7 @@ export default defineConfig({ | ||
| 9 | 9 | react(), |
| 10 | 10 | VitePWA({ |
| 11 | 11 | registerType: 'autoUpdate', |
| 12 | − includeAssets: ['icons/*.png', 'icons/*.svg', 'uqo-chat-logo.svg'], | |
| 12 | + includeAssets: ['icons/*.png', 'icons/*.svg', 'uqo-logo.png', 'uqo-logo-white.png'], | |
| 13 | 13 | manifest: { |
| 14 | 14 | name: 'UQO-Chat', |
| 15 | 15 | short_name: 'UQO-Chat', |
@@ -18,7 +18,7 @@ export default defineConfig({ | ||
| 18 | 18 | display: 'standalone', |
| 19 | 19 | start_url: '/', |
| 20 | 20 | scope: '/', |
| 21 | − theme_color: '#00467F', | |
| 21 | + theme_color: '#0F6180', | |
| 22 | 22 | background_color: '#FFFFFF', |
| 23 | 23 | icons: [ |
| 24 | 24 | { src: '/icons/icon-192.png', sizes: '192x192', type: 'image/png' }, |
| 25 | 25 | |