feat: add explore page and AI elements components

This commit is contained in:
ZhenYi 2026-05-12 13:07:58 +08:00
parent b0b33dfd9c
commit 033cfda6c5
8 changed files with 2980 additions and 0 deletions

View File

@ -0,0 +1,196 @@
import { useState, useCallback } from "react";
import { useNavigate, Link } from "react-router-dom";
import { Search, Lock, Globe, Users, Compass } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { search } from "@/client/api";
import { Input } from "@/components/ui/input";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import type { SearchParams } from "@/client/model";
import type { ProjectSearchItem } from "@/client/model";
const AVATAR_COLORS = [
"#6366f1", "#8b5cf6", "#d946ef", "#ec4899", "#f43f5e",
"#ef4444", "#f97316", "#eab308", "#22c55e", "#14b8a6",
"#06b6d4", "#3b82f6", "#2563eb", "#7c3aed", "#c026d3",
];
function hashColor(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = str.charCodeAt(i) + ((hash << 5) - hash);
}
return AVATAR_COLORS[Math.abs(hash) % AVATAR_COLORS.length];
}
export function ExplorePage() {
const navigate = useNavigate();
const [searchText, setSearchText] = useState("");
const searchParams: SearchParams = {
q: searchText.trim() || "a",
type: "projects",
page: 1,
per_page: 50,
};
const { data, isLoading } = useQuery({
queryKey: ["explore-projects", searchParams.q],
queryFn: async () => {
const res = await search(searchParams);
return res.data?.data;
},
});
const projects: ProjectSearchItem[] = data?.projects?.items ?? [];
const total = data?.projects?.total ?? 0;
const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchText(e.target.value);
}, []);
return (
<div className="flex flex-col h-full" style={{ backgroundColor: "var(--surface-ground)" }}>
{/* Header */}
<div
className="shrink-0 px-6 py-6"
style={{ backgroundColor: "var(--surface-ground)" }}
>
<div className="max-w-6xl mx-auto">
<div className="flex items-center gap-3 mb-4">
<Compass className="w-7 h-7" style={{ color: "var(--accent)" }} />
<h1 className="text-2xl font-bold" style={{ color: "var(--text-primary)" }}>
Explore Projects
</h1>
</div>
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>
Discover public projects and communities
</p>
<div className="relative max-w-lg">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="Search projects by name or description..."
value={searchText}
onChange={handleSearch}
className="pl-10 h-10"
/>
</div>
{!isLoading && (
<p className="text-xs mt-2" style={{ color: "var(--text-muted)" }}>
{searchText.trim()
? `${total} project${total !== 1 ? "s" : ""} found`
: `Showing discoverable projects`}
</p>
)}
</div>
</div>
{/* Project grid */}
<div className="flex-1 overflow-y-auto px-6 pb-6">
<div className="max-w-6xl mx-auto">
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="rounded-xl p-5 animate-pulse"
style={{ backgroundColor: "var(--surface-elevated)" }}
>
<div className="flex items-center gap-3 mb-3">
<div
className="w-12 h-12 rounded-2xl"
style={{ backgroundColor: "var(--surface-ground)" }}
/>
<div className="flex-1 space-y-2">
<div className="h-4 w-24 rounded" style={{ backgroundColor: "var(--surface-ground)" }} />
<div className="h-3 w-16 rounded" style={{ backgroundColor: "var(--surface-ground)" }} />
</div>
</div>
<div className="h-3 w-full rounded mb-2" style={{ backgroundColor: "var(--surface-ground)" }} />
<div className="h-3 w-2/3 rounded" style={{ backgroundColor: "var(--surface-ground)" }} />
</div>
))}
</div>
) : projects.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-center">
<Compass className="w-12 h-12 mb-4" style={{ color: "var(--text-muted)" }} />
<p className="text-lg font-medium" style={{ color: "var(--text-primary)" }}>
No projects found
</p>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
Try a different search term
</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{projects.map((project) => (
<Link
key={project.uid}
to={`/${project.name}/repos`}
className="block rounded-xl p-5 transition-all hover:-translate-y-0.5 group"
style={{
backgroundColor: "var(--surface-elevated)",
border: "1px solid var(--border-subtle)",
}}
>
<div className="flex items-center gap-3 mb-3">
<Avatar className="w-12 h-12 rounded-2xl shrink-0">
<AvatarImage
src={project.avatar_url || undefined}
alt={project.display_name}
className="object-cover"
/>
<AvatarFallback
style={{
backgroundColor: hashColor(project.display_name),
color: "#ffffff",
}}
>
<span className="text-[15px] font-semibold">
{project.display_name[0]?.toUpperCase() || "?"}
</span>
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h3
className="font-semibold text-sm truncate group-hover:underline"
style={{ color: "var(--text-primary)" }}
>
{project.display_name}
</h3>
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>
{project.name}
</p>
</div>
{project.is_public ? (
<Globe className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
) : (
<Lock className="w-4 h-4 shrink-0" style={{ color: "var(--text-muted)" }} />
)}
</div>
{project.description ? (
<p
className="text-xs line-clamp-2"
style={{ color: "var(--text-secondary)" }}
>
{project.description}
</p>
) : (
<p className="text-xs italic" style={{ color: "var(--text-muted)" }}>
No description
</p>
)}
<div className="flex items-center gap-2 mt-3 pt-3" style={{ borderTop: "1px solid var(--border-subtle)" }}>
<Users className="w-3.5 h-3.5" style={{ color: "var(--text-muted)" }} />
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
View project
</span>
</div>
</Link>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,222 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import type { LucideIcon } from "lucide-react";
import { BrainIcon, ChevronDownIcon, DotIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import { createContext, memo, useContext, useMemo } from "react";
interface ChainOfThoughtContextValue {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
}
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
null
);
const useChainOfThought = () => {
const context = useContext(ChainOfThoughtContext);
if (!context) {
throw new Error(
"ChainOfThought components must be used within ChainOfThought"
);
}
return context;
};
export type ChainOfThoughtProps = ComponentProps<"div"> & {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
export const ChainOfThought = memo(
({
className,
open,
defaultOpen = false,
onOpenChange,
children,
...props
}: ChainOfThoughtProps) => {
const [isOpen, setIsOpen] = useControllableState({
defaultProp: defaultOpen,
onChange: onOpenChange,
prop: open,
});
const chainOfThoughtContext = useMemo(
() => ({ isOpen, setIsOpen }),
[isOpen, setIsOpen]
);
return (
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
<div className={cn("not-prose w-full space-y-4", className)} {...props}>
{children}
</div>
</ChainOfThoughtContext.Provider>
);
}
);
export type ChainOfThoughtHeaderProps = ComponentProps<
typeof CollapsibleTrigger
>;
export const ChainOfThoughtHeader = memo(
({ className, children, ...props }: ChainOfThoughtHeaderProps) => {
const { isOpen, setIsOpen } = useChainOfThought();
return (
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
<BrainIcon className="size-4" />
<span className="flex-1 text-left">
{children ?? "Chain of Thought"}
</span>
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</CollapsibleTrigger>
</Collapsible>
);
}
);
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
icon?: LucideIcon;
label: ReactNode;
description?: ReactNode;
status?: "complete" | "active" | "pending";
};
const stepStatusStyles = {
active: "text-foreground",
complete: "text-muted-foreground",
pending: "text-muted-foreground/50",
};
export const ChainOfThoughtStep = memo(
({
className,
icon: Icon = DotIcon,
label,
description,
status = "complete",
children,
...props
}: ChainOfThoughtStepProps) => (
<div
className={cn(
"flex gap-2 text-sm",
stepStatusStyles[status],
"fade-in-0 slide-in-from-top-2 animate-in",
className
)}
{...props}
>
<div className="relative mt-0.5">
<Icon className="size-4" />
<div className="absolute top-7 bottom-0 left-1/2 -mx-px w-px bg-border" />
</div>
<div className="flex-1 space-y-2 overflow-hidden">
<div>{label}</div>
{description && (
<div className="text-muted-foreground text-xs">{description}</div>
)}
{children}
</div>
</div>
)
);
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
export const ChainOfThoughtSearchResults = memo(
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
<div
className={cn("flex flex-wrap items-center gap-2", className)}
{...props}
/>
)
);
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
export const ChainOfThoughtSearchResult = memo(
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
<Badge
className={cn("gap-1 px-2 py-0.5 font-normal text-xs", className)}
variant="secondary"
{...props}
>
{children}
</Badge>
)
);
export type ChainOfThoughtContentProps = ComponentProps<
typeof CollapsibleContent
>;
export const ChainOfThoughtContent = memo(
({ className, children, ...props }: ChainOfThoughtContentProps) => {
const { isOpen } = useChainOfThought();
return (
<Collapsible open={isOpen}>
<CollapsibleContent
className={cn(
"mt-2 space-y-3",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
{children}
</CollapsibleContent>
</Collapsible>
);
}
);
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
caption?: string;
};
export const ChainOfThoughtImage = memo(
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
<div className={cn("mt-2 space-y-2", className)} {...props}>
<div className="relative flex max-h-[22rem] items-center justify-center overflow-hidden rounded-lg bg-muted p-3">
{children}
</div>
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
</div>
)
);
ChainOfThought.displayName = "ChainOfThought";
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";

View File

@ -0,0 +1,213 @@
import {
Command,
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
CommandShortcut,
} from "@/components/ui/command";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import type { ComponentProps, ReactNode } from "react";
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
export const ModelSelector = (props: ModelSelectorProps) => (
<Dialog {...props} />
);
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
<DialogTrigger {...props} />
);
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
title?: ReactNode;
};
export const ModelSelectorContent = ({
className,
children,
title = "Model Selector",
...props
}: ModelSelectorContentProps) => (
<DialogContent
aria-describedby={undefined}
className={cn(
"outline! border-none! p-0 outline-border! outline-solid!",
className
)}
{...props}
>
<DialogTitle className="sr-only">{title}</DialogTitle>
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
{children}
</Command>
</DialogContent>
);
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
<CommandDialog {...props} />
);
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
export const ModelSelectorInput = ({
className,
...props
}: ModelSelectorInputProps) => (
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
);
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
export const ModelSelectorList = (props: ModelSelectorListProps) => (
<CommandList {...props} />
);
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
<CommandEmpty {...props} />
);
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
<CommandGroup {...props} />
);
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
<CommandItem {...props} />
);
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
<CommandShortcut {...props} />
);
export type ModelSelectorSeparatorProps = ComponentProps<
typeof CommandSeparator
>;
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
<CommandSeparator {...props} />
);
export type ModelSelectorLogoProps = Omit<
ComponentProps<"img">,
"src" | "alt"
> & {
provider:
| "moonshotai-cn"
| "lucidquery"
| "moonshotai"
| "zai-coding-plan"
| "alibaba"
| "xai"
| "vultr"
| "nvidia"
| "upstage"
| "groq"
| "github-copilot"
| "mistral"
| "vercel"
| "nebius"
| "deepseek"
| "alibaba-cn"
| "google-vertex-anthropic"
| "venice"
| "chutes"
| "cortecs"
| "github-models"
| "togetherai"
| "azure"
| "baseten"
| "huggingface"
| "opencode"
| "fastrouter"
| "google"
| "google-vertex"
| "cloudflare-workers-ai"
| "inception"
| "wandb"
| "openai"
| "zhipuai-coding-plan"
| "perplexity"
| "openrouter"
| "zenmux"
| "v0"
| "iflowcn"
| "synthetic"
| "deepinfra"
| "zhipuai"
| "submodel"
| "zai"
| "inference"
| "requesty"
| "morph"
| "lmstudio"
| "anthropic"
| "aihubmix"
| "fireworks-ai"
| "modelscope"
| "llama"
| "scaleway"
| "amazon-bedrock"
| "cerebras"
// oxlint-disable-next-line typescript-eslint(ban-types) -- intentional pattern for autocomplete-friendly string union
| (string & {});
};
export const ModelSelectorLogo = ({
provider,
className,
...props
}: ModelSelectorLogoProps) => (
<img
{...props}
alt={`${provider} logo`}
className={cn("size-3 dark:invert", className)}
height={12}
src={`https://models.dev/logos/${provider}.svg`}
width={12}
/>
);
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
export const ModelSelectorLogoGroup = ({
className,
...props
}: ModelSelectorLogoGroupProps) => (
<div
className={cn(
"flex shrink-0 items-center -space-x-1 [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 dark:[&>img]:bg-foreground",
className
)}
{...props}
/>
);
export type ModelSelectorNameProps = ComponentProps<"span">;
export const ModelSelectorName = ({
className,
...props
}: ModelSelectorNameProps) => (
<span className={cn("flex-1 truncate text-left", className)} {...props} />
);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,226 @@
"use client";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { math } from "@streamdown/math";
import { mermaid } from "@streamdown/mermaid";
import { BrainIcon, ChevronDownIcon } from "lucide-react";
import type { ComponentProps, ReactNode } from "react";
import {
createContext,
memo,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { Streamdown } from "streamdown";
import { Shimmer } from "./shimmer";
interface ReasoningContextValue {
isStreaming: boolean;
isOpen: boolean;
setIsOpen: (open: boolean) => void;
duration: number | undefined;
}
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
export const useReasoning = () => {
const context = useContext(ReasoningContext);
if (!context) {
throw new Error("Reasoning components must be used within Reasoning");
}
return context;
};
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
isStreaming?: boolean;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
duration?: number;
};
const AUTO_CLOSE_DELAY = 1000;
const MS_IN_S = 1000;
export const Reasoning = memo(
({
className,
isStreaming = false,
open,
defaultOpen,
onOpenChange,
duration: durationProp,
children,
...props
}: ReasoningProps) => {
const resolvedDefaultOpen = defaultOpen ?? isStreaming;
// Track if defaultOpen was explicitly set to false (to prevent auto-open)
const isExplicitlyClosed = defaultOpen === false;
const [isOpen, setIsOpen] = useControllableState<boolean>({
defaultProp: resolvedDefaultOpen,
onChange: onOpenChange,
prop: open,
});
const [duration, setDuration] = useControllableState<number | undefined>({
defaultProp: undefined,
prop: durationProp,
});
const hasEverStreamedRef = useRef(isStreaming);
const [hasAutoClosed, setHasAutoClosed] = useState(false);
const startTimeRef = useRef<number | null>(null);
// Track when streaming starts and compute duration
useEffect(() => {
if (isStreaming) {
hasEverStreamedRef.current = true;
if (startTimeRef.current === null) {
startTimeRef.current = Date.now();
}
} else if (startTimeRef.current !== null) {
setDuration(Math.ceil((Date.now() - startTimeRef.current) / MS_IN_S));
startTimeRef.current = null;
}
}, [isStreaming, setDuration]);
// Auto-open when streaming starts (unless explicitly closed)
useEffect(() => {
if (isStreaming && !isOpen && !isExplicitlyClosed) {
setIsOpen(true);
}
}, [isStreaming, isOpen, setIsOpen, isExplicitlyClosed]);
// Auto-close when streaming ends (once only, and only if it ever streamed)
useEffect(() => {
if (
hasEverStreamedRef.current &&
!isStreaming &&
isOpen &&
!hasAutoClosed
) {
const timer = setTimeout(() => {
setIsOpen(false);
setHasAutoClosed(true);
}, AUTO_CLOSE_DELAY);
return () => clearTimeout(timer);
}
}, [isStreaming, isOpen, setIsOpen, hasAutoClosed]);
const handleOpenChange = useCallback(
(newOpen: boolean) => {
setIsOpen(newOpen);
},
[setIsOpen]
);
const contextValue = useMemo(
() => ({ duration, isOpen, isStreaming, setIsOpen }),
[duration, isOpen, isStreaming, setIsOpen]
);
return (
<ReasoningContext.Provider value={contextValue}>
<Collapsible
className={cn("not-prose mb-4", className)}
onOpenChange={handleOpenChange}
open={isOpen}
{...props}
>
{children}
</Collapsible>
</ReasoningContext.Provider>
);
}
);
export type ReasoningTriggerProps = ComponentProps<
typeof CollapsibleTrigger
> & {
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
};
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
if (isStreaming || duration === 0) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
}
if (duration === undefined) {
return <p>Thought for a few seconds</p>;
}
return <p>Thought for {duration} seconds</p>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
...props
}: ReasoningTriggerProps) => {
const { isStreaming, isOpen, duration } = useReasoning();
return (
<CollapsibleTrigger
className={cn(
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
className
)}
{...props}
>
{children ?? (
<>
<BrainIcon className="size-4" />
{getThinkingMessage(isStreaming, duration)}
<ChevronDownIcon
className={cn(
"size-4 transition-transform",
isOpen ? "rotate-180" : "rotate-0"
)}
/>
</>
)}
</CollapsibleTrigger>
);
}
);
export type ReasoningContentProps = ComponentProps<
typeof CollapsibleContent
> & {
children: string;
};
const streamdownPlugins = { cjk, code, math, mermaid };
export const ReasoningContent = memo(
({ className, children, ...props }: ReasoningContentProps) => (
<CollapsibleContent
className={cn(
"mt-4 text-sm",
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<Streamdown plugins={streamdownPlugins}>{children}</Streamdown>
</CollapsibleContent>
)
);
Reasoning.displayName = "Reasoning";
ReasoningTrigger.displayName = "ReasoningTrigger";
ReasoningContent.displayName = "ReasoningContent";

View File

@ -0,0 +1,77 @@
"use client";
import { cn } from "@/lib/utils";
import type { MotionProps } from "motion/react";
import { motion } from "motion/react";
import type { CSSProperties, ElementType, JSX } from "react";
import { memo, useMemo } from "react";
type MotionHTMLProps = MotionProps & Record<string, unknown>;
// Cache motion components at module level to avoid creating during render
const motionComponentCache = new Map<
keyof JSX.IntrinsicElements,
React.ComponentType<MotionHTMLProps>
>();
const getMotionComponent = (element: keyof JSX.IntrinsicElements) => {
let component = motionComponentCache.get(element);
if (!component) {
component = motion.create(element);
motionComponentCache.set(element, component);
}
return component;
};
export interface TextShimmerProps {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
}
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = getMotionComponent(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
duration,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);

View File

@ -0,0 +1,87 @@
"use client";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import { ChevronDownIcon, SearchIcon } from "lucide-react";
import type { ComponentProps } from "react";
export type TaskItemFileProps = ComponentProps<"div">;
export const TaskItemFile = ({
children,
className,
...props
}: TaskItemFileProps) => (
<div
className={cn(
"inline-flex items-center gap-1 rounded-md border bg-secondary px-1.5 py-0.5 text-foreground text-xs",
className
)}
{...props}
>
{children}
</div>
);
export type TaskItemProps = ComponentProps<"div">;
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
{children}
</div>
);
export type TaskProps = ComponentProps<typeof Collapsible>;
export const Task = ({
defaultOpen = true,
className,
...props
}: TaskProps) => (
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
);
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
title: string;
};
export const TaskTrigger = ({
children,
className,
title,
...props
}: TaskTriggerProps) => (
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
{children ?? (
<div className="flex w-full cursor-pointer items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground">
<SearchIcon className="size-4" />
<p className="text-sm">{title}</p>
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
</div>
)}
</CollapsibleTrigger>
);
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
export const TaskContent = ({
children,
className,
...props
}: TaskContentProps) => (
<CollapsibleContent
className={cn(
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-popover-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
className
)}
{...props}
>
<div className="mt-4 space-y-2 border-muted border-l-2 pl-4">
{children}
</div>
</CollapsibleContent>
);

View File

@ -0,0 +1,496 @@
"use client";
import { Badge } from "@/components/ui/badge";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { cn } from "@/lib/utils";
import {
CheckCircle2Icon,
ChevronRightIcon,
CircleDotIcon,
CircleIcon,
XCircleIcon,
} from "lucide-react";
import type { ComponentProps, HTMLAttributes } from "react";
import { createContext, useContext, useMemo } from "react";
type TestStatus = "passed" | "failed" | "skipped" | "running";
interface TestResultsSummary {
passed: number;
failed: number;
skipped: number;
total: number;
duration?: number;
}
interface TestResultsContextType {
summary?: TestResultsSummary;
}
const TestResultsContext = createContext<TestResultsContextType>({});
const formatDuration = (ms: number) => {
if (ms < 1000) {
return `${ms}ms`;
}
return `${(ms / 1000).toFixed(2)}s`;
};
export type TestResultsHeaderProps = HTMLAttributes<HTMLDivElement>;
export const TestResultsHeader = ({
className,
children,
...props
}: TestResultsHeaderProps) => (
<div
className={cn(
"flex items-center justify-between border-b px-4 py-3",
className
)}
{...props}
>
{children}
</div>
);
export type TestResultsDurationProps = HTMLAttributes<HTMLSpanElement>;
export const TestResultsDuration = ({
className,
children,
...props
}: TestResultsDurationProps) => {
const { summary } = useContext(TestResultsContext);
if (!summary?.duration) {
return null;
}
return (
<span className={cn("text-muted-foreground text-sm", className)} {...props}>
{children ?? formatDuration(summary.duration)}
</span>
);
};
export type TestResultsSummaryProps = HTMLAttributes<HTMLDivElement>;
export const TestResultsSummary = ({
className,
children,
...props
}: TestResultsSummaryProps) => {
const { summary } = useContext(TestResultsContext);
if (!summary) {
return null;
}
return (
<div className={cn("flex items-center gap-3", className)} {...props}>
{children ?? (
<>
<Badge
className="gap-1 bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"
variant="secondary"
>
<CheckCircle2Icon className="size-3" />
{summary.passed} passed
</Badge>
{summary.failed > 0 && (
<Badge
className="gap-1 bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"
variant="secondary"
>
<XCircleIcon className="size-3" />
{summary.failed} failed
</Badge>
)}
{summary.skipped > 0 && (
<Badge
className="gap-1 bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"
variant="secondary"
>
<CircleIcon className="size-3" />
{summary.skipped} skipped
</Badge>
)}
</>
)}
</div>
);
};
export type TestResultsProps = HTMLAttributes<HTMLDivElement> & {
summary?: TestResultsSummary;
};
export const TestResults = ({
summary,
className,
children,
...props
}: TestResultsProps) => {
const contextValue = useMemo(() => ({ summary }), [summary]);
return (
<TestResultsContext.Provider value={contextValue}>
<div
className={cn("rounded-lg border bg-background", className)}
{...props}
>
{children ??
(summary && (
<TestResultsHeader>
<TestResultsSummary />
<TestResultsDuration />
</TestResultsHeader>
))}
</div>
</TestResultsContext.Provider>
);
};
export type TestResultsProgressProps = HTMLAttributes<HTMLDivElement>;
export const TestResultsProgress = ({
className,
children,
...props
}: TestResultsProgressProps) => {
const { summary } = useContext(TestResultsContext);
if (!summary) {
return null;
}
const passedPercent = (summary.passed / summary.total) * 100;
const failedPercent = (summary.failed / summary.total) * 100;
return (
<div className={cn("space-y-2", className)} {...props}>
{children ?? (
<>
<div className="flex h-2 overflow-hidden rounded-full bg-muted">
<div
className="bg-green-500 transition-all"
style={{ width: `${passedPercent}%` }}
/>
<div
className="bg-red-500 transition-all"
style={{ width: `${failedPercent}%` }}
/>
</div>
<div className="flex justify-between text-muted-foreground text-xs">
<span>
{summary.passed}/{summary.total} tests passed
</span>
<span>{passedPercent.toFixed(0)}%</span>
</div>
</>
)}
</div>
);
};
export type TestResultsContentProps = HTMLAttributes<HTMLDivElement>;
export const TestResultsContent = ({
className,
children,
...props
}: TestResultsContentProps) => (
<div className={cn("space-y-2 p-4", className)} {...props}>
{children}
</div>
);
interface TestSuiteContextType {
name: string;
status: TestStatus;
}
const TestSuiteContext = createContext<TestSuiteContextType>({
name: "",
status: "passed",
});
const statusStyles: Record<TestStatus, string> = {
failed: "text-red-600 dark:text-red-400",
passed: "text-green-600 dark:text-green-400",
running: "text-blue-600 dark:text-blue-400",
skipped: "text-yellow-600 dark:text-yellow-400",
};
const statusIcons: Record<TestStatus, React.ReactNode> = {
failed: <XCircleIcon className="size-4" />,
passed: <CheckCircle2Icon className="size-4" />,
running: <CircleDotIcon className="size-4 animate-pulse" />,
skipped: <CircleIcon className="size-4" />,
};
const TestStatusIcon = ({ status }: { status: TestStatus }) => (
<span className={cn("shrink-0", statusStyles[status])}>
{statusIcons[status]}
</span>
);
export type TestSuiteProps = ComponentProps<typeof Collapsible> & {
name: string;
status: TestStatus;
};
export const TestSuite = ({
name,
status,
className,
children,
...props
}: TestSuiteProps) => {
const contextValue = useMemo(() => ({ name, status }), [name, status]);
return (
<TestSuiteContext.Provider value={contextValue}>
<Collapsible className={cn("rounded-lg border", className)} {...props}>
{children}
</Collapsible>
</TestSuiteContext.Provider>
);
};
export type TestSuiteNameProps = ComponentProps<typeof CollapsibleTrigger>;
export const TestSuiteName = ({
className,
children,
...props
}: TestSuiteNameProps) => {
const { name, status } = useContext(TestSuiteContext);
return (
<CollapsibleTrigger
className={cn(
"group flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-muted/50",
className
)}
{...props}
>
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-90" />
<TestStatusIcon status={status} />
<span className="font-medium text-sm">{children ?? name}</span>
</CollapsibleTrigger>
);
};
export type TestSuiteStatsProps = HTMLAttributes<HTMLDivElement> & {
passed?: number;
failed?: number;
skipped?: number;
};
export const TestSuiteStats = ({
passed = 0,
failed = 0,
skipped = 0,
className,
children,
...props
}: TestSuiteStatsProps) => (
<div
className={cn("ml-auto flex items-center gap-2 text-xs", className)}
{...props}
>
{children ?? (
<>
{passed > 0 && (
<span className="text-green-600 dark:text-green-400">
{passed} passed
</span>
)}
{failed > 0 && (
<span className="text-red-600 dark:text-red-400">
{failed} failed
</span>
)}
{skipped > 0 && (
<span className="text-yellow-600 dark:text-yellow-400">
{skipped} skipped
</span>
)}
</>
)}
</div>
);
export type TestSuiteContentProps = ComponentProps<typeof CollapsibleContent>;
export const TestSuiteContent = ({
className,
children,
...props
}: TestSuiteContentProps) => (
<CollapsibleContent className={cn("border-t", className)} {...props}>
<div className="divide-y">{children}</div>
</CollapsibleContent>
);
interface TestContextType {
name: string;
status: TestStatus;
duration?: number;
}
const TestContext = createContext<TestContextType>({
name: "",
status: "passed",
});
export type TestNameProps = HTMLAttributes<HTMLSpanElement>;
export const TestName = ({ className, children, ...props }: TestNameProps) => {
const { name } = useContext(TestContext);
return (
<span className={cn("flex-1", className)} {...props}>
{children ?? name}
</span>
);
};
export type TestDurationProps = HTMLAttributes<HTMLSpanElement>;
export const TestDuration = ({
className,
children,
...props
}: TestDurationProps) => {
const { duration } = useContext(TestContext);
if (duration === undefined) {
return null;
}
return (
<span
className={cn("ml-auto text-muted-foreground text-xs", className)}
{...props}
>
{children ?? `${duration}ms`}
</span>
);
};
export type TestStatusProps = HTMLAttributes<HTMLSpanElement>;
export const TestStatus = ({
className,
children,
...props
}: TestStatusProps) => {
const { status } = useContext(TestContext);
return (
<span
className={cn("shrink-0", statusStyles[status], className)}
{...props}
>
{children ?? statusIcons[status]}
</span>
);
};
export type TestProps = HTMLAttributes<HTMLDivElement> & {
name: string;
status: TestStatus;
duration?: number;
};
export const Test = ({
name,
status,
duration,
className,
children,
...props
}: TestProps) => {
const contextValue = useMemo(
() => ({ duration, name, status }),
[duration, name, status]
);
return (
<TestContext.Provider value={contextValue}>
<div
className={cn("flex items-center gap-2 px-4 py-2 text-sm", className)}
{...props}
>
{children ?? (
<>
<TestStatus />
<TestName />
{duration !== undefined && <TestDuration />}
</>
)}
</div>
</TestContext.Provider>
);
};
export type TestErrorProps = HTMLAttributes<HTMLDivElement>;
export const TestError = ({
className,
children,
...props
}: TestErrorProps) => (
<div
className={cn(
"mt-2 rounded-md bg-red-50 p-3 dark:bg-red-900/20",
className
)}
{...props}
>
{children}
</div>
);
export type TestErrorMessageProps = HTMLAttributes<HTMLParagraphElement>;
export const TestErrorMessage = ({
className,
children,
...props
}: TestErrorMessageProps) => (
<p
className={cn(
"font-medium text-red-700 text-sm dark:text-red-400",
className
)}
{...props}
>
{children}
</p>
);
export type TestErrorStackProps = HTMLAttributes<HTMLPreElement>;
export const TestErrorStack = ({
className,
children,
...props
}: TestErrorStackProps) => (
<pre
className={cn(
"mt-2 overflow-auto font-mono text-red-600 text-xs dark:text-red-400",
className
)}
{...props}
>
{children}
</pre>
);