mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-01-28 07:43:18 +00:00
🎬 Add ActivityItem component for displaying activity feed entries
- 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 <noreply@anthropic.com>
This commit is contained in:
181
videos/src/components/ActivityItem.test.tsx
Normal file
181
videos/src/components/ActivityItem.test.tsx
Normal file
@@ -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(<ActivityItem {...defaultProps} />);
|
||||||
|
expect(container).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays the event name", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
const nameElement = container.querySelector(".font-bold.text-white");
|
||||||
|
expect(nameElement).toHaveTextContent("EINUNDZWANZIG Kempten");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays a custom event name", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ActivityItem
|
||||||
|
{...defaultProps}
|
||||||
|
eventName="BitcoinWalk Würzburg"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const nameElement = container.querySelector(".font-bold.text-white");
|
||||||
|
expect(nameElement).toHaveTextContent("BitcoinWalk Würzburg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays the timestamp", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
const timestampElement = container.querySelector(".timestamp-text");
|
||||||
|
expect(timestampElement).toHaveTextContent("vor 13 Stunden");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays a custom timestamp", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ActivityItem
|
||||||
|
{...defaultProps}
|
||||||
|
timestamp="vor 2 Tagen"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const timestampElement = container.querySelector(".timestamp-text");
|
||||||
|
expect(timestampElement).toHaveTextContent("vor 2 Tagen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays the badge by default", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
const badge = container.querySelector(".rounded-full.font-semibold");
|
||||||
|
expect(badge).toBeInTheDocument();
|
||||||
|
expect(badge).toHaveTextContent("Neuer Termin");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("displays custom badge text", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ActivityItem
|
||||||
|
{...defaultProps}
|
||||||
|
badgeText="Neues Meetup"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const badge = container.querySelector(".rounded-full.font-semibold");
|
||||||
|
expect(badge).toHaveTextContent("Neues Meetup");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides badge when showBadge is false", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ActivityItem {...defaultProps} showBadge={false} />
|
||||||
|
);
|
||||||
|
const badge = container.querySelector(".rounded-full.font-semibold");
|
||||||
|
expect(badge).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies custom width style", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ActivityItem {...defaultProps} width={500} />
|
||||||
|
);
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveStyle({ width: "500px" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies default width style", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveStyle({ width: "400px" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders clock SVG icon for timestamp", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
const svg = container.querySelector(".text-zinc-400 svg");
|
||||||
|
expect(svg).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has rounded corners styling", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveClass("rounded-xl");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has proper card background styling", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveClass("bg-zinc-900/90");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has backdrop blur styling", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveClass("backdrop-blur-sm");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has border styling", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
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(<ActivityItem {...defaultProps} />);
|
||||||
|
const nameElement = container.querySelector(".font-bold.text-white");
|
||||||
|
expect(nameElement).toHaveClass("truncate");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies default accent color to badge background", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
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(
|
||||||
|
<ActivityItem {...defaultProps} accentColor="#00ff00" />
|
||||||
|
);
|
||||||
|
const badge = container.querySelector(".rounded-full.font-semibold") as HTMLElement;
|
||||||
|
expect(badge).toHaveStyle({ backgroundColor: "#00ff00" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("timestamp uses monospace font family", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
const timestampContainer = container.querySelector(".text-zinc-400") as HTMLElement;
|
||||||
|
expect(timestampContainer).toHaveStyle({ fontFamily: "Inconsolata, monospace" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses flex column layout", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
const card = container.firstChild as HTMLElement;
|
||||||
|
expect(card).toHaveClass("flex");
|
||||||
|
expect(card).toHaveClass("flex-col");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("badge has self-start alignment", () => {
|
||||||
|
const { container } = render(<ActivityItem {...defaultProps} />);
|
||||||
|
const badge = container.querySelector(".rounded-full.font-semibold");
|
||||||
|
expect(badge).toHaveClass("self-start");
|
||||||
|
});
|
||||||
|
});
|
||||||
164
videos/src/components/ActivityItem.tsx
Normal file
164
videos/src/components/ActivityItem.tsx
Normal file
@@ -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<ActivityItemProps> = ({
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
className="flex flex-col rounded-xl bg-zinc-900/90 backdrop-blur-sm border border-zinc-700/50"
|
||||||
|
style={{
|
||||||
|
width,
|
||||||
|
padding,
|
||||||
|
gap: padding * 0.6,
|
||||||
|
transform: `translateX(${cardTranslateX}px)`,
|
||||||
|
opacity: cardOpacity,
|
||||||
|
boxShadow: `0 0 ${25 * glowIntensity}px ${accentColor}25, 0 4px 16px rgba(0, 0, 0, 0.4)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Badge */}
|
||||||
|
{showBadge && (
|
||||||
|
<div
|
||||||
|
className="self-start rounded-full font-semibold text-white"
|
||||||
|
style={{
|
||||||
|
backgroundColor: accentColor,
|
||||||
|
paddingLeft: badgePaddingX,
|
||||||
|
paddingRight: badgePaddingX,
|
||||||
|
paddingTop: badgePaddingY,
|
||||||
|
paddingBottom: badgePaddingY,
|
||||||
|
fontSize: width * 0.04,
|
||||||
|
transform: `scale(${badgeScale})`,
|
||||||
|
transformOrigin: "left center",
|
||||||
|
boxShadow: `0 0 ${12 * glowIntensity}px ${accentColor}50`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{badgeText}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event Name */}
|
||||||
|
<div
|
||||||
|
className="font-bold text-white truncate"
|
||||||
|
style={{
|
||||||
|
fontSize: width * 0.06,
|
||||||
|
opacity: nameOpacity,
|
||||||
|
transform: `translateY(${nameTranslateY}px)`,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{eventName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamp */}
|
||||||
|
<div
|
||||||
|
className="text-zinc-400 flex items-center"
|
||||||
|
style={{
|
||||||
|
fontSize: width * 0.04,
|
||||||
|
opacity: timestampOpacity,
|
||||||
|
fontFamily: "Inconsolata, monospace",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Clock icon */}
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
style={{
|
||||||
|
width: width * 0.04,
|
||||||
|
height: width * 0.04,
|
||||||
|
marginRight: width * 0.02,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<polyline points="12,6 12,12 16,14" />
|
||||||
|
</svg>
|
||||||
|
<span className="timestamp-text">{timestamp}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user