Initial project import
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
import { flushSync } from "react-dom"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export type TransitionVariant =
|
||||
| "circle"
|
||||
| "square"
|
||||
| "triangle"
|
||||
| "diamond"
|
||||
| "hexagon"
|
||||
| "rectangle"
|
||||
| "star"
|
||||
|
||||
interface AnimatedThemeTogglerProps extends React.ComponentPropsWithoutRef<"button"> {
|
||||
duration?: number
|
||||
variant?: TransitionVariant
|
||||
/** When true, the transition expands from the viewport center instead of the button center. */
|
||||
fromCenter?: boolean
|
||||
}
|
||||
|
||||
function polygonCollapsed(cx: number, cy: number, vertexCount: number): string {
|
||||
const pairs = Array.from(
|
||||
{ length: vertexCount },
|
||||
() => `${cx}px ${cy}px`
|
||||
).join(", ")
|
||||
return `polygon(${pairs})`
|
||||
}
|
||||
|
||||
function getThemeTransitionClipPaths(
|
||||
variant: TransitionVariant,
|
||||
cx: number,
|
||||
cy: number,
|
||||
maxRadius: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number
|
||||
): [string, string] {
|
||||
switch (variant) {
|
||||
case "circle":
|
||||
return [
|
||||
`circle(0px at ${cx}px ${cy}px)`,
|
||||
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
|
||||
]
|
||||
case "square": {
|
||||
const halfW = Math.max(cx, viewportWidth - cx)
|
||||
const halfH = Math.max(cy, viewportHeight - cy)
|
||||
const halfSide = Math.max(halfW, halfH) * 1.05
|
||||
const end = [
|
||||
`${cx - halfSide}px ${cy - halfSide}px`,
|
||||
`${cx + halfSide}px ${cy - halfSide}px`,
|
||||
`${cx + halfSide}px ${cy + halfSide}px`,
|
||||
`${cx - halfSide}px ${cy + halfSide}px`,
|
||||
].join(", ")
|
||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
|
||||
}
|
||||
case "triangle": {
|
||||
const scale = maxRadius * 2.2
|
||||
const dx = (Math.sqrt(3) / 2) * scale
|
||||
const verts = [
|
||||
`${cx}px ${cy - scale}px`,
|
||||
`${cx + dx}px ${cy + 0.5 * scale}px`,
|
||||
`${cx - dx}px ${cy + 0.5 * scale}px`,
|
||||
].join(", ")
|
||||
return [polygonCollapsed(cx, cy, 3), `polygon(${verts})`]
|
||||
}
|
||||
case "diamond": {
|
||||
// Slightly larger than the view-transition circle radius so axis-aligned coverage matches the circle reveal.
|
||||
const R = maxRadius * Math.SQRT2
|
||||
const end = [
|
||||
`${cx}px ${cy - R}px`,
|
||||
`${cx + R}px ${cy}px`,
|
||||
`${cx}px ${cy + R}px`,
|
||||
`${cx - R}px ${cy}px`,
|
||||
].join(", ")
|
||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
|
||||
}
|
||||
case "hexagon": {
|
||||
const R = maxRadius * Math.SQRT2
|
||||
const verts: string[] = []
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const a = -Math.PI / 2 + (i * Math.PI) / 3
|
||||
verts.push(`${cx + R * Math.cos(a)}px ${cy + R * Math.sin(a)}px`)
|
||||
}
|
||||
return [polygonCollapsed(cx, cy, 6), `polygon(${verts.join(", ")})`]
|
||||
}
|
||||
case "rectangle": {
|
||||
const halfW = Math.max(cx, viewportWidth - cx)
|
||||
const halfH = Math.max(cy, viewportHeight - cy)
|
||||
const end = [
|
||||
`${cx - halfW}px ${cy - halfH}px`,
|
||||
`${cx + halfW}px ${cy - halfH}px`,
|
||||
`${cx + halfW}px ${cy + halfH}px`,
|
||||
`${cx - halfW}px ${cy + halfH}px`,
|
||||
].join(", ")
|
||||
return [polygonCollapsed(cx, cy, 4), `polygon(${end})`]
|
||||
}
|
||||
case "star": {
|
||||
// Small overscan so the last frames never leave a 1px seam before the transition group ends.
|
||||
const R = maxRadius * Math.SQRT2 * 1.03
|
||||
const innerRatio = 0.42
|
||||
const starPolygon = (radius: number) => {
|
||||
const verts: string[] = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const outerA = -Math.PI / 2 + (i * 2 * Math.PI) / 5
|
||||
verts.push(
|
||||
`${cx + radius * Math.cos(outerA)}px ${cy + radius * Math.sin(outerA)}px`
|
||||
)
|
||||
const innerA = outerA + Math.PI / 5
|
||||
verts.push(
|
||||
`${cx + radius * innerRatio * Math.cos(innerA)}px ${cy + radius * innerRatio * Math.sin(innerA)}px`
|
||||
)
|
||||
}
|
||||
return `polygon(${verts.join(", ")})`
|
||||
}
|
||||
const startR = Math.max(2, R * 0.025)
|
||||
return [starPolygon(startR), starPolygon(R)]
|
||||
}
|
||||
default:
|
||||
return [
|
||||
`circle(0px at ${cx}px ${cy}px)`,
|
||||
`circle(${maxRadius}px at ${cx}px ${cy}px)`,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export const AnimatedThemeToggler = ({
|
||||
className,
|
||||
duration = 400,
|
||||
variant,
|
||||
fromCenter = false,
|
||||
...props
|
||||
}: AnimatedThemeTogglerProps) => {
|
||||
const shape = variant ?? "circle"
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const updateTheme = () => {
|
||||
setIsDark(document.documentElement.classList.contains("dark"))
|
||||
}
|
||||
|
||||
updateTheme()
|
||||
|
||||
const observer = new MutationObserver(updateTheme)
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
const button = buttonRef.current
|
||||
if (!button) return
|
||||
|
||||
const viewportWidth = window.visualViewport?.width ?? window.innerWidth
|
||||
const viewportHeight = window.visualViewport?.height ?? window.innerHeight
|
||||
|
||||
let x: number
|
||||
let y: number
|
||||
if (fromCenter) {
|
||||
x = viewportWidth / 2
|
||||
y = viewportHeight / 2
|
||||
} else {
|
||||
const { top, left, width, height } = button.getBoundingClientRect()
|
||||
x = left + width / 2
|
||||
y = top + height / 2
|
||||
}
|
||||
|
||||
const maxRadius = Math.hypot(
|
||||
Math.max(x, viewportWidth - x),
|
||||
Math.max(y, viewportHeight - y)
|
||||
)
|
||||
|
||||
const applyTheme = () => {
|
||||
const newTheme = !isDark
|
||||
setIsDark(newTheme)
|
||||
document.documentElement.classList.toggle("dark")
|
||||
document.documentElement.style.colorScheme = newTheme ? "dark" : "light"
|
||||
localStorage.setItem("chatgpt2api-theme", newTheme ? "dark" : "light")
|
||||
}
|
||||
|
||||
if (typeof document.startViewTransition !== "function") {
|
||||
applyTheme()
|
||||
return
|
||||
}
|
||||
|
||||
const clipPath = getThemeTransitionClipPaths(
|
||||
shape,
|
||||
x,
|
||||
y,
|
||||
maxRadius,
|
||||
viewportWidth,
|
||||
viewportHeight
|
||||
)
|
||||
|
||||
const root = document.documentElement
|
||||
root.dataset.magicuiThemeVt = "active"
|
||||
root.style.setProperty(
|
||||
"--magicui-theme-toggle-vt-duration",
|
||||
`${duration}ms`
|
||||
)
|
||||
// Pin the collapsed clip-path via CSS so Firefox does not paint the new
|
||||
// theme unclipped between snapshot and the ready.then() JS animation.
|
||||
root.style.setProperty("--magicui-theme-vt-clip-from", clipPath[0])
|
||||
const cleanup = () => {
|
||||
delete root.dataset.magicuiThemeVt
|
||||
root.style.removeProperty("--magicui-theme-toggle-vt-duration")
|
||||
root.style.removeProperty("--magicui-theme-vt-clip-from")
|
||||
}
|
||||
|
||||
const transition = document.startViewTransition(() => {
|
||||
flushSync(applyTheme)
|
||||
})
|
||||
if (typeof transition?.finished?.finally === "function") {
|
||||
transition.finished.finally(cleanup)
|
||||
} else {
|
||||
cleanup()
|
||||
}
|
||||
|
||||
const ready = transition?.ready
|
||||
if (ready && typeof ready.then === "function") {
|
||||
ready.then(() => {
|
||||
document.documentElement.animate(
|
||||
{
|
||||
clipPath,
|
||||
},
|
||||
{
|
||||
duration,
|
||||
// Star: linear avoids easing overshoot that fights polygon interpolation at t→1; VT group duration is synced above.
|
||||
easing: shape === "star" ? "linear" : "ease-in-out",
|
||||
fill: "forwards",
|
||||
pseudoElement: "::view-transition-new(root)",
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
}, [shape, fromCenter, duration, isDark])
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={buttonRef}
|
||||
onClick={toggleTheme}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
>
|
||||
{isDark ? <Sun /> : <Moon />}
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border-transparent bg-primary/10 text-primary",
|
||||
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
||||
outline: "border-border bg-background text-foreground",
|
||||
success:
|
||||
"border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-300",
|
||||
warning:
|
||||
"border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-950/30 dark:text-amber-300",
|
||||
danger:
|
||||
"border-rose-200 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/30 dark:text-rose-300",
|
||||
info: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-800 dark:bg-sky-950/30 dark:text-sky-300",
|
||||
violet:
|
||||
"border-violet-200 bg-violet-50 text-violet-700 dark:border-violet-800 dark:bg-violet-950/30 dark:text-violet-300",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return (
|
||||
<span className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -0,0 +1,46 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker>) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-1 text-sm", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col gap-4 sm:flex-row",
|
||||
month: "relative",
|
||||
month_caption: "flex h-9 items-center justify-center font-medium",
|
||||
nav: "absolute inset-x-2 top-2 flex items-center justify-between",
|
||||
button_previous: "inline-flex size-8 items-center justify-center rounded-lg hover:bg-stone-100",
|
||||
button_next: "inline-flex size-8 items-center justify-center rounded-lg hover:bg-stone-100",
|
||||
weekdays: "mt-2 grid grid-cols-7 text-xs text-stone-400",
|
||||
weekday: "flex h-8 items-center justify-center font-normal",
|
||||
week: "grid grid-cols-7",
|
||||
day: "size-9 p-0 text-center",
|
||||
day_button: "size-9 rounded-lg text-sm transition hover:bg-stone-100",
|
||||
today: "font-semibold text-stone-950",
|
||||
selected: "[&_button]:bg-stone-950 [&_button]:text-white [&_button]:hover:bg-stone-800",
|
||||
outside: "text-stone-300",
|
||||
disabled: "text-stone-300 opacity-50",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Chevron: ({ orientation }) =>
|
||||
orientation === "left" ? <ChevronLeft className="size-4" /> : <ChevronRight className="size-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Calendar };
|
||||
@@ -0,0 +1,61 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col rounded-3xl border border-white/70 shadow-[0_20px_60px_-28px_rgba(25,33,61,0.18)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn("flex flex-col gap-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Card, CardContent, CardDescription, CardHeader, CardTitle };
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
|
||||
import { Check } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:bg-input/30 size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator className="flex items-center justify-center transition-none">
|
||||
<Check className="size-3.5 text-white" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
@@ -0,0 +1,267 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react"
|
||||
import {
|
||||
animate,
|
||||
motion,
|
||||
useInView,
|
||||
useMotionValue,
|
||||
useReducedMotion,
|
||||
useTransform,
|
||||
type HTMLMotionProps,
|
||||
} from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const DEFAULT_COLORS = ["#c679c4", "#fa3d1d", "#ffb005", "#e1e1fe", "#0358f7"]
|
||||
const BAND_HALF = 17
|
||||
const SWEEP_START = -BAND_HALF
|
||||
const SWEEP_END = 100 + BAND_HALF
|
||||
|
||||
const sweepEase = (t: number) =>
|
||||
t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2
|
||||
|
||||
function buildGradient(pos: number, colors: string[], textColor: string) {
|
||||
const bandStart = pos - BAND_HALF
|
||||
const bandEnd = pos + BAND_HALF
|
||||
|
||||
if (bandStart >= 100) {
|
||||
return `linear-gradient(90deg, ${textColor}, ${textColor})`
|
||||
}
|
||||
const n = colors.length
|
||||
const parts: string[] = []
|
||||
|
||||
if (bandStart > 0)
|
||||
parts.push(`${textColor} 0%`, `${textColor} ${bandStart.toFixed(2)}%`)
|
||||
|
||||
colors.forEach((c, i) => {
|
||||
const pct = n === 1 ? pos : bandStart + (i / (n - 1)) * BAND_HALF * 2
|
||||
parts.push(`${c} ${pct.toFixed(2)}%`)
|
||||
})
|
||||
|
||||
if (bandEnd < 100)
|
||||
parts.push(`transparent ${bandEnd.toFixed(2)}%`, `transparent 100%`)
|
||||
|
||||
return `linear-gradient(90deg, ${parts.join(", ")})`
|
||||
}
|
||||
|
||||
function measureWidths(el: HTMLElement, texts: string[]) {
|
||||
const ghost = el.cloneNode() as HTMLElement
|
||||
Object.assign(ghost.style, {
|
||||
position: "absolute",
|
||||
visibility: "hidden",
|
||||
pointerEvents: "none",
|
||||
width: "auto",
|
||||
whiteSpace: "nowrap",
|
||||
})
|
||||
el.parentElement!.appendChild(ghost)
|
||||
const widths = texts.map((t) => {
|
||||
ghost.textContent = t
|
||||
return ghost.getBoundingClientRect().width
|
||||
})
|
||||
ghost.remove()
|
||||
return widths
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for {@link DiaTextReveal}.
|
||||
*/
|
||||
export interface DiaTextRevealProps extends Omit<
|
||||
HTMLMotionProps<"span">,
|
||||
"ref" | "children" | "style" | "animate" | "transition" | "color"
|
||||
> {
|
||||
/**
|
||||
* Text to reveal. Pass multiple strings to rotate when {@link DiaTextRevealProps.repeat} is `true`.
|
||||
*/
|
||||
text: string | string[]
|
||||
/**
|
||||
* Colors sampled across the moving gradient band. Defaults to a built-in palette.
|
||||
*/
|
||||
colors?: string[]
|
||||
/**
|
||||
* CSS color for revealed text after the sweep and for leading/trailing regions during the animation.
|
||||
* @defaultValue `"var(--foreground)"`
|
||||
*/
|
||||
textColor?: string
|
||||
/**
|
||||
* Duration of one sweep pass, in seconds.
|
||||
* @defaultValue `1.5`
|
||||
*/
|
||||
duration?: number
|
||||
/**
|
||||
* Delay before the sweep starts, in seconds.
|
||||
* @defaultValue `0`
|
||||
*/
|
||||
delay?: number
|
||||
/**
|
||||
* When `text` is an array, replay the sweep and advance to the next string after each completion.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
repeat?: boolean
|
||||
/**
|
||||
* Pause between cycles when {@link DiaTextRevealProps.repeat} is `true`, in seconds.
|
||||
* @defaultValue `0.5`
|
||||
*/
|
||||
repeatDelay?: number
|
||||
/**
|
||||
* If `true`, the animation starts only after the element enters the viewport.
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
startOnView?: boolean
|
||||
/**
|
||||
* Passed to `useInView`: if `true`, in-view detection fires at most once (no replay on scroll-back).
|
||||
* @defaultValue `true`
|
||||
*/
|
||||
once?: boolean
|
||||
/**
|
||||
* Additional class names for the animated `span` (e.g. typography utilities).
|
||||
*/
|
||||
className?: string
|
||||
/**
|
||||
* When `text` has multiple entries, use the widest string’s width for layout instead of animating width per line.
|
||||
* @defaultValue `false`
|
||||
*/
|
||||
fixedWidth?: boolean
|
||||
}
|
||||
|
||||
export function DiaTextReveal({
|
||||
text,
|
||||
colors = DEFAULT_COLORS,
|
||||
textColor = "var(--foreground)",
|
||||
duration = 1.5,
|
||||
delay = 0,
|
||||
repeat = false,
|
||||
repeatDelay = 0.5,
|
||||
startOnView = true,
|
||||
once = true,
|
||||
className,
|
||||
fixedWidth = false,
|
||||
...props
|
||||
}: DiaTextRevealProps) {
|
||||
const texts = useMemo(() => (Array.isArray(text) ? text : [text]), [text])
|
||||
const textKey = texts.join("\0")
|
||||
const isMulti = texts.length > 1
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
|
||||
const spanRef = useRef<HTMLSpanElement>(null)
|
||||
const optsRef = useRef({
|
||||
colors,
|
||||
textColor,
|
||||
duration,
|
||||
delay,
|
||||
repeat,
|
||||
repeatDelay,
|
||||
texts,
|
||||
})
|
||||
|
||||
const indexRef = useRef(0)
|
||||
const hasPlayedRef = useRef(false)
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const playRef = useRef<() => void>(null!)
|
||||
const stopRef = useRef<(() => void) | null>(null)
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0)
|
||||
const [measuredWidths, setMeasuredWidths] = useState<number[]>([])
|
||||
|
||||
const sweepPos = useMotionValue(SWEEP_START)
|
||||
|
||||
const backgroundImage = useTransform(sweepPos, (pos) =>
|
||||
buildGradient(pos, optsRef.current.colors, optsRef.current.textColor)
|
||||
)
|
||||
|
||||
const isInView = useInView(spanRef, { once, amount: 0.1 })
|
||||
|
||||
optsRef.current = {
|
||||
colors,
|
||||
textColor,
|
||||
duration,
|
||||
delay,
|
||||
repeat,
|
||||
repeatDelay,
|
||||
texts,
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const el = spanRef.current
|
||||
if (!el || !isMulti) return
|
||||
setMeasuredWidths(measureWidths(el, texts))
|
||||
}, [isMulti, textKey, texts])
|
||||
|
||||
useEffect(() => {
|
||||
playRef.current = () => {
|
||||
const { duration, delay, repeat, repeatDelay, texts } = optsRef.current
|
||||
|
||||
sweepPos.set(SWEEP_START)
|
||||
|
||||
const controls = animate(sweepPos, SWEEP_END, {
|
||||
duration,
|
||||
delay,
|
||||
ease: sweepEase,
|
||||
onComplete() {
|
||||
if (!repeat) return
|
||||
timerRef.current = setTimeout(() => {
|
||||
const next = (indexRef.current + 1) % texts.length
|
||||
indexRef.current = next
|
||||
setActiveIndex(next)
|
||||
playRef.current()
|
||||
}, repeatDelay * 1000)
|
||||
},
|
||||
})
|
||||
|
||||
stopRef.current = () => controls.stop()
|
||||
}
|
||||
}, [sweepPos])
|
||||
|
||||
useEffect(() => {
|
||||
if (prefersReducedMotion) {
|
||||
sweepPos.set(SWEEP_END)
|
||||
return
|
||||
}
|
||||
if (startOnView && !isInView) return
|
||||
if (once && hasPlayedRef.current) return
|
||||
hasPlayedRef.current = true
|
||||
playRef.current()
|
||||
|
||||
return () => {
|
||||
stopRef.current?.()
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
}, [isInView, startOnView, once, prefersReducedMotion, sweepPos])
|
||||
|
||||
const fixedW =
|
||||
isMulti && fixedWidth && measuredWidths.length > 0
|
||||
? Math.max(...measuredWidths)
|
||||
: undefined
|
||||
|
||||
const animatedW =
|
||||
isMulti && !fixedWidth && measuredWidths[activeIndex] != null
|
||||
? measuredWidths[activeIndex]
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
ref={spanRef}
|
||||
className={cn("align-bottom leading-[100%] text-inherit", className)}
|
||||
style={{
|
||||
transform: "translateY(-2px)",
|
||||
color: "transparent",
|
||||
backgroundClip: "text",
|
||||
WebkitBackgroundClip: "text",
|
||||
backgroundSize: "100% 100%",
|
||||
backgroundImage,
|
||||
...(isMulti && {
|
||||
display: "inline-block",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
verticalAlign: "text-center",
|
||||
...(fixedW != null && { width: fixedW }),
|
||||
}),
|
||||
}}
|
||||
animate={animatedW != null ? { width: animatedW } : undefined}
|
||||
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
|
||||
{...props}
|
||||
>
|
||||
{texts[activeIndex]}
|
||||
</motion.span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog(props: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger(
|
||||
props: React.ComponentProps<typeof DialogPrimitive.Trigger>,
|
||||
) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal(
|
||||
props: React.ComponentProps<typeof DialogPrimitive.Portal>,
|
||||
) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose(props: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out fixed inset-0 z-50 bg-black/30 backdrop-blur-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed top-[50%] left-[50%] z-50 grid w-[min(92vw,560px)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-[28px] border border-white/80 p-6 shadow-[0_36px_120px_-45px_rgba(16,24,40,0.4)] duration-200",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton ? (
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-full p-1 opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:outline-none disabled:pointer-events-none">
|
||||
<X className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
) : null}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-xl leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Field({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div className={cn("grid gap-2", className)} {...props} />;
|
||||
}
|
||||
|
||||
function FieldLabel({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return <label className={cn("text-sm font-medium text-stone-700", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Field, FieldLabel };
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"border-input file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-11 w-full min-w-0 rounded-2xl border bg-white/90 px-4 py-2 text-sm shadow-sm transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 focus-visible:border-stone-300 focus-visible:ring-[3px] focus-visible:ring-stone-200/80",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Input };
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,149 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const morphTime = 1.5
|
||||
const cooldownTime = 0.5
|
||||
|
||||
const useMorphingText = (texts: string[]) => {
|
||||
const textIndexRef = useRef(0)
|
||||
const morphRef = useRef(0)
|
||||
const cooldownRef = useRef(0)
|
||||
const timeRef = useRef(new Date())
|
||||
|
||||
const text1Ref = useRef<HTMLSpanElement>(null)
|
||||
const text2Ref = useRef<HTMLSpanElement>(null)
|
||||
|
||||
const setStyles = useCallback(
|
||||
(fraction: number) => {
|
||||
const [current1, current2] = [text1Ref.current, text2Ref.current]
|
||||
if (!current1 || !current2) return
|
||||
|
||||
current2.style.filter = `blur(${Math.min(8 / fraction - 8, 100)}px)`
|
||||
current2.style.opacity = `${Math.pow(fraction, 0.4) * 100}%`
|
||||
|
||||
const invertedFraction = 1 - fraction
|
||||
current1.style.filter = `blur(${Math.min(
|
||||
8 / invertedFraction - 8,
|
||||
100
|
||||
)}px)`
|
||||
current1.style.opacity = `${Math.pow(invertedFraction, 0.4) * 100}%`
|
||||
|
||||
current1.textContent = texts[textIndexRef.current % texts.length]
|
||||
current2.textContent = texts[(textIndexRef.current + 1) % texts.length]
|
||||
},
|
||||
[texts]
|
||||
)
|
||||
|
||||
const doMorph = useCallback(() => {
|
||||
morphRef.current -= cooldownRef.current
|
||||
cooldownRef.current = 0
|
||||
|
||||
let fraction = morphRef.current / morphTime
|
||||
|
||||
if (fraction > 1) {
|
||||
cooldownRef.current = cooldownTime
|
||||
fraction = 1
|
||||
}
|
||||
|
||||
setStyles(fraction)
|
||||
|
||||
if (fraction === 1) {
|
||||
textIndexRef.current++
|
||||
}
|
||||
}, [setStyles])
|
||||
|
||||
const doCooldown = useCallback(() => {
|
||||
morphRef.current = 0
|
||||
const [current1, current2] = [text1Ref.current, text2Ref.current]
|
||||
if (current1 && current2) {
|
||||
current2.style.filter = "none"
|
||||
current2.style.opacity = "100%"
|
||||
current1.style.filter = "none"
|
||||
current1.style.opacity = "0%"
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let animationFrameId: number
|
||||
|
||||
const animate = () => {
|
||||
animationFrameId = requestAnimationFrame(animate)
|
||||
|
||||
const newTime = new Date()
|
||||
const dt = (newTime.getTime() - timeRef.current.getTime()) / 1000
|
||||
timeRef.current = newTime
|
||||
|
||||
cooldownRef.current -= dt
|
||||
|
||||
if (cooldownRef.current <= 0) doMorph()
|
||||
else doCooldown()
|
||||
}
|
||||
|
||||
animate()
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
}
|
||||
}, [doMorph, doCooldown])
|
||||
|
||||
return { text1Ref, text2Ref }
|
||||
}
|
||||
|
||||
interface MorphingTextProps {
|
||||
className?: string
|
||||
texts: string[]
|
||||
}
|
||||
|
||||
const Texts: React.FC<Pick<MorphingTextProps, "texts">> = ({ texts }) => {
|
||||
const { text1Ref, text2Ref } = useMorphingText(texts)
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
className="absolute inset-x-0 top-0 m-auto inline-block w-full"
|
||||
ref={text1Ref}
|
||||
/>
|
||||
<span
|
||||
className="absolute inset-x-0 top-0 m-auto inline-block w-full"
|
||||
ref={text2Ref}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SvgFilters: React.FC = () => (
|
||||
<svg
|
||||
id="filters"
|
||||
className="fixed h-0 w-0"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
>
|
||||
<defs>
|
||||
<filter id="threshold">
|
||||
<feColorMatrix
|
||||
in="SourceGraphic"
|
||||
type="matrix"
|
||||
values="1 0 0 0 0
|
||||
0 1 0 0 0
|
||||
0 0 1 0 0
|
||||
0 0 0 255 -140"
|
||||
/>
|
||||
</filter>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
|
||||
export const MorphingText: React.FC<MorphingTextProps> = ({
|
||||
texts,
|
||||
className,
|
||||
}) => (
|
||||
<div
|
||||
className={cn(
|
||||
"relative mx-auto h-16 w-full max-w-3xl text-center font-sans text-[40pt] leading-none font-bold filter-[url(#threshold)_blur(0.6px)] md:h-24 lg:text-[6rem]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Texts texts={texts} />
|
||||
<SvgFilters />
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Popover(props: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverTrigger(props: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 rounded-2xl border border-white/80 bg-white p-3 text-stone-950 shadow-[0_20px_60px_-30px_rgba(15,23,42,0.35)] outline-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export { Popover, PopoverContent, PopoverTrigger };
|
||||
@@ -0,0 +1,175 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Select(props: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroup(
|
||||
props: React.ComponentProps<typeof SelectPrimitive.Group>,
|
||||
) {
|
||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||
}
|
||||
|
||||
function SelectValue(
|
||||
props: React.ComponentProps<typeof SelectPrimitive.Value>,
|
||||
) {
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger>) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
className={cn(
|
||||
"border-input bg-background data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 flex h-11 w-full items-center justify-between gap-2 rounded-2xl border px-4 py-2 text-sm whitespace-nowrap shadow-sm outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="size-4 opacity-60" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="size-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "popper",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out relative z-50 max-h-96 min-w-[8rem] overflow-x-hidden overflow-y-auto rounded-2xl border border-white/80 shadow-[0_20px_60px_-30px_rgba(15,23,42,0.35)]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||
return (
|
||||
<SelectPrimitive.Label
|
||||
data-slot="select-label"
|
||||
className={cn("px-2 py-1.5 text-xs font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-xl py-2 pr-8 pl-3 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex size-4 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { XIcon } from "lucide-react"
|
||||
import { Dialog as SheetPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
side === "bottom" &&
|
||||
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn("font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return <table className={cn("w-full caption-bottom text-sm", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return <thead className={cn("border-b border-stone-100 text-[11px] tracking-[0.18em] text-stone-400 uppercase", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return <tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return <tr className={cn("border-b border-stone-100 transition-colors hover:bg-stone-50/70", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return <th className={cn("h-11 px-4 text-left align-middle font-medium", className)} {...props} />;
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return <td className={cn("px-4 py-3 align-middle", className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Table, TableBody, TableCell, TableHead, TableHeader, TableRow };
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none dark:text-muted-foreground dark:hover:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 dark:data-[state=active]:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client"
|
||||
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
type ElementType,
|
||||
} from "react"
|
||||
import {
|
||||
useAnimate,
|
||||
type AnimationOptions,
|
||||
type ValueAnimationTransition,
|
||||
} from "motion/react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const HAS_SEGMENTER = typeof Intl !== "undefined" && "Segmenter" in Intl
|
||||
|
||||
const splitIntoCharacters = (text: string): string[] => {
|
||||
if (HAS_SEGMENTER) {
|
||||
const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" })
|
||||
return Array.from(segmenter.segment(text), ({ segment }) => segment)
|
||||
}
|
||||
return Array.from(text)
|
||||
}
|
||||
|
||||
const extractTextFromChildren = (children: React.ReactNode): string => {
|
||||
if (children == null) return ""
|
||||
if (typeof children === "string") return children
|
||||
if (typeof children === "number") return String(children)
|
||||
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(extractTextFromChildren).join("")
|
||||
}
|
||||
|
||||
if (React.isValidElement(children)) {
|
||||
const props = children.props as Record<string, unknown>
|
||||
const childText = props.children as React.ReactNode
|
||||
if (childText != null) {
|
||||
return extractTextFromChildren(childText)
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
const ROTATION_MAP = {
|
||||
top: "rotateX(90deg)",
|
||||
right: "rotateY(90deg)",
|
||||
bottom: "rotateX(-90deg)",
|
||||
left: "rotateY(-90deg)",
|
||||
} as const
|
||||
|
||||
const DEFAULT_TRANSITION: ValueAnimationTransition = {
|
||||
type: "spring",
|
||||
damping: 30,
|
||||
stiffness: 300,
|
||||
}
|
||||
|
||||
interface Text3DFlipProps {
|
||||
children: React.ReactNode
|
||||
as?: ElementType
|
||||
className?: string
|
||||
textClassName?: string
|
||||
flipTextClassName?: string
|
||||
staggerDuration?: number
|
||||
staggerFrom?: "first" | "last" | "center" | number | "random"
|
||||
transition?: ValueAnimationTransition | AnimationOptions
|
||||
rotateDirection?: "top" | "right" | "bottom" | "left"
|
||||
}
|
||||
|
||||
const Text3DFlip = ({
|
||||
children,
|
||||
as: ElementTag = "p",
|
||||
className,
|
||||
textClassName,
|
||||
flipTextClassName,
|
||||
staggerDuration = 0.05,
|
||||
staggerFrom = "first",
|
||||
transition = DEFAULT_TRANSITION,
|
||||
rotateDirection = "right",
|
||||
...props
|
||||
}: Text3DFlipProps) => {
|
||||
const isAnimatingRef = useRef(false)
|
||||
const isMountedRef = useRef(false)
|
||||
const [scope, animate] = useAnimate()
|
||||
|
||||
const rotationTransform = ROTATION_MAP[rotateDirection]
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
isAnimatingRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const text = useMemo(() => {
|
||||
try {
|
||||
return extractTextFromChildren(children)
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}, [children])
|
||||
|
||||
const characters = useMemo(() => {
|
||||
const words = text.split(" ")
|
||||
return words.map((word, i) => ({
|
||||
characters: splitIntoCharacters(word),
|
||||
needsSpace: i !== words.length - 1,
|
||||
}))
|
||||
}, [text])
|
||||
|
||||
const charOffsets = useMemo(() => {
|
||||
const offsets = [0]
|
||||
for (const word of characters) {
|
||||
offsets.push(offsets.at(-1)! + word.characters.length)
|
||||
}
|
||||
return offsets
|
||||
}, [characters])
|
||||
|
||||
const getStaggerDelay = useCallback(
|
||||
(index: number, totalChars: number) => {
|
||||
if (staggerFrom === "first") return index * staggerDuration
|
||||
if (staggerFrom === "last")
|
||||
return (totalChars - 1 - index) * staggerDuration
|
||||
if (staggerFrom === "center") {
|
||||
const center = Math.floor(totalChars / 2)
|
||||
return Math.abs(center - index) * staggerDuration
|
||||
}
|
||||
if (staggerFrom === "random") {
|
||||
const randomIndex = Math.floor(Math.random() * totalChars)
|
||||
return Math.abs(randomIndex - index) * staggerDuration
|
||||
}
|
||||
return Math.abs(staggerFrom - index) * staggerDuration
|
||||
},
|
||||
[staggerFrom, staggerDuration]
|
||||
)
|
||||
|
||||
const handleHoverStart = useCallback(async () => {
|
||||
if (isAnimatingRef.current) return
|
||||
isAnimatingRef.current = true
|
||||
|
||||
try {
|
||||
const totalChars = characters.reduce(
|
||||
(sum, word) => sum + word.characters.length,
|
||||
0
|
||||
)
|
||||
|
||||
const delays = Array.from({ length: totalChars }, (_, i) =>
|
||||
getStaggerDelay(i, totalChars)
|
||||
)
|
||||
|
||||
await animate(
|
||||
".text-3d-flip-char",
|
||||
{ transform: rotationTransform },
|
||||
{
|
||||
...transition,
|
||||
delay: (i: number) => delays[i],
|
||||
}
|
||||
)
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
await animate(
|
||||
".text-3d-flip-char",
|
||||
{ transform: "rotateX(0deg) rotateY(0deg)" },
|
||||
{ duration: 0 }
|
||||
)
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
isAnimatingRef.current = false
|
||||
}
|
||||
}
|
||||
}, [characters, transition, getStaggerDelay, rotationTransform, animate])
|
||||
|
||||
return (
|
||||
<ElementTag
|
||||
className={cn("relative flex flex-wrap", className)}
|
||||
onMouseEnter={handleHoverStart}
|
||||
ref={scope}
|
||||
{...props}
|
||||
>
|
||||
<span className="sr-only">{text}</span>
|
||||
|
||||
{characters.map((wordObj, wordIndex) => (
|
||||
<span key={wordIndex} className="inline-flex">
|
||||
{wordObj.characters.map((char, charIndex) => (
|
||||
<CharBox
|
||||
key={charOffsets[wordIndex] + charIndex}
|
||||
char={char}
|
||||
textClassName={textClassName}
|
||||
flipTextClassName={flipTextClassName}
|
||||
rotateDirection={rotateDirection}
|
||||
/>
|
||||
))}
|
||||
{wordObj.needsSpace && <span className="whitespace-pre"> </span>}
|
||||
</span>
|
||||
))}
|
||||
</ElementTag>
|
||||
)
|
||||
}
|
||||
|
||||
interface CharBoxProps {
|
||||
char: string
|
||||
textClassName?: string
|
||||
flipTextClassName?: string
|
||||
rotateDirection: "top" | "right" | "bottom" | "left"
|
||||
}
|
||||
|
||||
const SECOND_FACE_TRANSFORMS = {
|
||||
top: "rotateX(-90deg) translateZ(0.5lh)",
|
||||
right:
|
||||
"rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(-50%) rotateY(-90deg) translateX(50%)",
|
||||
bottom: "rotateX(90deg) translateZ(0.5lh)",
|
||||
left: "rotateY(90deg) translateX(50%) rotateY(-90deg) translateX(50%) rotateY(-90deg) translateX(50%)",
|
||||
} as const
|
||||
|
||||
const FRONT_FACE_TRANSFORMS = {
|
||||
top: "translateZ(0.5lh)",
|
||||
bottom: "translateZ(0.5lh)",
|
||||
left: "rotateY(90deg) translateX(50%) rotateY(-90deg)",
|
||||
right: "rotateY(-90deg) translateX(50%) rotateY(90deg)",
|
||||
} as const
|
||||
|
||||
const CONTAINER_TRANSFORMS = {
|
||||
top: "translateZ(-0.5lh)",
|
||||
bottom: "translateZ(-0.5lh)",
|
||||
left: "rotateY(90deg) translateX(50%) rotateY(-90deg)",
|
||||
right: "rotateY(90deg) translateX(50%) rotateY(-90deg)",
|
||||
} as const
|
||||
|
||||
const CharBox = memo(
|
||||
({
|
||||
char,
|
||||
textClassName,
|
||||
flipTextClassName,
|
||||
rotateDirection,
|
||||
}: CharBoxProps) => (
|
||||
<span
|
||||
className="text-3d-flip-char inline transform-3d"
|
||||
style={{ transform: CONTAINER_TRANSFORMS[rotateDirection] }}
|
||||
>
|
||||
<span
|
||||
className={cn("relative h-[1lh] backface-hidden", textClassName)}
|
||||
style={{ transform: FRONT_FACE_TRANSFORMS[rotateDirection] }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute top-0 left-0 h-[1lh] backface-hidden",
|
||||
flipTextClassName
|
||||
)}
|
||||
style={{ transform: SECOND_FACE_TRANSFORMS[rotateDirection] }}
|
||||
>
|
||||
{char}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
)
|
||||
|
||||
CharBox.displayName = "CharBox"
|
||||
Text3DFlip.displayName = "Text3DFlip"
|
||||
|
||||
export default Text3DFlip
|
||||
@@ -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(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 block min-h-32 w-full rounded-[24px] border bg-white/90 px-4 py-3 text-sm shadow-sm outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Textarea };
|
||||
Reference in New Issue
Block a user