Loading panel/src/components/AppShell.tsx +74 −14 Changes for panel/src/components/AppShell.tsx: 74 added lines, 14 removed lines. Original line number Diff line number Diff line import { useEffect } from "react"; import { useEffect, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; import { Cat, Globe, Sun, Moon } from "lucide-react"; import { Cat, Globe, Sun, Moon, LogOut, UserRound } from "lucide-react"; import { useTranslation } from "react-i18next"; import * as SelectPrimitive from "@radix-ui/react-select"; import { cn } from "@/lib/utils"; Loading @@ -8,6 +8,7 @@ import { useTier } from "@/lib/tier"; import { visibleSections, type NavSection } from "@/lib/nav"; import { Select, SelectContent, SelectItem } from "@/components/ui/select"; import { useTheme } from "@/lib/theme"; import { api } from "@/lib/api"; function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) { const { t } = useTranslation("navigation"); Loading Loading @@ -48,12 +49,66 @@ function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: bool ); } // Shared visual baseline for every clickable icon button in the sidebar foot. // Styled with a subtle border and background to make them feel like tangible widgets, // resolving the flat "floating icons in empty space" visual issue. const FOOT_ICON_BTN = "flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border/40 bg-muted/20 text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none transition-all duration-150 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"; function UserStrip() { // The sidebar foot identifies the principal and exposes one action — sign out. // identity?.email is the only display-safe field (user_id is a UUID, role is // server-truth not display). While /me is loading or has failed we render a // muted placeholder rather than a broken row, so the strip never flashes empty. const { identity, refresh } = useTier(); const { t } = useTranslation("account"); const [signingOut, setSigningOut] = useState(false); // Mirrors Account.tsx#signOut: idempotent on the server; refresh() flips // unauthenticated → RequireAuth bounces to /login. No navigate() needed. async function signOut() { if (signingOut) return; setSigningOut(true); try { await api.logout(); } finally { await refresh(); } } return ( <div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 min-w-0 shadow-sm"> <div className="min-w-0 flex-1 leading-tight"> <div className="truncate text-[13px] font-semibold text-foreground" title={identity?.email ?? ""} > {identity?.email ?? <span className="text-muted-foreground/40">—</span>} </div> <div className="text-[10px] text-muted-foreground/70 capitalize font-medium"> {identity?.role ?? "user"} </div> </div> <button type="button" onClick={signOut} disabled={signingOut || !identity} className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-destructive border border-transparent hover:border-border/50 focus:outline-none transition-all active:scale-95 disabled:opacity-50" aria-label={t("sign_out")} title={t("sign_out")} > <LogOut className="h-3.5 w-3.5" /> </button> </div> ); } function LangToggle() { const { i18n } = useTranslation(); return ( <Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}> <SelectPrimitive.Trigger className="h-7 w-7 p-0 flex items-center justify-center border border-border rounded-md bg-muted/40 hover:bg-accent text-muted-foreground hover:text-foreground focus:outline-none transition-colors shrink-0"> <SelectPrimitive.Trigger className={FOOT_ICON_BTN}> <Globe className="h-4 w-4" /> </SelectPrimitive.Trigger> <SelectContent align="start" className="min-w-[6rem]"> Loading @@ -71,15 +126,11 @@ function ThemeToggle() { return ( <button onClick={toggleTheme} className="h-7 w-7 p-0 flex items-center justify-center border border-border rounded-md bg-muted/40 hover:bg-accent text-muted-foreground hover:text-foreground focus:outline-none transition-all duration-200 active:scale-95 shrink-0" className={FOOT_ICON_BTN} aria-label={t("toggle_theme")} title={t("toggle_theme")} > {theme === "light" ? ( <Sun className="h-4 w-4" /> ) : ( <Moon className="h-4 w-4" /> )} {theme === "light" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />} </button> ); } Loading Loading @@ -112,16 +163,25 @@ export function AppShell() { ))} </nav> <div className="mt-auto px-3 pt-4 border-t border-border/60"> <div className="flex items-start gap-2"> <div className="flex items-center gap-1.5 shrink-0"> <div className="mt-auto flex flex-col gap-2 border-t border-border/50 pt-2.5"> {/* 1. Toggles & Meta */} <div className="flex items-center justify-between px-1"> <div className="flex items-center gap-1.5"> <LangToggle /> <ThemeToggle /> </div> <span className="text-[10px] text-muted-foreground/60 leading-tight pt-1"> {t("common:brand_tagline")} <span className="text-[10px] font-mono text-muted-foreground/40 select-none"> Felis v0.1.0 </span> </div> {/* 2. Profile Card */} <UserStrip /> {/* 3. Branding Sign-off (sits tight at the absolute bottom with leading-tight and centered) */} <div className="px-1 text-[10px] text-muted-foreground/50 leading-tight text-center"> {t("common:brand_tagline")} </div> </div> </aside> Loading panel/src/pages/Account.tsx +20 −20 Changes for panel/src/pages/Account.tsx: 20 added lines, 20 removed lines. Original line number Diff line number Diff line Loading @@ -78,26 +78,6 @@ export function Account() { </div> </div> <Card> <CardHeader> <CardTitle className="flex items-center gap-2 text-base"> <ShieldCheck className="h-4 w-4 text-primary" /> {t("session")} </CardTitle> </CardHeader> <CardContent className="space-y-4 text-sm text-muted-foreground"> <p>{t("session_desc")}</p> <Button variant="outline" size="sm" onClick={signOut} disabled={signingOut} > <LogOut className="mr-2 h-4 w-4" /> {signingOut ? t("signing_out") : t("sign_out")} </Button> </CardContent> </Card> <Card> <CardHeader> <CardTitle className="flex items-center gap-2 text-base"> Loading @@ -122,6 +102,26 @@ export function Account() { )} </CardContent> </Card> <Card> <CardHeader> <CardTitle className="flex items-center gap-2 text-base"> <ShieldCheck className="h-4 w-4 text-primary" /> {t("session")} </CardTitle> </CardHeader> <CardContent className="space-y-4 text-sm text-muted-foreground"> <p>{t("session_desc")}</p> <Button variant="outline" size="sm" onClick={signOut} disabled={signingOut} > <LogOut className="mr-2 h-4 w-4" /> {signingOut ? t("signing_out") : t("sign_out")} </Button> </CardContent> </Card> </> ); } Loading Loading
panel/src/components/AppShell.tsx +74 −14 Changes for panel/src/components/AppShell.tsx: 74 added lines, 14 removed lines. Original line number Diff line number Diff line import { useEffect } from "react"; import { useEffect, useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; import { Cat, Globe, Sun, Moon } from "lucide-react"; import { Cat, Globe, Sun, Moon, LogOut, UserRound } from "lucide-react"; import { useTranslation } from "react-i18next"; import * as SelectPrimitive from "@radix-ui/react-select"; import { cn } from "@/lib/utils"; Loading @@ -8,6 +8,7 @@ import { useTier } from "@/lib/tier"; import { visibleSections, type NavSection } from "@/lib/nav"; import { Select, SelectContent, SelectItem } from "@/components/ui/select"; import { useTheme } from "@/lib/theme"; import { api } from "@/lib/api"; function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) { const { t } = useTranslation("navigation"); Loading Loading @@ -48,12 +49,66 @@ function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: bool ); } // Shared visual baseline for every clickable icon button in the sidebar foot. // Styled with a subtle border and background to make them feel like tangible widgets, // resolving the flat "floating icons in empty space" visual issue. const FOOT_ICON_BTN = "flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border/40 bg-muted/20 text-muted-foreground hover:bg-accent hover:text-foreground focus:outline-none transition-all duration-150 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"; function UserStrip() { // The sidebar foot identifies the principal and exposes one action — sign out. // identity?.email is the only display-safe field (user_id is a UUID, role is // server-truth not display). While /me is loading or has failed we render a // muted placeholder rather than a broken row, so the strip never flashes empty. const { identity, refresh } = useTier(); const { t } = useTranslation("account"); const [signingOut, setSigningOut] = useState(false); // Mirrors Account.tsx#signOut: idempotent on the server; refresh() flips // unauthenticated → RequireAuth bounces to /login. No navigate() needed. async function signOut() { if (signingOut) return; setSigningOut(true); try { await api.logout(); } finally { await refresh(); } } return ( <div className="flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 min-w-0 shadow-sm"> <div className="min-w-0 flex-1 leading-tight"> <div className="truncate text-[13px] font-semibold text-foreground" title={identity?.email ?? ""} > {identity?.email ?? <span className="text-muted-foreground/40">—</span>} </div> <div className="text-[10px] text-muted-foreground/70 capitalize font-medium"> {identity?.role ?? "user"} </div> </div> <button type="button" onClick={signOut} disabled={signingOut || !identity} className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-background hover:text-destructive border border-transparent hover:border-border/50 focus:outline-none transition-all active:scale-95 disabled:opacity-50" aria-label={t("sign_out")} title={t("sign_out")} > <LogOut className="h-3.5 w-3.5" /> </button> </div> ); } function LangToggle() { const { i18n } = useTranslation(); return ( <Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}> <SelectPrimitive.Trigger className="h-7 w-7 p-0 flex items-center justify-center border border-border rounded-md bg-muted/40 hover:bg-accent text-muted-foreground hover:text-foreground focus:outline-none transition-colors shrink-0"> <SelectPrimitive.Trigger className={FOOT_ICON_BTN}> <Globe className="h-4 w-4" /> </SelectPrimitive.Trigger> <SelectContent align="start" className="min-w-[6rem]"> Loading @@ -71,15 +126,11 @@ function ThemeToggle() { return ( <button onClick={toggleTheme} className="h-7 w-7 p-0 flex items-center justify-center border border-border rounded-md bg-muted/40 hover:bg-accent text-muted-foreground hover:text-foreground focus:outline-none transition-all duration-200 active:scale-95 shrink-0" className={FOOT_ICON_BTN} aria-label={t("toggle_theme")} title={t("toggle_theme")} > {theme === "light" ? ( <Sun className="h-4 w-4" /> ) : ( <Moon className="h-4 w-4" /> )} {theme === "light" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />} </button> ); } Loading Loading @@ -112,16 +163,25 @@ export function AppShell() { ))} </nav> <div className="mt-auto px-3 pt-4 border-t border-border/60"> <div className="flex items-start gap-2"> <div className="flex items-center gap-1.5 shrink-0"> <div className="mt-auto flex flex-col gap-2 border-t border-border/50 pt-2.5"> {/* 1. Toggles & Meta */} <div className="flex items-center justify-between px-1"> <div className="flex items-center gap-1.5"> <LangToggle /> <ThemeToggle /> </div> <span className="text-[10px] text-muted-foreground/60 leading-tight pt-1"> {t("common:brand_tagline")} <span className="text-[10px] font-mono text-muted-foreground/40 select-none"> Felis v0.1.0 </span> </div> {/* 2. Profile Card */} <UserStrip /> {/* 3. Branding Sign-off (sits tight at the absolute bottom with leading-tight and centered) */} <div className="px-1 text-[10px] text-muted-foreground/50 leading-tight text-center"> {t("common:brand_tagline")} </div> </div> </aside> Loading
panel/src/pages/Account.tsx +20 −20 Changes for panel/src/pages/Account.tsx: 20 added lines, 20 removed lines. Original line number Diff line number Diff line Loading @@ -78,26 +78,6 @@ export function Account() { </div> </div> <Card> <CardHeader> <CardTitle className="flex items-center gap-2 text-base"> <ShieldCheck className="h-4 w-4 text-primary" /> {t("session")} </CardTitle> </CardHeader> <CardContent className="space-y-4 text-sm text-muted-foreground"> <p>{t("session_desc")}</p> <Button variant="outline" size="sm" onClick={signOut} disabled={signingOut} > <LogOut className="mr-2 h-4 w-4" /> {signingOut ? t("signing_out") : t("sign_out")} </Button> </CardContent> </Card> <Card> <CardHeader> <CardTitle className="flex items-center gap-2 text-base"> Loading @@ -122,6 +102,26 @@ export function Account() { )} </CardContent> </Card> <Card> <CardHeader> <CardTitle className="flex items-center gap-2 text-base"> <ShieldCheck className="h-4 w-4 text-primary" /> {t("session")} </CardTitle> </CardHeader> <CardContent className="space-y-4 text-sm text-muted-foreground"> <p>{t("session_desc")}</p> <Button variant="outline" size="sm" onClick={signOut} disabled={signingOut} > <LogOut className="mr-2 h-4 w-4" /> {signingOut ? t("signing_out") : t("sign_out")} </Button> </CardContent> </Card> </> ); } Loading