mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-01-27 06:33:18 +00:00
🌍 Add CountryBar component for displaying country information
Add a new CountryBar component that displays: - Country flag emoji with scale animation - Country name with slide-in animation - Animated user count with tabular numbers - Progress bar that fills based on user count ratio The component follows existing patterns from StatsCounter and MeetupCard, using Remotion's spring animations and interpolation for smooth entrance effects and a subtle glow pulse. Includes comprehensive test suite with 30 tests covering: - Basic rendering and props - Custom styling and colors - Bar rendering and animation - Real-world country data scenarios Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
258
videos/src/components/CountryBar.test.tsx
Normal file
258
videos/src/components/CountryBar.test.tsx
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
import { CountryBar } from "./CountryBar";
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("CountryBar", () => {
|
||||||
|
const defaultProps = {
|
||||||
|
countryName: "Germany",
|
||||||
|
flagEmoji: "🇩🇪",
|
||||||
|
userCount: 458,
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders without errors", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
expect(container).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays the country name", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const nameElement = container.querySelector(".font-semibold.text-white");
|
||||||
|
expect(nameElement).toHaveTextContent("Germany");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays the flag emoji", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
expect(container.textContent).toContain("🇩🇪");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays the user count by default", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const countElement = container.querySelector(".tabular-nums");
|
||||||
|
expect(countElement).toBeInTheDocument();
|
||||||
|
expect(countElement).toHaveTextContent("458");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides user count when showCount is false", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar {...defaultProps} showCount={false} />
|
||||||
|
);
|
||||||
|
const countElement = container.querySelector(".tabular-nums");
|
||||||
|
expect(countElement).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays a custom country name", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar {...defaultProps} countryName="Austria" />
|
||||||
|
);
|
||||||
|
const nameElement = container.querySelector(".font-semibold.text-white");
|
||||||
|
expect(nameElement).toHaveTextContent("Austria");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays a custom flag emoji", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar {...defaultProps} flagEmoji="🇦🇹" />
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain("🇦🇹");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays a custom user count", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar {...defaultProps} userCount={59} />
|
||||||
|
);
|
||||||
|
const countElement = container.querySelector(".tabular-nums");
|
||||||
|
expect(countElement).toHaveTextContent("59");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies custom width style", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar {...defaultProps} width={600} />
|
||||||
|
);
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveStyle({ width: "600px" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies default width of 500px", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveStyle({ width: "500px" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies default accent color to user count", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const countElement = container.querySelector(".tabular-nums");
|
||||||
|
expect(countElement).toHaveStyle({ color: "#f7931a" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies custom accent color to user count", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar {...defaultProps} accentColor="#00ff00" />
|
||||||
|
);
|
||||||
|
const countElement = container.querySelector(".tabular-nums");
|
||||||
|
expect(countElement).toHaveStyle({ color: "#00ff00" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has rounded corners styling", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveClass("rounded-xl");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has proper card background styling", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveClass("bg-zinc-900/90");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has backdrop blur styling", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveClass("backdrop-blur-sm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has border styling", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveClass("border");
|
||||||
|
expect(card).toHaveClass("border-zinc-700/50");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the progress bar container", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const barContainer = container.querySelector(".bg-zinc-800\\/80");
|
||||||
|
expect(barContainer).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the fill bar inside the container", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const fillBar = container.querySelector(".absolute.inset-y-0.left-0");
|
||||||
|
expect(fillBar).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("country name has truncate class for overflow handling", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const nameElement = container.querySelector(".font-semibold.text-white");
|
||||||
|
expect(nameElement).toHaveClass("truncate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses tabular-nums class for consistent number width", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const countElement = container.querySelector(".tabular-nums");
|
||||||
|
expect(countElement).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses monospace font family for user count", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const countElement = container.querySelector(".tabular-nums");
|
||||||
|
expect(countElement).toHaveStyle({ fontFamily: "Inconsolata, monospace" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders correctly with zero user count", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar {...defaultProps} userCount={0} />
|
||||||
|
);
|
||||||
|
const countElement = container.querySelector(".tabular-nums");
|
||||||
|
expect(countElement).toHaveTextContent("0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders correctly with large user count", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar {...defaultProps} userCount={10000} />
|
||||||
|
);
|
||||||
|
const countElement = container.querySelector(".tabular-nums");
|
||||||
|
expect(countElement).toHaveTextContent("10000");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders bar container with rounded-full class", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const barContainer = container.querySelector(".bg-zinc-800\\/80");
|
||||||
|
expect(barContainer).toHaveClass("rounded-full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders fill bar with rounded-full class", () => {
|
||||||
|
const { container } = render(<CountryBar {...defaultProps} />);
|
||||||
|
const fillBar = container.querySelector(".absolute.inset-y-0.left-0");
|
||||||
|
expect(fillBar).toHaveClass("rounded-full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts maxCount prop for calculating bar width", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar {...defaultProps} userCount={229} maxCount={458} />
|
||||||
|
);
|
||||||
|
// Component should render without errors with maxCount
|
||||||
|
expect(container).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders correctly with Switzerland data", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar
|
||||||
|
countryName="Switzerland"
|
||||||
|
flagEmoji="🇨🇭"
|
||||||
|
userCount={34}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain("Switzerland");
|
||||||
|
expect(container.textContent).toContain("🇨🇭");
|
||||||
|
expect(container.textContent).toContain("34");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders correctly with Luxembourg data", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar
|
||||||
|
countryName="Luxembourg"
|
||||||
|
flagEmoji="🇱🇺"
|
||||||
|
userCount={8}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain("Luxembourg");
|
||||||
|
expect(container.textContent).toContain("🇱🇺");
|
||||||
|
expect(container.textContent).toContain("8");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders correctly with Bulgaria data", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar
|
||||||
|
countryName="Bulgaria"
|
||||||
|
flagEmoji="🇧🇬"
|
||||||
|
userCount={7}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain("Bulgaria");
|
||||||
|
expect(container.textContent).toContain("🇧🇬");
|
||||||
|
expect(container.textContent).toContain("7");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders correctly with Spain data", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CountryBar
|
||||||
|
countryName="Spain"
|
||||||
|
flagEmoji="🇪🇸"
|
||||||
|
userCount={3}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(container.textContent).toContain("Spain");
|
||||||
|
expect(container.textContent).toContain("🇪🇸");
|
||||||
|
expect(container.textContent).toContain("3");
|
||||||
|
});
|
||||||
|
});
|
||||||
185
videos/src/components/CountryBar.tsx
Normal file
185
videos/src/components/CountryBar.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import {
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
interpolate,
|
||||||
|
spring,
|
||||||
|
} from "remotion";
|
||||||
|
|
||||||
|
export interface CountryBarProps {
|
||||||
|
/** Country name (e.g., "Germany") */
|
||||||
|
countryName: string;
|
||||||
|
/** Country flag emoji (e.g., "🇩🇪") */
|
||||||
|
flagEmoji: string;
|
||||||
|
/** Number of users in this country */
|
||||||
|
userCount: number;
|
||||||
|
/** Maximum user count for calculating bar width (default: same as userCount for full bar) */
|
||||||
|
maxCount?: number;
|
||||||
|
/** Total width of the component in pixels (default: 500) */
|
||||||
|
width?: number;
|
||||||
|
/** Delay in frames before animation starts */
|
||||||
|
delay?: number;
|
||||||
|
/** Custom accent color for the bar (default: #f7931a - Bitcoin orange) */
|
||||||
|
accentColor?: string;
|
||||||
|
/** Whether to show the user count number (default: true) */
|
||||||
|
showCount?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CountryBar: React.FC<CountryBarProps> = ({
|
||||||
|
countryName,
|
||||||
|
flagEmoji,
|
||||||
|
userCount,
|
||||||
|
maxCount,
|
||||||
|
width = 500,
|
||||||
|
delay = 0,
|
||||||
|
accentColor = "#f7931a",
|
||||||
|
showCount = true,
|
||||||
|
}) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const adjustedFrame = Math.max(0, frame - delay);
|
||||||
|
|
||||||
|
// Calculate bar fill percentage
|
||||||
|
const effectiveMax = maxCount ?? userCount;
|
||||||
|
const fillPercentage = effectiveMax > 0 ? userCount / effectiveMax : 0;
|
||||||
|
|
||||||
|
// Card entrance animation
|
||||||
|
const cardSpring = spring({
|
||||||
|
frame: adjustedFrame,
|
||||||
|
fps,
|
||||||
|
config: { damping: 15, stiffness: 80 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const cardScale = interpolate(cardSpring, [0, 1], [0.8, 1]);
|
||||||
|
const cardOpacity = interpolate(cardSpring, [0, 1], [0, 1]);
|
||||||
|
|
||||||
|
// Flag animation (slightly delayed)
|
||||||
|
const flagSpring = spring({
|
||||||
|
frame: adjustedFrame - 5,
|
||||||
|
fps,
|
||||||
|
config: { damping: 12, stiffness: 100 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const flagScale = interpolate(flagSpring, [0, 1], [0, 1]);
|
||||||
|
|
||||||
|
// Country name animation
|
||||||
|
const nameSpring = spring({
|
||||||
|
frame: adjustedFrame - 8,
|
||||||
|
fps,
|
||||||
|
config: { damping: 15, stiffness: 90 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const nameOpacity = interpolate(nameSpring, [0, 1], [0, 1]);
|
||||||
|
const nameTranslateX = interpolate(nameSpring, [0, 1], [-20, 0]);
|
||||||
|
|
||||||
|
// Bar fill animation (delayed further)
|
||||||
|
const barSpring = spring({
|
||||||
|
frame: adjustedFrame - 15,
|
||||||
|
fps,
|
||||||
|
config: { damping: 20, stiffness: 60 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const barWidth = interpolate(barSpring, [0, 1], [0, fillPercentage]);
|
||||||
|
|
||||||
|
// Count animation (animates the number up)
|
||||||
|
const countSpring = spring({
|
||||||
|
frame: adjustedFrame - 20,
|
||||||
|
fps,
|
||||||
|
config: { damping: 20, stiffness: 80 },
|
||||||
|
durationInFrames: 45,
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayCount = Math.round(countSpring * userCount);
|
||||||
|
|
||||||
|
// Subtle glow pulse
|
||||||
|
const glowIntensity = interpolate(
|
||||||
|
Math.sin(adjustedFrame * 0.08),
|
||||||
|
[-1, 1],
|
||||||
|
[0.3, 0.5]
|
||||||
|
);
|
||||||
|
|
||||||
|
const padding = width * 0.04;
|
||||||
|
const flagSize = width * 0.1;
|
||||||
|
const barHeight = width * 0.06;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col rounded-xl bg-zinc-900/90 backdrop-blur-sm border border-zinc-700/50"
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
padding,
|
||||||
|
transform: `scale(${cardScale})`,
|
||||||
|
opacity: cardOpacity,
|
||||||
|
boxShadow: `0 0 ${20 * glowIntensity}px ${accentColor}20, 0 4px 15px rgba(0, 0, 0, 0.3)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Top row: Flag + Country name + User count */}
|
||||||
|
<div
|
||||||
|
className="flex items-center"
|
||||||
|
style={{
|
||||||
|
gap: padding * 0.75,
|
||||||
|
marginBottom: padding * 0.75,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Flag */}
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
fontSize: flagSize,
|
||||||
|
transform: `scale(${flagScale})`,
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flagEmoji}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Country name */}
|
||||||
|
<div
|
||||||
|
className="font-semibold text-white truncate flex-1 min-w-0"
|
||||||
|
style={{
|
||||||
|
fontSize: width * 0.055,
|
||||||
|
opacity: nameOpacity,
|
||||||
|
transform: `translateX(${nameTranslateX}px)`,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{countryName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User count */}
|
||||||
|
{showCount && (
|
||||||
|
<div
|
||||||
|
className="flex-shrink-0 font-bold tabular-nums"
|
||||||
|
style={{
|
||||||
|
fontSize: width * 0.055,
|
||||||
|
color: accentColor,
|
||||||
|
opacity: nameOpacity,
|
||||||
|
fontFamily: "Inconsolata, monospace",
|
||||||
|
textShadow: `0 0 ${10 * glowIntensity}px ${accentColor}60`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{displayCount}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bar container */}
|
||||||
|
<div
|
||||||
|
className="relative rounded-full overflow-hidden bg-zinc-800/80"
|
||||||
|
style={{
|
||||||
|
height: barHeight,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Animated fill bar */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 left-0 rounded-full"
|
||||||
|
style={{
|
||||||
|
width: `${barWidth * 100}%`,
|
||||||
|
background: `linear-gradient(90deg, ${accentColor}cc, ${accentColor})`,
|
||||||
|
boxShadow: `0 0 ${15 * glowIntensity}px ${accentColor}60`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user