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
+25 -6
View File
@@ -1,24 +1,37 @@
import { BrowserRouter, Routes, Route, NavLink, Navigate } from "react-router-dom";
import { MessageSquare, ShieldCheck, LogOut, Cpu } from "lucide-react";
import { useAuth } from "./auth";
import { AppDataProvider, useAppData } from "./appdata";
import { Login } from "./pages/Login";
import { Chat } from "./pages/Chat";
import { Review } from "./pages/Review";
function Shell() {
const { logout } = useAuth();
const { phase, tools, assets } = useAppData();
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 (
<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">
<span className="font-bold tracking-wide text-accent glow mr-2">CHLOVA</span>
<nav className="flex gap-1">
<NavLink to="/chat" className={link}>Chat</NavLink>
<NavLink to="/review" className={link}>Review</NavLink>
<NavLink to="/chat" className={link}>
<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>
<button onClick={logout} className="ml-auto text-sm text-muted hover:text-fg cursor-pointer">
Déconnexion
<span className="ml-auto flex items-center gap-1.5 text-xs text-muted" title="Phase · outils">
<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>
</header>
<main className="flex-1 min-h-0">
@@ -36,7 +49,13 @@ export function App() {
const { token } = useAuth();
return (
<BrowserRouter>
{token ? <Shell /> : <Login />}
{token ? (
<AppDataProvider>
<Shell />
</AppDataProvider>
) : (
<Login />
)}
</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 { Mic, Square, Volume2, VolumeX, Radio, Send } from "lucide-react";
import { useAuth } from "../auth";
import { api, ApiError } from "../api";
import { useSpeech } from "../useSpeech";
@@ -106,9 +107,9 @@ export function Chat() {
onClick={toggleSpeak}
aria-label={speakReplies ? "Couper la voix" : "Activer la voix"}
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>
)}
<input
@@ -123,9 +124,9 @@ export function Chat() {
type="button"
onClick={mic}
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>
)}
{speech.sttSupported && (
@@ -134,17 +135,18 @@ export function Chat() {
onClick={toggleHandsFree}
aria-label={speech.handsFree ? "Couper les mains libres" : "Activer les mains libres"}
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
type="submit"
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>
</form>
</div>
+18 -53
View File
@@ -1,60 +1,25 @@
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "../auth";
import { api, ApiError, type Asset } from "../api";
import { Check, X, RefreshCw } from "lucide-react";
import { useAppData } from "../appdata";
import type { Asset } from "../api";
export function Review() {
const { token, logout } = useAuth();
const [assets, setAssets] = useState<Asset[]>([]);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const { assets, loading, error, refresh, approve, refuse } = useAppData();
const guard = 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 { 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 onRefuse = (id: string): void => {
if (confirm(`Refuser définitivement ${id} ?`)) void refuse(id);
};
const badge = (a: Asset): string =>
a.riskTier === "privileged" ? "text-danger" : "text-success";
const badge = (a: Asset): string => (a.riskTier === "privileged" ? "text-danger" : "text-success");
return (
<div className="p-4 space-y-3">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Review</h2>
<button onClick={() => void refresh()} className="ml-auto text-sm text-muted hover:text-fg cursor-pointer">
Rafraîchir
<button
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>
</div>
@@ -76,16 +41,16 @@ export function Review() {
)}
<div className="ml-auto flex gap-2">
<button
onClick={() => void decide(a.id, "approve")}
className="rounded-md bg-success/20 text-success border border-success/40 px-3 py-1 text-sm cursor-pointer ring-accent"
onClick={() => void approve(a.id)}
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
onClick={() => void decide(a.id, "refuse")}
className="rounded-md bg-danger/20 text-danger border border-danger/50 px-3 py-1 text-sm cursor-pointer ring-accent"
onClick={() => onRefuse(a.id)}
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>
</div>
</div>