From fb9da68451cd3a76c24916604ff20239bf0c7fe7 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Sat, 24 Jan 2026 13:04:43 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=AC=20Add=20ActivityItem=20component?= =?UTF-8?q?=20for=20displaying=20activity=20feed=20entries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Implements animated activity item with slide-in from right animation - Badge with bounce effect ("Neuer Termin" default) - Event name with fade/slide animation - Timestamp with clock icon and monospace font - Configurable props: eventName, timestamp, badgeText, showBadge, delay, width, accentColor - Follows established component patterns with spring animations and glow effects - Includes comprehensive test suite with 21 tests Co-Authored-By: Claude Opus 4.5 --- videos/src/components/ActivityItem.test.tsx | 181 ++++++++++++++++++++ videos/src/components/ActivityItem.tsx | 164 ++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 videos/src/components/ActivityItem.test.tsx create mode 100644 videos/src/components/ActivityItem.tsx diff --git a/videos/src/components/ActivityItem.test.tsx b/videos/src/components/ActivityItem.test.tsx new file mode 100644 index 0000000..0857ebd --- /dev/null +++ b/videos/src/components/ActivityItem.test.tsx @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, cleanup } from "@testing-library/react"; +import { ActivityItem } from "./ActivityItem"; + +// Mock Remotion hooks +vi.mock("remotion", () => ({ + useCurrentFrame: vi.fn(() => 30), + 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("ActivityItem", () => { + const defaultProps = { + eventName: "EINUNDZWANZIG Kempten", + timestamp: "vor 13 Stunden", + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + vi.resetAllMocks(); + }); + + it("renders without errors", () => { + const { container } = render(); + expect(container).toBeInTheDocument(); + }); + + it("displays the event name", () => { + const { container } = render(); + const nameElement = container.querySelector(".font-bold.text-white"); + expect(nameElement).toHaveTextContent("EINUNDZWANZIG Kempten"); + }); + + it("displays a custom event name", () => { + const { container } = render( + + ); + const nameElement = container.querySelector(".font-bold.text-white"); + expect(nameElement).toHaveTextContent("BitcoinWalk Würzburg"); + }); + + it("displays the timestamp", () => { + const { container } = render(); + const timestampElement = container.querySelector(".timestamp-text"); + expect(timestampElement).toHaveTextContent("vor 13 Stunden"); + }); + + it("displays a custom timestamp", () => { + const { container } = render( + + ); + const timestampElement = container.querySelector(".timestamp-text"); + expect(timestampElement).toHaveTextContent("vor 2 Tagen"); + }); + + it("displays the badge by default", () => { + const { container } = render(); + const badge = container.querySelector(".rounded-full.font-semibold"); + expect(badge).toBeInTheDocument(); + expect(badge).toHaveTextContent("Neuer Termin"); + }); + + it("displays custom badge text", () => { + const { container } = render( + + ); + const badge = container.querySelector(".rounded-full.font-semibold"); + expect(badge).toHaveTextContent("Neues Meetup"); + }); + + it("hides badge when showBadge is false", () => { + const { container } = render( + + ); + const badge = container.querySelector(".rounded-full.font-semibold"); + expect(badge).not.toBeInTheDocument(); + }); + + it("applies custom width style", () => { + const { container } = render( + + ); + const card = container.firstChild as HTMLElement; + expect(card).toHaveStyle({ width: "500px" }); + }); + + it("applies default width style", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveStyle({ width: "400px" }); + }); + + it("renders clock SVG icon for timestamp", () => { + const { container } = render(); + const svg = container.querySelector(".text-zinc-400 svg"); + expect(svg).toBeInTheDocument(); + }); + + it("has rounded corners styling", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("rounded-xl"); + }); + + it("has proper card background styling", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("bg-zinc-900/90"); + }); + + it("has backdrop blur styling", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("backdrop-blur-sm"); + }); + + it("has border styling", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("border"); + expect(card).toHaveClass("border-zinc-700/50"); + }); + + it("event name has truncate class for overflow handling", () => { + const { container } = render(); + const nameElement = container.querySelector(".font-bold.text-white"); + expect(nameElement).toHaveClass("truncate"); + }); + + it("applies default accent color to badge background", () => { + const { container } = render(); + const badge = container.querySelector(".rounded-full.font-semibold") as HTMLElement; + expect(badge).toHaveStyle({ backgroundColor: "#f7931a" }); + }); + + it("applies custom accent color to badge background", () => { + const { container } = render( + + ); + const badge = container.querySelector(".rounded-full.font-semibold") as HTMLElement; + expect(badge).toHaveStyle({ backgroundColor: "#00ff00" }); + }); + + it("timestamp uses monospace font family", () => { + const { container } = render(); + const timestampContainer = container.querySelector(".text-zinc-400") as HTMLElement; + expect(timestampContainer).toHaveStyle({ fontFamily: "Inconsolata, monospace" }); + }); + + it("uses flex column layout", () => { + const { container } = render(); + const card = container.firstChild as HTMLElement; + expect(card).toHaveClass("flex"); + expect(card).toHaveClass("flex-col"); + }); + + it("badge has self-start alignment", () => { + const { container } = render(); + const badge = container.querySelector(".rounded-full.font-semibold"); + expect(badge).toHaveClass("self-start"); + }); +}); diff --git a/videos/src/components/ActivityItem.tsx b/videos/src/components/ActivityItem.tsx new file mode 100644 index 0000000..cd5e095 --- /dev/null +++ b/videos/src/components/ActivityItem.tsx @@ -0,0 +1,164 @@ +import { + useCurrentFrame, + useVideoConfig, + interpolate, + spring, +} from "remotion"; + +export type ActivityItemProps = { + /** Name of the event/meetup */ + eventName: string; + /** Timestamp display (e.g., "vor 13 Stunden", "vor 2 Tagen") */ + timestamp: string; + /** Badge text (default: "Neuer Termin") */ + badgeText?: string; + /** Whether to show the badge (default: true) */ + showBadge?: boolean; + /** Delay in frames before animation starts */ + delay?: number; + /** Width of the component in pixels (default: 400) */ + width?: number; + /** Custom color for accent elements (default: #f7931a - Bitcoin orange) */ + accentColor?: string; +}; + +export const ActivityItem: React.FC = ({ + eventName, + timestamp, + badgeText = "Neuer Termin", + showBadge = true, + delay = 0, + width = 400, + accentColor = "#f7931a", +}) => { + const frame = useCurrentFrame(); + const { fps } = useVideoConfig(); + + const adjustedFrame = Math.max(0, frame - delay); + + // Card entrance animation (slide in from right) + const cardSpring = spring({ + frame: adjustedFrame, + fps, + config: { damping: 15, stiffness: 80 }, + }); + + const cardTranslateX = interpolate(cardSpring, [0, 1], [100, 0]); + const cardOpacity = interpolate(cardSpring, [0, 1], [0, 1]); + + // Badge bounce animation (slightly delayed, bouncier) + const badgeSpring = spring({ + frame: adjustedFrame - 8, + fps, + config: { damping: 8, stiffness: 150 }, + }); + + const badgeScale = interpolate(badgeSpring, [0, 1], [0, 1]); + + // Event name animation (staggered after badge) + const nameSpring = spring({ + frame: adjustedFrame - 12, + fps, + config: { damping: 15, stiffness: 90 }, + }); + + const nameOpacity = interpolate(nameSpring, [0, 1], [0, 1]); + const nameTranslateY = interpolate(nameSpring, [0, 1], [15, 0]); + + // Timestamp fade in (last element) + const timestampSpring = spring({ + frame: adjustedFrame - 18, + fps, + config: { damping: 15, stiffness: 90 }, + }); + + const timestampOpacity = interpolate(timestampSpring, [0, 1], [0, 1]); + + // Subtle glow pulse + const glowIntensity = interpolate( + Math.sin(adjustedFrame * 0.08), + [-1, 1], + [0.3, 0.5] + ); + + const padding = width * 0.05; + const badgePaddingX = width * 0.03; + const badgePaddingY = width * 0.015; + + return ( +
+ {/* Badge */} + {showBadge && ( +
+ {badgeText} +
+ )} + + {/* Event Name */} +
+ {eventName} +
+ + {/* Timestamp */} +
+ {/* Clock icon */} + + + + + {timestamp} +
+
+ ); +};