🎬 Add DashboardOverviewScene component for Dashboard Overview (Scene 3)

Implements the 12-second Dashboard Overview scene featuring:
- 3D perspective entrance animation (rotateX 30° → 0°, scale 0.85 → 1.0)
- DashboardSidebar with staggered navigation items
- Three StatsCounter cards (Meetups: 204, Users: 1247, Events: 89)
- SparklineCharts showing trend data for each metric
- Activity feed section with recent meetup activities
- Quick stats section with country and user metrics
- Audio: card-slide.mp3 and ui-appear.mp3 sound effects
- Vignette overlay and dark theme styling

Includes 23 comprehensive tests covering all components and animations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HolgerHatGarKeineNode
2026-01-24 13:20:56 +01:00
parent 74e1bb8742
commit 41acbc9324
3 changed files with 809 additions and 1 deletions

View File

@@ -2,6 +2,7 @@ import { AbsoluteFill, Sequence, useVideoConfig, Img, staticFile } from "remotio
import { inconsolataFont } from "./fonts/inconsolata";
import { PortalIntroScene } from "./scenes/portal/PortalIntroScene";
import { PortalTitleScene } from "./scenes/portal/PortalTitleScene";
import { DashboardOverviewScene } from "./scenes/portal/DashboardOverviewScene";
/**
* PortalPresentation - Main composition for the Einundzwanzig Portal presentation video
@@ -144,7 +145,7 @@ export const PortalPresentation: React.FC = () => {
durationInFrames={sceneFrames.dashboardOverview.duration}
premountFor={fps}
>
<PlaceholderScene name="Dashboard Overview" sceneNumber={3} />
<DashboardOverviewScene />
</Sequence>
{/* Scene 4: Meine Meetups (12s) */}

View File

@@ -0,0 +1,312 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, cleanup } from "@testing-library/react";
import { DashboardOverviewScene } from "./DashboardOverviewScene";
/* eslint-disable @remotion/warn-native-media-tag */
// 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, options) => {
const [inMin, inMax] = inputRange;
const [outMin, outMax] = outputRange;
let progress = (value - inMin) / (inMax - inMin);
if (options?.extrapolateLeft === "clamp") {
progress = Math.max(0, progress);
}
if (options?.extrapolateRight === "clamp") {
progress = Math.min(1, progress);
}
return outMin + progress * (outMax - outMin);
}),
spring: vi.fn(() => 1),
AbsoluteFill: vi.fn(({ children, className, style }) => (
<div data-testid="absolute-fill" className={className} style={style}>
{children}
</div>
)),
Img: vi.fn(({ src, className, style }) => (
<img data-testid="remotion-img" src={src} className={className} style={style} />
)),
staticFile: vi.fn((path: string) => `/static/${path}`),
Sequence: vi.fn(({ children, from, durationInFrames }) => (
<div data-testid="sequence" data-from={from} data-duration={durationInFrames}>
{children}
</div>
)),
Easing: {
out: vi.fn((fn) => fn),
cubic: vi.fn((t: number) => t),
},
}));
// Mock @remotion/media
vi.mock("@remotion/media", () => ({
Audio: vi.fn(({ src, volume }) => (
<audio data-testid="audio" src={src} data-volume={volume} />
)),
}));
/* eslint-enable @remotion/warn-native-media-tag */
// Mock DashboardSidebar component
vi.mock("../../components/DashboardSidebar", () => ({
DashboardSidebar: vi.fn(({ logoSrc, navItems, width, height, delay }) => (
<div
data-testid="dashboard-sidebar"
data-logo-src={logoSrc}
data-nav-items={navItems.length}
data-width={width}
data-height={height}
data-delay={delay}
>
DashboardSidebar
</div>
)),
}));
// Mock StatsCounter component
vi.mock("../../components/StatsCounter", () => ({
StatsCounter: vi.fn(({ targetNumber, delay, label, fontSize, color }) => (
<div
data-testid="stats-counter"
data-target={targetNumber}
data-delay={delay}
data-label={label}
data-font-size={fontSize}
data-color={color}
>
{targetNumber}
</div>
)),
}));
// Mock SparklineChart component
vi.mock("../../components/SparklineChart", () => ({
SparklineChart: vi.fn(({ data, width, height, delay, showFill }) => (
<div
data-testid="sparkline-chart"
data-points={data.length}
data-width={width}
data-height={height}
data-delay={delay}
data-show-fill={showFill}
>
SparklineChart
</div>
)),
}));
// Mock ActivityItem component
vi.mock("../../components/ActivityItem", () => ({
ActivityItem: vi.fn(({ eventName, timestamp, badgeText, delay, width }) => (
<div
data-testid="activity-item"
data-event-name={eventName}
data-timestamp={timestamp}
data-badge-text={badgeText}
data-delay={delay}
data-width={width}
>
{eventName}
</div>
)),
}));
describe("DashboardOverviewScene", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
vi.resetAllMocks();
});
it("renders without errors", () => {
const { container } = render(<DashboardOverviewScene />);
expect(container).toBeInTheDocument();
});
it("renders the AbsoluteFill container with correct classes", () => {
const { container } = render(<DashboardOverviewScene />);
const absoluteFill = container.querySelector('[data-testid="absolute-fill"]');
expect(absoluteFill).toBeInTheDocument();
expect(absoluteFill).toHaveClass("bg-zinc-900");
expect(absoluteFill).toHaveClass("overflow-hidden");
});
it("renders the wallpaper background image", () => {
const { container } = render(<DashboardOverviewScene />);
const images = container.querySelectorAll('[data-testid="remotion-img"]');
const wallpaper = Array.from(images).find((img) =>
img.getAttribute("src")?.includes("einundzwanzig-wallpaper.png")
);
expect(wallpaper).toBeInTheDocument();
});
it("renders the DashboardSidebar component with correct props", () => {
const { container } = render(<DashboardOverviewScene />);
const sidebar = container.querySelector('[data-testid="dashboard-sidebar"]');
expect(sidebar).toBeInTheDocument();
expect(sidebar).toHaveAttribute("data-width", "280");
expect(sidebar).toHaveAttribute("data-height", "1080");
expect(sidebar).toHaveAttribute("data-delay", "0");
// Should have navigation items
expect(parseInt(sidebar?.getAttribute("data-nav-items") || "0")).toBeGreaterThan(0);
});
it("renders the Dashboard header", () => {
const { container } = render(<DashboardOverviewScene />);
const header = container.querySelector("h1");
expect(header).toBeInTheDocument();
expect(header).toHaveTextContent("Dashboard");
expect(header).toHaveClass("text-5xl");
expect(header).toHaveClass("font-bold");
expect(header).toHaveClass("text-white");
});
it("renders the welcome subtitle", () => {
const { container } = render(<DashboardOverviewScene />);
const subtitle = container.querySelector("p");
expect(subtitle).toBeInTheDocument();
expect(subtitle).toHaveTextContent("Willkommen im Einundzwanzig Portal");
expect(subtitle).toHaveClass("text-zinc-400");
});
it("renders three StatsCounter components", () => {
const { container } = render(<DashboardOverviewScene />);
const statsCounters = container.querySelectorAll('[data-testid="stats-counter"]');
expect(statsCounters.length).toBe(3);
});
it("renders StatsCounter for Meetups with target 204", () => {
const { container } = render(<DashboardOverviewScene />);
const statsCounters = container.querySelectorAll('[data-testid="stats-counter"]');
const meetupsCounter = Array.from(statsCounters).find(
(counter) => counter.getAttribute("data-target") === "204"
);
expect(meetupsCounter).toBeInTheDocument();
expect(meetupsCounter).toHaveAttribute("data-label", "Aktive Gruppen");
});
it("renders StatsCounter for Users with target 1247", () => {
const { container } = render(<DashboardOverviewScene />);
const statsCounters = container.querySelectorAll('[data-testid="stats-counter"]');
const usersCounter = Array.from(statsCounters).find(
(counter) => counter.getAttribute("data-target") === "1247"
);
expect(usersCounter).toBeInTheDocument();
expect(usersCounter).toHaveAttribute("data-label", "Registrierte Nutzer");
});
it("renders StatsCounter for Events with target 89", () => {
const { container } = render(<DashboardOverviewScene />);
const statsCounters = container.querySelectorAll('[data-testid="stats-counter"]');
const eventsCounter = Array.from(statsCounters).find(
(counter) => counter.getAttribute("data-target") === "89"
);
expect(eventsCounter).toBeInTheDocument();
expect(eventsCounter).toHaveAttribute("data-label", "Diese Woche");
});
it("renders three SparklineChart components for trends", () => {
const { container } = render(<DashboardOverviewScene />);
const sparklines = container.querySelectorAll('[data-testid="sparkline-chart"]');
expect(sparklines.length).toBe(3);
});
it("renders SparklineCharts with fill enabled", () => {
const { container } = render(<DashboardOverviewScene />);
const sparklines = container.querySelectorAll('[data-testid="sparkline-chart"]');
sparklines.forEach((sparkline) => {
expect(sparkline).toHaveAttribute("data-show-fill", "true");
});
});
it("renders ActivityItem components for recent activities", () => {
const { container } = render(<DashboardOverviewScene />);
const activityItems = container.querySelectorAll('[data-testid="activity-item"]');
expect(activityItems.length).toBe(3);
});
it("renders ActivityItem for EINUNDZWANZIG Kempten", () => {
const { container } = render(<DashboardOverviewScene />);
const activityItems = container.querySelectorAll('[data-testid="activity-item"]');
const kemptenActivity = Array.from(activityItems).find(
(item) => item.getAttribute("data-event-name") === "EINUNDZWANZIG Kempten"
);
expect(kemptenActivity).toBeInTheDocument();
expect(kemptenActivity).toHaveAttribute("data-timestamp", "vor 13 Stunden");
});
it("renders audio sequences for sound effects", () => {
const { container } = render(<DashboardOverviewScene />);
const sequences = container.querySelectorAll('[data-testid="sequence"]');
expect(sequences.length).toBeGreaterThanOrEqual(2);
});
it("includes card-slide audio", () => {
const { container } = render(<DashboardOverviewScene />);
const audioElements = container.querySelectorAll('[data-testid="audio"]');
const cardSlideAudio = Array.from(audioElements).find((audio) =>
audio.getAttribute("src")?.includes("card-slide.mp3")
);
expect(cardSlideAudio).toBeInTheDocument();
});
it("includes ui-appear audio", () => {
const { container } = render(<DashboardOverviewScene />);
const audioElements = container.querySelectorAll('[data-testid="audio"]');
const uiAppearAudio = Array.from(audioElements).find((audio) =>
audio.getAttribute("src")?.includes("ui-appear.mp3")
);
expect(uiAppearAudio).toBeInTheDocument();
});
it("renders vignette overlay with pointer-events-none", () => {
const { container } = render(<DashboardOverviewScene />);
const vignettes = container.querySelectorAll(".pointer-events-none");
expect(vignettes.length).toBeGreaterThan(0);
});
it("renders the Letzte Aktivitäten section header", () => {
const { container } = render(<DashboardOverviewScene />);
const sectionHeaders = container.querySelectorAll("h3");
const activityHeader = Array.from(sectionHeaders).find(
(h3) => h3.textContent === "Letzte Aktivitäten"
);
expect(activityHeader).toBeInTheDocument();
});
it("renders the Schnellübersicht section header", () => {
const { container } = render(<DashboardOverviewScene />);
const sectionHeaders = container.querySelectorAll("h3");
const quickStatsHeader = Array.from(sectionHeaders).find(
(h3) => h3.textContent === "Schnellübersicht"
);
expect(quickStatsHeader).toBeInTheDocument();
});
it("renders quick stats labels", () => {
const { container } = render(<DashboardOverviewScene />);
expect(container.textContent).toContain("Länder");
expect(container.textContent).toContain("Neue diese Woche");
expect(container.textContent).toContain("Aktive Nutzer");
});
it("renders card section headers for stats", () => {
const { container } = render(<DashboardOverviewScene />);
const sectionHeaders = container.querySelectorAll("h3");
const headerTexts = Array.from(sectionHeaders).map((h3) => h3.textContent);
expect(headerTexts).toContain("Meetups");
expect(headerTexts).toContain("Benutzer");
expect(headerTexts).toContain("Events");
});
it("applies 3D perspective transform styles", () => {
const { container } = render(<DashboardOverviewScene />);
// Look for perspective transform in style attributes
const elements = container.querySelectorAll('[style*="perspective"]');
expect(elements.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,495 @@
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
Img,
staticFile,
Sequence,
} from "remotion";
import { Audio } from "@remotion/media";
import { DashboardSidebar, SidebarNavItem } from "../../components/DashboardSidebar";
import { StatsCounter } from "../../components/StatsCounter";
import { SparklineChart } from "../../components/SparklineChart";
import { ActivityItem } from "../../components/ActivityItem";
// Spring configurations
const SNAPPY = { damping: 15, stiffness: 80 };
// Stagger delay between content cards
const CARD_STAGGER_DELAY = 5;
// Navigation items for the sidebar
const NAV_ITEMS: SidebarNavItem[] = [
{ label: "Dashboard", icon: "dashboard", isActive: true },
{ label: "EINSTELLUNGEN", isSection: true },
{ label: "Nostr Relays", icon: "nostr" },
{ label: "Meetups", icon: "meetups", badgeCount: 204 },
{ label: "Benutzer", icon: "users", badgeCount: 1247 },
{ label: "Events", icon: "events", badgeCount: 89 },
{ label: "KONFIGURATION", isSection: true },
{ label: "Sprache", icon: "language" },
{ label: "Interface", icon: "interface" },
{ label: "Provider", icon: "provider" },
];
// Sample sparkline data
const MEETUP_TREND_DATA = [12, 15, 18, 14, 22, 25, 28, 32, 35, 42, 48, 55];
const USER_TREND_DATA = [100, 145, 180, 220, 280, 350, 420, 510, 620, 780, 950, 1100];
const EVENT_TREND_DATA = [5, 8, 12, 15, 18, 22, 28, 35, 42, 55, 68, 89];
/**
* DashboardOverviewScene - Scene 3: Dashboard Overview (12 seconds / 360 frames @ 30fps)
*
* Animation sequence:
* 1. 3D perspective entrance (rotateX from 30° to 0°, scale from 0.85 to 1.0)
* 2. Sidebar slides in from left with spring animation
* 3. Header animates in with fade and translateY
* 4. Content cards appear with staggered spring animations
* 5. Stats counters animate up
* 6. Audio: card-slide.mp3 for card entrances
*/
export const DashboardOverviewScene: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// 3D Perspective entrance animation (0-60 frames)
const perspectiveSpring = spring({
frame,
fps,
config: { damping: 20, stiffness: 60 },
});
const perspectiveX = interpolate(perspectiveSpring, [0, 1], [30, 0]);
const perspectiveScale = interpolate(perspectiveSpring, [0, 1], [0.85, 1]);
const perspectiveOpacity = interpolate(perspectiveSpring, [0, 1], [0, 1]);
// Header entrance animation (delayed)
const headerDelay = Math.floor(0.5 * fps);
const headerSpring = spring({
frame: frame - headerDelay,
fps,
config: SNAPPY,
});
const headerOpacity = interpolate(headerSpring, [0, 1], [0, 1]);
const headerY = interpolate(headerSpring, [0, 1], [-30, 0]);
// Subtle background glow pulse
const glowIntensity = interpolate(
Math.sin(frame * 0.04),
[-1, 1],
[0.3, 0.5]
);
// Sidebar dimensions
const sidebarWidth = 280;
// Content area calculations
const contentPadding = 40;
const cardWidth = 380;
const cardGap = 24;
// Card entrance delays (staggered)
const cardBaseDelay = Math.floor(1 * fps); // Start after 1 second
return (
<AbsoluteFill className="bg-zinc-900 overflow-hidden">
{/* Audio: card-slide for initial entrance */}
<Sequence from={cardBaseDelay} durationInFrames={Math.floor(1 * fps)}>
<Audio src={staticFile("sfx/card-slide.mp3")} volume={0.5} />
</Sequence>
{/* Audio: ui-appear for stats */}
<Sequence from={cardBaseDelay + Math.floor(0.5 * fps)} durationInFrames={Math.floor(0.5 * fps)}>
<Audio src={staticFile("sfx/ui-appear.mp3")} volume={0.4} />
</Sequence>
{/* Wallpaper Background */}
<div className="absolute inset-0">
<Img
src={staticFile("einundzwanzig-wallpaper.png")}
className="absolute inset-0 w-full h-full object-cover"
style={{ opacity: 0.1 }}
/>
</div>
{/* Dark gradient overlay */}
<div
className="absolute inset-0"
style={{
background:
"radial-gradient(circle at center, transparent 0%, rgba(24, 24, 27, 0.6) 50%, rgba(24, 24, 27, 0.95) 100%)",
}}
/>
{/* 3D Perspective Container */}
<div
className="absolute inset-0"
style={{
transform: `perspective(1200px) rotateX(${perspectiveX}deg) scale(${perspectiveScale})`,
transformOrigin: "center center",
opacity: perspectiveOpacity,
}}
>
{/* Main Layout: Sidebar + Content */}
<div className="absolute inset-0 flex">
{/* Sidebar */}
<DashboardSidebar
logoSrc={staticFile("einundzwanzig-logo.png")}
navItems={NAV_ITEMS}
width={sidebarWidth}
height={1080}
delay={0}
staggerItems={true}
staggerDelay={3}
/>
{/* Main Content Area */}
<div
className="flex-1 flex flex-col"
style={{
padding: contentPadding,
paddingLeft: contentPadding,
}}
>
{/* Header */}
<div
className="mb-8"
style={{
opacity: headerOpacity,
transform: `translateY(${headerY}px)`,
}}
>
<h1 className="text-5xl font-bold text-white mb-2">Dashboard</h1>
<p className="text-xl text-zinc-400">
Willkommen im Einundzwanzig Portal
</p>
</div>
{/* Stats Cards Row */}
<div
className="flex gap-6 mb-8"
style={{ gap: cardGap }}
>
{/* Meetups Card */}
<StatsCard
title="Meetups"
delay={cardBaseDelay}
width={cardWidth}
glowIntensity={glowIntensity}
>
<StatsCounter
targetNumber={204}
delay={cardBaseDelay + 15}
duration={60}
label="Aktive Gruppen"
fontSize={72}
color="#f7931a"
/>
<div className="mt-4">
<SparklineChart
data={MEETUP_TREND_DATA}
width={cardWidth - 48}
height={60}
delay={cardBaseDelay + 30}
showFill={true}
fillOpacity={0.15}
/>
</div>
</StatsCard>
{/* Users Card */}
<StatsCard
title="Benutzer"
delay={cardBaseDelay + CARD_STAGGER_DELAY}
width={cardWidth}
glowIntensity={glowIntensity}
>
<StatsCounter
targetNumber={1247}
delay={cardBaseDelay + CARD_STAGGER_DELAY + 15}
duration={60}
label="Registrierte Nutzer"
fontSize={72}
color="#f7931a"
/>
<div className="mt-4">
<SparklineChart
data={USER_TREND_DATA}
width={cardWidth - 48}
height={60}
delay={cardBaseDelay + CARD_STAGGER_DELAY + 30}
showFill={true}
fillOpacity={0.15}
/>
</div>
</StatsCard>
{/* Events Card */}
<StatsCard
title="Events"
delay={cardBaseDelay + CARD_STAGGER_DELAY * 2}
width={cardWidth}
glowIntensity={glowIntensity}
>
<StatsCounter
targetNumber={89}
delay={cardBaseDelay + CARD_STAGGER_DELAY * 2 + 15}
duration={60}
label="Diese Woche"
fontSize={72}
color="#f7931a"
/>
<div className="mt-4">
<SparklineChart
data={EVENT_TREND_DATA}
width={cardWidth - 48}
height={60}
delay={cardBaseDelay + CARD_STAGGER_DELAY * 2 + 30}
showFill={true}
fillOpacity={0.15}
/>
</div>
</StatsCard>
</div>
{/* Activity Section */}
<div className="flex gap-6" style={{ gap: cardGap }}>
{/* Activity Feed */}
<ActivitySection
delay={cardBaseDelay + CARD_STAGGER_DELAY * 3}
glowIntensity={glowIntensity}
/>
{/* Quick Stats */}
<QuickStatsSection
delay={cardBaseDelay + CARD_STAGGER_DELAY * 4}
glowIntensity={glowIntensity}
/>
</div>
</div>
</div>
</div>
{/* Vignette overlay */}
<div
className="absolute inset-0 pointer-events-none"
style={{
boxShadow: "inset 0 0 200px 50px rgba(0, 0, 0, 0.6)",
}}
/>
</AbsoluteFill>
);
};
/**
* Stats Card container with animated entrance
*/
type StatsCardProps = {
title: string;
delay: number;
width: number;
glowIntensity: number;
children: React.ReactNode;
};
const StatsCard: React.FC<StatsCardProps> = ({
title,
delay,
width,
glowIntensity,
children,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const adjustedFrame = Math.max(0, frame - delay);
const cardSpring = spring({
frame: adjustedFrame,
fps,
config: SNAPPY,
});
const cardScale = interpolate(cardSpring, [0, 1], [0.8, 1]);
const cardOpacity = interpolate(cardSpring, [0, 1], [0, 1]);
const cardY = interpolate(cardSpring, [0, 1], [30, 0]);
return (
<div
className="rounded-2xl bg-zinc-800/80 backdrop-blur-md border border-zinc-700/50 p-6"
style={{
width,
transform: `translateY(${cardY}px) scale(${cardScale})`,
opacity: cardOpacity,
boxShadow: `0 0 ${30 * glowIntensity}px rgba(247, 147, 26, 0.1), 0 8px 32px rgba(0, 0, 0, 0.4)`,
}}
>
<h3 className="text-lg font-semibold text-zinc-300 mb-4">{title}</h3>
{children}
</div>
);
};
/**
* Activity Feed Section
*/
type ActivitySectionProps = {
delay: number;
glowIntensity: number;
};
const ActivitySection: React.FC<ActivitySectionProps> = ({
delay,
glowIntensity,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const adjustedFrame = Math.max(0, frame - delay);
const sectionSpring = spring({
frame: adjustedFrame,
fps,
config: SNAPPY,
});
const sectionOpacity = interpolate(sectionSpring, [0, 1], [0, 1]);
const sectionY = interpolate(sectionSpring, [0, 1], [30, 0]);
const activities = [
{ eventName: "EINUNDZWANZIG Kempten", timestamp: "vor 13 Stunden", badgeText: "Neuer Termin" },
{ eventName: "EINUNDZWANZIG Frankfurt", timestamp: "vor 1 Tag", badgeText: "Update" },
{ eventName: "EINUNDZWANZIG Saarland", timestamp: "vor 2 Tagen", badgeText: "Neuer Termin" },
];
return (
<div
className="flex-1 rounded-2xl bg-zinc-800/80 backdrop-blur-md border border-zinc-700/50 p-6"
style={{
transform: `translateY(${sectionY}px)`,
opacity: sectionOpacity,
boxShadow: `0 0 ${30 * glowIntensity}px rgba(247, 147, 26, 0.1), 0 8px 32px rgba(0, 0, 0, 0.4)`,
}}
>
<h3 className="text-lg font-semibold text-zinc-300 mb-4">Letzte Aktivitäten</h3>
<div className="flex flex-col gap-3">
{activities.map((activity, index) => (
<ActivityItem
key={index}
eventName={activity.eventName}
timestamp={activity.timestamp}
badgeText={activity.badgeText}
delay={delay + 10 + index * 8}
width={480}
/>
))}
</div>
</div>
);
};
/**
* Quick Stats Section with additional metrics
*/
type QuickStatsSectionProps = {
delay: number;
glowIntensity: number;
};
const QuickStatsSection: React.FC<QuickStatsSectionProps> = ({
delay,
glowIntensity,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const adjustedFrame = Math.max(0, frame - delay);
const sectionSpring = spring({
frame: adjustedFrame,
fps,
config: SNAPPY,
});
const sectionOpacity = interpolate(sectionSpring, [0, 1], [0, 1]);
const sectionY = interpolate(sectionSpring, [0, 1], [30, 0]);
return (
<div
className="rounded-2xl bg-zinc-800/80 backdrop-blur-md border border-zinc-700/50 p-6"
style={{
width: 320,
transform: `translateY(${sectionY}px)`,
opacity: sectionOpacity,
boxShadow: `0 0 ${30 * glowIntensity}px rgba(247, 147, 26, 0.1), 0 8px 32px rgba(0, 0, 0, 0.4)`,
}}
>
<h3 className="text-lg font-semibold text-zinc-300 mb-4">Schnellübersicht</h3>
<div className="flex flex-col gap-4">
<QuickStatRow
label="Länder"
value={23}
delay={delay + 10}
/>
<QuickStatRow
label="Neue diese Woche"
value={12}
delay={delay + 18}
/>
<QuickStatRow
label="Aktive Nutzer"
value={847}
delay={delay + 26}
/>
</div>
</div>
);
};
/**
* Individual quick stat row
*/
type QuickStatRowProps = {
label: string;
value: number;
delay: number;
};
const QuickStatRow: React.FC<QuickStatRowProps> = ({ label, value, delay }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const adjustedFrame = Math.max(0, frame - delay);
const rowSpring = spring({
frame: adjustedFrame,
fps,
config: { damping: 15, stiffness: 90 },
});
const rowOpacity = interpolate(rowSpring, [0, 1], [0, 1]);
const rowX = interpolate(rowSpring, [0, 1], [-20, 0]);
// Animated counter
const counterValue = interpolate(rowSpring, [0, 1], [0, value]);
return (
<div
className="flex items-center justify-between"
style={{
opacity: rowOpacity,
transform: `translateX(${rowX}px)`,
}}
>
<span className="text-zinc-400 text-base">{label}</span>
<span
className="text-white font-bold text-xl tabular-nums"
style={{ fontFamily: "Inconsolata, monospace" }}
>
{Math.round(counterValue)}
</span>
</div>
);
};