This commit is contained in:
Carl-Gerhard Lindesvärd
2026-02-09 09:54:22 +00:00
parent 38d9b65ec8
commit 551927af06
33 changed files with 1746 additions and 142 deletions

View File

@@ -0,0 +1,42 @@
import { cn } from '@/utils/cn';
import type { ReactNode } from 'react';
export function BrowserChrome({
url,
children,
right,
controls = (
<div className="flex gap-1.5">
<div className="w-3 h-3 rounded-full bg-red-500" />
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<div className="w-3 h-3 rounded-full bg-green-500" />
</div>
),
className,
}: {
url?: ReactNode;
children: ReactNode;
right?: ReactNode;
controls?: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
'flex flex-col overflow-hidden rounded-lg border border-border bg-background',
className,
)}
>
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background h-10">
{controls}
{url !== false && (
<div className="flex-1 mx-4 px-3 h-8 py-1 text-sm bg-def-100 rounded-md border border-border flex items-center truncate">
{url}
</div>
)}
{right}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,139 @@
'use client';
import {
ReplayProvider,
useReplayContext,
} from '@/components/sessions/replay/replay-context';
import { ReplayEventFeed } from '@/components/sessions/replay/replay-event-feed';
import { ReplayPlayer } from '@/components/sessions/replay/replay-player';
import { ReplayTimeline } from '@/components/sessions/replay/replay-timeline';
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
import type { IServiceEvent } from '@openpanel/db';
import { type ReactNode, useMemo } from 'react';
import { BrowserChrome } from './browser-chrome';
import { ReplayTime } from './replay-controls';
function getEventOffsetMs(event: IServiceEvent, startTime: number): number {
const t =
typeof event.createdAt === 'object' && event.createdAt instanceof Date
? event.createdAt.getTime()
: new Date(event.createdAt).getTime();
return t - startTime;
}
function BrowserUrlBar({ events }: { events: IServiceEvent[] }) {
const { currentTime, startTime } = useReplayContext();
const currentUrl = useMemo(() => {
if (startTime == null || !events.length) return '';
const withOffset = events
.map((ev) => ({
event: ev,
offsetMs: getEventOffsetMs(ev, startTime),
}))
.filter(({ offsetMs }) => offsetMs >= -10_000 && offsetMs <= currentTime)
.sort((a, b) => a.offsetMs - b.offsetMs);
const latest = withOffset[withOffset.length - 1];
if (!latest) return '';
const { origin = '', path = '/' } = latest.event;
const pathPart = path.startsWith('/') ? path : `/${path}`;
return `${origin}${pathPart}`;
}, [events, currentTime, startTime]);
return <span className="text-muted-foreground truncate">{currentUrl}</span>;
}
function ReplayContent({
sessionId,
projectId,
}: {
sessionId: string;
projectId: string;
}) {
const trpc = useTRPC();
const {
data: replayData,
isLoading: replayLoading,
isError: replayError,
} = useQuery(trpc.session.replay.queryOptions({ sessionId, projectId }));
const { data: eventsData } = useQuery(
trpc.event.events.queryOptions({
projectId,
sessionId,
filters: [],
columnVisibility: {},
}),
);
const events = eventsData?.data ?? [];
const replayEvents = replayData?.events as
| Array<{ type: number; data: unknown; timestamp: number }>
| undefined;
if (replayLoading) {
return (
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
<div className="flex min-w-0 flex-col overflow-hidden">
<BrowserChrome>
<div className="flex h-[320px] items-center justify-center bg-black">
<div className="h-8 w-8 animate-pulse rounded-full bg-muted-foreground/20" />
</div>
</BrowserChrome>
</div>
<div className="hidden lg:block" />
</div>
);
}
if (replayError || !replayEvents?.length) {
return (
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
<div className="flex min-w-0 flex-col overflow-hidden">
<BrowserChrome
url={<span className="text-muted-foreground">about:blank</span>}
>
<div className="flex h-[320px] items-center justify-center bg-black text-muted-foreground">
No replay data available for this session.
</div>
</BrowserChrome>
</div>
<div className="hidden lg:block" />
</div>
);
}
return (
<ReplayProvider>
<div className="grid gap-4 lg:grid-cols-[1fr_380px]" id="replay">
<div className="flex min-w-0 flex-col overflow-hidden">
<BrowserChrome
url={<BrowserUrlBar events={events} />}
right={<ReplayTime />}
>
<ReplayPlayer events={replayEvents} />
<ReplayTimeline events={events} />
</BrowserChrome>
</div>
<div className="hidden lg:block relative">
<div className="absolute inset-0">
<ReplayEventFeed events={events} />
</div>
</div>
</div>
</ReplayProvider>
);
}
export function ReplayShell({
sessionId,
projectId,
}: {
sessionId: string;
projectId: string;
}) {
return <ReplayContent sessionId={sessionId} projectId={projectId} />;
}

View File

@@ -0,0 +1,210 @@
'use client';
import {
type ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from 'react';
export interface ReplayPlayerInstance {
play: () => void;
pause: () => void;
toggle: () => void;
goto: (timeOffset: number, play?: boolean) => void;
setSpeed: (speed: number) => void;
getMetaData: () => { startTime: number; endTime: number; totalTime: number };
getReplayer: () => { getCurrentTime: () => number };
$destroy?: () => void;
}
interface ReplayContextValue {
currentTime: number;
isPlaying: boolean;
speed: number;
duration: number;
startTime: number | null;
isReady: boolean;
play: () => void;
pause: () => void;
toggle: () => void;
seek: (timeOffset: number, play?: boolean) => void;
setSpeed: (speed: number) => void;
registerPlayer: (player: ReplayPlayerInstance) => void;
unregisterPlayer: () => void;
}
const ReplayContext = createContext<ReplayContextValue | null>(null);
const SPEED_OPTIONS = [0.5, 1, 2, 4, 8] as const;
export function useReplayContext() {
const ctx = useContext(ReplayContext);
if (!ctx) {
throw new Error('useReplayContext must be used within ReplayProvider');
}
return ctx;
}
export function ReplayProvider({ children }: { children: ReactNode }) {
const playerRef = useRef<ReplayPlayerInstance | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const [isPlaying, setIsPlaying] = useState(false);
const [speed, setSpeedState] = useState(1);
const [duration, setDuration] = useState(0);
const [startTime, setStartTime] = useState<number | null>(null);
const [isReady, setIsReady] = useState(false);
const rafIdRef = useRef<number | null>(null);
const lastUpdateRef = useRef(0);
// Refs so stable callbacks can read latest values
const isPlayingRef = useRef(false);
const durationRef = useRef(0);
const currentTimeRef = useRef(0);
const registerPlayer = useCallback((player: ReplayPlayerInstance) => {
playerRef.current = player;
try {
const meta = player.getMetaData();
durationRef.current = meta.totalTime;
setDuration(meta.totalTime);
setStartTime(meta.startTime);
setCurrentTime(0);
currentTimeRef.current = 0;
setIsReady(true);
} catch {
setIsReady(false);
}
}, []);
const unregisterPlayer = useCallback(() => {
if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
playerRef.current = null;
setIsReady(false);
setCurrentTime(0);
currentTimeRef.current = 0;
setDuration(0);
durationRef.current = 0;
setStartTime(null);
setIsPlaying(false);
isPlayingRef.current = false;
}, []);
const play = useCallback(() => {
playerRef.current?.play();
setIsPlaying(true);
isPlayingRef.current = true;
}, []);
const pause = useCallback(() => {
playerRef.current?.pause();
setIsPlaying(false);
isPlayingRef.current = false;
}, []);
const toggle = useCallback(() => {
const player = playerRef.current;
if (!player) return;
// If at the end, reset to start and play
const atEnd = currentTimeRef.current >= durationRef.current - 100;
if (atEnd && !isPlayingRef.current) {
player.goto(0, true);
setCurrentTime(0);
currentTimeRef.current = 0;
setIsPlaying(true);
isPlayingRef.current = true;
return;
}
player.toggle();
const next = !isPlayingRef.current;
setIsPlaying(next);
isPlayingRef.current = next;
}, []);
const seek = useCallback((timeOffset: number, play?: boolean) => {
const player = playerRef.current;
if (!player) return;
const shouldPlay = play ?? isPlayingRef.current;
player.goto(timeOffset, shouldPlay);
setCurrentTime(timeOffset);
currentTimeRef.current = timeOffset;
setIsPlaying(shouldPlay);
isPlayingRef.current = shouldPlay;
}, []);
const setSpeed = useCallback((s: number) => {
if (!SPEED_OPTIONS.includes(s as (typeof SPEED_OPTIONS)[number])) return;
playerRef.current?.setSpeed(s);
setSpeedState(s);
}, []);
useEffect(() => {
if (!isReady || !playerRef.current) return;
const tick = () => {
const player = playerRef.current;
if (!player) return;
try {
const replayer = player.getReplayer();
const now = replayer.getCurrentTime();
// Throttle state updates to ~10fps (every 100ms) to avoid excessive re-renders
const t = Math.floor(now / 100);
if (t !== lastUpdateRef.current) {
lastUpdateRef.current = t;
setCurrentTime(now);
currentTimeRef.current = now;
}
// Detect end of replay
if (
now >= durationRef.current - 50 &&
durationRef.current > 0 &&
isPlayingRef.current
) {
setIsPlaying(false);
isPlayingRef.current = false;
}
} catch {
// Player may be destroyed
}
rafIdRef.current = requestAnimationFrame(tick);
};
rafIdRef.current = requestAnimationFrame(tick);
return () => {
if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
};
}, [isReady]);
const value: ReplayContextValue = {
currentTime,
isPlaying,
speed,
duration,
startTime,
isReady,
play,
pause,
toggle,
seek,
setSpeed,
registerPlayer,
unregisterPlayer,
};
return (
<ReplayContext.Provider value={value}>{children}</ReplayContext.Provider>
);
}
export { SPEED_OPTIONS };

View File

@@ -0,0 +1,69 @@
'use client';
import {
SPEED_OPTIONS,
useReplayContext,
} from '@/components/sessions/replay/replay-context';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ChevronDown, Pause, Play, SkipBack, SkipForward } from 'lucide-react';
function formatTime(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
export function ReplayTime() {
const { currentTime, duration } = useReplayContext();
return (
<span className="text-sm tabular-nums text-muted-foreground font-mono">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
);
}
export function ReplayPlayPauseButton() {
const { isPlaying, isReady, toggle, seek } = useReplayContext();
if (!isReady) return null;
return (
<Button
type="button"
variant={isPlaying ? 'outline' : 'default'}
size="icon"
onClick={toggle}
aria-label={isPlaying ? 'Pause' : 'Play'}
>
{isPlaying ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
);
}
// {/* <DropdownMenu>
// <DropdownMenuTrigger asChild>
// <Button variant="outline" size="sm" className="h-8 gap-1">
// {speed}x
// <ChevronDown className="h-3.5 w-3.5" />
// </Button>
// </DropdownMenuTrigger>
// <DropdownMenuContent align="end">
// {SPEED_OPTIONS.map((s) => (
// <DropdownMenuItem
// key={s}
// onClick={() => setSpeed(s)}
// className={speed === s ? 'bg-accent' : ''}
// >
// {s}x
// </DropdownMenuItem>
// ))}
// </DropdownMenuContent>
// </DropdownMenu> */}

View File

@@ -0,0 +1,95 @@
'use client';
import { useReplayContext } from '@/components/sessions/replay/replay-context';
import { ReplayEventItem } from '@/components/sessions/replay/replay-event-item';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { IServiceEvent } from '@openpanel/db';
import { useEffect, useMemo, useRef } from 'react';
import { BrowserChrome } from './browser-chrome';
function getEventOffsetMs(event: IServiceEvent, startTime: number): number {
const t =
typeof event.createdAt === 'object' && event.createdAt instanceof Date
? event.createdAt.getTime()
: new Date(event.createdAt).getTime();
return t - startTime;
}
export function ReplayEventFeed({ events }: { events: IServiceEvent[] }) {
const { currentTime, startTime, isReady, seek } = useReplayContext();
const viewportRef = useRef<HTMLDivElement | null>(null);
const prevCountRef = useRef(0);
const { visibleEvents, currentEventId } = useMemo(() => {
if (startTime == null || !isReady) {
return { visibleEvents: [], currentEventId: null as string | null };
}
const withOffset = events
.map((ev) => ({
event: ev,
offsetMs: getEventOffsetMs(ev, startTime),
}))
// Include events up to 10s before recording started (e.g. screen views)
.filter(({ offsetMs }) => offsetMs >= -10_000 && offsetMs <= currentTime)
.sort((a, b) => a.offsetMs - b.offsetMs);
const visibleEvents = withOffset.map(({ event, offsetMs }) => ({
event,
offsetMs,
}));
const current =
visibleEvents.length > 0 ? visibleEvents[visibleEvents.length - 1] : null;
const currentEventId = current?.event.id ?? null;
return { visibleEvents, currentEventId };
}, [events, startTime, isReady, currentTime]);
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport || visibleEvents.length === 0) return;
const isNewItem = visibleEvents.length > prevCountRef.current;
prevCountRef.current = visibleEvents.length;
requestAnimationFrame(() => {
viewport.scrollTo({
top: viewport.scrollHeight,
behavior: isNewItem ? 'smooth' : 'instant',
});
});
}, [visibleEvents.length]);
if (!isReady) return null;
return (
<BrowserChrome
url={false}
controls={<span className="text-lg font-medium">Timeline</span>}
className="h-full"
>
<ScrollArea className="flex-1 min-h-0" ref={viewportRef}>
<div className="flex flex-col">
{visibleEvents.map(({ event, offsetMs }) => (
<div
key={event.id}
className="animate-in fade-in-0 slide-in-from-bottom-3 duration-300 fill-mode-both"
>
<ReplayEventItem
event={event}
offsetMs={offsetMs}
isCurrent={event.id === currentEventId}
onClick={() => seek(Math.max(0, offsetMs))}
/>
</div>
))}
{visibleEvents.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
Events will appear as the replay plays.
</div>
)}
</div>
</ScrollArea>
</BrowserChrome>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import { EventIcon } from '@/components/events/event-icon';
import { cn } from '@/lib/utils';
import type { IServiceEvent } from '@openpanel/db';
function formatOffset(ms: number): string {
const sign = ms < 0 ? '-' : '+';
const abs = Math.abs(ms);
const totalSeconds = Math.floor(abs / 1000);
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${sign}${m}:${s.toString().padStart(2, '0')}`;
}
export function ReplayEventItem({
event,
offsetMs,
isCurrent,
onClick,
}: {
event: IServiceEvent;
offsetMs: number;
isCurrent: boolean;
onClick: () => void;
}) {
const displayName =
event.name === 'screen_view' && event.path
? event.path
: event.name.replace(/_/g, ' ');
return (
<button
type="button"
onClick={onClick}
className={cn(
'col w-full gap-3 border-b px-3 py-2 text-left transition-colors hover:bg-accent bg-card',
isCurrent ? 'bg-accent/10' : 'bg-card',
)}
>
<div className="row items-center gap-2">
<div className="flex-shrink-0">
<EventIcon name={event.name} meta={event.meta} size="sm" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium text-foreground">
{displayName}
</div>
</div>
<span className="flex-shrink-0 text-xs tabular-nums text-muted-foreground">
{formatOffset(offsetMs)}
</span>
</div>
</button>
);
}

View File

@@ -0,0 +1,99 @@
'use client';
import { useReplayContext } from '@/components/sessions/replay/replay-context';
import type { ReplayPlayerInstance } from '@/components/sessions/replay/replay-context';
import { useEffect, useMemo, useRef } from 'react';
import 'rrweb-player/dist/style.css';
/** rrweb meta event (type 4) carries the recorded viewport size */
function getRecordedDimensions(
events: Array<{ type: number; data: unknown }>,
): { width: number; height: number } | null {
const meta = events.find((e) => e.type === 4);
if (
meta &&
typeof meta.data === 'object' &&
meta.data !== null &&
'width' in meta.data &&
'height' in meta.data
) {
const { width, height } = meta.data as { width: number; height: number };
if (width > 0 && height > 0) return { width, height };
}
return null;
}
export function ReplayPlayer({
events,
}: {
events: Array<{ type: number; data: unknown; timestamp: number }>;
}) {
const containerRef = useRef<HTMLDivElement>(null);
const playerRef = useRef<ReplayPlayerInstance | null>(null);
const { registerPlayer, unregisterPlayer } = useReplayContext();
const recordedDimensions = useMemo(
() => getRecordedDimensions(events),
[events],
);
useEffect(() => {
if (!events.length || !containerRef.current) return;
let mounted = true;
import('rrweb-player').then((module) => {
const PlayerConstructor = module.default;
if (!containerRef.current || !mounted) return;
containerRef.current.innerHTML = '';
const maxHeight = window.innerHeight * 0.7;
const containerWidth = containerRef.current.offsetWidth;
const aspectRatio = recordedDimensions
? recordedDimensions.width / recordedDimensions.height
: 16 / 9;
const height = Math.min(
Math.round(containerWidth / aspectRatio),
maxHeight,
);
const width = Math.min(
containerWidth,
Math.round(height * aspectRatio),
);
const player = new PlayerConstructor({
target: containerRef.current,
props: {
events,
width,
height,
autoPlay: false,
showController: false,
speedOption: [0.5, 1, 2, 4, 8],
},
}) as ReplayPlayerInstance;
playerRef.current = player;
registerPlayer(player);
});
return () => {
mounted = false;
unregisterPlayer();
if (playerRef.current?.$destroy) {
playerRef.current.$destroy();
playerRef.current = null;
}
};
}, [events, registerPlayer, unregisterPlayer, recordedDimensions]);
return (
<div className="relative flex w-full justify-center overflow-hidden">
<div
ref={containerRef}
className="w-full"
style={{ maxHeight: '70vh' }}
/>
</div>
);
}

View File

@@ -0,0 +1,234 @@
'use client';
import { useReplayContext } from '@/components/sessions/replay/replay-context';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import type { IServiceEvent } from '@openpanel/db';
import { AnimatePresence, motion } from 'framer-motion';
import { useCallback, useRef, useState } from 'react';
import { EventIcon } from '@/components/events/event-icon';
import { cn } from '@/lib/utils';
import { ReplayPlayPauseButton } from './replay-controls';
function formatTime(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
const m = Math.floor(totalSeconds / 60);
const s = totalSeconds % 60;
return `${m}:${s.toString().padStart(2, '0')}`;
}
function getEventOffsetMs(event: IServiceEvent, startTime: number): number {
const t =
typeof event.createdAt === 'object' && event.createdAt instanceof Date
? event.createdAt.getTime()
: new Date(event.createdAt).getTime();
return t - startTime;
}
export function ReplayTimeline({ events }: { events: IServiceEvent[] }) {
const { currentTime, duration, startTime, isReady, seek } =
useReplayContext();
const trackRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [hoverInfo, setHoverInfo] = useState<{
pct: number;
timeMs: number;
} | null>(null);
const getTimeFromClientX = useCallback(
(clientX: number) => {
if (!trackRef.current || duration <= 0) return null;
const rect = trackRef.current.getBoundingClientRect();
const x = clientX - rect.left;
const pct = Math.max(0, Math.min(1, x / rect.width));
return { pct, timeMs: pct * duration };
},
[duration],
);
const handleTrackMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if ((e.target as HTMLElement).closest('[data-timeline-event]')) {
setHoverInfo(null);
return;
}
const info = getTimeFromClientX(e.clientX);
if (info) setHoverInfo(info);
},
[getTimeFromClientX],
);
const handleTrackMouseLeave = useCallback(() => {
if (!isDragging) setHoverInfo(null);
}, [isDragging]);
const seekToPosition = useCallback(
(clientX: number) => {
const info = getTimeFromClientX(clientX);
if (info) seek(info.timeMs);
},
[getTimeFromClientX, seek],
);
const handleTrackMouseDown = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only handle direct clicks on the track, not on child elements like the thumb
if (
e.target !== trackRef.current &&
!(e.target as HTMLElement).closest('.replay-track-bg')
)
return;
seekToPosition(e.clientX);
},
[seekToPosition],
);
const handleThumbMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
const onMouseMove = (moveEvent: MouseEvent) => {
seekToPosition(moveEvent.clientX);
const info = getTimeFromClientX(moveEvent.clientX);
if (info) setHoverInfo(info);
};
const onMouseUp = () => {
setIsDragging(false);
setHoverInfo(null);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[seekToPosition, getTimeFromClientX],
);
if (!isReady || duration <= 0) return null;
const progressPct =
duration > 0
? Math.max(0, Math.min(100, (currentTime / duration) * 100))
: 0;
const eventsWithOffset = events
.map((ev) => ({
event: ev,
offsetMs: startTime != null ? getEventOffsetMs(ev, startTime) : 0,
}))
.filter(({ offsetMs }) => offsetMs >= 0 && offsetMs <= duration);
return (
<TooltipProvider delayDuration={300}>
<div className="row items-center gap-4 p-4">
<ReplayPlayPauseButton />
<div className="col gap-4 flex-1 px-2">
<div
ref={trackRef}
role="slider"
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={currentTime}
tabIndex={0}
className="relative flex h-8 cursor-pointer items-center"
onMouseDown={handleTrackMouseDown}
onMouseMove={handleTrackMouseMove}
onMouseLeave={handleTrackMouseLeave}
onKeyDown={(e) => {
const step = 5000;
if (e.key === 'ArrowLeft') {
e.preventDefault();
seek(Math.max(0, currentTime - step));
} else if (e.key === 'ArrowRight') {
e.preventDefault();
seek(Math.min(duration, currentTime + step));
}
}}
>
<div className="replay-track-bg bg-muted h-1.5 w-full overflow-hidden rounded-full">
<div
className="bg-primary h-full rounded-full transition-[width] duration-75"
style={{ width: `${progressPct}%` }}
/>
</div>
<div
className="absolute left-0 top-1/2 z-10 h-4 w-4 -translate-y-1/2 cursor-grab rounded-full border-2 border-primary bg-background shadow-sm transition-[left] duration-75 active:cursor-grabbing"
style={{ left: `calc(${progressPct}% - 8px)` }}
onMouseDown={handleThumbMouseDown}
aria-hidden
/>
{/* Hover timestamp tooltip */}
<AnimatePresence>
{hoverInfo && (
<motion.div
className="pointer-events-none absolute z-20"
style={{
left: `${hoverInfo.pct * 100}%`,
top: 0,
bottom: 0,
}}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* Vertical line */}
<div className="absolute left-0 top-1/2 h-4 w-px -translate-x-1/2 -translate-y-1/2 bg-foreground/30" />
{/* Timestamp badge */}
<motion.div
className="absolute bottom-6 left-1/2 mb-1.5 -translate-x-1/2 whitespace-nowrap rounded bg-foreground px-1.5 py-0.5 text-[10px] tabular-nums text-background shadow"
initial={{ opacity: 0, y: 16, scale: 0.5 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 16, scale: 0.5 }}
transition={{ duration: 0.2 }}
>
{formatTime(hoverInfo.timeMs)}
</motion.div>
</motion.div>
)}
</AnimatePresence>
{eventsWithOffset.map(({ event: ev, offsetMs }) => {
const pct = (offsetMs / duration) * 100;
return (
<Tooltip key={ev.id}>
<TooltipTrigger asChild>
<button
type="button"
data-timeline-event
className={cn(
'absolute top-1/2 z-[5] flex h-6 w-6 -translate-y-1/2 items-center justify-center transition-transform hover:scale-125',
)}
style={{ left: `${pct}%`, marginLeft: -12 }}
onClick={(e) => {
e.stopPropagation();
seek(offsetMs);
}}
aria-label={`${ev.name} at ${formatTime(offsetMs)}`}
>
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="col gap-2">
<div className="font-medium row items-center gap-2">
<EventIcon name={ev.name} meta={ev.meta} size="sm" />
{ev.name === 'screen_view' ? ev.path : ev.name}
</div>
<div className="text-muted-foreground">
{formatTime(offsetMs)}
</div>
</TooltipContent>
</Tooltip>
);
})}
</div>
</div>
</div>
</TooltipProvider>
);
}

View File

@@ -2,6 +2,7 @@ import { ProjectLink } from '@/components/links';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { formatDateTime, formatTimeAgoOrDateTime } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import { Video } from 'lucide-react';
import { ColumnCreatedAt } from '@/components/column-created-at';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
@@ -44,13 +45,25 @@ export function useColumns() {
cell: ({ row }) => {
const session = row.original;
return (
<ProjectLink
href={`/sessions/${session.id}`}
className="font-medium"
title={session.id}
>
{session.id.slice(0, 8)}...
</ProjectLink>
<div className="row gap-2 items-center">
<ProjectLink
href={`/sessions/${session.id}`}
className="font-medium"
title={session.id}
>
{session.id.slice(0, 8)}...
</ProjectLink>
{session.hasReplay && (
<ProjectLink
href={`/sessions/${session.id}#replay`}
className="text-muted-foreground hover:text-foreground"
title="View replay"
aria-label="View replay"
>
<Video className="size-4" />
</ProjectLink>
)}
</div>
);
},
},

View File

@@ -3,6 +3,7 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
import { PageContainer } from '@/components/page-container';
import { PageHeader } from '@/components/page-header';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { ReplayShell } from '@/components/sessions/replay';
import { useReadColumnVisibility } from '@/components/ui/data-table/data-table-hooks';
import {
useEventQueryFilters,
@@ -117,6 +118,9 @@ function Component() {
)}
</div>
</PageHeader>
<div className="mb-6">
<ReplayShell sessionId={sessionId} projectId={projectId} />
</div>
<EventsTable query={query} />
</PageContainer>
);

43
apps/start/src/types/rrweb-player.d.ts vendored Normal file
View File

@@ -0,0 +1,43 @@
declare module 'rrweb-player' {
interface RrwebPlayerProps {
events: Array<{ type: number; data: unknown; timestamp: number }>;
width?: number;
height?: number;
autoPlay?: boolean;
showController?: boolean;
speedOption?: number[];
}
interface RrwebPlayerOptions {
target: HTMLElement;
props: RrwebPlayerProps;
}
interface RrwebReplayer {
getCurrentTime: () => number;
}
interface RrwebPlayerMetaData {
startTime: number;
endTime: number;
totalTime: number;
}
interface RrwebPlayerInstance {
play: () => void;
pause: () => void;
toggle: () => void;
goto: (timeOffset: number, play?: boolean) => void;
setSpeed: (speed: number) => void;
getMetaData: () => RrwebPlayerMetaData;
getReplayer: () => RrwebReplayer;
addEventListener?: (
event: string,
handler: (...args: unknown[]) => void,
) => void;
$destroy?: () => void;
}
const rrwebPlayer: new (options: RrwebPlayerOptions) => RrwebPlayerInstance;
export default rrwebPlayer;
}