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:
@@ -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`),
|
||||||
|
|||||||
Generated
+10
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user