mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-01-28 07:43:18 +00:00
🎬 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:
@@ -2,6 +2,7 @@ import { AbsoluteFill, Sequence, useVideoConfig, Img, staticFile } from "remotio
|
|||||||
import { inconsolataFont } from "./fonts/inconsolata";
|
import { inconsolataFont } from "./fonts/inconsolata";
|
||||||
import { PortalIntroScene } from "./scenes/portal/PortalIntroScene";
|
import { PortalIntroScene } from "./scenes/portal/PortalIntroScene";
|
||||||
import { PortalTitleScene } from "./scenes/portal/PortalTitleScene";
|
import { PortalTitleScene } from "./scenes/portal/PortalTitleScene";
|
||||||
|
import { DashboardOverviewScene } from "./scenes/portal/DashboardOverviewScene";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PortalPresentation - Main composition for the Einundzwanzig Portal presentation video
|
* PortalPresentation - Main composition for the Einundzwanzig Portal presentation video
|
||||||
@@ -144,7 +145,7 @@ export const PortalPresentation: React.FC = () => {
|
|||||||
durationInFrames={sceneFrames.dashboardOverview.duration}
|
durationInFrames={sceneFrames.dashboardOverview.duration}
|
||||||
premountFor={fps}
|
premountFor={fps}
|
||||||
>
|
>
|
||||||
<PlaceholderScene name="Dashboard Overview" sceneNumber={3} />
|
<DashboardOverviewScene />
|
||||||
</Sequence>
|
</Sequence>
|
||||||
|
|
||||||
{/* Scene 4: Meine Meetups (12s) */}
|
{/* Scene 4: Meine Meetups (12s) */}
|
||||||
|
|||||||
312
videos/src/scenes/portal/DashboardOverviewScene.test.tsx
Normal file
312
videos/src/scenes/portal/DashboardOverviewScene.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 { 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
495
videos/src/scenes/portal/DashboardOverviewScene.tsx
Normal file
495
videos/src/scenes/portal/DashboardOverviewScene.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user