feat(panel): sidebar foot shows current account + sign-out; reorder Account cards
This commit is contained in:
2 files changed
+94
-34
No files matched your search
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { NavLink, Outlet } from "react-router-dom";
|
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 { useTranslation } from "react-i18next";
|
||||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -8,6 +8,7 @@ import { useTier } from "@/lib/tier";
|
|||||||
import { visibleSections, type NavSection } from "@/lib/nav";
|
import { visibleSections, type NavSection } from "@/lib/nav";
|
||||||
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem } from "@/components/ui/select";
|
||||||
import { useTheme } from "@/lib/theme";
|
import { useTheme } from "@/lib/theme";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) {
|
function SectionGroup({ section, isFirst }: { section: NavSection; isFirst: boolean }) {
|
||||||
const { t } = useTranslation("navigation");
|
const { t } = useTranslation("navigation");
|
||||||
@@ -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() {
|
function LangToggle() {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Select value={i18n.language} onValueChange={(v) => i18n.changeLanguage(v)}>
|
<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" />
|
<Globe className="h-4 w-4" />
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
<SelectContent align="start" className="min-w-[6rem]">
|
<SelectContent align="start" className="min-w-[6rem]">
|
||||||
@@ -71,15 +126,11 @@ function ThemeToggle() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
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")}
|
aria-label={t("toggle_theme")}
|
||||||
title={t("toggle_theme")}
|
title={t("toggle_theme")}
|
||||||
>
|
>
|
||||||
{theme === "light" ? (
|
{theme === "light" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
<Sun className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Moon className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -112,16 +163,25 @@ export function AppShell() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="mt-auto px-3 pt-4 border-t border-border/60">
|
<div className="mt-auto flex flex-col gap-2 border-t border-border/50 pt-2.5">
|
||||||
<div className="flex items-start gap-2">
|
{/* 1. Toggles & Meta */}
|
||||||
<div className="flex items-center gap-1.5 shrink-0">
|
<div className="flex items-center justify-between px-1">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<LangToggle />
|
<LangToggle />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-muted-foreground/60 leading-tight pt-1">
|
<span className="text-[10px] font-mono text-muted-foreground/40 select-none">
|
||||||
{t("common:brand_tagline")}
|
Felis v0.1.0
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|||||||
+20
-20
@@ -78,26 +78,6 @@ export function Account() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
@@ -122,6 +102,26 @@ export function Account() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user