🧭 Add DashboardSidebar component for portal navigation sidebar

Adds a fully animated sidebar component for the portal dashboard with:
- Slide-in animation from left with spring physics
- Animated logo section with scale entrance
- Navigation items with staggered reveal animations
- Badge counters with bounce animation and glow effects
- Support for section headers and nested items with indentation
- Active state highlighting with accent color border
- SVG icons for all navigation types (dashboard, nostr, meetups, etc.)
- Customizable dimensions, accent color, and animation timing

Includes comprehensive test suite with 28 tests covering:
- Rendering and layout
- Navigation item display
- Badge count display
- Section headers and nested items
- Custom styling and accent colors
- Icon rendering for all icon types

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HolgerHatGarKeineNode
2026-01-24 13:07:54 +01:00
parent fb9da68451
commit 68e4ea1743
2 changed files with 686 additions and 0 deletions

View File

@@ -0,0 +1,312 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { DashboardSidebar, SidebarNavItem } from "./DashboardSidebar";
// Mock Remotion hooks
vi.mock("remotion", () => ({
useCurrentFrame: vi.fn(() => 60),
useVideoConfig: vi.fn(() => ({ fps: 30, width: 1920, height: 1080 })),
interpolate: vi.fn((value, inputRange, outputRange) => {
const [inMin, inMax] = inputRange;
const [outMin, outMax] = outputRange;
const progress = Math.max(0, Math.min(1, (value - inMin) / (inMax - inMin)));
return outMin + progress * (outMax - outMin);
}),
spring: vi.fn(() => 1),
Img: vi.fn(({ src, style }: { src: string; style?: React.CSSProperties }) => (
// eslint-disable-next-line @remotion/warn-native-media-tag
<img src={src} style={style} alt="logo" data-testid="sidebar-logo" />
)),
}));
describe("DashboardSidebar", () => {
const defaultNavItems: SidebarNavItem[] = [
{ label: "Dashboard", icon: "dashboard", isActive: true },
{ label: "Meetups", icon: "meetups", badgeCount: 204 },
{ label: "Users", icon: "users", badgeCount: 587 },
{ label: "Events", icon: "events" },
];
const defaultProps = {
logoSrc: "/einundzwanzig-horizontal-inverted.svg",
navItems: defaultNavItems,
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
it("renders without errors", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
expect(container).toBeInTheDocument();
});
it("renders the logo image", () => {
const { getByTestId } = render(<DashboardSidebar {...defaultProps} />);
const logo = getByTestId("sidebar-logo");
expect(logo).toBeInTheDocument();
expect(logo).toHaveAttribute("src", "/einundzwanzig-horizontal-inverted.svg");
});
it("renders all navigation items", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
expect(container.textContent).toContain("Dashboard");
expect(container.textContent).toContain("Meetups");
expect(container.textContent).toContain("Users");
expect(container.textContent).toContain("Events");
});
it("displays badge counts for items with badges", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
expect(container.textContent).toContain("204");
expect(container.textContent).toContain("587");
});
it("applies active styling to active items", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const activeItem = container.querySelector(".bg-zinc-800\\/80");
expect(activeItem).toBeInTheDocument();
});
it("renders icons for nav items with icons", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const svgIcons = container.querySelectorAll("svg");
// Should have at least 4 icons for the 4 nav items
expect(svgIcons.length).toBeGreaterThanOrEqual(4);
});
it("applies custom width", () => {
const { container } = render(
<DashboardSidebar {...defaultProps} width={320} />
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveStyle({ width: "320px" });
});
it("applies default width of 280px", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveStyle({ width: "280px" });
});
it("applies custom height", () => {
const { container } = render(
<DashboardSidebar {...defaultProps} height={900} />
);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveStyle({ height: "900px" });
});
it("applies default height of 1080px", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveStyle({ height: "1080px" });
});
it("renders section headers correctly", () => {
const navItemsWithSection: SidebarNavItem[] = [
{ label: "Dashboard", icon: "dashboard" },
{ label: "Einstellungen", isSection: true },
{ label: "Settings", icon: "settings" },
];
const { container } = render(
<DashboardSidebar
logoSrc="/logo.svg"
navItems={navItemsWithSection}
/>
);
const sectionHeader = container.querySelector(".uppercase.tracking-wider");
expect(sectionHeader).toBeInTheDocument();
expect(sectionHeader).toHaveTextContent("Einstellungen");
});
it("renders nested items with indentation", () => {
const navItemsWithIndent: SidebarNavItem[] = [
{ label: "Settings", icon: "settings" },
{ label: "Language", icon: "language", indentLevel: 1 },
{ label: "Interface", icon: "interface", indentLevel: 1 },
];
const { container } = render(
<DashboardSidebar
logoSrc="/logo.svg"
navItems={navItemsWithIndent}
/>
);
expect(container.textContent).toContain("Settings");
expect(container.textContent).toContain("Language");
expect(container.textContent).toContain("Interface");
});
it("has flex column layout", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass("flex");
expect(sidebar).toHaveClass("flex-col");
});
it("has proper background styling", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass("bg-zinc-900/95");
});
it("has backdrop blur styling", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass("backdrop-blur-md");
});
it("has border-right styling", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const sidebar = container.firstChild as HTMLElement;
expect(sidebar).toHaveClass("border-r");
expect(sidebar).toHaveClass("border-zinc-700/50");
});
it("applies custom accent color to badges", () => {
const { container } = render(
<DashboardSidebar {...defaultProps} accentColor="#00ff00" />
);
const badge = container.querySelector(".rounded-full.font-bold");
expect(badge).toHaveStyle({ backgroundColor: "#00ff00" });
});
it("applies default Bitcoin orange accent color to badges", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const badge = container.querySelector(".rounded-full.font-bold");
expect(badge).toHaveStyle({ backgroundColor: "#f7931a" });
});
it("renders empty sidebar with no nav items", () => {
const { container } = render(
<DashboardSidebar logoSrc="/logo.svg" navItems={[]} />
);
expect(container).toBeInTheDocument();
// Should still have logo
const logo = container.querySelector('[data-testid="sidebar-logo"]');
expect(logo).toBeInTheDocument();
});
it("truncates long navigation labels", () => {
const navItemsWithLongLabel: SidebarNavItem[] = [
{ label: "This is a very long navigation item label that should be truncated", icon: "dashboard" },
];
const { container } = render(
<DashboardSidebar
logoSrc="/logo.svg"
navItems={navItemsWithLongLabel}
/>
);
const label = container.querySelector(".truncate");
expect(label).toBeInTheDocument();
});
it("renders badges with monospace font", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const badge = container.querySelector(".rounded-full.font-bold.tabular-nums");
expect(badge).toHaveStyle({ fontFamily: "Inconsolata, monospace" });
});
it("renders items without icons correctly", () => {
const navItemsNoIcons: SidebarNavItem[] = [
{ label: "Item without icon" },
{ label: "Another item without icon", badgeCount: 5 },
];
const { container } = render(
<DashboardSidebar
logoSrc="/logo.svg"
navItems={navItemsNoIcons}
/>
);
expect(container.textContent).toContain("Item without icon");
expect(container.textContent).toContain("Another item without icon");
expect(container.textContent).toContain("5");
});
it("applies active border color correctly", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const activeItem = container.querySelector(".bg-zinc-800\\/80") as HTMLElement;
expect(activeItem).toHaveStyle({ borderLeft: "3px solid #f7931a" });
});
it("renders logo section with border bottom", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const logoSection = container.querySelector(".border-b.border-zinc-700\\/50");
expect(logoSection).toBeInTheDocument();
});
it("has overflow hidden on navigation container", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const navContainer = container.querySelector(".flex-1.overflow-hidden");
expect(navContainer).toBeInTheDocument();
});
it("renders footer gradient overlay", () => {
const { container } = render(<DashboardSidebar {...defaultProps} />);
const gradient = container.querySelector(".pointer-events-none");
expect(gradient).toBeInTheDocument();
});
it("renders multiple icons with correct names", () => {
const navItemsAllIcons: SidebarNavItem[] = [
{ label: "Dashboard", icon: "dashboard" },
{ label: "Nostr", icon: "nostr" },
{ label: "Meetups", icon: "meetups" },
{ label: "Users", icon: "users" },
{ label: "Events", icon: "events" },
{ label: "Settings", icon: "settings" },
{ label: "Language", icon: "language" },
{ label: "Interface", icon: "interface" },
{ label: "Provider", icon: "provider" },
];
const { container } = render(
<DashboardSidebar
logoSrc="/logo.svg"
navItems={navItemsAllIcons}
/>
);
// Should render all items
navItemsAllIcons.forEach(item => {
expect(container.textContent).toContain(item.label);
});
// Should have SVG icons
const svgIcons = container.querySelectorAll("svg");
expect(svgIcons.length).toBeGreaterThanOrEqual(9);
});
it("renders fallback icon for unknown icon names", () => {
const navItemsUnknownIcon: SidebarNavItem[] = [
{ label: "Unknown", icon: "unknown-icon-name" },
];
const { container } = render(
<DashboardSidebar
logoSrc="/logo.svg"
navItems={navItemsUnknownIcon}
/>
);
// Should still render without crashing
expect(container.textContent).toContain("Unknown");
const svgIcon = container.querySelector("svg");
expect(svgIcon).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,374 @@
import {
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
Img,
} from "remotion";
export type SidebarNavItem = {
/** Label for the navigation item */
label: string;
/** Optional icon name (dashboard, nostr, meetups, users, events, settings, language, interface, provider) */
icon?: string;
/** Optional badge count to display */
badgeCount?: number;
/** Whether this item is currently active/selected */
isActive?: boolean;
/** Whether this is a section header */
isSection?: boolean;
/** Indent level for nested items (0 = top level) */
indentLevel?: number;
};
export type DashboardSidebarProps = {
/** URL or staticFile path for the logo */
logoSrc: string;
/** Navigation items to display */
navItems: SidebarNavItem[];
/** Width of the sidebar in pixels (default: 280) */
width?: number;
/** Height of the sidebar in pixels (default: 1080) */
height?: number;
/** Delay in frames before animation starts */
delay?: number;
/** Custom accent color (default: #f7931a - Bitcoin orange) */
accentColor?: string;
/** Whether to animate items with stagger effect (default: true) */
staggerItems?: boolean;
/** Stagger delay in frames between items (default: 3) */
staggerDelay?: number;
};
/**
* SVG icons for navigation items
*/
const NavIcon: React.FC<{
name: string;
size: number;
color: string;
}> = ({ name, size, color }) => {
const iconProps = {
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: color,
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
switch (name) {
case "dashboard":
return (
<svg {...iconProps}>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
);
case "nostr":
return (
<svg {...iconProps}>
<circle cx="12" cy="12" r="10" />
<path d="M8 14s1.5 2 4 2 4-2 4-2" />
<line x1="9" y1="9" x2="9.01" y2="9" />
<line x1="15" y1="9" x2="15.01" y2="9" />
</svg>
);
case "meetups":
return (
<svg {...iconProps}>
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
);
case "users":
return (
<svg {...iconProps}>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);
case "events":
return (
<svg {...iconProps}>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
);
case "settings":
return (
<svg {...iconProps}>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);
case "language":
return (
<svg {...iconProps}>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
);
case "interface":
return (
<svg {...iconProps}>
<rect x="2" y="3" width="20" height="14" rx="2" ry="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
);
case "provider":
return (
<svg {...iconProps}>
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
);
case "chevron":
return (
<svg {...iconProps}>
<polyline points="9 18 15 12 9 6" />
</svg>
);
default:
return (
<svg {...iconProps}>
<circle cx="12" cy="12" r="10" />
</svg>
);
}
};
export const DashboardSidebar: React.FC<DashboardSidebarProps> = ({
logoSrc,
navItems,
width = 280,
height = 1080,
delay = 0,
accentColor = "#f7931a",
staggerItems = true,
staggerDelay = 3,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const adjustedFrame = Math.max(0, frame - delay);
// Sidebar entrance animation (slide in from left)
const sidebarSpring = spring({
frame: adjustedFrame,
fps,
config: { damping: 15, stiffness: 80 },
});
const sidebarTranslateX = interpolate(sidebarSpring, [0, 1], [-width, 0]);
const sidebarOpacity = interpolate(sidebarSpring, [0, 1], [0, 1]);
// Logo animation (slightly delayed)
const logoSpring = spring({
frame: adjustedFrame - 10,
fps,
config: { damping: 12, stiffness: 100 },
});
const logoScale = interpolate(logoSpring, [0, 1], [0.5, 1]);
const logoOpacity = interpolate(logoSpring, [0, 1], [0, 1]);
// Subtle glow pulse
const glowIntensity = interpolate(
Math.sin(adjustedFrame * 0.06),
[-1, 1],
[0.2, 0.4]
);
const padding = width * 0.06;
const logoHeight = width * 0.15;
const itemHeight = width * 0.14;
const iconSize = width * 0.07;
const fontSize = width * 0.05;
const badgeFontSize = width * 0.04;
return (
<div
className="flex flex-col bg-zinc-900/95 backdrop-blur-md border-r border-zinc-700/50"
style={{
width,
height,
transform: `translateX(${sidebarTranslateX}px)`,
opacity: sidebarOpacity,
boxShadow: `${10 * glowIntensity}px 0 ${30 * glowIntensity}px rgba(0, 0, 0, 0.5)`,
}}
>
{/* Logo Section */}
<div
className="flex items-center border-b border-zinc-700/50"
style={{
padding,
height: logoHeight + padding * 2,
}}
>
<div
style={{
transform: `scale(${logoScale})`,
opacity: logoOpacity,
}}
>
<Img
src={logoSrc}
style={{
height: logoHeight,
width: "auto",
objectFit: "contain",
}}
/>
</div>
</div>
{/* Navigation Items */}
<div
className="flex-1 overflow-hidden"
style={{
paddingTop: padding * 0.5,
paddingBottom: padding * 0.5,
}}
>
{navItems.map((item, index) => {
// Staggered animation for each item
const itemDelay = staggerItems ? index * staggerDelay : 0;
const itemFrame = adjustedFrame - 15 - itemDelay;
const itemSpring = spring({
frame: itemFrame,
fps,
config: { damping: 15, stiffness: 90 },
});
const itemOpacity = interpolate(itemSpring, [0, 1], [0, 1]);
const itemTranslateX = interpolate(itemSpring, [0, 1], [-30, 0]);
// Badge animation (delayed)
const badgeSpring = spring({
frame: itemFrame - 5,
fps,
config: { damping: 10, stiffness: 120 },
});
const badgeScale = interpolate(badgeSpring, [0, 1], [0, 1]);
const indentPadding = (item.indentLevel || 0) * (padding * 0.8);
// Section header styling
if (item.isSection) {
return (
<div
key={`section-${index}`}
className="text-zinc-500 font-medium uppercase tracking-wider"
style={{
fontSize: fontSize * 0.75,
paddingLeft: padding + indentPadding,
paddingRight: padding,
paddingTop: padding * 0.8,
paddingBottom: padding * 0.4,
opacity: itemOpacity,
transform: `translateX(${itemTranslateX}px)`,
}}
>
{item.label}
</div>
);
}
return (
<div
key={`item-${index}`}
className={`flex items-center cursor-pointer transition-colors ${
item.isActive
? "bg-zinc-800/80"
: "hover:bg-zinc-800/40"
}`}
style={{
height: itemHeight,
paddingLeft: padding + indentPadding,
paddingRight: padding,
opacity: itemOpacity,
transform: `translateX(${itemTranslateX}px)`,
borderLeft: item.isActive
? `3px solid ${accentColor}`
: "3px solid transparent",
}}
>
{/* Icon */}
{item.icon && (
<div
className="flex-shrink-0"
style={{
marginRight: padding * 0.6,
opacity: item.isActive ? 1 : 0.7,
}}
>
<NavIcon
name={item.icon}
size={iconSize}
color={item.isActive ? accentColor : "#a1a1aa"}
/>
</div>
)}
{/* Label */}
<span
className={`flex-1 truncate ${
item.isActive ? "text-white font-semibold" : "text-zinc-300"
}`}
style={{
fontSize,
lineHeight: 1.2,
}}
>
{item.label}
</span>
{/* Badge */}
{item.badgeCount !== undefined && (
<div
className="flex-shrink-0 rounded-full font-bold tabular-nums text-white"
style={{
backgroundColor: accentColor,
paddingLeft: padding * 0.4,
paddingRight: padding * 0.4,
paddingTop: padding * 0.15,
paddingBottom: padding * 0.15,
fontSize: badgeFontSize,
transform: `scale(${badgeScale})`,
minWidth: padding * 1.2,
textAlign: "center",
boxShadow: `0 0 ${8 * glowIntensity}px ${accentColor}60`,
fontFamily: "Inconsolata, monospace",
}}
>
{item.badgeCount}
</div>
)}
</div>
);
})}
</div>
{/* Footer gradient overlay */}
<div
className="pointer-events-none"
style={{
height: padding * 2,
background: `linear-gradient(to top, rgba(24, 24, 27, 0.95), transparent)`,
}}
/>
</div>
);
};