feat(ui): icônes Lucide + état/badge review partagés (v0.30.0)

Icônes SVG Lucide (chat, voix, review, nav). Contexte appdata : phase+outils
dans le header, badge d'assets en attente dans la nav, refresh après
décision ; Review consomme appdata. Build OK, 0 vuln.

Palier de risque : reversible (front).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Kantin-Petit
2026-06-23 07:29:46 +02:00
parent 476c89ce3d
commit aa108e847b
7 changed files with 167 additions and 67 deletions
+8
View File
@@ -6,6 +6,14 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi
## [Unreleased] ## [Unreleased]
## [0.30.0] — 2026-06-23 — polish UI
### Added
- Icônes **Lucide** (SVG) partout (chat, voix, review, nav) — fini le texte/emoji.
- Contexte `appdata` : état backend (phase/outils) + assets en attente partagés ;
header affiche phase·outils, **badge de review** dans la nav, refresh après décision.
### Changed
- `Review` consomme `appdata` (plus de fetch local). Build OK, 0 vuln.
## [0.29.0] — 2026-06-23 — fin Phase 6 (voix v1) ## [0.29.0] — 2026-06-23 — fin Phase 6 (voix v1)
### Added ### Added
- `useSpeech` : mode **mains-libres** + wake-word « CHLOVA » (`extractCommand`), - `useSpeech` : mode **mains-libres** + wake-word « CHLOVA » (`extractCommand`),
+10
View File
@@ -8,6 +8,7 @@
"name": "chlova-web", "name": "chlova-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"lucide-react": "^1.21.0",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7", "react-dom": "19.2.7",
"react-router-dom": "7.18.0" "react-router-dom": "7.18.0"
@@ -4939,6 +4940,15 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lucide-react": {
"version": "1.21.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz",
"integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -11,6 +11,7 @@
"typecheck": "tsc -b --noEmit" "typecheck": "tsc -b --noEmit"
}, },
"dependencies": { "dependencies": {
"lucide-react": "1.21.0",
"react": "19.2.7", "react": "19.2.7",
"react-dom": "19.2.7", "react-dom": "19.2.7",
"react-router-dom": "7.18.0" "react-router-dom": "7.18.0"
+25 -6
View File
@@ -1,24 +1,37 @@
import { BrowserRouter, Routes, Route, NavLink, Navigate } from "react-router-dom"; import { BrowserRouter, Routes, Route, NavLink, Navigate } from "react-router-dom";
import { MessageSquare, ShieldCheck, LogOut, Cpu } from "lucide-react";
import { useAuth } from "./auth"; import { useAuth } from "./auth";
import { AppDataProvider, useAppData } from "./appdata";
import { Login } from "./pages/Login"; import { Login } from "./pages/Login";
import { Chat } from "./pages/Chat"; import { Chat } from "./pages/Chat";
import { Review } from "./pages/Review"; import { Review } from "./pages/Review";
function Shell() { function Shell() {
const { logout } = useAuth(); const { logout } = useAuth();
const { phase, tools, assets } = useAppData();
const link = ({ isActive }: { isActive: boolean }): string => const link = ({ isActive }: { isActive: boolean }): string =>
`px-3 py-2 rounded-md text-sm ${isActive ? "bg-surface-2 text-accent" : "text-muted hover:text-fg"}`; `flex items-center gap-1.5 px-3 py-2 rounded-md text-sm ${isActive ? "bg-surface-2 text-accent" : "text-muted hover:text-fg"}`;
return ( return (
<div className="min-h-dvh flex flex-col"> <div className="min-h-dvh flex flex-col">
<header className="flex items-center gap-2 border-b border-border bg-surface px-4 py-2"> <header className="flex items-center gap-2 border-b border-border bg-surface px-4 py-2">
<span className="font-bold tracking-wide text-accent glow mr-2">CHLOVA</span> <span className="font-bold tracking-wide text-accent glow mr-2">CHLOVA</span>
<nav className="flex gap-1"> <nav className="flex gap-1">
<NavLink to="/chat" className={link}>Chat</NavLink> <NavLink to="/chat" className={link}>
<NavLink to="/review" className={link}>Review</NavLink> <MessageSquare size={16} /> Chat
</NavLink>
<NavLink to="/review" className={link}>
<ShieldCheck size={16} /> Review
{assets.length > 0 && (
<span className="ml-1 rounded-full bg-accent px-1.5 text-xs text-bg">{assets.length}</span>
)}
</NavLink>
</nav> </nav>
<button onClick={logout} className="ml-auto text-sm text-muted hover:text-fg cursor-pointer"> <span className="ml-auto flex items-center gap-1.5 text-xs text-muted" title="Phase · outils">
Déconnexion <Cpu size={14} /> {phase || "…"} · {tools} outils
</span>
<button onClick={logout} className="ml-3 text-muted hover:text-fg cursor-pointer" aria-label="Déconnexion" title="Déconnexion">
<LogOut size={18} />
</button> </button>
</header> </header>
<main className="flex-1 min-h-0"> <main className="flex-1 min-h-0">
@@ -36,7 +49,13 @@ export function App() {
const { token } = useAuth(); const { token } = useAuth();
return ( return (
<BrowserRouter> <BrowserRouter>
{token ? <Shell /> : <Login />} {token ? (
<AppDataProvider>
<Shell />
</AppDataProvider>
) : (
<Login />
)}
</BrowserRouter> </BrowserRouter>
); );
} }
+95
View File
@@ -0,0 +1,95 @@
import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react";
import { api, ApiError, type Asset } from "./api";
import { useAuth } from "./auth";
/**
* État applicatif partagé : phase/outils du backend + assets en attente de
* review (pour le badge de nav et la vue Review). Rafraîchi à la connexion et
* après chaque décision.
*/
interface AppData {
phase: string;
tools: number;
assets: Asset[];
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
approve: (id: string) => Promise<void>;
refuse: (id: string) => Promise<void>;
}
const Ctx = createContext<AppData | null>(null);
export function AppDataProvider({ children }: { children: ReactNode }) {
const { token, logout } = useAuth();
const [phase, setPhase] = useState("");
const [tools, setTools] = useState(0);
const [assets, setAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const onErr = useCallback(
(err: unknown): void => {
if (err instanceof ApiError && err.status === 401) logout();
else setError(err instanceof Error ? err.message : "Erreur");
},
[logout],
);
const refresh = useCallback(async (): Promise<void> => {
if (!token) return;
setLoading(true);
try {
const [s, r] = await Promise.all([api.state(token), api.review(token)]);
setPhase(s.phase);
setTools(s.tools);
setAssets(r.assets);
setError(null);
} catch (err) {
onErr(err);
} finally {
setLoading(false);
}
}, [token, onErr]);
const decide = useCallback(
async (id: string, action: "approve" | "refuse"): Promise<void> => {
if (!token) return;
try {
if (action === "approve") await api.approve(token, id);
else await api.refuse(token, id);
await refresh();
} catch (err) {
onErr(err);
}
},
[token, refresh, onErr],
);
useEffect(() => {
void refresh();
}, [refresh]);
return (
<Ctx.Provider
value={{
phase,
tools,
assets,
loading,
error,
refresh,
approve: (id) => decide(id, "approve"),
refuse: (id) => decide(id, "refuse"),
}}
>
{children}
</Ctx.Provider>
);
}
export function useAppData(): AppData {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useAppData hors AppDataProvider");
return ctx;
}
+10 -8
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; import { useCallback, useEffect, useRef, useState, type FormEvent } from "react";
import { Mic, Square, Volume2, VolumeX, Radio, Send } from "lucide-react";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import { api, ApiError } from "../api"; import { api, ApiError } from "../api";
import { useSpeech } from "../useSpeech"; import { useSpeech } from "../useSpeech";
@@ -106,9 +107,9 @@ export function Chat() {
onClick={toggleSpeak} onClick={toggleSpeak}
aria-label={speakReplies ? "Couper la voix" : "Activer la voix"} aria-label={speakReplies ? "Couper la voix" : "Activer la voix"}
title={speakReplies ? "Voix activée" : "Voix coupée"} title={speakReplies ? "Voix activée" : "Voix coupée"}
className={`rounded-md border px-3 py-2 text-sm cursor-pointer ring-accent ${speakReplies ? "border-accent text-accent" : "border-border text-muted"}`} className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speakReplies ? "border-accent text-accent" : "border-border text-muted"}`}
> >
{speakReplies ? "Voix ON" : "Voix OFF"} {speakReplies ? <Volume2 size={18} /> : <VolumeX size={18} />}
</button> </button>
)} )}
<input <input
@@ -123,9 +124,9 @@ export function Chat() {
type="button" type="button"
onClick={mic} onClick={mic}
aria-label={speech.listening ? "Arrêter le micro" : "Parler"} aria-label={speech.listening ? "Arrêter le micro" : "Parler"}
className={`rounded-md border px-3 py-2 text-sm cursor-pointer ring-accent ${speech.listening ? "border-accent text-accent animate-pulse" : "border-border text-muted"}`} className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speech.listening ? "border-accent text-accent animate-pulse" : "border-border text-muted"}`}
> >
{speech.listening ? "Stop" : "Parler"} {speech.listening ? <Square size={18} /> : <Mic size={18} />}
</button> </button>
)} )}
{speech.sttSupported && ( {speech.sttSupported && (
@@ -134,17 +135,18 @@ export function Chat() {
onClick={toggleHandsFree} onClick={toggleHandsFree}
aria-label={speech.handsFree ? "Couper les mains libres" : "Activer les mains libres"} aria-label={speech.handsFree ? "Couper les mains libres" : "Activer les mains libres"}
title="Mains libres (wake-word CHLOVA)" title="Mains libres (wake-word CHLOVA)"
className={`rounded-md border px-3 py-2 text-sm cursor-pointer ring-accent ${speech.handsFree ? "border-accent text-accent" : "border-border text-muted"}`} className={`rounded-md border px-3 py-2 cursor-pointer ring-accent ${speech.handsFree ? "border-accent text-accent" : "border-border text-muted"}`}
> >
{speech.handsFree ? "Libre ON" : "Libre"} <Radio size={18} />
</button> </button>
)} )}
<button <button
type="submit" type="submit"
disabled={busy || !input.trim()} disabled={busy || !input.trim()}
className="rounded-md bg-accent px-4 py-2 font-medium text-bg disabled:opacity-50 ring-accent cursor-pointer" aria-label="Envoyer"
className="flex items-center gap-1.5 rounded-md bg-accent px-4 py-2 font-medium text-bg disabled:opacity-50 ring-accent cursor-pointer"
> >
Envoyer <Send size={16} /> Envoyer
</button> </button>
</form> </form>
</div> </div>
+18 -53
View File
@@ -1,60 +1,25 @@
import { useCallback, useEffect, useState } from "react"; import { Check, X, RefreshCw } from "lucide-react";
import { useAuth } from "../auth"; import { useAppData } from "../appdata";
import { api, ApiError, type Asset } from "../api"; import type { Asset } from "../api";
export function Review() { export function Review() {
const { token, logout } = useAuth(); const { assets, loading, error, refresh, approve, refuse } = useAppData();
const [assets, setAssets] = useState<Asset[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const guard = useCallback( const onRefuse = (id: string): void => {
(err: unknown): void => { if (confirm(`Refuser définitivement ${id} ?`)) void refuse(id);
if (err instanceof ApiError && err.status === 401) logout();
else setError(err instanceof Error ? err.message : "Erreur");
},
[logout],
);
const refresh = useCallback(async (): Promise<void> => {
if (!token) return;
setLoading(true);
try {
const { assets } = await api.review(token);
setAssets(assets);
setError(null);
} catch (err) {
guard(err);
} finally {
setLoading(false);
}
}, [token, guard]);
useEffect(() => {
void refresh();
}, [refresh]);
const decide = async (id: string, action: "approve" | "refuse"): Promise<void> => {
if (!token) return;
if (action === "refuse" && !confirm(`Refuser définitivement ${id} ?`)) return;
try {
if (action === "approve") await api.approve(token, id);
else await api.refuse(token, id);
await refresh();
} catch (err) {
guard(err);
}
}; };
const badge = (a: Asset): string => const badge = (a: Asset): string => (a.riskTier === "privileged" ? "text-danger" : "text-success");
a.riskTier === "privileged" ? "text-danger" : "text-success";
return ( return (
<div className="p-4 space-y-3"> <div className="p-4 space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Review</h2> <h2 className="text-lg font-semibold">Review</h2>
<button onClick={() => void refresh()} className="ml-auto text-sm text-muted hover:text-fg cursor-pointer"> <button
Rafraîchir onClick={() => void refresh()}
className="ml-auto flex items-center gap-1.5 text-sm text-muted hover:text-fg cursor-pointer"
>
<RefreshCw size={15} /> Rafraîchir
</button> </button>
</div> </div>
@@ -76,16 +41,16 @@ export function Review() {
)} )}
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
<button <button
onClick={() => void decide(a.id, "approve")} onClick={() => void approve(a.id)}
className="rounded-md bg-success/20 text-success border border-success/40 px-3 py-1 text-sm cursor-pointer ring-accent" className="flex items-center gap-1 rounded-md bg-success/15 text-success border border-success/40 px-3 py-1 text-sm cursor-pointer ring-accent"
> >
Approuver <Check size={15} /> Approuver
</button> </button>
<button <button
onClick={() => void decide(a.id, "refuse")} onClick={() => onRefuse(a.id)}
className="rounded-md bg-danger/20 text-danger border border-danger/50 px-3 py-1 text-sm cursor-pointer ring-accent" className="flex items-center gap-1 rounded-md bg-danger/15 text-danger border border-danger/50 px-3 py-1 text-sm cursor-pointer ring-accent"
> >
Refuser <X size={15} /> Refuser
</button> </button>
</div> </div>
</div> </div>