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