diff --git a/CHANGELOG.md b/CHANGELOG.md index b827650..7ca8733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ incompatibles. Chaque ligne renvoie à un commit dédié (un artefact = un commi ## [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) ### Added - `useSpeech` : mode **mains-libres** + wake-word « CHLOVA » (`extractCommand`), diff --git a/web/package-lock.json b/web/package-lock.json index 825d9b1..92533fe 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -8,6 +8,7 @@ "name": "chlova-web", "version": "0.1.0", "dependencies": { + "lucide-react": "^1.21.0", "react": "19.2.7", "react-dom": "19.2.7", "react-router-dom": "7.18.0" @@ -4939,6 +4940,15 @@ "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": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", diff --git a/web/package.json b/web/package.json index b75b363..e04eb93 100644 --- a/web/package.json +++ b/web/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc -b --noEmit" }, "dependencies": { + "lucide-react": "1.21.0", "react": "19.2.7", "react-dom": "19.2.7", "react-router-dom": "7.18.0" diff --git a/web/src/App.tsx b/web/src/App.tsx index c403f49..d68861a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 (
CHLOVA -
@@ -36,7 +49,13 @@ export function App() { const { token } = useAuth(); return ( - {token ? : } + {token ? ( + + + + ) : ( + + )} ); } diff --git a/web/src/appdata.tsx b/web/src/appdata.tsx new file mode 100644 index 0000000..03d7a55 --- /dev/null +++ b/web/src/appdata.tsx @@ -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; + approve: (id: string) => Promise; + refuse: (id: string) => Promise; +} + +const Ctx = createContext(null); + +export function AppDataProvider({ children }: { children: ReactNode }) { + const { token, logout } = useAuth(); + const [phase, setPhase] = useState(""); + const [tools, setTools] = useState(0); + const [assets, setAssets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 => { + 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 => { + 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 ( + decide(id, "approve"), + refuse: (id) => decide(id, "refuse"), + }} + > + {children} + + ); +} + +export function useAppData(): AppData { + const ctx = useContext(Ctx); + if (!ctx) throw new Error("useAppData hors AppDataProvider"); + return ctx; +} diff --git a/web/src/pages/Chat.tsx b/web/src/pages/Chat.tsx index d5a2322..486bcdd 100644 --- a/web/src/pages/Chat.tsx +++ b/web/src/pages/Chat.tsx @@ -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 ? : } )} - {speech.listening ? "Stop" : "Parler"} + {speech.listening ? : } )} {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"} + )}
diff --git a/web/src/pages/Review.tsx b/web/src/pages/Review.tsx index f4ff596..f60601d 100644 --- a/web/src/pages/Review.tsx +++ b/web/src/pages/Review.tsx @@ -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([]); - const [error, setError] = useState(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 => { - 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 => { - 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 (

Review

-
@@ -76,16 +41,16 @@ export function Review() { )}