Skip to content

Commit 964a8d3

Browse files
committed
feat(eleves+chat): QR code, partage par code (page /e), date au survol
- Espace élève : bouton « QR code » → modale plein écran (QR + « Scannez pour rejoindre »). Toggle « Masquer le code » : partage un lien sans le token (/e) + affiche le code à saisir. Nouvelle page /e (saisie du code, responsive). - Fix : la saisie du code depuis /e renvoyait « Lien invalide » (une requête annulée — double-effet React/navigation client — était traitée comme une erreur). On ignore désormais AbortError dans l'espace élève. - Chat : date d'envoi affichée au survol d'un message (createdAt remonté depuis la base → métadonnées → MessageItem), masquée pour l'élève. - Indicateur de Cadre en haut du fil fondu dans le fond (canvas + accent-soft) ; composer aligné sur le fond ; saisie épurée. - Dépendance : qrcode.react (génération du QR côté client, souveraine).
1 parent fa1598e commit 964a8d3

15 files changed

Lines changed: 258 additions & 45 deletions

apps/api/src/services/chat-stream.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export function streamChatResponse(
9898
messageMetadata: ({ part }) => {
9999
if (part.type !== 'finish') return undefined;
100100
totalTokens = part.totalUsage.totalTokens;
101-
return { totalTokens, sources: opts.sources };
101+
return { totalTokens, sources: opts.sources, createdAt: new Date().toISOString() };
102102
},
103103
onFinish: async ({ messages: finalMessages }) => {
104104
try {

apps/api/src/services/conversations.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface StoredMessageRow {
2020
role: string;
2121
parts: Array<Record<string, unknown>>;
2222
metadata: Record<string, unknown> | null;
23+
createdAt: Date;
2324
}
2425

2526
function toSummary(row: Conversation): ConversationSummaryRow {
@@ -151,6 +152,7 @@ export async function getMessages(
151152
role: messages.role,
152153
parts: messages.parts,
153154
metadata: messages.metadata,
155+
createdAt: messages.createdAt,
154156
})
155157
.from(messages)
156158
.where(eq(messages.conversationId, conversationId))

apps/web/app/_components/access-detail.tsx

Lines changed: 115 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import { QRCodeSVG } from 'qrcode.react';
34
import { useEffect, useState } from 'react';
45
import type { AccessDetailDTO } from '@/lib/types';
56
import {
@@ -9,11 +10,53 @@ import {
910
ExternalLinkIcon,
1011
PlusIcon,
1112
PowerIcon,
13+
QrIcon,
1214
SparkIcon,
1315
TrashIcon,
16+
XIcon,
1417
} from './icons';
1518
import { SupervisionDrawer } from './supervision-drawer';
1619

20+
/** Modale plein écran : QR code de l'accès + consigne « Scannez ». */
21+
function QrModal({ url, code, onClose }: { url: string; code?: string; onClose: () => void }) {
22+
useEffect(() => {
23+
const onKey = (e: KeyboardEvent) => {
24+
if (e.key === 'Escape') onClose();
25+
};
26+
document.addEventListener('keydown', onKey);
27+
return () => document.removeEventListener('keydown', onKey);
28+
}, [onClose]);
29+
30+
return (
31+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-6">
32+
<button type="button" aria-label="Fermer" onClick={onClose} className="absolute inset-0" />
33+
<div className="relative z-10 w-full max-w-md animate-pop-in rounded-card bg-surface p-7 text-center shadow-pop">
34+
<button
35+
type="button"
36+
onClick={onClose}
37+
aria-label="Fermer"
38+
className="absolute right-3 top-3 inline-flex size-8 items-center justify-center rounded-button text-muted-foreground transition-colors hover:bg-surface-muted hover:text-foreground"
39+
>
40+
<XIcon className="size-5" />
41+
</button>
42+
<h2 className="font-display text-2xl font-bold tracking-tight">Scannez pour rejoindre</h2>
43+
<p className="mt-1 text-sm text-muted-foreground">Avec l’appareil photo du téléphone.</p>
44+
{/* Fond blanc requis pour la lisibilité du QR code (contraste). */}
45+
<div className="mx-auto mt-5 w-fit rounded-2xl bg-white p-4 shadow-card">
46+
<QRCodeSVG value={url} size={232} marginSize={1} className="h-auto w-[min(60vw,232px)]" />
47+
</div>
48+
{code ? (
49+
<p className="mt-5 text-sm text-muted-foreground">
50+
Puis saisissez le code&nbsp;:{' '}
51+
<span className="font-mono font-bold tracking-wider text-foreground">{code}</span>
52+
</p>
53+
) : null}
54+
<code className="mt-3 block truncate text-xs text-muted-foreground">{url}</code>
55+
</div>
56+
</div>
57+
);
58+
}
59+
1760
function CopyLinkButton({ url }: { url: string }) {
1861
const [copied, setCopied] = useState(false);
1962
return (
@@ -55,12 +98,16 @@ export function AccessDetail({
5598
const [origin, setOrigin] = useState('');
5699
const [newName, setNewName] = useState('');
57100
const [supervising, setSupervising] = useState<{ id: string; name: string } | null>(null);
101+
const [qrOpen, setQrOpen] = useState(false);
102+
const [hideCode, setHideCode] = useState(false);
58103

59104
useEffect(() => {
60105
setOrigin(window.location.origin);
61106
}, []);
62107

63-
const link = `${origin}/e/${access.token}`;
108+
// `hideCode` : on partage un lien sans le code (`/e`) ; l'élève saisit le code.
109+
const baseLink = `${origin}/e`;
110+
const link = hideCode ? baseLink : `${origin}/e/${access.token}`;
64111

65112
return (
66113
<div className="animate-fade-in space-y-5">
@@ -126,27 +173,66 @@ export function AccessDetail({
126173
</section>
127174

128175
<section className="rounded-card border border-border bg-surface p-5 shadow-card">
129-
<h3 className="text-sm font-bold">Lien d’accès élève</h3>
130-
<p className="mt-0.5 text-xs text-muted-foreground">
131-
Partagez ce lien avec vos élèves. Ils choisissent leur prénom et discutent avec
132-
l’assistant <em>sous ce Cadre</em>. (Données fictives — préfiguration d’un compte élève.)
133-
</p>
134-
{access.active ? (
135-
<div className="mt-3 flex flex-wrap items-center gap-2">
136-
<code className="min-w-0 flex-1 truncate rounded-button border border-border bg-surface-muted px-3 py-2 font-mono text-sm">
137-
{link}
138-
</code>
139-
<CopyLinkButton url={link} />
140-
<a
141-
href={link}
142-
target="_blank"
143-
rel="noopener noreferrer"
144-
className="inline-flex items-center gap-1.5 rounded-button border border-border px-3 py-2 text-sm font-medium transition-colors hover:bg-surface-muted"
145-
>
146-
Ouvrir
147-
<ExternalLinkIcon className="size-3.5" />
148-
</a>
176+
<div className="flex flex-wrap items-start justify-between gap-2">
177+
<div className="min-w-0">
178+
<h3 className="text-sm font-bold">Lien d’accès élève</h3>
179+
<p className="mt-0.5 text-xs text-muted-foreground">
180+
Partagez ce lien avec vos élèves. Ils choisissent leur prénom et discutent avec
181+
l’assistant <em>sous ce Cadre</em>.
182+
</p>
149183
</div>
184+
<button
185+
type="button"
186+
aria-pressed={hideCode}
187+
onClick={() => setHideCode((v) => !v)}
188+
title="Partager un lien sans le code : l’élève saisit le code lui-même."
189+
className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${
190+
hideCode
191+
? 'border-accent/40 bg-accent-soft text-accent'
192+
: 'border-border text-muted-foreground hover:bg-surface-muted hover:text-foreground'
193+
}`}
194+
>
195+
{hideCode ? <CheckIcon className="size-3.5" /> : null}
196+
Masquer le code
197+
</button>
198+
</div>
199+
{access.active ? (
200+
<>
201+
<div className="mt-3 flex flex-wrap items-center gap-2">
202+
<code className="min-w-0 flex-1 truncate rounded-button border border-border bg-surface-muted px-3 py-2 font-mono text-sm">
203+
{link}
204+
</code>
205+
<CopyLinkButton url={link} />
206+
<a
207+
href={link}
208+
target="_blank"
209+
rel="noopener noreferrer"
210+
className="inline-flex items-center gap-1.5 rounded-button border border-border px-3 py-2 text-sm font-medium transition-colors hover:bg-surface-muted"
211+
>
212+
Ouvrir
213+
<ExternalLinkIcon className="size-3.5" />
214+
</a>
215+
<button
216+
type="button"
217+
onClick={() => setQrOpen(true)}
218+
aria-label="Afficher le QR code"
219+
title="Afficher le QR code"
220+
className="inline-flex items-center gap-1.5 rounded-button border border-border px-3 py-2 text-sm font-medium transition-colors hover:bg-surface-muted"
221+
>
222+
<QrIcon className="size-4" />
223+
QR code
224+
</button>
225+
</div>
226+
{hideCode ? (
227+
<p className="mt-2 rounded-button bg-surface-muted px-3 py-2 text-xs text-muted-foreground">
228+
Les élèves vont sur <span className="font-mono text-foreground">{baseLink}</span>{' '}
229+
puis saisissent le code&nbsp;:{' '}
230+
<span className="font-mono font-bold tracking-wider text-foreground">
231+
{access.token}
232+
</span>
233+
</p>
234+
) : null}
235+
</>
150236
) : (
151237
<p className="mt-3 rounded-button border border-dashed border-border bg-surface-muted px-3 py-2.5 text-sm text-muted-foreground">
152238
Lien désactivé — réactivez l’espace pour le partager à nouveau.
@@ -237,6 +323,14 @@ export function AccessDetail({
237323
onClose={() => setSupervising(null)}
238324
/>
239325
) : null}
326+
327+
{qrOpen && access.active ? (
328+
<QrModal
329+
url={link}
330+
code={hideCode ? access.token : undefined}
331+
onClose={() => setQrOpen(false)}
332+
/>
333+
) : null}
240334
</div>
241335
);
242336
}

apps/web/app/_components/chat-panel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function ChatPanel({
3737
id: m.id,
3838
role: m.role,
3939
parts: m.parts,
40-
metadata: m.metadata ?? undefined,
40+
metadata: { ...(m.metadata ?? {}), createdAt: m.createdAt },
4141
})) as unknown as UIMessage[];
4242
setState({
4343
kind: 'ready',

apps/web/app/_components/chat-thread.tsx

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -116,17 +116,17 @@ export function ChatThread({
116116

117117
return (
118118
<div className="flex h-full min-h-0 flex-col">
119-
<div className="flex-1 overflow-y-auto">
119+
<div className="relative flex-1 overflow-y-auto">
120+
{activeFramework ? (
121+
<div className="pointer-events-none sticky top-0 z-10 flex justify-center bg-gradient-to-b from-canvas via-canvas/85 to-transparent px-4 pt-3 pb-5">
122+
<span className="pointer-events-auto inline-flex items-center gap-1.5 rounded-full bg-accent-soft px-3 py-1 text-xs font-medium text-accent">
123+
<SparkIcon className="size-3.5" />
124+
Cadre&nbsp;: {activeFramework.name}
125+
</span>
126+
</div>
127+
) : null}
120128
<div className="mx-auto max-w-3xl px-4 pb-4">
121-
{activeFramework ? (
122-
<div className="sticky top-0 z-10 -mx-4 flex justify-center bg-background/80 px-4 py-2.5 backdrop-blur">
123-
<span className="inline-flex items-center gap-1.5 rounded-full border border-border bg-surface/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-card">
124-
<SparkIcon className="size-3.5 text-accent" />
125-
Cadre&nbsp;: <span className="text-foreground">{activeFramework.name}</span>
126-
</span>
127-
</div>
128-
) : null}
129-
<div className={`space-y-7 ${activeFramework ? 'pt-4' : 'pt-8'}`}>
129+
<div className={`space-y-7 ${activeFramework ? 'pt-2' : 'pt-8'}`}>
130130
{messages.length === 0 ? (
131131
<EmptyState onPick={(text) => sendMessage({ text })} />
132132
) : (

apps/web/app/_components/composer.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ export function Composer({
164164
const model = MODEL_OPTIONS[modelTier];
165165

166166
return (
167-
<div className="bg-background/80 backdrop-blur-sm">
167+
<div className="bg-canvas">
168168
<div className="mx-auto max-w-3xl px-4 pt-3 pb-4">
169169
<form
170170
onSubmit={(e) => {

apps/web/app/_components/icons.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,17 @@ export function PowerIcon(props: IconProps) {
255255
);
256256
}
257257

258+
export function QrIcon(props: IconProps) {
259+
return (
260+
<Svg {...props}>
261+
<rect x="3" y="3" width="7" height="7" rx="1" />
262+
<rect x="14" y="3" width="7" height="7" rx="1" />
263+
<rect x="3" y="14" width="7" height="7" rx="1" />
264+
<path d="M14 14h3v3M21 17v4h-4M21 14v.01M17 21v.01" />
265+
</Svg>
266+
);
267+
}
268+
258269
export function LockIcon(props: IconProps) {
259270
return (
260271
<Svg {...props}>

apps/web/app/_components/message-item.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ function partsToText(message: UIMessage): string {
2222
.join('');
2323
}
2424

25+
/** Date d'envoi formatée proprement (ex. « 1 juin 2026 à 14:32 »). */
26+
function formatDate(iso?: string): string | null {
27+
if (!iso) return null;
28+
const d = new Date(iso);
29+
if (Number.isNaN(d.getTime())) return null;
30+
return new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long', timeStyle: 'short' }).format(d);
31+
}
32+
2533
function CopyButton({ text }: { text: string }) {
2634
const [copied, setCopied] = useState(false);
2735
const copy = async () => {
@@ -56,25 +64,30 @@ export function MessageItem({
5664
variant?: MessageVariant;
5765
}) {
5866
const text = partsToText(message);
59-
const metadata = message.metadata as { totalTokens?: number; sources?: ChatSource[] } | undefined;
60-
const tokens = metadata?.totalTokens;
67+
const metadata = message.metadata as { sources?: ChatSource[]; createdAt?: string } | undefined;
6168
const sources = metadata?.sources ?? [];
6269
// L'élève ne voit ni les sources ni les métadonnées : interface épurée.
6370
const showSources = variant !== 'student' && !streaming && sources.length > 0;
6471
const showMeta = variant !== 'student' && !streaming && Boolean(text);
72+
const dateLabel = variant !== 'student' ? formatDate(metadata?.createdAt) : null;
6573

6674
if (message.role === 'user') {
6775
return (
68-
<div className="flex animate-rise-in justify-end">
76+
<div className="group flex animate-rise-in flex-col items-end">
6977
<div className="max-w-[82%] whitespace-pre-wrap rounded-card rounded-tr-sm bg-accent-soft px-4 py-2.5 text-[0.95rem] leading-relaxed text-foreground shadow-card">
7078
{text}
7179
</div>
80+
{dateLabel ? (
81+
<time className="mt-1 px-1 text-[0.7rem] text-muted-foreground/70 opacity-0 transition-opacity duration-150 group-hover:opacity-100">
82+
{dateLabel}
83+
</time>
84+
) : null}
7285
</div>
7386
);
7487
}
7588

7689
return (
77-
<div className="flex animate-rise-in gap-3">
90+
<div className="group flex animate-rise-in gap-3">
7891
<div
7992
aria-hidden
8093
className="mt-0.5 inline-flex size-7 shrink-0 items-center justify-center rounded-lg bg-accent text-accent-foreground shadow-card"
@@ -122,8 +135,10 @@ export function MessageItem({
122135
{showMeta ? (
123136
<div className="mt-1.5 flex items-center gap-2">
124137
<CopyButton text={text} />
125-
{typeof tokens === 'number' ? (
126-
<span className="text-xs text-muted-foreground/80">· {tokens} tokens</span>
138+
{dateLabel ? (
139+
<time className="text-xs text-muted-foreground/60 opacity-0 transition-opacity duration-150 group-hover:opacity-100">
140+
· {dateLabel}
141+
</time>
127142
) : null}
128143
</div>
129144
) : null}

apps/web/app/_components/student-chat-thread.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ export function StudentChatThread({
121121
</div>
122122
</div>
123123

124-
<div className="bg-background/80 backdrop-blur-sm">
124+
<div className="bg-canvas">
125125
<div className="mx-auto max-w-2xl px-4 pt-3 pb-4">
126126
<form
127127
onSubmit={(e) => {

apps/web/app/_components/student-space.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ function StudentChatPanel({
4242
id: m.id,
4343
role: m.role,
4444
parts: m.parts,
45-
metadata: m.metadata ?? undefined,
45+
metadata: { ...(m.metadata ?? {}), createdAt: m.createdAt },
4646
})) as unknown as UIMessage[];
4747
setState({ kind: 'ready', messages: ui });
4848
})
@@ -97,9 +97,14 @@ export function StudentSpace({ token }: { token: string }) {
9797
} catch {
9898
/* localStorage indisponible */
9999
}
100+
setLoadingAccess(false);
100101
})
101-
.catch(() => setNotFound(true))
102-
.finally(() => setLoadingAccess(false));
102+
.catch((err: unknown) => {
103+
// Une requête annulée (navigation client / double-effet dev) n'est pas une erreur.
104+
if (err instanceof DOMException && err.name === 'AbortError') return;
105+
setNotFound(true);
106+
setLoadingAccess(false);
107+
});
103108
return () => controller.abort();
104109
}, [token]);
105110

0 commit comments

Comments
 (0)