diff --git a/app/globals.css b/app/globals.css index 7f59790..0279b52 100644 --- a/app/globals.css +++ b/app/globals.css @@ -41,6 +41,14 @@ --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); + --color-main: var(--main); + --color-main-secondary: var(--main-secondary); + --color-main-foreground: var(--main-foreground); + --color-main-muted: var(--main-muted); + --color-main-background: var(--main-background); + --color-main-invert: var(--main-invert); + --color-primary-muted: var(--primary-muted); + --color-primary-invert: var(--primary-invert); } :root { @@ -51,7 +59,7 @@ --card-foreground: oklch(0.145 0 0); --popover: oklch(1 0 0); --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); + --primary: oklch(0 0 0); --primary-foreground: oklch(0.985 0 0); --secondary: oklch(0.97 0 0); --secondary-foreground: oklch(0.205 0 0); @@ -76,6 +84,16 @@ --sidebar-accent-foreground: oklch(0.205 0 0); --sidebar-border: oklch(0.922 0 0); --sidebar-ring: oklch(0.708 0 0); + + /* Luxe UI variables */ + --main: oklch(0.97 0 0); + --main-secondary: oklch(97% 0 0); + --main-foreground: oklch(0.925 0 0); + --main-muted: oklch(0.96 0 0); + --main-background: oklch(0.97 0 0); + --main-invert: oklch(0.205 0 0); + --primary-muted: oklch(0.556 0 0); + --primary-invert: oklch(1 0 0); } .dark { @@ -85,7 +103,7 @@ --card-foreground: oklch(0.985 0 0); --popover: oklch(0.205 0 0); --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.922 0 0); + --primary: oklch(1 0 0); --primary-foreground: oklch(0.205 0 0); --secondary: oklch(0.269 0 0); --secondary-foreground: oklch(0.985 0 0); @@ -110,17 +128,57 @@ --sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-border: oklch(1 0 0 / 10%); --sidebar-ring: oklch(0.556 0 0); + + /* Luxe UI dark mode variables */ + --main: oklch(0.178 0 0); + --main-foreground: oklch(0.269 0 0); + --main-muted: oklch(0.168 0 0); + --main-background: oklch(0.145 0 0); + --primary-muted: oklch(0.708 0 0); + --primary-invert: oklch(0 0 0); } + + @layer base { * { @apply border-border outline-ring/50; } + html { + scroll-behavior: smooth; + } html, body, #__next { - @apply h-full overflow-hidden; + @apply h-full; + } + + body { + @apply overflow-x-hidden overflow-y-auto; + } + + /* shadcn-inspired scrollbar */ + :root, + html { + scrollbar-width: thin; + scrollbar-color: var(--muted-foreground) transparent; + } + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + ::-webkit-scrollbar-track { + background: transparent; + } + ::-webkit-scrollbar-thumb { + background-color: color-mix(in oklab, var(--muted-foreground) 45%, transparent); + border-radius: 9999px; + border: 2px solid transparent; + background-clip: padding-box; + } + ::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in oklab, var(--muted-foreground) 65%, transparent); } body { @@ -141,6 +199,43 @@ .animate-breathe { animation: breathe 5s ease-in-out infinite; } + + @keyframes shine { + 0% { + background-position: 0% 0%; + } + 50% { + background-position: 100% 100%; + } + 100% { + background-position: 0% 0%; + } + } + + .animate-shine { + animation: shine 10s linear infinite; + } + + @keyframes brightness { + 0% { + opacity: 0; + transform: translateX(-100%) skewX(-45deg); + } + 5% { + opacity: 1; + } + 95% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translateX(200%) skewX(-45deg); + } + } + + .animate-brightness { + animation: brightness 2s ease-in-out infinite; + } } /* Tiptap Editor Styles */ diff --git a/app/page.tsx b/app/page.tsx index ca4726b..75dfa92 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,11 +1,23 @@ 'use client' -import { Button } from "@/components/ui/button"; import Link from "next/link"; +import Image from "next/image"; import { AsciiEye } from "@/components/ascii-eye"; -import Dither from '@/components/dither' import { useState, useEffect } from 'react'; import TextType from "@/components/text-type" +import DecryptedText from "@/components/fancy/text/decrypted-text"; +import { LuxeButton } from "@/components/ui/luxe-button"; +import { + NavigationMenu, + NavigationMenuList, + NavigationMenuItem, + NavigationMenuLink, +} from "@/components/ui/navigation-menu"; +import Dither from "@/components/dither"; +import { BrowserComponent } from "@/components/browser-component"; +import { FeaturesAlternating } from "@/components/features-alternating"; +import { ModelsSection } from "@/components/models-section"; +import { SiteFooter } from "@/components/site-footer"; export default function Home() { const [isLoading, setIsLoading] = useState(true); @@ -28,6 +40,113 @@ export default function Home() { loadContent(); }, []); + const featuresContent = [ + { + title: "Live Dashboard", + description: "Dashboard showcasing all current RTMP streams with automated video asset creation.", + content: ( +
+ + Argus Dashboard + +
+ ), + }, + { + title: "Entity & Event Detection", + description: "Entity and event detection with AI-powered analysis.", + content: ( +
+ + Argus Detection + +
+ ), + }, + { + title: "Analytics & Statistics", + description: "Comprehensive stats from all events and anomalies detected.", + content: ( +
+ + Argus Statistics + +
+ ), + }, + { + title: "Automated Reports", + description: "Auto-generated editable rich incident reports based on detected events.", + content: ( +
+ + Argus Reports + +
+ ), + }, + { + title: "Elasticsearch Agent", + description: "Search through all detected anomalies and events with powerful Elasticsearch Agent integration.", + content: ( +
+ + Argus Search + +
+ ), + }, + { + title: "AI Assistant", + description: "Search for specific event types, ask questions, generate reports, and more.", + content: ( +
+ + Argus Chat + +
+ ), + }, + ]; + return ( <> {/* Loading Screen */} @@ -37,52 +156,92 @@ export default function Home() { fadeOut ? 'opacity-0' : 'opacity-100' }`} > - +
+ + +
)} - {/* Main Content */} -
- {/* Dither background */} -
- -
+
+ +
+ +
+ + + + Overview + + + Models + + + Features + + + Dashboard + + + +
- {/* Left side - Main content */} -
-
-

ARGUS

- +
+ +
+
+

ARGUS

+ + Open Argus +
- {/* Right side - ASCII Eye */} -
-
+
+
+ +
+
+

Technology for the Future

+

Argus is built on the latest and greatest technologies to deliver the best possible experience for you and your team.

+ +
+
+ +
+
+

Features

+

+ Discover the powerful capabilities that make Argus the ultimate surveillance and monitoring solution. +

+ +
+
+ + ); } \ No newline at end of file diff --git a/components.json b/components.json index b7b9791..9c87867 100644 --- a/components.json +++ b/components.json @@ -18,5 +18,8 @@ "lib": "@/lib", "hooks": "@/hooks" }, - "registries": {} + "registries": { + "@aceternity": "https://ui.aceternity.com/registry/{name}.json", + "@fancy": "https://fancycomponents.dev/r/{name}.json" + } } diff --git a/components/ascii-eye.tsx b/components/ascii-eye.tsx index f8bea7c..4e4457e 100644 --- a/components/ascii-eye.tsx +++ b/components/ascii-eye.tsx @@ -71,9 +71,6 @@ interface CharData { export const AsciiEye = memo(function AsciiEye() { const spansRef = useRef([]); const nonWhitespaceIndicesRef = useRef([]); - const rafIdRef = useRef(undefined); - const lastColorUpdateRef = useRef(0); - const lastScrambleUpdateRef = useRef(0); const scrambleTimeoutRef = useRef(undefined); // Memoize character data - only parse once @@ -122,77 +119,64 @@ export const AsciiEye = memo(function AsciiEye() { return data; }, []); - // Optimized animation loop using requestAnimationFrame + // Use setInterval instead of RAF for much better performance useEffect(() => { - let isActive = true; + let colorInterval: NodeJS.Timeout | undefined; + let scrambleInterval: NodeJS.Timeout | undefined; - const animate = (timestamp: number) => { - if (!isActive) return; - - // Color update - every 800ms, but only update a batch of characters - if (timestamp - lastColorUpdateRef.current >= 800) { - lastColorUpdateRef.current = timestamp; - - // Batch update colors - only update a subset for better performance - const batchSize = Math.ceil(nonWhitespaceIndicesRef.current.length / 3); - const startIdx = Math.floor(Math.random() * (nonWhitespaceIndicesRef.current.length - batchSize)); + // Color update - every 2000ms (increased from 800ms) + colorInterval = setInterval(() => { + // Update only a small batch of characters + const batchSize = Math.min(50, Math.ceil(nonWhitespaceIndicesRef.current.length / 10)); + const startIdx = Math.floor(Math.random() * (nonWhitespaceIndicesRef.current.length - batchSize)); + + for (let i = startIdx; i < startIdx + batchSize && i < nonWhitespaceIndicesRef.current.length; i++) { + const idx = nonWhitespaceIndicesRef.current[i]; + const char = charData[idx]; + const span = spansRef.current[idx]; - for (let i = startIdx; i < startIdx + batchSize && i < nonWhitespaceIndicesRef.current.length; i++) { - const idx = nonWhitespaceIndicesRef.current[i]; - const char = charData[idx]; - const span = spansRef.current[idx]; - - if (span) { - const newColor = getRandomColor(char.isDarkChar); - span.style.color = newColor; - } + if (span) { + const newColor = getRandomColor(char.isDarkChar); + span.style.color = newColor; } } + }, 2000); - // Character scrambling - every 1000ms - if (timestamp - lastScrambleUpdateRef.current >= 1000) { - lastScrambleUpdateRef.current = timestamp; - - const numToScramble = 15 + Math.floor(Math.random() * 5); - const scrambledIndices: number[] = []; + // Character scrambling - every 2000ms (increased from 1000ms) + scrambleInterval = setInterval(() => { + const numToScramble = 10; // Reduced from 15-20 + const scrambledIndices: number[] = []; + + // Scramble random characters + for (let i = 0; i < numToScramble; i++) { + const randomIdx = Math.floor(Math.random() * nonWhitespaceIndicesRef.current.length); + const idx = nonWhitespaceIndicesRef.current[randomIdx]; + scrambledIndices.push(idx); - // Scramble random characters - for (let i = 0; i < numToScramble; i++) { - const randomIdx = Math.floor(Math.random() * nonWhitespaceIndicesRef.current.length); - const idx = nonWhitespaceIndicesRef.current[randomIdx]; - scrambledIndices.push(idx); - - const span = spansRef.current[idx]; - if (span) { - span.textContent = getRandomBrailleChar(); - } - } - - // Reset scrambled characters after delay - if (scrambleTimeoutRef.current) { - window.clearTimeout(scrambleTimeoutRef.current); + const span = spansRef.current[idx]; + if (span) { + span.textContent = getRandomBrailleChar(); } - - scrambleTimeoutRef.current = window.setTimeout(() => { - scrambledIndices.forEach((idx) => { - const span = spansRef.current[idx]; - if (span) { - span.textContent = charData[idx].originalChar; - } - }); - }, 150); } - rafIdRef.current = requestAnimationFrame(animate); - }; - - rafIdRef.current = requestAnimationFrame(animate); + // Reset scrambled characters after delay + if (scrambleTimeoutRef.current) { + window.clearTimeout(scrambleTimeoutRef.current); + } + + scrambleTimeoutRef.current = window.setTimeout(() => { + scrambledIndices.forEach((idx) => { + const span = spansRef.current[idx]; + if (span) { + span.textContent = charData[idx].originalChar; + } + }); + }, 150); + }, 2000); return () => { - isActive = false; - if (rafIdRef.current) { - cancelAnimationFrame(rafIdRef.current); - } + if (colorInterval) clearInterval(colorInterval); + if (scrambleInterval) clearInterval(scrambleInterval); if (scrambleTimeoutRef.current) { window.clearTimeout(scrambleTimeoutRef.current); } diff --git a/components/browser-component.tsx b/components/browser-component.tsx new file mode 100644 index 0000000..aca3a04 --- /dev/null +++ b/components/browser-component.tsx @@ -0,0 +1,51 @@ +import { cn } from '@/utils/cn' + +export const BrowserComponent: React.FC<{ children?: React.ReactNode; className?: string; url?: string }> = ({ + className, + children, + url = "yourwebsite.com/admin/sales" +}) => ( +
+
+
+
+
+
+
+
+ + + + + + {url} + +
+
+
+
{children}
+
+) diff --git a/components/fancy/text/decrypted-text.tsx b/components/fancy/text/decrypted-text.tsx new file mode 100644 index 0000000..5b0d664 --- /dev/null +++ b/components/fancy/text/decrypted-text.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; + +type DecryptedTextProps = { + text: string; + revealDurationMs?: number; + scrambleSpeed?: number; + characters?: string; + className?: string; +}; + +export default function DecryptedText({ + text, + revealDurationMs = 1500, + scrambleSpeed = 30, + characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+{}[]<>?", + className, +}: DecryptedTextProps) { + const [display, setDisplay] = useState(text); + const startTsRef = useRef(null); + const rafRef = useRef(null); + const intervalRef = useRef | null>(null); + + const charPool = useMemo(() => characters.split(""), [characters]); + + useEffect(() => { + // Reset state when text changes + setDisplay(text); + startTsRef.current = null; + + const tick = (ts: number) => { + if (startTsRef.current == null) startTsRef.current = ts; + const elapsed = ts - startTsRef.current; + const progress = Math.min(1, elapsed / revealDurationMs); + const revealCount = Math.floor(progress * text.length); + + setDisplay((prev) => { + const revealed = text.slice(0, revealCount); + const remaining = text + .slice(revealCount) + .split("") + .map((ch) => (ch === " " ? " " : charPool[Math.floor(Math.random() * charPool.length)])) + .join(""); + return revealed + remaining; + }); + + if (progress < 1) { + rafRef.current = requestAnimationFrame(tick); + } else { + setDisplay(text); + } + }; + + // Keep scrambling unrevealed characters between RAF frames for a smoother effect + intervalRef.current = setInterval(() => { + if (startTsRef.current == null) return; + const elapsed = performance.now() - startTsRef.current; + const progress = Math.min(1, elapsed / revealDurationMs); + const revealCount = Math.floor(progress * text.length); + setDisplay((prev) => { + const revealed = text.slice(0, revealCount); + const remaining = text + .slice(revealCount) + .split("") + .map((ch) => (ch === " " ? " " : charPool[Math.floor(Math.random() * charPool.length)])) + .join(""); + return revealed + remaining; + }); + }, scrambleSpeed); + + rafRef.current = requestAnimationFrame(tick); + + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [text, revealDurationMs, scrambleSpeed, charPool]); + + return {display}; +} + + diff --git a/components/fancy/text/scramble-hover.tsx b/components/fancy/text/scramble-hover.tsx new file mode 100644 index 0000000..6ae8025 --- /dev/null +++ b/components/fancy/text/scramble-hover.tsx @@ -0,0 +1,188 @@ +"use client" + +import { useEffect, useState } from "react" +import { motion } from "motion/react" + +import { cn } from "@/lib/utils" + +interface ScrambleHoverProps { + text: string + scrambleSpeed?: number + maxIterations?: number + sequential?: boolean + revealDirection?: "start" | "end" | "center" + useOriginalCharsOnly?: boolean + characters?: string + className?: string + scrambledClassName?: string +} + +const ScrambleHover: React.FC = ({ + text, + scrambleSpeed = 50, + maxIterations = 10, + useOriginalCharsOnly = false, + characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+", + className, + scrambledClassName, + sequential = false, + revealDirection = "start", + ...props +}) => { + const [displayText, setDisplayText] = useState(text) + const [isHovering, setIsHovering] = useState(false) + const [isScrambling, setIsScrambling] = useState(false) + const [revealedIndices, setRevealedIndices] = useState(new Set()) + + useEffect(() => { + let interval: NodeJS.Timeout + let currentIteration = 0 + + const getNextIndex = () => { + const textLength = text.length + switch (revealDirection) { + case "start": + return revealedIndices.size + case "end": + return textLength - 1 - revealedIndices.size + case "center": + const middle = Math.floor(textLength / 2) + const offset = Math.floor(revealedIndices.size / 2) + const nextIndex = + revealedIndices.size % 2 === 0 + ? middle + offset + : middle - offset - 1 + + if ( + nextIndex >= 0 && + nextIndex < textLength && + !revealedIndices.has(nextIndex) + ) { + return nextIndex + } + + for (let i = 0; i < textLength; i++) { + if (!revealedIndices.has(i)) return i + } + return 0 + default: + return revealedIndices.size + } + } + + const shuffleText = (text: string) => { + if (useOriginalCharsOnly) { + const positions = text.split("").map((char, i) => ({ + char, + isSpace: char === " ", + index: i, + isRevealed: revealedIndices.has(i), + })) + + const nonSpaceChars = positions + .filter((p) => !p.isSpace && !p.isRevealed) + .map((p) => p.char) + + // Shuffle remaining non-revealed, non-space characters + for (let i = nonSpaceChars.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)) + ;[nonSpaceChars[i], nonSpaceChars[j]] = [ + nonSpaceChars[j], + nonSpaceChars[i], + ] + } + + let charIndex = 0 + return positions + .map((p) => { + if (p.isSpace) return " " + if (p.isRevealed) return text[p.index] + return nonSpaceChars[charIndex++] + }) + .join("") + } else { + return text + .split("") + .map((char, i) => { + if (char === " ") return " " + if (revealedIndices.has(i)) return text[i] + return availableChars[ + Math.floor(Math.random() * availableChars.length) + ] + }) + .join("") + } + } + + const availableChars = useOriginalCharsOnly + ? Array.from(new Set(text.split(""))).filter((char) => char !== " ") + : characters.split("") + + if (isHovering) { + setIsScrambling(true) + interval = setInterval(() => { + if (sequential) { + if (revealedIndices.size < text.length) { + const nextIndex = getNextIndex() + revealedIndices.add(nextIndex) + setDisplayText(shuffleText(text)) + } else { + clearInterval(interval) + setIsScrambling(false) + } + } else { + setDisplayText(shuffleText(text)) + currentIteration++ + if (currentIteration >= maxIterations) { + clearInterval(interval) + setIsScrambling(false) + setDisplayText(text) + } + } + }, scrambleSpeed) + } else { + setDisplayText(text) + revealedIndices.clear() + } + + return () => { + if (interval) clearInterval(interval) + } + }, [ + isHovering, + text, + characters, + scrambleSpeed, + useOriginalCharsOnly, + sequential, + revealDirection, + maxIterations, + ]) + + return ( + setIsHovering(true)} + onHoverEnd={() => setIsHovering(false)} + className={cn("inline-block whitespace-pre-wrap", className)} + {...props} + > + {displayText} + + + ) +} + +export default ScrambleHover diff --git a/components/fancy/text/text-highlighter.tsx b/components/fancy/text/text-highlighter.tsx new file mode 100644 index 0000000..0e7d035 --- /dev/null +++ b/components/fancy/text/text-highlighter.tsx @@ -0,0 +1,211 @@ +"use client" + +import { + ElementType, + forwardRef, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react" +import { motion, Transition, useInView, UseInViewOptions } from "motion/react" + +import { cn } from "@/lib/utils" + +type HighlightDirection = "ltr" | "rtl" | "ttb" | "btt" + +type TextHighlighterProps = { + /** + * The text content to be highlighted + */ + children: React.ReactNode + + /** + * HTML element to render as + * @default "p" + */ + as?: ElementType + + /** + * How to trigger the animation + * @default "inView" + */ + triggerType?: "hover" | "ref" | "inView" | "auto" + + /** + * Animation transition configuration + * @default { duration: 0.4, type: "spring", bounce: 0 } + */ + transition?: Transition + + /** + * Options for useInView hook when triggerType is "inView" + */ + useInViewOptions?: UseInViewOptions + + /** + * Class name for the container element + */ + className?: string + + /** + * Highlight color (CSS color string). Also can be a function that returns a color string, eg: + * @default 'hsl(60, 90%, 68%)' (yellow) + */ + highlightColor?: string + + /** + * Direction of the highlight animation + * @default "ltr" (left to right) + */ + direction?: HighlightDirection +} & React.HTMLAttributes + +export type TextHighlighterRef = { + /** + * Trigger the highlight animation + * @param direction - Optional direction override for this animation + */ + animate: (direction?: HighlightDirection) => void + + /** + * Reset the highlight animation + */ + reset: () => void +} + +export const TextHighlighter = forwardRef< + TextHighlighterRef, + TextHighlighterProps +>( + ( + { + children, + as = "span", + triggerType = "inView", + transition = { type: "spring", duration: 1, delay: 0, bounce: 0 }, + useInViewOptions = { + once: true, + initial: false, + amount: 0.1, + }, + className, + highlightColor = "hsl(25, 90%, 80%)", + direction = "ltr", + ...props + }, + ref + ) => { + const componentRef = useRef(null) + const [isAnimating, setIsAnimating] = useState(false) + const [isHovered, setIsHovered] = useState(false) + const [currentDirection, setCurrentDirection] = + useState(direction) + + // this allows us to change the direction whenever the direction prop changes + useEffect(() => { + setCurrentDirection(direction) + }, [direction]) + + const isInView = + triggerType === "inView" + ? useInView(componentRef, useInViewOptions) + : false + + useImperativeHandle(ref, () => ({ + animate: (animationDirection?: HighlightDirection) => { + if (animationDirection) { + setCurrentDirection(animationDirection) + } + setIsAnimating(true) + }, + reset: () => setIsAnimating(false), + })) + + const shouldAnimate = + triggerType === "hover" + ? isHovered + : triggerType === "inView" + ? isInView + : triggerType === "ref" + ? isAnimating + : triggerType === "auto" + ? true + : false + + const ElementTag = as || "span" + + // Get background size based on direction + const getBackgroundSize = (animated: boolean) => { + switch (currentDirection) { + case "ltr": + return animated ? "100% 100%" : "0% 100%" + case "rtl": + return animated ? "100% 100%" : "0% 100%" + case "ttb": + return animated ? "100% 100%" : "100% 0%" + case "btt": + return animated ? "100% 100%" : "100% 0%" + default: + return animated ? "100% 100%" : "0% 100%" + } + } + + // Get background position based on direction + const getBackgroundPosition = () => { + switch (currentDirection) { + case "ltr": + return "0% 0%" + case "rtl": + return "100% 0%" + case "ttb": + return "0% 0%" + case "btt": + return "0% 100%" + default: + return "0% 0%" + } + } + + const animatedSize = useMemo(() => getBackgroundSize(shouldAnimate), [shouldAnimate, currentDirection]) + const initialSize = useMemo(() => getBackgroundSize(false), [currentDirection]) + const backgroundPosition = useMemo(() => getBackgroundPosition(), [currentDirection]) + + const highlightStyle = { + backgroundImage: `linear-gradient(${highlightColor}, ${highlightColor})`, + backgroundRepeat: "no-repeat", + backgroundPosition: backgroundPosition, + backgroundSize: animatedSize, + boxDecorationBreak: "clone", + WebkitBoxDecorationBreak: "clone", + } as React.CSSProperties + + return ( + triggerType === "hover" && setIsHovered(true)} + onMouseLeave={() => triggerType === "hover" && setIsHovered(false)} + {...props} + > + + {children} + + + ) + } +) + +TextHighlighter.displayName = "TextHighlighter" + +export default TextHighlighter diff --git a/components/features-alternating.tsx b/components/features-alternating.tsx new file mode 100644 index 0000000..d9d000e --- /dev/null +++ b/components/features-alternating.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import { gsap } from "gsap"; +import { ScrollTrigger } from "gsap/ScrollTrigger"; +import { LuxeCard, LuxeCardHeader, LuxeCardTitle, LuxeCardDescription } from "@/components/ui/luxe-card"; + +gsap.registerPlugin(ScrollTrigger); + +interface FeaturesAlternatingProps { + features: Array<{ + title: string; + description: string; + content: React.ReactNode; + }>; +} + +export function FeaturesAlternating({ + features, +}: FeaturesAlternatingProps) { + const sectionRefs = useRef<(HTMLDivElement | null)[]>([]); + + useEffect(() => { + sectionRefs.current.forEach((section, index) => { + if (!section) return; + + const isEven = index % 2 === 0; + const cardElement = section.querySelector('.feature-card'); + const contentElement = section.querySelector('.feature-content'); + + // Animate card sliding in from its side + if (cardElement) { + gsap.fromTo( + cardElement, + { + x: isEven ? -100 : 100, + opacity: 0, + }, + { + x: 0, + opacity: 1, + duration: 0.8, + ease: "power3.out", + scrollTrigger: { + trigger: section, + start: "top 80%", + end: "top 50%", + toggleActions: "play none none reverse", + }, + } + ); + } + + // Animate content sliding in from opposite side + if (contentElement) { + gsap.fromTo( + contentElement, + { + x: isEven ? 100 : -100, + opacity: 0, + }, + { + x: 0, + opacity: 1, + duration: 0.8, + ease: "power3.out", + scrollTrigger: { + trigger: section, + start: "top 80%", + end: "top 50%", + toggleActions: "play none none reverse", + }, + } + ); + } + }); + + return () => { + ScrollTrigger.getAll().forEach((trigger) => trigger.kill()); + }; + }, [features]); + + return ( +
+ {features.map((feature, index) => { + const isEven = index % 2 === 0; + + return ( +
{ + sectionRefs.current[index] = el; + }} + className="grid grid-cols-1 lg:grid-cols-2 gap-12 items-start" + > + {isEven ? ( + <> + {/* Left: Text Content */} + + + + {feature.title} + + + {feature.description} + + + + + {/* Right: Browser Component */} +
+ {feature.content} +
+ + ) : ( + <> + {/* Left: Browser Component */} +
+ {feature.content} +
+ + {/* Right: Text Content */} + + + + {feature.title} + + + {feature.description} + + + + + )} +
+ ); + })} +
+ ); +} + diff --git a/components/landing-animated-tabs.tsx b/components/landing-animated-tabs.tsx new file mode 100644 index 0000000..f9894ec --- /dev/null +++ b/components/landing-animated-tabs.tsx @@ -0,0 +1,76 @@ +"use client"; // @NOTE: Add in case you are using Next.js + +import { useEffect, useRef } from "react"; + +import { cn } from "@/utils/cn"; + +type AnimatedTabsProps = { + tabs: Array; + activeTab: string; + onTabChange: (tab: string) => void; +}; + +export function AnimatedTabs({ tabs, activeTab, onTabChange }: AnimatedTabsProps) { + + const containerRef = useRef(null); + const activeTabRef = useRef(null); + + useEffect(() => { + const container = containerRef.current; + + if (container && activeTab) { + const activeTabElement = activeTabRef.current; + + if (activeTabElement) { + const { offsetLeft, offsetWidth } = activeTabElement; + + const clipLeft = offsetLeft; + const clipRight = offsetLeft + offsetWidth; + + container.style.clipPath = `inset(0 ${Number(100 - (clipRight / container.offsetWidth) * 100).toFixed()}% 0 ${Number((clipLeft / container.offsetWidth) * 100).toFixed()}% round 17px)`; + } + } + }, [activeTab]); + + return ( +
+
+
+ {tabs.map((tab, index) => ( + + ))} +
+
+
+ {tabs.map((tab, index) => { + const isActive = activeTab === tab; + + return ( + + ); + })} +
+
+ ); +} diff --git a/components/models-section.tsx b/components/models-section.tsx new file mode 100644 index 0000000..925c576 --- /dev/null +++ b/components/models-section.tsx @@ -0,0 +1,188 @@ +"use client"; + +import TextHighlighter from "@/components/fancy/text/text-highlighter"; +import ScrambleHover from "@/components/fancy/text/scramble-hover"; +import { Badge } from "@/components/ui/badge"; + +const aiModels = [ + "Letta Stateful Agent", + "Elasticsearch Agent", + "Groq Kimi K2 Instruct", + "Gemini 2.5 Pro", + "Claude 4.5 Haiku", + "Claude 4.5 Sonnet", + "Roboflow 3.0 Object Detection", +]; + +export function ModelsSection() { + return ( +
+
+ {/* Left: Text with Highlighter */} +
+

+ Argus leverages the{" "} + + best in AI technology + + , combining cutting-edge{" "} + + computer vision + {" "} + and{" "} + + natural language processing + {" "} + to deliver unparalleled{" "} + + security and surveillance + {" "} + capabilities. Argus intelligently analyzes video feeds in{" "} + + real-time + , detecting {" "} + + anomalies + , identifying threats, and generating{" "} + + comprehensive reports + to keep you informed and protected. + +
+ +

+

+ Built on a {" "} + + real-time streaming pipeline + {" "} + with {" "} + + Mux + {" "} + and {" "} + + FFmpeg transmuxing + + , Argus delivers {" "} + + low-latency playback + {" "} + and resilient delivery while segments are scheduled and analyzed at the edge. +

+ +

+ Detections from {" "} + + Roboflow object detection + {" "} + and AI summaries from {" "} + + Gemini + {" "} + are indexed into {" "} + + Elasticsearch + {" "} + with rich {" "} + + structured metadata + + , and persisted via {" "} + + Supabase Realtime & Edge Functions + {" "} + so you're always in the loop. +

+
+ + {/* Right: AI Models List */} +
+
+ + Powered by + +
+
+ {aiModels.map((model, index) => ( +
+ +
+ ))} +
+
+
+
+ ); +} + diff --git a/components/site-footer.tsx b/components/site-footer.tsx new file mode 100644 index 0000000..a4fb290 --- /dev/null +++ b/components/site-footer.tsx @@ -0,0 +1,33 @@ +"use client"; + +export function SiteFooter() { + return ( + + ); +} + + diff --git a/components/ui/flip-words.tsx b/components/ui/flip-words.tsx new file mode 100644 index 0000000..43e1cc5 --- /dev/null +++ b/components/ui/flip-words.tsx @@ -0,0 +1,98 @@ +"use client"; +import React, { useCallback, useEffect, useRef, useState } from "react"; +import { AnimatePresence, motion, LayoutGroup } from "motion/react"; +import { cn } from "@/lib/utils"; + +export const FlipWords = ({ + words, + duration = 3000, + className, +}: { + words: string[]; + duration?: number; + className?: string; +}) => { + const [currentWord, setCurrentWord] = useState(words[0]); + const [isAnimating, setIsAnimating] = useState(false); + + // thanks for the fix Julian - https://github.com/Julian-AT + const startAnimation = useCallback(() => { + const word = words[words.indexOf(currentWord) + 1] || words[0]; + setCurrentWord(word); + setIsAnimating(true); + }, [currentWord, words]); + + useEffect(() => { + if (!isAnimating) + setTimeout(() => { + startAnimation(); + }, duration); + }, [isAnimating, duration, startAnimation]); + + return ( + { + setIsAnimating(false); + }} + > + + {/* edit suggested by Sajal: https://x.com/DewanganSajal */} + {currentWord.split(" ").map((word, wordIndex) => ( + + {word.split("").map((letter, letterIndex) => ( + + {letter} + + ))} +   + + ))} + + + ); +}; diff --git a/components/ui/luxe-button.tsx b/components/ui/luxe-button.tsx new file mode 100644 index 0000000..baad29a --- /dev/null +++ b/components/ui/luxe-button.tsx @@ -0,0 +1,271 @@ +'use client' // @NOTE: Add in case you are using Next.js + +import { useRef, useState } from 'react' + +import { motion } from 'motion/react' +import * as Slot from '@radix-ui/react-slot' + +import { cn } from '@/utils/cn' + +type Variant = { + variant: string + component: React.FC> +} + +const variants = [ + { + variant: 'default', + component: ({ className, ...props }) => ( + + ), + }, + { + variant: 'rotate-border', + component: ({ children, className, ...props }) => ( + + ), + }, + { + variant: 'glitch-brightness', + component: ({ children, className, ...props }) => { + return ( + + ) + + function TextGlitch({ children }: { children: React.ReactNode }) { + return ( +
+ {children} + + {children} + + + {children} + +
+ ) + } + + function Brightness() { + return ( +
+
+
+ ) + } + }, + }, +] as const satisfies readonly Variant[] + +export type LuxeButtonProps = { + variant?: (typeof variants)[number]['variant'] + isMagnetic?: boolean +} & React.ComponentProps<'button'> + +export function LuxeButton({ + variant = 'default', + isMagnetic = false, + className, + ...props +}: LuxeButtonProps) { + const FALLBACK_INDEX = 0 + + const variantComponent = variants.find(v => v.variant === variant)?.component + + const Component = variantComponent || variants[FALLBACK_INDEX].component + + const buttonContent = ( + + + + ) + + if (isMagnetic) { + return ( + + {buttonContent} + + ) + } + + return buttonContent +} + +function Magnetic({ children }: { children: React.ReactNode }) { + const { ref, handleMouseMove, handleMouseLeave, x, y } = useMagnetic() + + return ( + + {children} + + ) +} + +function useMagnetic() { + const [position, setPosition] = useState({ x: 0, y: 0 }) + const ref = useRef(null) + + function handleMouseMove(e: React.MouseEvent) { + const { clientX, clientY } = e + const { height, width, left, top } = ref.current!.getBoundingClientRect() + + const middleX = clientX - (left + width / 2) + const middleY = clientY - (top + height / 2) + + setPosition({ x: middleX, y: middleY }) + } + + function handleMouseLeave() { + setPosition({ x: 0, y: 0 }) + } + + const { x, y } = position + + return { ref, handleMouseMove, handleMouseLeave, x, y } +} diff --git a/components/ui/navigation-menu.tsx b/components/ui/navigation-menu.tsx new file mode 100644 index 0000000..3b50737 --- /dev/null +++ b/components/ui/navigation-menu.tsx @@ -0,0 +1,210 @@ +'use client' // @NOTE: Add in case you are using Next.js + +import { useState } from 'react' + +import * as RadixNavigationMenu from '@radix-ui/react-navigation-menu' +import { AnimatePresence, motion, useReducedMotion } from 'motion/react' + +import { cn } from '@/utils/cn' + +type NavigationMenuProps = React.ComponentProps + +export function NavigationMenu({ + className, + children, + ...props +}: NavigationMenuProps) { + return ( + + {children} + + ) +} + +type NavigationMenuListProps = React.ComponentProps< + typeof RadixNavigationMenu.List +> + +export function NavigationMenuList({ + className, + ...props +}: NavigationMenuListProps) { + return ( + + ) +} + +type NavigationMenuItemProps = React.ComponentProps< + typeof RadixNavigationMenu.Item +> + +export function NavigationMenuItem({ + className, + ...props +}: NavigationMenuItemProps) { + return ( + + ) +} + +type NavigationMenuTriggerProps = React.ComponentProps< + typeof RadixNavigationMenu.Trigger +> + +export function NavigationMenuTrigger({ + className, + children, + ...props +}: NavigationMenuTriggerProps) { + return ( + + {children} + + Chevron Down + + + + ) +} + +type NavigationMenuContentProps = React.ComponentProps< + typeof RadixNavigationMenu.Content +> + +export function NavigationMenuContent({ + className, + ...props +}: NavigationMenuContentProps) { + return ( + + ) +} + +type NavigationMenuContentItemProps = React.ComponentProps< + typeof RadixNavigationMenu.Link +> + +export function NavigationMenuContentItem({ + children, + className, + ...props +}: NavigationMenuContentItemProps) { + const [isHovered, setIsHovered] = useState(false) + + const isReducedMotion = useReducedMotion() + + const interactionProps = !isReducedMotion && { + onMouseEnter: () => setIsHovered(true), + onMouseLeave: () => setIsHovered(false), + onFocus: () => setIsHovered(true), + onBlur: () => setIsHovered(false), + } + + return ( + +
+ {children} +
+ {!isReducedMotion && ( + + {isHovered && ( + + )} + + )} +
+ ) +} + +type NavigationMenuLinkProps = React.ComponentProps< + typeof RadixNavigationMenu.Link +> + +export function NavigationMenuLink({ + className, + ...props +}: NavigationMenuLinkProps) { + return ( + + ) +} diff --git a/components/ui/sticky-scroll-reveal.tsx b/components/ui/sticky-scroll-reveal.tsx new file mode 100644 index 0000000..1d99094 --- /dev/null +++ b/components/ui/sticky-scroll-reveal.tsx @@ -0,0 +1,97 @@ +"use client"; +import React, { useEffect, useRef, useState } from "react"; +import { useMotionValueEvent, useScroll } from "motion/react"; +import { motion } from "motion/react"; +import { cn } from "@/lib/utils"; + +export const StickyScroll = ({ + content, + contentClassName, + onActiveCardChange, + scrollContainerRef, +}: { + content: { + title: string; + description: string; + content?: React.ReactNode | any; + }[]; + contentClassName?: string; + onActiveCardChange?: (index: number) => void; + scrollContainerRef?: React.RefObject; +}) => { + const [activeCard, setActiveCard] = React.useState(0); + const ref = scrollContainerRef || useRef(null); + const { scrollYProgress } = useScroll({ + // uncomment line 22 and comment line 23 if you DONT want the overflow container and want to have it change on the entire page scroll + // target: ref + container: ref, + offset: ["start start", "end start"], + }); + const cardLength = content.length; + + useMotionValueEvent(scrollYProgress, "change", (latest) => { + // Calculate which card should be active based on scroll progress + // Divide scroll progress into equal segments for each card + const newActiveCard = Math.min( + cardLength - 1, + Math.floor(latest * cardLength) + ); + + if (newActiveCard !== activeCard) { + setActiveCard(newActiveCard); + if (onActiveCardChange) { + onActiveCardChange(newActiveCard); + } + } + }); + + + return ( + +
+
+ {content.map((item, index) => ( +
+
+ + {item.title} + + + {item.description} + +
+
+ ))} +
+
+
+ + + ); +}; diff --git a/package.json b/package.json index 9068e16..bda5e9f 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-navigation-menu": "^1.2.14", "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-scroll-area": "^1.2.10", @@ -101,6 +102,7 @@ "gsap": "^3.13.0", "lodash.throttle": "^4.1.1", "lucide-react": "^0.546.0", + "mini-svg-data-uri": "^1.4.4", "marked": "^16.4.1", "motion": "^12.23.24", "nanoid": "^5.1.6", @@ -118,6 +120,7 @@ "sonner": "^2.0.7", "streamdown": "^1.4.0", "tailwind-merge": "^3.3.1", + "tailwind-variants": "^3.1.1", "three": "^0.180.0", "tokenlens": "^1.3.1", "use-stick-to-bottom": "^1.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 018e2a0..f224917 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -77,6 +77,9 @@ importers: '@radix-ui/react-label': specifier: ^2.1.7 version: 2.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-navigation-menu': + specifier: ^1.2.14 + version: 1.2.14(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-popover': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -248,6 +251,9 @@ importers: marked: specifier: ^16.4.1 version: 16.4.1 + mini-svg-data-uri: + specifier: ^1.4.4 + version: 1.4.4 motion: specifier: ^12.23.24 version: 12.23.24(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -296,6 +302,9 @@ importers: tailwind-merge: specifier: ^3.3.1 version: 3.3.1 + tailwind-variants: + specifier: ^3.1.1 + version: 3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.15) three: specifier: ^0.180.0 version: 0.180.0 @@ -1131,6 +1140,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-navigation-menu@1.2.14': + resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-popover@1.1.15': resolution: {integrity: sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==} peerDependencies: @@ -3186,6 +3208,10 @@ packages: min-document@2.19.0: resolution: {integrity: sha512-9Wy1B3m3f66bPPmU5hdA4DR4PB2OfDU/+GS3yAB7IQozE3tqXaVv2zOjgla7MEGSRv95+ILmOuvhLkOK6wJtCQ==} + mini-svg-data-uri@1.4.4: + resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==} + hasBin: true + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -3742,6 +3768,16 @@ packages: tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + tailwind-variants@3.1.1: + resolution: {integrity: sha512-ftLXe3krnqkMHsuBTEmaVUXYovXtPyTK7ckEfDRXS8PBZx0bAUas+A0jYxuKA5b8qg++wvQ3d2MQ7l/xeZxbZQ==} + engines: {node: '>=16.x', pnpm: '>=7.x'} + peerDependencies: + tailwind-merge: '>=3.0.0' + tailwindcss: '*' + peerDependenciesMeta: + tailwind-merge: + optional: true + tailwindcss@4.1.15: resolution: {integrity: sha512-k2WLnWkYFkdpRv+Oby3EBXIyQC8/s1HOFMBUViwtAh6Z5uAozeUSMQlIsn/c6Q2iJzqG6aJT3wdPaRNj70iYxQ==} @@ -4762,6 +4798,28 @@ snapshots: '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-navigation-menu@1.2.14(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.2)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.2.2)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.2.2)(react@19.1.0) + '@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.2)(react@19.1.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.2)(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.2)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.2)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.2.2)(react@19.1.0) + '@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.2.2 + '@types/react-dom': 19.2.2(@types/react@19.2.2) + '@radix-ui/react-popover@1.1.15(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.3 @@ -7163,6 +7221,8 @@ snapshots: dependencies: dom-walk: 0.1.2 + mini-svg-data-uri@1.4.4: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.12 @@ -7868,6 +7928,12 @@ snapshots: tailwind-merge@3.3.1: {} + tailwind-variants@3.1.1(tailwind-merge@3.3.1)(tailwindcss@4.1.15): + dependencies: + tailwindcss: 4.1.15 + optionalDependencies: + tailwind-merge: 3.3.1 + tailwindcss@4.1.15: {} tapable@2.3.0: {} diff --git a/public/assets/arguschat.webp b/public/assets/arguschat.webp new file mode 100644 index 0000000..6625a30 Binary files /dev/null and b/public/assets/arguschat.webp differ diff --git a/public/assets/argusdash.webp b/public/assets/argusdash.webp new file mode 100644 index 0000000..79b6de7 Binary files /dev/null and b/public/assets/argusdash.webp differ diff --git a/public/assets/argusdetection.webp b/public/assets/argusdetection.webp new file mode 100644 index 0000000..3a83670 Binary files /dev/null and b/public/assets/argusdetection.webp differ diff --git a/public/assets/argusreports.webp b/public/assets/argusreports.webp new file mode 100644 index 0000000..4c561dd Binary files /dev/null and b/public/assets/argusreports.webp differ diff --git a/public/assets/argussearch.webp b/public/assets/argussearch.webp new file mode 100644 index 0000000..ffd9e00 Binary files /dev/null and b/public/assets/argussearch.webp differ diff --git a/public/assets/argusstats.webp b/public/assets/argusstats.webp new file mode 100644 index 0000000..8adf46c Binary files /dev/null and b/public/assets/argusstats.webp differ diff --git a/utils/cn.ts b/utils/cn.ts new file mode 100644 index 0000000..365058c --- /dev/null +++ b/utils/cn.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +}