mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-01-27 06:33:18 +00:00
🧭 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:
312
videos/src/components/DashboardSidebar.test.tsx
Normal file
312
videos/src/components/DashboardSidebar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
374
videos/src/components/DashboardSidebar.tsx
Normal file
374
videos/src/components/DashboardSidebar.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user