feat: add sidebar

This commit is contained in:
Austin Pickett 2026-04-22 23:25:17 -04:00
parent 7db2703b33
commit e5d2815b41
41 changed files with 2469 additions and 1391 deletions

View file

@ -1,9 +1,8 @@
import { useEffect, useState, useMemo } from "react";
import { useEffect, useLayoutEffect, useState, useMemo } from "react";
import {
Package,
Search,
Wrench,
ChevronRight,
X,
Cpu,
Globe,
@ -14,8 +13,8 @@ import {
Blocks,
Code,
Zap,
Filter,
} from "lucide-react";
import { H2 } from "@nous-research/ui";
import { api } from "@/lib/api";
import type { SkillInfo, ToolsetInfo } from "@/lib/api";
import { useToast } from "@/hooks/useToast";
@ -25,6 +24,7 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/i18n";
import { usePageHeader } from "@/contexts/usePageHeader";
/* ------------------------------------------------------------------ */
/* Types & helpers */
@ -98,6 +98,7 @@ export default function SkillsPage() {
const [togglingSkills, setTogglingSkills] = useState<Set<string>>(new Set());
const { toast, showToast } = useToast();
const { t } = useI18n();
const { setAfterTitle, setEnd } = usePageHeader();
useEffect(() => {
Promise.all([api.getSkills(), api.getToolsets()])
@ -182,6 +183,53 @@ export default function SkillsPage() {
const enabledCount = skills.filter((s) => s.enabled).length;
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
setEnd(null);
return;
}
setAfterTitle(
<span className="whitespace-nowrap text-xs text-muted-foreground">
{t.skills.enabledOf
.replace("{enabled}", String(enabledCount))
.replace("{total}", String(skills.length))}
</span>,
);
setEnd(
<div className="relative w-full min-w-0 sm:max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="h-8 pl-8 pr-7 text-xs"
placeholder={t.common.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-3 w-3" />
</button>
)}
</div>,
);
return () => {
setAfterTitle(null);
setEnd(null);
};
}, [
enabledCount,
loading,
search,
setAfterTitle,
setEnd,
skills.length,
t,
]);
const filteredToolsets = useMemo(() => {
return toolsets.filter(
(ts) =>
@ -205,122 +253,98 @@ export default function SkillsPage() {
<div className="flex flex-col gap-4">
<Toast toast={toast} />
{/* ═══════════════ Header ═══════════════ */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Package className="h-5 w-5 text-muted-foreground" />
<H2 variant="sm">{t.skills.title}</H2>
<span className="text-xs text-muted-foreground">
{t.skills.enabledOf
.replace("{enabled}", String(enabledCount))
.replace("{total}", String(skills.length))}
</span>
</div>
</div>
{/* ═══════════════ Filter panel + Content ═══════════════ */}
<div className="flex flex-col sm:flex-row gap-4">
{/* ---- Filter panel ---- */}
<aside
aria-label={t.skills.title}
className="sm:w-56 sm:shrink-0"
>
<div className="sm:sticky sm:top-4">
<div
className={`
flex flex-col
border border-border bg-muted/20
`}
>
{/* Filter heading */}
<div className="hidden sm:flex items-center gap-2 px-3 py-2 border-b border-border">
<Filter className="h-3 w-3 text-muted-foreground" />
<span className="font-mondwest text-[0.65rem] tracking-[0.12em] uppercase text-muted-foreground">
{t.skills.filters}
</span>
</div>
{/* ═══════════════ Sidebar + Content ═══════════════ */}
<div
className="flex flex-col sm:flex-row gap-4"
style={{ minHeight: "calc(100vh - 180px)" }}
>
{/* ---- Sidebar ---- */}
<div className="sm:w-52 sm:shrink-0">
<div className="sm:sticky sm:top-[72px] flex flex-col gap-1">
{/* Search */}
<div className="relative mb-2 hidden sm:block">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<Input
className="pl-8 h-8 text-xs"
placeholder={t.common.search}
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
{search && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
>
<X className="h-3 w-3" />
</button>
{/* View switch (Skills / Toolsets) */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none p-2">
<PanelItem
icon={Package}
label={`${t.skills.all} (${skills.length})`}
active={view === "skills" && !isSearching}
onClick={() => {
setView("skills");
setActiveCategory(null);
setSearch("");
}}
/>
<PanelItem
icon={Wrench}
label={`${t.skills.toolsets} (${toolsets.length})`}
active={view === "toolsets"}
onClick={() => {
setView("toolsets");
setSearch("");
}}
/>
</div>
{/* Category sub-filters (only for Skills view) */}
{view === "skills" && !isSearching && allCategories.length > 0 && (
<div className="hidden sm:flex flex-col border-t border-border">
<div className="px-3 pt-2 pb-1 font-mondwest text-[0.6rem] tracking-[0.12em] uppercase text-muted-foreground/70">
{t.skills.categories}
</div>
<div className="flex flex-col p-2 pt-1 gap-px max-h-[calc(100vh-340px)] overflow-y-auto">
{allCategories.map(({ key, name, count }) => {
const isActive = activeCategory === key;
return (
<button
key={key}
type="button"
onClick={() =>
setActiveCategory(isActive ? null : key)
}
className={`
group flex items-center gap-2 px-2 py-1
rounded-sm text-left text-[11px] cursor-pointer
transition-colors
${
isActive
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-foreground/5"
}
`}
>
<span className="flex-1 truncate">{name}</span>
<span
className={`text-[10px] tabular-nums ${
isActive
? "text-foreground/60"
: "text-muted-foreground/50"
}`}
>
{count}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
{/* Top-level nav */}
<div className="flex sm:flex-col gap-1 overflow-x-auto sm:overflow-x-visible scrollbar-none pb-1 sm:pb-0">
<button
type="button"
onClick={() => {
setView("skills");
setActiveCategory(null);
setSearch("");
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
view === "skills" && !isSearching
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Package className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">
{t.skills.all} ({skills.length})
</span>
{view === "skills" && !isSearching && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
{/* Skill categories (nested under All Skills) */}
{view === "skills" &&
!isSearching &&
allCategories.map(({ key, name, count }) => {
const isActive = activeCategory === key;
return (
<button
key={key}
type="button"
onClick={() =>
setActiveCategory(activeCategory === key ? null : key)
}
className={`group flex items-center gap-2 px-2.5 py-1 pl-7 text-left text-[11px] transition-colors cursor-pointer ${
isActive
? "text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<span className="flex-1 truncate">{name}</span>
<span
className={`text-[10px] tabular-nums ${isActive ? "text-primary/60" : "text-muted-foreground/50"}`}
>
{count}
</span>
</button>
);
})}
<button
type="button"
onClick={() => {
setView("toolsets");
setSearch("");
}}
className={`group flex items-center gap-2 px-2.5 py-1.5 text-left text-xs transition-colors cursor-pointer ${
view === "toolsets"
? "bg-primary/10 text-primary font-medium"
: "text-muted-foreground hover:text-foreground hover:bg-muted/50"
}`}
>
<Wrench className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">
{t.skills.toolsets} ({toolsets.length})
</span>
{view === "toolsets" && (
<ChevronRight className="h-3 w-3 text-primary/50 shrink-0" />
)}
</button>
</div>
</div>
</div>
</aside>
{/* ---- Content ---- */}
<div className="flex-1 min-w-0">
@ -522,9 +546,39 @@ function SkillRow({
);
}
function PanelItem({ active, icon: Icon, label, onClick }: PanelItemProps) {
return (
<button
type="button"
onClick={onClick}
className={`
group flex items-center gap-2 px-2.5 py-1.5
font-mondwest text-[0.7rem] tracking-[0.08em] uppercase
rounded-sm text-left cursor-pointer whitespace-nowrap
transition-colors
${
active
? "bg-foreground/90 text-background"
: "text-muted-foreground hover:text-foreground hover:bg-foreground/10"
}
`}
>
<Icon className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate">{label}</span>
</button>
);
}
interface PanelItemProps {
active: boolean;
icon: React.ComponentType<{ className?: string }>;
label: string;
onClick: () => void;
}
interface SkillRowProps {
noDescriptionLabel: string;
onToggle: () => void;
skill: SkillInfo;
toggling: boolean;
onToggle: () => void;
noDescriptionLabel: string;
}