diff --git a/videos/src/components/DashboardSidebar.test.tsx b/videos/src/components/DashboardSidebar.test.tsx new file mode 100644 index 0000000..5bfc6f1 --- /dev/null +++ b/videos/src/components/DashboardSidebar.test.tsx @@ -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 + 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(); + expect(container).toBeInTheDocument(); + }); + + it("renders the logo image", () => { + const { getByTestId } = render(); + const logo = getByTestId("sidebar-logo"); + expect(logo).toBeInTheDocument(); + expect(logo).toHaveAttribute("src", "/einundzwanzig-horizontal-inverted.svg"); + }); + + it("renders all navigation items", () => { + const { container } = render(); + + 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(); + + expect(container.textContent).toContain("204"); + expect(container.textContent).toContain("587"); + }); + + it("applies active styling to active items", () => { + const { container } = render(); + const activeItem = container.querySelector(".bg-zinc-800\\/80"); + expect(activeItem).toBeInTheDocument(); + }); + + it("renders icons for nav items with icons", () => { + const { container } = render(); + 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( + + ); + const sidebar = container.firstChild as HTMLElement; + expect(sidebar).toHaveStyle({ width: "320px" }); + }); + + it("applies default width of 280px", () => { + const { container } = render(); + const sidebar = container.firstChild as HTMLElement; + expect(sidebar).toHaveStyle({ width: "280px" }); + }); + + it("applies custom height", () => { + const { container } = render( + + ); + const sidebar = container.firstChild as HTMLElement; + expect(sidebar).toHaveStyle({ height: "900px" }); + }); + + it("applies default height of 1080px", () => { + const { container } = render(); + 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( + + ); + + 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( + + ); + + expect(container.textContent).toContain("Settings"); + expect(container.textContent).toContain("Language"); + expect(container.textContent).toContain("Interface"); + }); + + it("has flex column layout", () => { + const { container } = render(); + const sidebar = container.firstChild as HTMLElement; + expect(sidebar).toHaveClass("flex"); + expect(sidebar).toHaveClass("flex-col"); + }); + + it("has proper background styling", () => { + const { container } = render(); + const sidebar = container.firstChild as HTMLElement; + expect(sidebar).toHaveClass("bg-zinc-900/95"); + }); + + it("has backdrop blur styling", () => { + const { container } = render(); + const sidebar = container.firstChild as HTMLElement; + expect(sidebar).toHaveClass("backdrop-blur-md"); + }); + + it("has border-right styling", () => { + const { container } = render(); + 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( + + ); + 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(); + const badge = container.querySelector(".rounded-full.font-bold"); + expect(badge).toHaveStyle({ backgroundColor: "#f7931a" }); + }); + + it("renders empty sidebar with no nav items", () => { + const { container } = render( + + ); + 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( + + ); + + const label = container.querySelector(".truncate"); + expect(label).toBeInTheDocument(); + }); + + it("renders badges with monospace font", () => { + const { container } = render(); + 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( + + ); + + 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(); + 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(); + const logoSection = container.querySelector(".border-b.border-zinc-700\\/50"); + expect(logoSection).toBeInTheDocument(); + }); + + it("has overflow hidden on navigation container", () => { + const { container } = render(); + const navContainer = container.querySelector(".flex-1.overflow-hidden"); + expect(navContainer).toBeInTheDocument(); + }); + + it("renders footer gradient overlay", () => { + const { container } = render(); + 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( + + ); + + // 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( + + ); + + // Should still render without crashing + expect(container.textContent).toContain("Unknown"); + const svgIcon = container.querySelector("svg"); + expect(svgIcon).toBeInTheDocument(); + }); +}); diff --git a/videos/src/components/DashboardSidebar.tsx b/videos/src/components/DashboardSidebar.tsx new file mode 100644 index 0000000..e7c893b --- /dev/null +++ b/videos/src/components/DashboardSidebar.tsx @@ -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 ( + + + + + + + ); + case "nostr": + return ( + + + + + + + ); + case "meetups": + return ( + + + + + + + ); + case "users": + return ( + + + + + ); + case "events": + return ( + + + + + + + ); + case "settings": + return ( + + + + + ); + case "language": + return ( + + + + + + ); + case "interface": + return ( + + + + + + ); + case "provider": + return ( + + + + ); + case "chevron": + return ( + + + + ); + default: + return ( + + + + ); + } +}; + +export const DashboardSidebar: React.FC = ({ + 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 ( +
+ {/* Logo Section */} +
+
+ +
+
+ + {/* Navigation Items */} +
+ {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 ( +
+ {item.label} +
+ ); + } + + return ( +
+ {/* Icon */} + {item.icon && ( +
+ +
+ )} + + {/* Label */} + + {item.label} + + + {/* Badge */} + {item.badgeCount !== undefined && ( +
+ {item.badgeCount} +
+ )} +
+ ); + })} +
+ + {/* Footer gradient overlay */} +
+
+ ); +};