feat: add command palette, accessibility, scroll animations, demo workspace, and keyboard navigation
- COMP-139: Command palette for quick navigation - COMP-140: Accessibility improvements - COMP-141: Scroll animations with animate-on-scroll component - COMP-143: Demo workspace with seed data and demo banner - COMP-145: Keyboard navigation and shortcuts help Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-Claude) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
60
apps/web/src/components/animate-on-scroll.tsx
Normal file
60
apps/web/src/components/animate-on-scroll.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, ReactNode } from "react";
|
||||
|
||||
interface AnimateOnScrollProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
delay?: number;
|
||||
threshold?: number;
|
||||
}
|
||||
|
||||
export function AnimateOnScroll({
|
||||
children,
|
||||
className = "",
|
||||
delay = 0,
|
||||
threshold = 0.15,
|
||||
}: AnimateOnScrollProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
const prefersReduced = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
).matches;
|
||||
|
||||
if (prefersReduced) {
|
||||
el.setAttribute("data-animate", "visible");
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.setAttribute("data-animate", "visible");
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ threshold }
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [threshold]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
data-animate="hidden"
|
||||
className={className}
|
||||
style={{ animationDelay: delay ? `${delay}ms` : undefined }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
258
apps/web/src/components/command-palette.tsx
Normal file
258
apps/web/src/components/command-palette.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { signOut } from "next-auth/react";
|
||||
import { Command } from "cmdk";
|
||||
import {
|
||||
Activity,
|
||||
GitBranch,
|
||||
Key,
|
||||
Settings,
|
||||
LogOut,
|
||||
Plus,
|
||||
Search,
|
||||
ArrowRight,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface RecentTrace {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
startedAt: string;
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [recentTraces, setRecentTraces] = useState<RecentTrace[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchRecentTraces = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/traces?limit=5", { cache: "no-store" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRecentTraces(data.traces ?? []);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail -- palette still works for navigation
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchRecentTraces();
|
||||
}
|
||||
}, [open, fetchRecentTraces]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "k") {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
function runCommand(command: () => void) {
|
||||
setOpen(false);
|
||||
command();
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100]">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Palette */}
|
||||
<div className="absolute inset-0 flex items-start justify-center pt-[20vh] px-4">
|
||||
<Command
|
||||
className="w-full max-w-xl rounded-xl border border-neutral-800 bg-neutral-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden"
|
||||
loop
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center gap-3 border-b border-neutral-800 px-4">
|
||||
<Search className="w-4 h-4 text-neutral-500 shrink-0" />
|
||||
<Command.Input
|
||||
placeholder="Search traces, navigate, or run actions..."
|
||||
className="w-full py-4 bg-transparent text-sm text-neutral-100 placeholder-neutral-500 outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<kbd className="hidden sm:inline-flex items-center gap-1 px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-[10px] font-mono text-neutral-500">
|
||||
ESC
|
||||
</kbd>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<Command.List className="max-h-80 overflow-y-auto p-2">
|
||||
<Command.Empty className="py-8 text-center text-sm text-neutral-500">
|
||||
No results found.
|
||||
</Command.Empty>
|
||||
|
||||
{/* Recent Traces */}
|
||||
{recentTraces.length > 0 && (
|
||||
<Command.Group
|
||||
heading="Recent Traces"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="px-2 py-3 text-xs text-neutral-500">
|
||||
Loading traces...
|
||||
</div>
|
||||
) : (
|
||||
recentTraces.map((trace) => (
|
||||
<Command.Item
|
||||
key={trace.id}
|
||||
value={`trace ${trace.name} ${trace.id}`}
|
||||
onSelect={() =>
|
||||
runCommand(() =>
|
||||
router.push(`/dashboard/traces/${trace.id}`)
|
||||
)
|
||||
}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer transition-colors",
|
||||
"text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400"
|
||||
)}
|
||||
>
|
||||
<Activity className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1 truncate">{trace.name}</span>
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs px-1.5 py-0.5 rounded",
|
||||
trace.status === "COMPLETED" &&
|
||||
"bg-emerald-500/10 text-emerald-400",
|
||||
trace.status === "ERROR" &&
|
||||
"bg-red-500/10 text-red-400",
|
||||
trace.status === "RUNNING" &&
|
||||
"bg-amber-500/10 text-amber-400"
|
||||
)}
|
||||
>
|
||||
{trace.status.toLowerCase()}
|
||||
</span>
|
||||
<ArrowRight className="w-3.5 h-3.5 shrink-0 opacity-0 group-data-[selected=true]:opacity-100" />
|
||||
</Command.Item>
|
||||
))
|
||||
)}
|
||||
</Command.Group>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<Command.Group
|
||||
heading="Navigation"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
|
||||
>
|
||||
<Command.Item
|
||||
value="Dashboard Traces"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Activity className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Dashboard</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="Decisions"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/decisions"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<GitBranch className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Decisions</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="API Keys"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/keys"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Key className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">API Keys</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="Settings"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/settings"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Settings className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Settings</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
{/* Actions */}
|
||||
<Command.Group
|
||||
heading="Actions"
|
||||
className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-2 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-neutral-500"
|
||||
>
|
||||
<Command.Item
|
||||
value="Create New API Key"
|
||||
onSelect={() =>
|
||||
runCommand(() => router.push("/dashboard/keys"))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-emerald-500/10 data-[selected=true]:text-emerald-400 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">New API Key</span>
|
||||
</Command.Item>
|
||||
|
||||
<Command.Item
|
||||
value="Sign Out Logout"
|
||||
onSelect={() =>
|
||||
runCommand(() => signOut({ callbackUrl: "/" }))
|
||||
}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm cursor-pointer text-neutral-300 data-[selected=true]:bg-red-500/10 data-[selected=true]:text-red-400 transition-colors"
|
||||
>
|
||||
<LogOut className="w-4 h-4 shrink-0 text-neutral-500" />
|
||||
<span className="flex-1">Logout</span>
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-neutral-800 px-4 py-2.5">
|
||||
<div className="flex items-center gap-3 text-[11px] text-neutral-500">
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
|
||||
↑↓
|
||||
</kbd>
|
||||
Navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
|
||||
↵
|
||||
</kbd>
|
||||
Select
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 font-mono">
|
||||
esc
|
||||
</kbd>
|
||||
Close
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/components/demo-banner.tsx
Normal file
65
apps/web/src/components/demo-banner.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Beaker, ArrowRight, X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DISMISS_KEY = "agentlens-demo-banner-dismissed";
|
||||
|
||||
interface DemoBannerProps {
|
||||
allTracesAreDemo: boolean;
|
||||
}
|
||||
|
||||
export function DemoBanner({ allTracesAreDemo }: DemoBannerProps) {
|
||||
const [dismissed, setDismissed] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setDismissed(localStorage.getItem(DISMISS_KEY) === "true");
|
||||
}, []);
|
||||
|
||||
if (dismissed || !allTracesAreDemo) return null;
|
||||
|
||||
function handleDismiss() {
|
||||
setDismissed(true);
|
||||
localStorage.setItem(DISMISS_KEY, "true");
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mb-6 rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4",
|
||||
"flex items-center gap-4"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg bg-emerald-500/10 border border-emerald-500/20 shrink-0">
|
||||
<Beaker className="w-5 h-5 text-emerald-400" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-emerald-200 font-medium">
|
||||
You are viewing sample data.
|
||||
</p>
|
||||
<p className="text-xs text-emerald-400/60 mt-0.5">
|
||||
Connect your agent to start collecting real traces.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/docs/getting-started"
|
||||
className="hidden sm:flex items-center gap-1.5 px-4 py-2 rounded-lg bg-emerald-500/10 border border-emerald-500/20 text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 transition-colors shrink-0"
|
||||
>
|
||||
View Setup Guide
|
||||
<ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss demo banner"
|
||||
className="p-1.5 rounded-lg text-emerald-400/40 hover:text-emerald-400/80 hover:bg-emerald-500/10 transition-colors shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
43
apps/web/src/components/demo-seed-trigger.tsx
Normal file
43
apps/web/src/components/demo-seed-trigger.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface DemoSeedTriggerProps {
|
||||
hasTraces: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function DemoSeedTrigger({ hasTraces, children }: DemoSeedTriggerProps) {
|
||||
const [seeding, setSeeding] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasTraces || seeding) return;
|
||||
|
||||
async function seedIfNeeded() {
|
||||
setSeeding(true);
|
||||
try {
|
||||
const res = await fetch("/api/demo/seed", { method: "POST" });
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
}
|
||||
} catch {
|
||||
// Seed failed, continue showing empty state
|
||||
} finally {
|
||||
setSeeding(false);
|
||||
}
|
||||
}
|
||||
|
||||
seedIfNeeded();
|
||||
}, [hasTraces, seeding]);
|
||||
|
||||
if (!hasTraces && seeding) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="w-10 h-10 rounded-xl border-2 border-emerald-500/30 border-t-emerald-500 animate-spin mb-4" />
|
||||
<p className="text-sm text-neutral-400">Setting up your workspace with sample data...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
113
apps/web/src/components/keyboard-shortcuts-help.tsx
Normal file
113
apps/web/src/components/keyboard-shortcuts-help.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: ["j"], description: "Move selection down" },
|
||||
{ keys: ["k"], description: "Move selection up" },
|
||||
{ keys: ["Enter"], description: "Open selected item" },
|
||||
{ keys: ["Escape"], description: "Clear selection / go back" },
|
||||
{ keys: ["g", "h"], description: "Go to Dashboard" },
|
||||
{ keys: ["g", "s"], description: "Go to Settings" },
|
||||
{ keys: ["g", "k"], description: "Go to API Keys" },
|
||||
{ keys: ["g", "d"], description: "Go to Decisions" },
|
||||
{ keys: ["Cmd", "K"], description: "Open command palette" },
|
||||
{ keys: ["?"], description: "Show this help" },
|
||||
];
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
const el = document.activeElement;
|
||||
if (el) {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select") return;
|
||||
if ((el as HTMLElement).isContentEditable) return;
|
||||
}
|
||||
|
||||
if (e.key === "?" && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
|
||||
if (e.key === "Escape" && open) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[90]">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => setOpen(false)}
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 flex items-center justify-center px-4">
|
||||
<div className="w-full max-w-md rounded-xl border border-neutral-800 bg-neutral-900/95 backdrop-blur-xl shadow-2xl shadow-black/40 overflow-hidden">
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-800">
|
||||
<h2 className="text-sm font-semibold text-neutral-100">
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close shortcuts help"
|
||||
className="p-1.5 rounded-lg text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-2 max-h-[60vh] overflow-y-auto">
|
||||
{shortcuts.map((shortcut, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-neutral-800/50"
|
||||
>
|
||||
<span className="text-sm text-neutral-400">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key, j) => (
|
||||
<span key={j}>
|
||||
{j > 0 && (
|
||||
<span className="text-neutral-600 text-xs mx-0.5">
|
||||
then
|
||||
</span>
|
||||
)}
|
||||
<kbd className="inline-flex items-center justify-center min-w-[24px] px-2 py-1 rounded bg-neutral-800 border border-neutral-700 text-xs font-mono text-neutral-300">
|
||||
{key}
|
||||
</kbd>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShortcutsHint() {
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-30">
|
||||
<span className="text-xs text-neutral-600 flex items-center gap-1.5">
|
||||
Press
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-neutral-800 border border-neutral-700 text-[10px] font-mono text-neutral-500">
|
||||
?
|
||||
</kbd>
|
||||
for shortcuts
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
WifiOff,
|
||||
} from "lucide-react";
|
||||
import { cn, formatDuration, formatRelativeTime } from "@/lib/utils";
|
||||
import { useKeyboardNav } from "@/hooks/use-keyboard-nav";
|
||||
|
||||
type TraceStatus = "RUNNING" | "COMPLETED" | "ERROR";
|
||||
|
||||
@@ -87,6 +88,7 @@ export function TraceList({
|
||||
initialTotalPages,
|
||||
initialPage,
|
||||
}: TraceListProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [traces, setTraces] = useState<Trace[]>(initialTraces);
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
@@ -283,6 +285,19 @@ export function TraceList({
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
const { selectedIndex } = useKeyboardNav({
|
||||
itemCount: filteredTraces.length,
|
||||
onSelect: useCallback(
|
||||
(index: number) => {
|
||||
const trace = filteredTraces[index];
|
||||
if (trace) {
|
||||
router.push(`/dashboard/traces/${trace.id}`);
|
||||
}
|
||||
},
|
||||
[filteredTraces, router]
|
||||
),
|
||||
});
|
||||
|
||||
const filterChips: { value: FilterStatus; label: string }[] = [
|
||||
{ value: "ALL", label: "All" },
|
||||
{ value: "RUNNING", label: "Running" },
|
||||
@@ -376,7 +391,9 @@ export function TraceList({
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-neutral-500" />
|
||||
<label htmlFor="trace-search" className="sr-only">Search traces</label>
|
||||
<input
|
||||
id="trace-search"
|
||||
type="text"
|
||||
placeholder="Search traces..."
|
||||
value={searchQuery}
|
||||
@@ -422,8 +439,9 @@ export function TraceList({
|
||||
{showAdvancedFilters && (
|
||||
<div className="mt-4 grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-neutral-500 font-medium">Sort by</label>
|
||||
<label htmlFor="sort-filter" className="text-xs text-neutral-500 font-medium">Sort by</label>
|
||||
<select
|
||||
id="sort-filter"
|
||||
value={sortFilter}
|
||||
onChange={(e) => setSortFilter(e.target.value as SortOption)}
|
||||
className="w-full bg-neutral-900 border border-neutral-700 rounded-lg px-3 py-2 text-sm text-neutral-100 focus:outline-none focus:ring-2 focus:ring-emerald-500/50 focus:border-emerald-500 transition-all"
|
||||
@@ -437,8 +455,9 @@ export function TraceList({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-neutral-500 font-medium">Date from</label>
|
||||
<label htmlFor="date-from" className="text-xs text-neutral-500 font-medium">Date from</label>
|
||||
<input
|
||||
id="date-from"
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
@@ -447,8 +466,9 @@ export function TraceList({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-neutral-500 font-medium">Date to</label>
|
||||
<label htmlFor="date-to" className="text-xs text-neutral-500 font-medium">Date to</label>
|
||||
<input
|
||||
id="date-to"
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
@@ -457,8 +477,9 @@ export function TraceList({
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-3 space-y-2">
|
||||
<label className="text-xs text-neutral-500 font-medium">Tags (comma-separated)</label>
|
||||
<label htmlFor="tags-filter" className="text-xs text-neutral-500 font-medium">Tags (comma-separated)</label>
|
||||
<input
|
||||
id="tags-filter"
|
||||
type="text"
|
||||
placeholder="e.g., production, critical, api"
|
||||
value={tagsFilter}
|
||||
@@ -473,8 +494,13 @@ export function TraceList({
|
||||
|
||||
{/* Trace List */}
|
||||
<div className="space-y-3">
|
||||
{filteredTraces.map((trace) => (
|
||||
<TraceCard key={trace.id} trace={trace} />
|
||||
{filteredTraces.map((trace, index) => (
|
||||
<TraceCard
|
||||
key={trace.id}
|
||||
trace={trace}
|
||||
index={index}
|
||||
isSelected={index === selectedIndex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -497,6 +523,7 @@ export function TraceList({
|
||||
<button
|
||||
disabled={currentPage <= 1}
|
||||
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
|
||||
aria-label="Previous page"
|
||||
className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
@@ -504,6 +531,7 @@ export function TraceList({
|
||||
<button
|
||||
disabled={currentPage >= totalPages}
|
||||
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
|
||||
aria-label="Next page"
|
||||
className="p-2 rounded-lg border border-neutral-800 text-neutral-400 hover:text-neutral-100 hover:border-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
@@ -515,13 +543,29 @@ export function TraceList({
|
||||
);
|
||||
}
|
||||
|
||||
function TraceCard({ trace }: { trace: Trace }) {
|
||||
function TraceCard({
|
||||
trace,
|
||||
index,
|
||||
isSelected,
|
||||
}: {
|
||||
trace: Trace;
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const status = statusConfig[trace.status];
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<Link href={`/dashboard/traces/${trace.id}`}>
|
||||
<div className="group p-5 bg-neutral-900 border border-neutral-800 rounded-xl hover:border-emerald-500/30 hover:bg-neutral-800/50 transition-all duration-200 cursor-pointer">
|
||||
<div
|
||||
data-keyboard-index={index}
|
||||
className={cn(
|
||||
"group p-5 bg-neutral-900 border rounded-xl hover:border-emerald-500/30 hover:bg-neutral-800/50 transition-all duration-200 cursor-pointer",
|
||||
isSelected
|
||||
? "border-emerald-500/40 bg-emerald-500/5 ring-1 ring-emerald-500/20"
|
||||
: "border-neutral-800"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row lg:items-center gap-4">
|
||||
{/* Left: Name and Status */}
|
||||
<div className="flex-1 min-w-0">
|
||||
|
||||
Reference in New Issue
Block a user