refactor: extract components folder

This commit is contained in:
Li Xin
2025-05-02 10:43:14 +08:00
parent 18d896d15d
commit fdfc607747
44 changed files with 44 additions and 44 deletions

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { cn } from "~/lib/utils";
export function FavIcon({
className,
url,
title,
}: {
className?: string;
url: string;
title?: string;
}) {
return (
<img
className={cn("bg-accent h-4 w-4 rounded-full shadow-sm", className)}
width={16}
height={16}
src={new URL(url).origin + "/favicon.ico"}
alt={title}
onError={(e) => {
e.currentTarget.src =
"https://perishablepress.com/wp/wp-content/images/2021/favicon-standard.png";
}}
/>
);
}

View File

@@ -0,0 +1,26 @@
export function Detective({ className }: { className?: string }) {
return (
<svg
className={className}
version="1.1"
width="800px"
height="800px"
viewBox="0 0 512 512"
>
<g fill="currentcolor">
<path
d="M392.692,257.322c-1.172-8.125-2.488-16.98-3.807-25.984c-5.856-39.012-12.59-81.688-14.86-87.832
c-4.318-11.715-18.371-44.723-68.217-25.984c-15.738,5.926-18.812,11.93-41.648,8.93c-17.273-2.27-28.326-15.59-52.336-24.668
c-49.844-18.883-71.584,11.711-75.902,23.422c-2.27,6.148-9.004,67.121-14.86,106.133c-1.39,8.86-2.633,17.566-3.804,25.621
c37.256,7.535,84.174,12.879,138.705,12.879C309.541,269.837,355.801,264.716,392.692,257.322z"
/>
<path
d="M443.707,306.509c-8.051-2.196-16.834-4.246-26.057-6.148c-1.83-0.805-3.66-1.535-5.49-2.27h-0.072
c-46.918,10.394-102.254,15.664-156.125,15.664c-53.652,0-108.768-5.27-155.541-15.516c-1.316,0.512-2.707,1.098-4.098,1.684
c-8.858,1.828-17.348,3.73-25.106,5.781l-0.148,0.074C27.008,317.49,0,333.372,0,350.939c0,36.012,114.549,65.289,256.035,65.289
c141.34,0,255.965-29.278,255.965-65.289C512,333.74,486.016,318.22,443.707,306.509z"
/>
</g>
</svg>
);
}

View File

@@ -0,0 +1,68 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { memo, useCallback, useEffect, useState } from "react";
import { cn } from "~/lib/utils";
import { Tooltip } from "./tooltip";
function Image({
className,
imageClassName,
imageTransition,
src,
alt,
fallback = null,
}: {
className?: string;
imageClassName?: string;
imageTransition?: boolean;
src: string;
alt: string;
fallback?: React.ReactNode;
}) {
const [, setIsLoading] = useState(true);
const [isError, setIsError] = useState(false);
useEffect(() => {
setIsError(false);
setIsLoading(true);
}, [src]);
const handleLoad = useCallback(() => {
setIsError(false);
setIsLoading(false);
}, []);
const handleError = useCallback(
(e: React.SyntheticEvent<HTMLImageElement>) => {
e.currentTarget.style.display = "none";
console.warn(`Markdown: Image "${e.currentTarget.src}" failed to load`);
setIsError(true);
},
[],
);
return (
<span className={cn("block w-fit overflow-hidden", className)}>
{isError ? (
fallback
) : (
<Tooltip title={alt ?? "No caption"}>
<img
className={cn(
"size-full object-cover",
imageTransition && "transition-all duration-200 ease-out",
imageClassName,
)}
src={src}
alt={alt}
onLoad={handleLoad}
onError={handleError}
/>
</Tooltip>
)}
</span>
);
}
export default memo(Image);

View File

@@ -0,0 +1,34 @@
@keyframes bouncing-animation {
to {
opacity: 0.1;
transform: translateY(-8px);
}
}
.loadingAnimation {
display: flex;
}
.loadingAnimation > div {
width: 8px;
height: 8px;
margin: 2px 4px;
border-radius: 50%;
background-color: #a3a1a1;
opacity: 1;
animation: bouncing-animation 0.5s infinite alternate;
}
.loadingAnimation.sm > div {
width: 6px;
height: 6px;
margin: 1px 2px;
}
.loadingAnimation > div:nth-child(2) {
animation-delay: 0.2s;
}
.loadingAnimation > div:nth-child(3) {
animation-delay: 0.4s;
}

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { cn } from "~/lib/utils";
import styles from "./loading-animation.module.css";
export function LoadingAnimation({
className,
size = "normal",
}: {
className?: string;
size?: "normal" | "sm";
}) {
return (
<div
className={cn(
styles.loadingAnimation,
size === "sm" && styles.sm,
className,
)}
>
<div></div>
<div></div>
<div></div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import Link from "next/link";
export function Logo() {
return (
<Link
className="opacity-70 transition-opacity duration-300 hover:opacity-100"
href="/"
>
🦌 DeerFlow
</Link>
);
}

View File

@@ -0,0 +1,128 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { Check, Copy } from "lucide-react";
import { useMemo, useState } from "react";
import ReactMarkdown, {
type Options as ReactMarkdownOptions,
} from "react-markdown";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import "katex/dist/katex.min.css";
import { Button } from "~/components/ui/button";
import { rehypeSplitWordsIntoSpans } from "~/core/rehype";
import { cn } from "~/lib/utils";
import Image from "./image";
import { Tooltip } from "./tooltip";
export function Markdown({
className,
children,
style,
enableCopy,
animate = false,
...props
}: ReactMarkdownOptions & {
className?: string;
enableCopy?: boolean;
style?: React.CSSProperties;
animate?: boolean;
}) {
const rehypePlugins = useMemo(() => {
if (animate) {
return [rehypeKatex, rehypeSplitWordsIntoSpans];
}
return [rehypeKatex];
}, [animate]);
return (
<div
className={cn(
className,
"prose dark:prose-invert prose-p:my-0 prose-img:mt-0 flex flex-col gap-4",
)}
style={style}
>
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={rehypePlugins}
components={{
a: ({ href, children }) => (
<a href={href} target="_blank" rel="noopener noreferrer">
{children}
</a>
),
img: ({ src, alt }) => (
<a href={src as string} target="_blank" rel="noopener noreferrer">
<Image className="rounded" src={src as string} alt={alt ?? ""} />
</a>
),
}}
{...props}
>
{dropMarkdownQuote(processKatexInMarkdown(children))}
</ReactMarkdown>
{enableCopy && typeof children === "string" && (
<div className="flex">
<CopyButton content={children} />
</div>
)}
</div>
);
}
function CopyButton({ content }: { content: string }) {
const [copied, setCopied] = useState(false);
return (
<Tooltip title="Copy">
<Button
variant="outline"
size="sm"
className="rounded-full"
onClick={async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => {
setCopied(false);
}, 1000);
} catch (error) {
console.error(error);
}
}}
>
{copied ? (
<Check className="h-4 w-4" />
) : (
<Copy className="h-4 w-4" />
)}{" "}
</Button>
</Tooltip>
);
}
function processKatexInMarkdown(markdown?: string | null) {
if (!markdown) return markdown;
const markdownWithKatexSyntax = markdown
.replace(/\\\\\[/g, "$$$$") // Replace '\\[' with '$$'
.replace(/\\\\\]/g, "$$$$") // Replace '\\]' with '$$'
.replace(/\\\\\(/g, "$$$$") // Replace '\\(' with '$$'
.replace(/\\\\\)/g, "$$$$") // Replace '\\)' with '$$'
.replace(/\\\[/g, "$$$$") // Replace '\[' with '$$'
.replace(/\\\]/g, "$$$$") // Replace '\]' with '$$'
.replace(/\\\(/g, "$$$$") // Replace '\(' with '$$'
.replace(/\\\)/g, "$$$$"); // Replace '\)' with '$$';
return markdownWithKatexSyntax;
}
function dropMarkdownQuote(markdown?: string | null) {
if (!markdown) return markdown;
return markdown
.replace(/^```markdown\n/gm, "")
.replace(/^```text\n/gm, "")
.replace(/^```\n/gm, "")
.replace(/\n```$/gm, "");
}

View File

@@ -0,0 +1,24 @@
.animated {
background: linear-gradient(
to right,
rgb(from var(--card-foreground) r g b / 0.3) 15%,
rgb(from var(--card-foreground) r g b / 0.75) 35%,
rgb(from var(--card-foreground) r g b / 0.75) 65%,
rgb(from var(--card-foreground) r g b / 0.3) 85%
);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
text-fill-color: transparent;
background-size: 500% auto;
animation: textShine 2s ease-in-out infinite alternate;
}
@keyframes textShine {
0% {
background-position: 0% 50%;
}
100% {
background-position: 100% 50%;
}
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { cn } from "~/lib/utils";
import styles from "./rainbow-text.module.css";
export function RainbowText({
animated,
className,
children,
}: {
animated?: boolean;
className?: string;
children?: React.ReactNode;
}) {
return (
<span className={cn(animated && styles.animated, className)}>
{children}
</span>
);
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
export function Ray() {
return (
<svg
className="animate-spotlight pointer-events-none fixed -top-80 left-0 z-[99] h-[169%] w-[138%] opacity-0 md:-top-20 md:left-60 lg:w-[84%]"
viewBox="0 0 3787 2842"
fill="none"
>
<g filter="url(#filter)">
<ellipse
cx="1924.71"
cy="273.501"
rx="1924.71"
ry="273.501"
transform="matrix(-0.822377 -0.568943 -0.568943 0.822377 3631.88 2291.09)"
fill="white"
fillOpacity="0.21"
></ellipse>
</g>
<defs>
<filter
id="filter"
x="0.860352"
y="0.838989"
width="3785.16"
height="2840.26"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix"></feFlood>
<feBlend
mode="normal"
in="SourceGraphic"
in2="BackgroundImageFix"
result="shape"
></feBlend>
<feGaussianBlur
stdDeviation="151"
result="effect1_foregroundBlur_1065_8"
></feGaussianBlur>
</filter>
</defs>
</svg>
);
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { motion, AnimatePresence } from "framer-motion";
import { cn } from "~/lib/utils";
export function RollingText({
className,
children,
}: {
className?: string;
children?: string | string[];
}) {
return (
<span
className={cn(
"relative flex h-[2em] items-center overflow-hidden",
className,
)}
>
<AnimatePresence mode="popLayout">
<motion.div
className="absolute w-fit"
style={{ transition: "all 0.3s ease-in-out" }}
initial={{ y: "100%", opacity: 0 }}
animate={{ y: "0%", opacity: 1 }}
exit={{ y: "-100%", opacity: 0 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
{children}
</motion.div>
</AnimatePresence>
</span>
);
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import { useEffect, useRef } from "react";
import { useStickToBottom } from "use-stick-to-bottom";
import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils";
export function ScrollContainer({
className,
children,
scrollShadow = true,
scrollShadowColor = "var(--background)",
autoScrollToBottom = false,
}: {
className?: string;
children?: React.ReactNode;
scrollShadow?: boolean;
scrollShadowColor?: string;
autoScrollToBottom?: boolean;
}) {
const { scrollRef, contentRef } = useStickToBottom({
initial: "instant",
});
const tempScrollRef = useRef<HTMLElement>(null);
const tempContentRef = useRef<HTMLElement>(null);
useEffect(() => {
if (!autoScrollToBottom) {
tempScrollRef.current = scrollRef.current;
tempContentRef.current = contentRef.current;
scrollRef.current = null;
contentRef.current = null;
} else if (tempScrollRef.current && tempContentRef.current) {
scrollRef.current = tempScrollRef.current;
contentRef.current = tempContentRef.current;
}
}, [autoScrollToBottom, contentRef, scrollRef]);
return (
<div className={cn("relative", className)}>
{scrollShadow && (
<>
<div
className={cn(
"absolute top-0 right-0 left-0 z-10 h-10 bg-gradient-to-t",
`from-transparent to-[var(--scroll-shadow-color)]`,
)}
style={
{
"--scroll-shadow-color": scrollShadowColor,
} as React.CSSProperties
}
></div>
<div
className={cn(
"absolute right-0 bottom-0 left-0 z-10 h-10 bg-gradient-to-b",
`from-transparent to-[var(--scroll-shadow-color)]`,
)}
style={
{
"--scroll-shadow-color": scrollShadowColor,
} as React.CSSProperties
}
></div>
</>
)}
<ScrollArea ref={scrollRef} className="h-full w-full">
<div className="h-fit w-full" ref={contentRef}>
{children}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import { usePathname } from "next/navigation";
import { ThemeProvider } from "~/components/theme-provider";
export function ThemeProviderWrapper({
children,
}: {
children: React.ReactNode;
}) {
const pathname = usePathname();
const isChatPage = pathname?.startsWith("/chat");
return (
<ThemeProvider
attribute="class"
defaultTheme={"dark"}
enableSystem={isChatPage}
forcedTheme={isChatPage ? undefined : "dark"}
disableTransitionOnChange
>
{children}
</ThemeProvider>
);
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { cn } from "~/lib/utils";
import { Tooltip } from "./tooltip";
export function ThemeToggle() {
const { theme = "system", setTheme } = useTheme();
return (
<DropdownMenu>
<Tooltip title="Change theme">
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
</Button>
</DropdownMenuTrigger>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Sun className="mr-2 h-4 w-4" />
<span
className={cn(
theme === "light" ? "font-bold" : "text-muted-foreground",
)}
>
Light
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Moon className="mr-2 h-4 w-4" />
<span
className={cn(
theme === "dark" ? "font-bold" : "text-muted-foreground",
)}
>
Dark
</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Monitor className="mr-2 h-4 w-4" />
<span
className={cn(
theme === "system" ? "font-bold" : "text-muted-foreground",
)}
>
System
</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,33 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner } from "sonner";
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { resolvedTheme = "dark" } = useTheme();
return (
<Sonner
theme={resolvedTheme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
);
};
export { Toaster };

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
// SPDX-License-Identifier: MIT
import type { CSSProperties } from "react";
import {
Tooltip as ShadcnTooltip,
TooltipContent,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { cn } from "~/lib/utils";
export function Tooltip({
className,
style,
children,
title,
open,
side,
sideOffset,
}: {
className?: string;
style?: CSSProperties;
children: React.ReactNode;
title?: React.ReactNode;
open?: boolean;
side?: "left" | "right" | "top" | "bottom";
sideOffset?: number;
}) {
return (
<ShadcnTooltip delayDuration={750} open={open}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
className={cn(className)}
style={style}
side={side}
sideOffset={sideOffset}
>
{title}
</TooltipContent>
</ShadcnTooltip>
);
}