Update Next.js configuration and package dependencies
All checks were successful
Deploy / deploy (push) Successful in 9m29s
All checks were successful
Deploy / deploy (push) Successful in 9m29s
- Modified redirects in next.config.ts to change the destination for "/sohbet" to "/listem". - Added new dependencies in package.json: marked, motion, react-markdown, remark-breaks, remark-gfm, shiki, and use-stick-to-bottom. - Updated pnpm-lock.yaml to reflect the new package versions and dependencies. - Removed obsolete stack files for Flutter, Nuxt, Nuxt.js, React Native, and Svelte from the UI/UX Pro Max skill data. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
112
src/components/ui/avatar.tsx
Normal file
112
src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
67
src/components/ui/chat-container.tsx
Normal file
67
src/components/ui/chat-container.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { StickToBottom } from "use-stick-to-bottom"
|
||||
|
||||
export type ChatContainerRootProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export type ChatContainerContentProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export type ChatContainerScrollAnchorProps = {
|
||||
className?: string
|
||||
ref?: React.RefObject<HTMLDivElement>
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
function ChatContainerRoot({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ChatContainerRootProps) {
|
||||
return (
|
||||
<StickToBottom
|
||||
className={cn("flex overflow-y-auto", className)}
|
||||
resize="smooth"
|
||||
initial="instant"
|
||||
role="log"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StickToBottom>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatContainerContent({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ChatContainerContentProps) {
|
||||
return (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex w-full flex-col", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</StickToBottom.Content>
|
||||
)
|
||||
}
|
||||
|
||||
function ChatContainerScrollAnchor({
|
||||
className,
|
||||
...props
|
||||
}: ChatContainerScrollAnchorProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("h-px w-full shrink-0 scroll-mt-4", className)}
|
||||
aria-hidden="true"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { ChatContainerRoot, ChatContainerContent, ChatContainerScrollAnchor }
|
||||
94
src/components/ui/code-block.tsx
Normal file
94
src/components/ui/code-block.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { codeToHtml } from "shiki"
|
||||
|
||||
export type CodeBlockProps = {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
} & React.HTMLProps<HTMLDivElement>
|
||||
|
||||
function CodeBlock({ children, className, ...props }: CodeBlockProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"not-prose flex w-full flex-col overflow-clip border",
|
||||
"border-border bg-card text-card-foreground rounded-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type CodeBlockCodeProps = {
|
||||
code: string
|
||||
language?: string
|
||||
theme?: string
|
||||
className?: string
|
||||
} & React.HTMLProps<HTMLDivElement>
|
||||
|
||||
function CodeBlockCode({
|
||||
code,
|
||||
language = "tsx",
|
||||
theme = "github-light",
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCodeProps) {
|
||||
const [highlightedHtml, setHighlightedHtml] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
async function highlight() {
|
||||
if (!code) {
|
||||
setHighlightedHtml("<pre><code></code></pre>")
|
||||
return
|
||||
}
|
||||
|
||||
const html = await codeToHtml(code, { lang: language, theme })
|
||||
setHighlightedHtml(html)
|
||||
}
|
||||
highlight()
|
||||
}, [code, language, theme])
|
||||
|
||||
const classNames = cn(
|
||||
"w-full overflow-x-auto text-[13px] [&>pre]:px-4 [&>pre]:py-4",
|
||||
className
|
||||
)
|
||||
|
||||
// SSR fallback: render plain code if not hydrated yet
|
||||
return highlightedHtml ? (
|
||||
<div
|
||||
className={classNames}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
<div className={classNames} {...props}>
|
||||
<pre>
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type CodeBlockGroupProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
function CodeBlockGroup({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockGroupProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { CodeBlockGroup, CodeBlockCode, CodeBlock }
|
||||
499
src/components/ui/loader.tsx
Normal file
499
src/components/ui/loader.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
"use client"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import React from "react"
|
||||
|
||||
export interface LoaderProps {
|
||||
variant?:
|
||||
| "circular"
|
||||
| "classic"
|
||||
| "pulse"
|
||||
| "pulse-dot"
|
||||
| "dots"
|
||||
| "typing"
|
||||
| "wave"
|
||||
| "bars"
|
||||
| "terminal"
|
||||
| "text-blink"
|
||||
| "text-shimmer"
|
||||
| "loading-dots"
|
||||
size?: "sm" | "md" | "lg"
|
||||
text?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function CircularLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const sizeClasses = {
|
||||
sm: "size-4",
|
||||
md: "size-5",
|
||||
lg: "size-6",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-primary animate-spin rounded-full border-2 border-t-transparent",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ClassicLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const sizeClasses = {
|
||||
sm: "size-4",
|
||||
md: "size-5",
|
||||
lg: "size-6",
|
||||
}
|
||||
|
||||
const barSizes = {
|
||||
sm: { height: "6px", width: "1.5px" },
|
||||
md: { height: "8px", width: "2px" },
|
||||
lg: { height: "10px", width: "2.5px" },
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative", sizeClasses[size], className)}>
|
||||
<div className="absolute h-full w-full">
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="bg-primary absolute animate-[spinner-fade_1.2s_linear_infinite] rounded-full"
|
||||
style={{
|
||||
top: "0",
|
||||
left: "50%",
|
||||
marginLeft:
|
||||
size === "sm" ? "-0.75px" : size === "lg" ? "-1.25px" : "-1px",
|
||||
transformOrigin: `${size === "sm" ? "0.75px" : size === "lg" ? "1.25px" : "1px"} ${size === "sm" ? "10px" : size === "lg" ? "14px" : "12px"}`,
|
||||
transform: `rotate(${i * 30}deg)`,
|
||||
opacity: 0,
|
||||
animationDelay: `${i * 0.1}s`,
|
||||
height: barSizes[size].height,
|
||||
width: barSizes[size].width,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PulseLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const sizeClasses = {
|
||||
sm: "size-4",
|
||||
md: "size-5",
|
||||
lg: "size-6",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("relative", sizeClasses[size], className)}>
|
||||
<div className="border-primary absolute inset-0 animate-[thin-pulse_1.5s_ease-in-out_infinite] rounded-full border-2" />
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function PulseDotLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const sizeClasses = {
|
||||
sm: "size-1",
|
||||
md: "size-2",
|
||||
lg: "size-3",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-primary animate-[pulse-dot_1.2s_ease-in-out_infinite] rounded-full",
|
||||
sizeClasses[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function DotsLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const dotSizes = {
|
||||
sm: "h-1.5 w-1.5",
|
||||
md: "h-2 w-2",
|
||||
lg: "h-2.5 w-2.5",
|
||||
}
|
||||
|
||||
const containerSizes = {
|
||||
sm: "h-4",
|
||||
md: "h-5",
|
||||
lg: "h-6",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-1",
|
||||
containerSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"bg-primary animate-[bounce-dots_1.4s_ease-in-out_infinite] rounded-full",
|
||||
dotSizes[size]
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${i * 160}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TypingLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const dotSizes = {
|
||||
sm: "h-1 w-1",
|
||||
md: "h-1.5 w-1.5",
|
||||
lg: "h-2 w-2",
|
||||
}
|
||||
|
||||
const containerSizes = {
|
||||
sm: "h-4",
|
||||
md: "h-5",
|
||||
lg: "h-6",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-1",
|
||||
containerSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"bg-primary animate-[typing_1s_infinite] rounded-full",
|
||||
dotSizes[size]
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${i * 250}ms`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WaveLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const barWidths = {
|
||||
sm: "w-0.5",
|
||||
md: "w-0.5",
|
||||
lg: "w-1",
|
||||
}
|
||||
|
||||
const containerSizes = {
|
||||
sm: "h-4",
|
||||
md: "h-5",
|
||||
lg: "h-6",
|
||||
}
|
||||
|
||||
const heights = {
|
||||
sm: ["6px", "9px", "12px", "9px", "6px"],
|
||||
md: ["8px", "12px", "16px", "12px", "8px"],
|
||||
lg: ["10px", "15px", "20px", "15px", "10px"],
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-0.5",
|
||||
containerSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"bg-primary animate-[wave_1s_ease-in-out_infinite] rounded-full",
|
||||
barWidths[size]
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${i * 100}ms`,
|
||||
height: heights[size][i],
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function BarsLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const barWidths = {
|
||||
sm: "w-1",
|
||||
md: "w-1.5",
|
||||
lg: "w-2",
|
||||
}
|
||||
|
||||
const containerSizes = {
|
||||
sm: "h-4 gap-1",
|
||||
md: "h-5 gap-1.5",
|
||||
lg: "h-6 gap-2",
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex", containerSizes[size], className)}>
|
||||
{[...Array(3)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
"bg-primary h-full animate-[wave-bars_1.2s_ease-in-out_infinite]",
|
||||
barWidths[size]
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${i * 0.2}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TerminalLoader({
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const cursorSizes = {
|
||||
sm: "h-3 w-1.5",
|
||||
md: "h-4 w-2",
|
||||
lg: "h-5 w-2.5",
|
||||
}
|
||||
|
||||
const textSizes = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
}
|
||||
|
||||
const containerSizes = {
|
||||
sm: "h-4",
|
||||
md: "h-5",
|
||||
lg: "h-6",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center space-x-1",
|
||||
containerSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className={cn("text-primary font-mono", textSizes[size])}>
|
||||
{">"}
|
||||
</span>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-primary animate-[blink_1s_step-end_infinite]",
|
||||
cursorSizes[size]
|
||||
)}
|
||||
/>
|
||||
<span className="sr-only">Loading</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextBlinkLoader({
|
||||
text = "Thinking",
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
text?: string
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const textSizes = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"animate-[text-blink_2s_ease-in-out_infinite] font-medium",
|
||||
textSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextShimmerLoader({
|
||||
text = "Thinking",
|
||||
className,
|
||||
size = "md",
|
||||
}: {
|
||||
text?: string
|
||||
className?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const textSizes = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-[linear-gradient(to_right,var(--muted-foreground)_40%,var(--foreground)_60%,var(--muted-foreground)_80%)]",
|
||||
"bg-size-[200%_auto] bg-clip-text font-medium text-transparent",
|
||||
"animate-[shimmer_4s_infinite_linear]",
|
||||
textSizes[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function TextDotsLoader({
|
||||
className,
|
||||
text = "Thinking",
|
||||
size = "md",
|
||||
}: {
|
||||
className?: string
|
||||
text?: string
|
||||
size?: "sm" | "md" | "lg"
|
||||
}) {
|
||||
const textSizes = {
|
||||
sm: "text-xs",
|
||||
md: "text-sm",
|
||||
lg: "text-base",
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("inline-flex items-center", className)}
|
||||
>
|
||||
<span className={cn("text-primary font-medium", textSizes[size])}>
|
||||
{text}
|
||||
</span>
|
||||
<span className="inline-flex">
|
||||
<span className="text-primary animate-[loading-dots_1.4s_infinite_0.2s]">
|
||||
.
|
||||
</span>
|
||||
<span className="text-primary animate-[loading-dots_1.4s_infinite_0.4s]">
|
||||
.
|
||||
</span>
|
||||
<span className="text-primary animate-[loading-dots_1.4s_infinite_0.6s]">
|
||||
.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Loader({
|
||||
variant = "circular",
|
||||
size = "md",
|
||||
text,
|
||||
className,
|
||||
}: LoaderProps) {
|
||||
switch (variant) {
|
||||
case "circular":
|
||||
return <CircularLoader size={size} className={className} />
|
||||
case "classic":
|
||||
return <ClassicLoader size={size} className={className} />
|
||||
case "pulse":
|
||||
return <PulseLoader size={size} className={className} />
|
||||
case "pulse-dot":
|
||||
return <PulseDotLoader size={size} className={className} />
|
||||
case "dots":
|
||||
return <DotsLoader size={size} className={className} />
|
||||
case "typing":
|
||||
return <TypingLoader size={size} className={className} />
|
||||
case "wave":
|
||||
return <WaveLoader size={size} className={className} />
|
||||
case "bars":
|
||||
return <BarsLoader size={size} className={className} />
|
||||
case "terminal":
|
||||
return <TerminalLoader size={size} className={className} />
|
||||
case "text-blink":
|
||||
return <TextBlinkLoader text={text} size={size} className={className} />
|
||||
case "text-shimmer":
|
||||
return <TextShimmerLoader text={text} size={size} className={className} />
|
||||
case "loading-dots":
|
||||
return <TextDotsLoader text={text} size={size} className={className} />
|
||||
default:
|
||||
return <CircularLoader size={size} className={className} />
|
||||
}
|
||||
}
|
||||
|
||||
export { Loader }
|
||||
110
src/components/ui/markdown.tsx
Normal file
110
src/components/ui/markdown.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
import { marked } from "marked"
|
||||
import { memo, useId, useMemo } from "react"
|
||||
import ReactMarkdown, { Components } from "react-markdown"
|
||||
import remarkBreaks from "remark-breaks"
|
||||
import remarkGfm from "remark-gfm"
|
||||
import { CodeBlock, CodeBlockCode } from "./code-block"
|
||||
|
||||
export type MarkdownProps = {
|
||||
children: string
|
||||
id?: string
|
||||
className?: string
|
||||
components?: Partial<Components>
|
||||
}
|
||||
|
||||
function parseMarkdownIntoBlocks(markdown: string): string[] {
|
||||
const tokens = marked.lexer(markdown)
|
||||
return tokens.map((token) => token.raw)
|
||||
}
|
||||
|
||||
function extractLanguage(className?: string): string {
|
||||
if (!className) return "plaintext"
|
||||
const match = className.match(/language-(\w+)/)
|
||||
return match ? match[1] : "plaintext"
|
||||
}
|
||||
|
||||
const INITIAL_COMPONENTS: Partial<Components> = {
|
||||
code: function CodeComponent({ className, children, ...props }) {
|
||||
const isInline =
|
||||
!props.node?.position?.start.line ||
|
||||
props.node?.position?.start.line === props.node?.position?.end.line
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"bg-primary-foreground rounded-sm px-1 font-mono text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
const language = extractLanguage(className)
|
||||
|
||||
return (
|
||||
<CodeBlock className={className}>
|
||||
<CodeBlockCode code={children as string} language={language} />
|
||||
</CodeBlock>
|
||||
)
|
||||
},
|
||||
pre: function PreComponent({ children }) {
|
||||
return <>{children}</>
|
||||
},
|
||||
}
|
||||
|
||||
const MemoizedMarkdownBlock = memo(
|
||||
function MarkdownBlock({
|
||||
content,
|
||||
components = INITIAL_COMPONENTS,
|
||||
}: {
|
||||
content: string
|
||||
components?: Partial<Components>
|
||||
}) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
components={components}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
)
|
||||
},
|
||||
function propsAreEqual(prevProps, nextProps) {
|
||||
return prevProps.content === nextProps.content
|
||||
}
|
||||
)
|
||||
|
||||
MemoizedMarkdownBlock.displayName = "MemoizedMarkdownBlock"
|
||||
|
||||
function MarkdownComponent({
|
||||
children,
|
||||
id,
|
||||
className,
|
||||
components = INITIAL_COMPONENTS,
|
||||
}: MarkdownProps) {
|
||||
const generatedId = useId()
|
||||
const blockId = id ?? generatedId
|
||||
const blocks = useMemo(() => parseMarkdownIntoBlocks(children), [children])
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{blocks.map((block, index) => (
|
||||
<MemoizedMarkdownBlock
|
||||
key={`${blockId}-block-${index}`}
|
||||
content={block}
|
||||
components={components}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Markdown = memo(MarkdownComponent)
|
||||
Markdown.displayName = "Markdown"
|
||||
|
||||
export { Markdown }
|
||||
120
src/components/ui/message.tsx
Normal file
120
src/components/ui/message.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Markdown } from "./markdown"
|
||||
|
||||
export type MessageProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
} & React.HTMLProps<HTMLDivElement>
|
||||
|
||||
const Message = ({ children, className, ...props }: MessageProps) => (
|
||||
<div className={cn("flex gap-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type MessageAvatarProps = {
|
||||
src: string
|
||||
alt: string
|
||||
fallback?: string
|
||||
delayMs?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const MessageAvatar = ({
|
||||
src,
|
||||
alt,
|
||||
fallback,
|
||||
delayMs,
|
||||
className,
|
||||
}: MessageAvatarProps) => {
|
||||
return (
|
||||
<Avatar className={cn("h-8 w-8 shrink-0", className)}>
|
||||
<AvatarImage src={src} alt={alt} />
|
||||
{fallback && (
|
||||
<AvatarFallback delayMs={delayMs}>{fallback}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
)
|
||||
}
|
||||
|
||||
export type MessageContentProps = {
|
||||
children: React.ReactNode
|
||||
markdown?: boolean
|
||||
className?: string
|
||||
} & React.ComponentProps<typeof Markdown> &
|
||||
React.HTMLProps<HTMLDivElement>
|
||||
|
||||
const MessageContent = ({
|
||||
children,
|
||||
markdown = false,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => {
|
||||
const classNames = cn(
|
||||
"rounded-lg p-2 text-foreground bg-secondary prose break-words whitespace-normal",
|
||||
className
|
||||
)
|
||||
|
||||
return markdown ? (
|
||||
<Markdown className={classNames} {...props}>
|
||||
{children as string}
|
||||
</Markdown>
|
||||
) : (
|
||||
<div className={classNames} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type MessageActionsProps = {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
} & React.HTMLProps<HTMLDivElement>
|
||||
|
||||
const MessageActions = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div
|
||||
className={cn("text-muted-foreground flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
export type MessageActionProps = {
|
||||
className?: string
|
||||
tooltip: React.ReactNode
|
||||
children: React.ReactNode
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
} & React.ComponentProps<typeof Tooltip>
|
||||
|
||||
const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
className,
|
||||
side = "top",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip {...props}>
|
||||
<TooltipTrigger asChild>{children}</TooltipTrigger>
|
||||
<TooltipContent side={side} className={className}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export { Message, MessageAvatar, MessageContent, MessageActions, MessageAction }
|
||||
233
src/components/ui/prompt-input.tsx
Normal file
233
src/components/ui/prompt-input.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
"use client"
|
||||
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip"
|
||||
import { cn } from "@/lib/utils"
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
|
||||
type PromptInputContextType = {
|
||||
isLoading: boolean
|
||||
value: string
|
||||
setValue: (value: string) => void
|
||||
maxHeight: number | string
|
||||
onSubmit?: () => void
|
||||
disabled?: boolean
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
||||
}
|
||||
|
||||
const PromptInputContext = createContext<PromptInputContextType>({
|
||||
isLoading: false,
|
||||
value: "",
|
||||
setValue: () => {},
|
||||
maxHeight: 240,
|
||||
onSubmit: undefined,
|
||||
disabled: false,
|
||||
textareaRef: React.createRef<HTMLTextAreaElement>(),
|
||||
})
|
||||
|
||||
function usePromptInput() {
|
||||
return useContext(PromptInputContext)
|
||||
}
|
||||
|
||||
export type PromptInputProps = {
|
||||
isLoading?: boolean
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
maxHeight?: number | string
|
||||
onSubmit?: () => void
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
} & React.ComponentProps<"div">
|
||||
|
||||
function PromptInput({
|
||||
className,
|
||||
isLoading = false,
|
||||
maxHeight = 240,
|
||||
value,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
children,
|
||||
disabled = false,
|
||||
onClick,
|
||||
...props
|
||||
}: PromptInputProps) {
|
||||
const [internalValue, setInternalValue] = useState(value || "")
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
setInternalValue(newValue)
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
const handleClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
|
||||
if (!disabled) textareaRef.current?.focus()
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<PromptInputContext.Provider
|
||||
value={{
|
||||
isLoading,
|
||||
value: value ?? internalValue,
|
||||
setValue: onValueChange ?? handleChange,
|
||||
maxHeight,
|
||||
onSubmit,
|
||||
disabled,
|
||||
textareaRef,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"border-input bg-background cursor-text rounded-3xl border p-2 shadow-xs",
|
||||
disabled && "cursor-not-allowed opacity-60",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</PromptInputContext.Provider>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export type PromptInputTextareaProps = {
|
||||
disableAutosize?: boolean
|
||||
} & React.ComponentProps<typeof Textarea>
|
||||
|
||||
function PromptInputTextarea({
|
||||
className,
|
||||
onKeyDown,
|
||||
disableAutosize = false,
|
||||
...props
|
||||
}: PromptInputTextareaProps) {
|
||||
const { value, setValue, maxHeight, onSubmit, disabled, textareaRef } =
|
||||
usePromptInput()
|
||||
|
||||
const adjustHeight = (el: HTMLTextAreaElement | null) => {
|
||||
if (!el || disableAutosize) return
|
||||
|
||||
el.style.height = "auto"
|
||||
|
||||
if (typeof maxHeight === "number") {
|
||||
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
|
||||
} else {
|
||||
el.style.height = `min(${el.scrollHeight}px, ${maxHeight})`
|
||||
}
|
||||
}
|
||||
|
||||
const handleRef = (el: HTMLTextAreaElement | null) => {
|
||||
textareaRef.current = el
|
||||
adjustHeight(el)
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!textareaRef.current || disableAutosize) return
|
||||
|
||||
const el = textareaRef.current
|
||||
el.style.height = "auto"
|
||||
|
||||
if (typeof maxHeight === "number") {
|
||||
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`
|
||||
} else {
|
||||
el.style.height = `min(${el.scrollHeight}px, ${maxHeight})`
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [value, maxHeight, disableAutosize])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
adjustHeight(e.target)
|
||||
setValue(e.target.value)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
onSubmit?.()
|
||||
}
|
||||
onKeyDown?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
ref={handleRef}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(
|
||||
"text-primary min-h-[44px] w-full resize-none border-none bg-transparent shadow-none outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
className
|
||||
)}
|
||||
rows={1}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export type PromptInputActionsProps = React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
function PromptInputActions({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: PromptInputActionsProps) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export type PromptInputActionProps = {
|
||||
className?: string
|
||||
tooltip: React.ReactNode
|
||||
children: React.ReactNode
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
} & React.ComponentProps<typeof Tooltip>
|
||||
|
||||
function PromptInputAction({
|
||||
tooltip,
|
||||
children,
|
||||
className,
|
||||
side = "top",
|
||||
...props
|
||||
}: PromptInputActionProps) {
|
||||
const { disabled } = usePromptInput()
|
||||
|
||||
return (
|
||||
<Tooltip {...props}>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
disabled={disabled}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side} className={className}>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
PromptInput,
|
||||
PromptInputTextarea,
|
||||
PromptInputActions,
|
||||
PromptInputAction,
|
||||
}
|
||||
117
src/components/ui/prompt-suggestion.tsx
Normal file
117
src/components/ui/prompt-suggestion.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { VariantProps } from "class-variance-authority"
|
||||
|
||||
export type PromptSuggestionProps = {
|
||||
children: React.ReactNode
|
||||
variant?: VariantProps<typeof buttonVariants>["variant"]
|
||||
size?: VariantProps<typeof buttonVariants>["size"]
|
||||
className?: string
|
||||
highlight?: string
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
function PromptSuggestion({
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
className,
|
||||
highlight,
|
||||
...props
|
||||
}: PromptSuggestionProps) {
|
||||
const isHighlightMode = highlight !== undefined && highlight.trim() !== ""
|
||||
const content = typeof children === "string" ? children : ""
|
||||
|
||||
if (!isHighlightMode) {
|
||||
return (
|
||||
<Button
|
||||
variant={variant || "outline"}
|
||||
size={size || "lg"}
|
||||
className={cn("rounded-full", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return (
|
||||
<Button
|
||||
variant={variant || "ghost"}
|
||||
size={size || "sm"}
|
||||
className={cn(
|
||||
"w-full cursor-pointer justify-start rounded-xl py-2",
|
||||
"hover:bg-accent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const trimmedHighlight = highlight.trim()
|
||||
const contentLower = content.toLowerCase()
|
||||
const highlightLower = trimmedHighlight.toLowerCase()
|
||||
const shouldHighlight = contentLower.includes(highlightLower)
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant || "ghost"}
|
||||
size={size || "sm"}
|
||||
className={cn(
|
||||
"w-full cursor-pointer justify-start gap-0 rounded-xl py-2",
|
||||
"hover:bg-accent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{shouldHighlight ? (
|
||||
(() => {
|
||||
const index = contentLower.indexOf(highlightLower)
|
||||
if (index === -1)
|
||||
return (
|
||||
<span className="text-muted-foreground whitespace-pre-wrap">
|
||||
{content}
|
||||
</span>
|
||||
)
|
||||
|
||||
const actualHighlightedText = content.substring(
|
||||
index,
|
||||
index + highlightLower.length
|
||||
)
|
||||
|
||||
const before = content.substring(0, index)
|
||||
const after = content.substring(index + actualHighlightedText.length)
|
||||
|
||||
return (
|
||||
<>
|
||||
{before && (
|
||||
<span className="text-muted-foreground whitespace-pre-wrap">
|
||||
{before}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-primary font-medium whitespace-pre-wrap">
|
||||
{actualHighlightedText}
|
||||
</span>
|
||||
{after && (
|
||||
<span className="text-muted-foreground whitespace-pre-wrap">
|
||||
{after}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})()
|
||||
) : (
|
||||
<span className="text-muted-foreground whitespace-pre-wrap">
|
||||
{content}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { PromptSuggestion }
|
||||
42
src/components/ui/scroll-button.tsx
Normal file
42
src/components/ui/scroll-button.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import { ChevronDown } from "lucide-react"
|
||||
import { useStickToBottomContext } from "use-stick-to-bottom"
|
||||
|
||||
export type ScrollButtonProps = {
|
||||
className?: string
|
||||
variant?: VariantProps<typeof buttonVariants>["variant"]
|
||||
size?: VariantProps<typeof buttonVariants>["size"]
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
function ScrollButton({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
...props
|
||||
}: ScrollButtonProps) {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext()
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-full transition-all duration-150 ease-out",
|
||||
!isAtBottom
|
||||
? "translate-y-0 scale-100 opacity-100"
|
||||
: "pointer-events-none translate-y-4 scale-95 opacity-0",
|
||||
className
|
||||
)}
|
||||
onClick={() => scrollToBottom()}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollButton }
|
||||
18
src/components/ui/textarea.tsx
Normal file
18
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
57
src/components/ui/tooltip.tsx
Normal file
57
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Tooltip as TooltipPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 inline-flex w-fit max-w-xs origin-(--radix-tooltip-content-transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger }
|
||||
236
src/components/ui/typing-animation.tsx
Normal file
236
src/components/ui/typing-animation.tsx
Normal file
@@ -0,0 +1,236 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type RefAttributes,
|
||||
type RefObject,
|
||||
} from "react"
|
||||
import {
|
||||
motion,
|
||||
useInView,
|
||||
type DOMMotionComponents,
|
||||
type HTMLMotionProps,
|
||||
type MotionProps,
|
||||
} from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const motionElements = {
|
||||
article: motion.article,
|
||||
div: motion.div,
|
||||
h1: motion.h1,
|
||||
h2: motion.h2,
|
||||
h3: motion.h3,
|
||||
h4: motion.h4,
|
||||
h5: motion.h5,
|
||||
h6: motion.h6,
|
||||
li: motion.li,
|
||||
p: motion.p,
|
||||
section: motion.section,
|
||||
span: motion.span,
|
||||
} as const
|
||||
|
||||
type MotionElementType = Extract<
|
||||
keyof DOMMotionComponents,
|
||||
keyof typeof motionElements
|
||||
>
|
||||
type TypingAnimationMotionComponent = ComponentType<
|
||||
Omit<HTMLMotionProps<"span">, "ref"> & RefAttributes<HTMLElement>
|
||||
>
|
||||
|
||||
interface TypingAnimationProps extends Omit<MotionProps, "children"> {
|
||||
children?: string
|
||||
words?: string[]
|
||||
className?: string
|
||||
duration?: number
|
||||
typeSpeed?: number
|
||||
deleteSpeed?: number
|
||||
delay?: number
|
||||
pauseDelay?: number
|
||||
loop?: boolean
|
||||
as?: MotionElementType
|
||||
startOnView?: boolean
|
||||
showCursor?: boolean
|
||||
blinkCursor?: boolean
|
||||
cursorStyle?: "line" | "block" | "underscore"
|
||||
}
|
||||
|
||||
export function TypingAnimation({
|
||||
children,
|
||||
words,
|
||||
className,
|
||||
duration = 100,
|
||||
typeSpeed,
|
||||
deleteSpeed,
|
||||
delay = 0,
|
||||
pauseDelay = 1000,
|
||||
loop = false,
|
||||
as: Component = "span",
|
||||
startOnView = true,
|
||||
showCursor = true,
|
||||
blinkCursor = true,
|
||||
cursorStyle = "line",
|
||||
...props
|
||||
}: TypingAnimationProps) {
|
||||
const MotionComponent = motionElements[
|
||||
Component
|
||||
] as TypingAnimationMotionComponent
|
||||
|
||||
const [displayedText, setDisplayedText] = useState<string>("")
|
||||
const [currentWordIndex, setCurrentWordIndex] = useState(0)
|
||||
const [currentCharIndex, setCurrentCharIndex] = useState(0)
|
||||
const [phase, setPhase] = useState<"typing" | "pause" | "deleting">("typing")
|
||||
const elementRef = useRef<HTMLElement | null>(null)
|
||||
const isInView = useInView(elementRef as RefObject<Element>, {
|
||||
amount: 0.3,
|
||||
once: true,
|
||||
})
|
||||
|
||||
const wordsToAnimate = useMemo(
|
||||
() => words ?? (children ? [children] : []),
|
||||
[words, children]
|
||||
)
|
||||
const hasMultipleWords = wordsToAnimate.length > 1
|
||||
|
||||
const typingSpeed = typeSpeed ?? duration
|
||||
const deletingSpeed = deleteSpeed ?? typingSpeed / 2
|
||||
|
||||
const shouldStart = startOnView ? isInView : true
|
||||
const animationSourceKey = useMemo(
|
||||
() => (words ? words.join("\u0000") : (children ?? "")),
|
||||
[words, children]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayedText("")
|
||||
setCurrentWordIndex(0)
|
||||
setCurrentCharIndex(0)
|
||||
setPhase("typing")
|
||||
}, [animationSourceKey])
|
||||
|
||||
useEffect(() => {
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
if (shouldStart && wordsToAnimate.length > 0) {
|
||||
const timeoutDelay =
|
||||
delay > 0 && displayedText === ""
|
||||
? delay
|
||||
: phase === "typing"
|
||||
? typingSpeed
|
||||
: phase === "deleting"
|
||||
? deletingSpeed
|
||||
: pauseDelay
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
const currentWord = wordsToAnimate[currentWordIndex] || ""
|
||||
const graphemes = Array.from(currentWord)
|
||||
|
||||
switch (phase) {
|
||||
case "typing":
|
||||
if (currentCharIndex < graphemes.length) {
|
||||
setDisplayedText(
|
||||
graphemes.slice(0, currentCharIndex + 1).join("")
|
||||
)
|
||||
setCurrentCharIndex(currentCharIndex + 1)
|
||||
} else {
|
||||
if (hasMultipleWords || loop) {
|
||||
const isLastWord =
|
||||
currentWordIndex === wordsToAnimate.length - 1
|
||||
if (!isLastWord || loop) {
|
||||
setPhase("pause")
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case "pause":
|
||||
setPhase("deleting")
|
||||
break
|
||||
|
||||
case "deleting":
|
||||
if (currentCharIndex > 0) {
|
||||
setDisplayedText(
|
||||
graphemes.slice(0, currentCharIndex - 1).join("")
|
||||
)
|
||||
setCurrentCharIndex(currentCharIndex - 1)
|
||||
} else {
|
||||
const nextIndex = (currentWordIndex + 1) % wordsToAnimate.length
|
||||
setCurrentWordIndex(nextIndex)
|
||||
setPhase("typing")
|
||||
}
|
||||
break
|
||||
}
|
||||
}, timeoutDelay)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timeout !== null) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
shouldStart,
|
||||
phase,
|
||||
currentCharIndex,
|
||||
currentWordIndex,
|
||||
displayedText,
|
||||
wordsToAnimate,
|
||||
hasMultipleWords,
|
||||
loop,
|
||||
typingSpeed,
|
||||
deletingSpeed,
|
||||
pauseDelay,
|
||||
delay,
|
||||
])
|
||||
|
||||
const currentWordGraphemes = Array.from(
|
||||
wordsToAnimate[currentWordIndex] || ""
|
||||
)
|
||||
const isComplete =
|
||||
!loop &&
|
||||
currentWordIndex === wordsToAnimate.length - 1 &&
|
||||
currentCharIndex >= currentWordGraphemes.length &&
|
||||
phase !== "deleting"
|
||||
|
||||
const shouldShowCursor =
|
||||
showCursor &&
|
||||
!isComplete &&
|
||||
(hasMultipleWords || loop || currentCharIndex < currentWordGraphemes.length)
|
||||
|
||||
const getCursorChar = () => {
|
||||
switch (cursorStyle) {
|
||||
case "block":
|
||||
return "▌"
|
||||
case "underscore":
|
||||
return "_"
|
||||
case "line":
|
||||
default:
|
||||
return "|"
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
ref={elementRef}
|
||||
className={cn(
|
||||
"leading-20 tracking-[-0.02em]",
|
||||
Component === "span" && "inline-block",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{displayedText}
|
||||
{shouldShowCursor && (
|
||||
<span
|
||||
className={cn("inline-block", blinkCursor && "animate-blink-cursor")}
|
||||
>
|
||||
{getCursorChar()}
|
||||
</span>
|
||||
)}
|
||||
</MotionComponent>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user