);
@@ -162,7 +162,7 @@ export default function GeographicMap({
{countryData.length > 0 ? (
+
No geographic data available
)}
diff --git a/components/Map.tsx b/components/Map.tsx
index 2522ad6..b95f232 100644
--- a/components/Map.tsx
+++ b/components/Map.tsx
@@ -3,6 +3,8 @@
import { MapContainer, TileLayer, CircleMarker, Tooltip } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import { getLocalizedCountryName } from "../lib/localization";
+import { useTheme } from "next-themes";
+import { useEffect, useState } from "react";
interface CountryData {
code: string;
@@ -16,6 +18,29 @@ interface MapProps {
}
const Map = ({ countryData, maxCount }: MapProps) => {
+ const { theme } = useTheme();
+ const [mounted, setMounted] = useState(false);
+
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ // Don't render until mounted to avoid hydration mismatch
+ if (!mounted) {
+ return
;
+ }
+
+ const isDark = theme === "dark";
+
+ // Use different tile layers based on theme
+ const tileLayerUrl = isDark
+ ? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
+ : "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
+
+ const tileLayerAttribution = isDark
+ ? '©
OpenStreetMap contributors ©
CARTO '
+ : '©
OpenStreetMap contributors ©
CARTO ';
+
return (
{
style={{ height: "100%", width: "100%", borderRadius: "0.5rem" }}
>
{countryData.map((country) => (
{
center={country.coordinates}
radius={5 + (country.count / maxCount) * 20}
pathOptions={{
- fillColor: "#3B82F6",
- color: "#1E40AF",
- weight: 1,
- opacity: 0.8,
+ fillColor: "hsl(var(--primary))",
+ color: "hsl(var(--primary))",
+ weight: 2,
+ opacity: 0.9,
fillOpacity: 0.6,
}}
>
-
-
+
+
{getLocalizedCountryName(country.code)}
-
Sessions: {country.count}
+
Sessions: {country.count}
diff --git a/components/ResponseTimeDistribution.tsx b/components/ResponseTimeDistribution.tsx
index 255563a..a1417a4 100644
--- a/components/ResponseTimeDistribution.tsx
+++ b/components/ResponseTimeDistribution.tsx
@@ -114,7 +114,12 @@ export default function ResponseTimeDistribution({
/>
} />
-
+
{chartData.map((entry, index) => (
))}
diff --git a/components/Sidebar.tsx b/components/Sidebar.tsx
index e8e4065..0c83393 100644
--- a/components/Sidebar.tsx
+++ b/components/Sidebar.tsx
@@ -5,6 +5,7 @@ import Link from "next/link";
import Image from "next/image";
import { usePathname } from "next/navigation";
import { signOut } from "next-auth/react";
+import { SimpleThemeToggle } from "@/components/ui/theme-toggle";
// Icons for the sidebar
const DashboardIcon = () => (
@@ -158,8 +159,8 @@ const NavItem: React.FC = ({
href={href}
className={`relative flex items-center p-3 my-1 rounded-lg transition-all group ${
isActive
- ? "bg-sky-100 text-sky-800 font-medium"
- : "hover:bg-gray-100 text-gray-700 hover:text-gray-900"
+ ? "bg-primary/10 text-primary font-medium border border-primary/20"
+ : "hover:bg-muted text-muted-foreground hover:text-foreground"
}`}
onClick={() => {
if (onNavigate) {
@@ -175,7 +176,7 @@ const NavItem: React.FC = ({
) : (
@@ -202,13 +203,13 @@ export default function Sidebar({
{/* Backdrop overlay when sidebar is expanded on mobile */}
{isExpanded && isMobile && (
)}
@@ -248,7 +249,7 @@ export default function Sidebar({
/>
{isExpanded && (
-
+
LiveDash
)}
@@ -261,7 +262,7 @@ export default function Sidebar({
e.preventDefault(); // Prevent any navigation
onToggle();
}}
- className="p-1.5 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-sky-500 transition-colors group"
+ className="p-1.5 rounded-md hover:bg-muted focus:outline-none focus:ring-2 focus:ring-primary transition-colors group"
title="Collapse sidebar"
>
@@ -327,10 +328,17 @@ export default function Sidebar({
onNavigate={onNavigate}
/>
-
+
+ {/* Theme Toggle */}
+
+ {isExpanded && Theme }
+
+
+
+ {/* Logout Button */}
@@ -342,7 +350,7 @@ export default function Sidebar({
) : (
diff --git a/components/TopQuestionsChart.tsx b/components/TopQuestionsChart.tsx
index f1fbae2..b656547 100644
--- a/components/TopQuestionsChart.tsx
+++ b/components/TopQuestionsChart.tsx
@@ -2,6 +2,9 @@
import React from "react";
import { TopQuestion } from "../lib/types";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Separator } from "@/components/ui/separator";
interface TopQuestionsChartProps {
data: TopQuestion[];
@@ -14,12 +17,16 @@ export default function TopQuestionsChart({
}: TopQuestionsChartProps) {
if (!data || data.length === 0) {
return (
-
-
{title}
-
- No questions data available
-
-
+
+
+ {title}
+
+
+
+ No questions data available
+
+
+
);
}
@@ -27,52 +34,55 @@ export default function TopQuestionsChart({
const maxCount = Math.max(...data.map((q) => q.count));
return (
-
-
{title}
+
+
+ {title}
+
+
+
+ {data.map((question, index) => {
+ const percentage =
+ maxCount > 0 ? (question.count / maxCount) * 100 : 0;
-
- {data.map((question, index) => {
- const percentage =
- maxCount > 0 ? (question.count / maxCount) * 100 : 0;
+ return (
+
+ {/* Question text */}
+
+
+ {question.question}
+
+
+ {question.count}
+
+
- return (
-
- {/* Question text */}
-
-
- {question.question}
-
-
- {question.count}
-
+ {/* Progress bar */}
+
+
+ {/* Rank indicator */}
+
+ {index + 1}
+
+ );
+ })}
+
- {/* Progress bar */}
-
+
- {/* Rank indicator */}
-
- {index + 1}
-
-
- );
- })}
-
-
- {/* Summary */}
-
-
+ {/* Summary */}
+
Total questions analyzed
-
+
{data.reduce((sum, q) => sum + q.count, 0)}
-
-
+
+
);
}
diff --git a/components/magicui/animated-beam.tsx b/components/magicui/animated-beam.tsx
new file mode 100644
index 0000000..eefe1c4
--- /dev/null
+++ b/components/magicui/animated-beam.tsx
@@ -0,0 +1,188 @@
+"use client";
+
+import { motion } from "motion/react";
+import { RefObject, useEffect, useId, useState } from "react";
+
+import { cn } from "@/lib/utils";
+
+export interface AnimatedBeamProps {
+ className?: string;
+ containerRef: RefObject
; // Container ref
+ fromRef: RefObject;
+ toRef: RefObject;
+ curvature?: number;
+ reverse?: boolean;
+ pathColor?: string;
+ pathWidth?: number;
+ pathOpacity?: number;
+ gradientStartColor?: string;
+ gradientStopColor?: string;
+ delay?: number;
+ duration?: number;
+ startXOffset?: number;
+ startYOffset?: number;
+ endXOffset?: number;
+ endYOffset?: number;
+}
+
+export const AnimatedBeam: React.FC = ({
+ className,
+ containerRef,
+ fromRef,
+ toRef,
+ curvature = 0,
+ reverse = false, // Include the reverse prop
+ duration = Math.random() * 3 + 4,
+ delay = 0,
+ pathColor = "gray",
+ pathWidth = 2,
+ pathOpacity = 0.2,
+ gradientStartColor = "#ffaa40",
+ gradientStopColor = "#9c40ff",
+ startXOffset = 0,
+ startYOffset = 0,
+ endXOffset = 0,
+ endYOffset = 0,
+}) => {
+ const id = useId();
+ const [pathD, setPathD] = useState("");
+ const [svgDimensions, setSvgDimensions] = useState({ width: 0, height: 0 });
+
+ // Calculate the gradient coordinates based on the reverse prop
+ const gradientCoordinates = reverse
+ ? {
+ x1: ["90%", "-10%"],
+ x2: ["100%", "0%"],
+ y1: ["0%", "0%"],
+ y2: ["0%", "0%"],
+ }
+ : {
+ x1: ["10%", "110%"],
+ x2: ["0%", "100%"],
+ y1: ["0%", "0%"],
+ y2: ["0%", "0%"],
+ };
+
+ useEffect(() => {
+ const updatePath = () => {
+ if (containerRef.current && fromRef.current && toRef.current) {
+ const containerRect = containerRef.current.getBoundingClientRect();
+ const rectA = fromRef.current.getBoundingClientRect();
+ const rectB = toRef.current.getBoundingClientRect();
+
+ const svgWidth = containerRect.width;
+ const svgHeight = containerRect.height;
+ setSvgDimensions({ width: svgWidth, height: svgHeight });
+
+ const startX =
+ rectA.left - containerRect.left + rectA.width / 2 + startXOffset;
+ const startY =
+ rectA.top - containerRect.top + rectA.height / 2 + startYOffset;
+ const endX =
+ rectB.left - containerRect.left + rectB.width / 2 + endXOffset;
+ const endY =
+ rectB.top - containerRect.top + rectB.height / 2 + endYOffset;
+
+ const controlY = startY - curvature;
+ const d = `M ${startX},${startY} Q ${
+ (startX + endX) / 2
+ },${controlY} ${endX},${endY}`;
+ setPathD(d);
+ }
+ };
+
+ // Initialize ResizeObserver
+ const resizeObserver = new ResizeObserver((entries) => {
+ // For all entries, recalculate the path
+ for (const entry of entries) {
+ updatePath();
+ }
+ });
+
+ // Observe the container element
+ if (containerRef.current) {
+ resizeObserver.observe(containerRef.current);
+ }
+
+ // Call the updatePath initially to set the initial path
+ updatePath();
+
+ // Clean up the observer on component unmount
+ return () => {
+ resizeObserver.disconnect();
+ };
+ }, [
+ containerRef,
+ fromRef,
+ toRef,
+ curvature,
+ startXOffset,
+ startYOffset,
+ endXOffset,
+ endYOffset,
+ ]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/components/magicui/animated-circular-progress-bar.tsx b/components/magicui/animated-circular-progress-bar.tsx
new file mode 100644
index 0000000..a9823e3
--- /dev/null
+++ b/components/magicui/animated-circular-progress-bar.tsx
@@ -0,0 +1,108 @@
+import { cn } from "@/lib/utils";
+
+interface AnimatedCircularProgressBarProps {
+ max: number;
+ value: number;
+ min: number;
+ gaugePrimaryColor: string;
+ gaugeSecondaryColor: string;
+ className?: string;
+}
+
+export function AnimatedCircularProgressBar({
+ max = 100,
+ min = 0,
+ value = 0,
+ gaugePrimaryColor,
+ gaugeSecondaryColor,
+ className,
+}: AnimatedCircularProgressBarProps) {
+ const circumference = 2 * Math.PI * 45;
+ const percentPx = circumference / 100;
+ const currentPercent = Math.round(((value - min) / (max - min)) * 100);
+
+ return (
+
+
+ {currentPercent <= 90 && currentPercent >= 0 && (
+
+ )}
+
+
+
+ {currentPercent}
+
+
+ );
+}
diff --git a/components/magicui/animated-shiny-text.tsx b/components/magicui/animated-shiny-text.tsx
new file mode 100644
index 0000000..7a8506b
--- /dev/null
+++ b/components/magicui/animated-shiny-text.tsx
@@ -0,0 +1,39 @@
+import { ComponentPropsWithoutRef, CSSProperties, FC } from "react";
+
+import { cn } from "@/lib/utils";
+
+export interface AnimatedShinyTextProps
+ extends ComponentPropsWithoutRef<"span"> {
+ shimmerWidth?: number;
+}
+
+export const AnimatedShinyText: FC = ({
+ children,
+ className,
+ shimmerWidth = 100,
+ ...props
+}) => {
+ return (
+
+ {children}
+
+ );
+};
diff --git a/components/magicui/aurora-text.tsx b/components/magicui/aurora-text.tsx
new file mode 100644
index 0000000..4b37963
--- /dev/null
+++ b/components/magicui/aurora-text.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import React, { memo } from "react";
+
+interface AuroraTextProps {
+ children: React.ReactNode;
+ className?: string;
+ colors?: string[];
+ speed?: number;
+}
+
+export const AuroraText = memo(
+ ({
+ children,
+ className = "",
+ colors = ["#FF0080", "#7928CA", "#0070F3", "#38bdf8"],
+ speed = 1,
+ }: AuroraTextProps) => {
+ const gradientStyle = {
+ backgroundImage: `linear-gradient(135deg, ${colors.join(", ")}, ${
+ colors[0]
+ })`,
+ WebkitBackgroundClip: "text",
+ WebkitTextFillColor: "transparent",
+ animationDuration: `${10 / speed}s`,
+ };
+
+ return (
+
+ {children}
+
+ {children}
+
+
+ );
+ },
+);
+
+AuroraText.displayName = "AuroraText";
diff --git a/components/magicui/blur-fade.tsx b/components/magicui/blur-fade.tsx
new file mode 100644
index 0000000..5f83fc5
--- /dev/null
+++ b/components/magicui/blur-fade.tsx
@@ -0,0 +1,81 @@
+"use client";
+
+import {
+ AnimatePresence,
+ motion,
+ useInView,
+ UseInViewOptions,
+ Variants,
+ MotionProps,
+} from "motion/react";
+import { useRef } from "react";
+
+type MarginType = UseInViewOptions["margin"];
+
+interface BlurFadeProps extends MotionProps {
+ children: React.ReactNode;
+ className?: string;
+ variant?: {
+ hidden: { y: number };
+ visible: { y: number };
+ };
+ duration?: number;
+ delay?: number;
+ offset?: number;
+ direction?: "up" | "down" | "left" | "right";
+ inView?: boolean;
+ inViewMargin?: MarginType;
+ blur?: string;
+}
+
+export function BlurFade({
+ children,
+ className,
+ variant,
+ duration = 0.4,
+ delay = 0,
+ offset = 6,
+ direction = "down",
+ inView = false,
+ inViewMargin = "-50px",
+ blur = "6px",
+ ...props
+}: BlurFadeProps) {
+ const ref = useRef(null);
+ const inViewResult = useInView(ref, { once: true, margin: inViewMargin });
+ const isInView = !inView || inViewResult;
+ const defaultVariants: Variants = {
+ hidden: {
+ [direction === "left" || direction === "right" ? "x" : "y"]:
+ direction === "right" || direction === "down" ? -offset : offset,
+ opacity: 0,
+ filter: `blur(${blur})`,
+ },
+ visible: {
+ [direction === "left" || direction === "right" ? "x" : "y"]: 0,
+ opacity: 1,
+ filter: `blur(0px)`,
+ },
+ };
+ const combinedVariants = variant || defaultVariants;
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/components/magicui/border-beam.tsx b/components/magicui/border-beam.tsx
new file mode 100644
index 0000000..c9ca066
--- /dev/null
+++ b/components/magicui/border-beam.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { motion, MotionStyle, Transition } from "motion/react";
+
+interface BorderBeamProps {
+ /**
+ * The size of the border beam.
+ */
+ size?: number;
+ /**
+ * The duration of the border beam.
+ */
+ duration?: number;
+ /**
+ * The delay of the border beam.
+ */
+ delay?: number;
+ /**
+ * The color of the border beam from.
+ */
+ colorFrom?: string;
+ /**
+ * The color of the border beam to.
+ */
+ colorTo?: string;
+ /**
+ * The motion transition of the border beam.
+ */
+ transition?: Transition;
+ /**
+ * The class name of the border beam.
+ */
+ className?: string;
+ /**
+ * The style of the border beam.
+ */
+ style?: React.CSSProperties;
+ /**
+ * Whether to reverse the animation direction.
+ */
+ reverse?: boolean;
+ /**
+ * The initial offset position (0-100).
+ */
+ initialOffset?: number;
+ /**
+ * The border width of the beam.
+ */
+ borderWidth?: number;
+}
+
+export const BorderBeam = ({
+ className,
+ size = 50,
+ delay = 0,
+ duration = 6,
+ colorFrom = "#ffaa40",
+ colorTo = "#9c40ff",
+ transition,
+ style,
+ reverse = false,
+ initialOffset = 0,
+ borderWidth = 1,
+}: BorderBeamProps) => {
+ return (
+
+
+
+ );
+};
diff --git a/components/magicui/confetti.tsx b/components/magicui/confetti.tsx
new file mode 100644
index 0000000..c6df6ce
--- /dev/null
+++ b/components/magicui/confetti.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import type {
+ GlobalOptions as ConfettiGlobalOptions,
+ CreateTypes as ConfettiInstance,
+ Options as ConfettiOptions,
+} from "canvas-confetti";
+import confetti from "canvas-confetti";
+import type { ReactNode } from "react";
+import React, {
+ createContext,
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+} from "react";
+
+import { Button, ButtonProps } from "@/components/ui/button";
+
+type Api = {
+ fire: (options?: ConfettiOptions) => void;
+};
+
+type Props = React.ComponentPropsWithRef<"canvas"> & {
+ options?: ConfettiOptions;
+ globalOptions?: ConfettiGlobalOptions;
+ manualstart?: boolean;
+ children?: ReactNode;
+};
+
+export type ConfettiRef = Api | null;
+
+const ConfettiContext = createContext({} as Api);
+
+// Define component first
+const ConfettiComponent = forwardRef((props, ref) => {
+ const {
+ options,
+ globalOptions = { resize: true, useWorker: true },
+ manualstart = false,
+ children,
+ ...rest
+ } = props;
+ const instanceRef = useRef(null);
+
+ const canvasRef = useCallback(
+ (node: HTMLCanvasElement) => {
+ if (node !== null) {
+ if (instanceRef.current) return;
+ instanceRef.current = confetti.create(node, {
+ ...globalOptions,
+ resize: true,
+ });
+ } else {
+ if (instanceRef.current) {
+ instanceRef.current.reset();
+ instanceRef.current = null;
+ }
+ }
+ },
+ [globalOptions],
+ );
+
+ const fire = useCallback(
+ async (opts = {}) => {
+ try {
+ await instanceRef.current?.({ ...options, ...opts });
+ } catch (error) {
+ console.error("Confetti error:", error);
+ }
+ },
+ [options],
+ );
+
+ const api = useMemo(
+ () => ({
+ fire,
+ }),
+ [fire],
+ );
+
+ useImperativeHandle(ref, () => api, [api]);
+
+ useEffect(() => {
+ if (!manualstart) {
+ (async () => {
+ try {
+ await fire();
+ } catch (error) {
+ console.error("Confetti effect error:", error);
+ }
+ })();
+ }
+ }, [manualstart, fire]);
+
+ return (
+
+
+ {children}
+
+ );
+});
+
+// Set display name immediately
+ConfettiComponent.displayName = "Confetti";
+
+// Export as Confetti
+export const Confetti = ConfettiComponent;
+
+interface ConfettiButtonProps extends ButtonProps {
+ options?: ConfettiOptions &
+ ConfettiGlobalOptions & { canvas?: HTMLCanvasElement };
+ children?: React.ReactNode;
+}
+
+const ConfettiButtonComponent = ({
+ options,
+ children,
+ ...props
+}: ConfettiButtonProps) => {
+ const handleClick = async (event: React.MouseEvent) => {
+ try {
+ const rect = event.currentTarget.getBoundingClientRect();
+ const x = rect.left + rect.width / 2;
+ const y = rect.top + rect.height / 2;
+ await confetti({
+ ...options,
+ origin: {
+ x: x / window.innerWidth,
+ y: y / window.innerHeight,
+ },
+ });
+ } catch (error) {
+ console.error("Confetti button error:", error);
+ }
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+ConfettiButtonComponent.displayName = "ConfettiButton";
+
+export const ConfettiButton = ConfettiButtonComponent;
diff --git a/components/magicui/magic-card.tsx b/components/magicui/magic-card.tsx
new file mode 100644
index 0000000..58b71ef
--- /dev/null
+++ b/components/magicui/magic-card.tsx
@@ -0,0 +1,108 @@
+"use client";
+
+import { motion, useMotionTemplate, useMotionValue } from "motion/react";
+import React, { useCallback, useEffect, useRef } from "react";
+
+import { cn } from "@/lib/utils";
+
+interface MagicCardProps {
+ children?: React.ReactNode;
+ className?: string;
+ gradientSize?: number;
+ gradientColor?: string;
+ gradientOpacity?: number;
+ gradientFrom?: string;
+ gradientTo?: string;
+}
+
+export function MagicCard({
+ children,
+ className,
+ gradientSize = 200,
+ gradientColor = "#262626",
+ gradientOpacity = 0.8,
+ gradientFrom = "#9E7AFF",
+ gradientTo = "#FE8BBB",
+}: MagicCardProps) {
+ const cardRef = useRef(null);
+ const mouseX = useMotionValue(-gradientSize);
+ const mouseY = useMotionValue(-gradientSize);
+
+ const handleMouseMove = useCallback(
+ (e: MouseEvent) => {
+ if (cardRef.current) {
+ const { left, top } = cardRef.current.getBoundingClientRect();
+ const clientX = e.clientX;
+ const clientY = e.clientY;
+ mouseX.set(clientX - left);
+ mouseY.set(clientY - top);
+ }
+ },
+ [mouseX, mouseY],
+ );
+
+ const handleMouseOut = useCallback(
+ (e: MouseEvent) => {
+ if (!e.relatedTarget) {
+ document.removeEventListener("mousemove", handleMouseMove);
+ mouseX.set(-gradientSize);
+ mouseY.set(-gradientSize);
+ }
+ },
+ [handleMouseMove, mouseX, gradientSize, mouseY],
+ );
+
+ const handleMouseEnter = useCallback(() => {
+ document.addEventListener("mousemove", handleMouseMove);
+ mouseX.set(-gradientSize);
+ mouseY.set(-gradientSize);
+ }, [handleMouseMove, mouseX, gradientSize, mouseY]);
+
+ useEffect(() => {
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseout", handleMouseOut);
+ document.addEventListener("mouseenter", handleMouseEnter);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseout", handleMouseOut);
+ document.removeEventListener("mouseenter", handleMouseEnter);
+ };
+ }, [handleMouseEnter, handleMouseMove, handleMouseOut]);
+
+ useEffect(() => {
+ mouseX.set(-gradientSize);
+ mouseY.set(-gradientSize);
+ }, [gradientSize, mouseX, mouseY]);
+
+ return (
+
+ );
+}
diff --git a/components/magicui/meteors.tsx b/components/magicui/meteors.tsx
new file mode 100644
index 0000000..de2c61b
--- /dev/null
+++ b/components/magicui/meteors.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import React, { useEffect, useState } from "react";
+
+interface MeteorsProps {
+ number?: number;
+ minDelay?: number;
+ maxDelay?: number;
+ minDuration?: number;
+ maxDuration?: number;
+ angle?: number;
+ className?: string;
+}
+
+export const Meteors = ({
+ number = 20,
+ minDelay = 0.2,
+ maxDelay = 1.2,
+ minDuration = 2,
+ maxDuration = 10,
+ angle = 215,
+ className,
+}: MeteorsProps) => {
+ const [meteorStyles, setMeteorStyles] = useState>(
+ [],
+ );
+
+ useEffect(() => {
+ const styles = [...new Array(number)].map(() => ({
+ "--angle": -angle + "deg",
+ top: "-5%",
+ left: `calc(0% + ${Math.floor(Math.random() * window.innerWidth)}px)`,
+ animationDelay: Math.random() * (maxDelay - minDelay) + minDelay + "s",
+ animationDuration:
+ Math.floor(Math.random() * (maxDuration - minDuration) + minDuration) +
+ "s",
+ }));
+ setMeteorStyles(styles);
+ }, [number, minDelay, maxDelay, minDuration, maxDuration, angle]);
+
+ return (
+ <>
+ {[...meteorStyles].map((style, idx) => (
+ // Meteor Head
+
+ {/* Meteor Tail */}
+
+
+ ))}
+ >
+ );
+};
diff --git a/components/magicui/neon-gradient-card.tsx b/components/magicui/neon-gradient-card.tsx
new file mode 100644
index 0000000..1a2a76b
--- /dev/null
+++ b/components/magicui/neon-gradient-card.tsx
@@ -0,0 +1,149 @@
+"use client";
+
+import {
+ CSSProperties,
+ ReactElement,
+ ReactNode,
+ useEffect,
+ useRef,
+ useState,
+} from "react";
+
+import { cn } from "@/lib/utils";
+
+interface NeonColorsProps {
+ firstColor: string;
+ secondColor: string;
+}
+
+interface NeonGradientCardProps {
+ /**
+ * @default
+ * @type ReactElement
+ * @description
+ * The component to be rendered as the card
+ * */
+ as?: ReactElement;
+ /**
+ * @default ""
+ * @type string
+ * @description
+ * The className of the card
+ */
+ className?: string;
+
+ /**
+ * @default ""
+ * @type ReactNode
+ * @description
+ * The children of the card
+ * */
+ children?: ReactNode;
+
+ /**
+ * @default 5
+ * @type number
+ * @description
+ * The size of the border in pixels
+ * */
+ borderSize?: number;
+
+ /**
+ * @default 20
+ * @type number
+ * @description
+ * The size of the radius in pixels
+ * */
+ borderRadius?: number;
+
+ /**
+ * @default "{ firstColor: '#ff00aa', secondColor: '#00FFF1' }"
+ * @type string
+ * @description
+ * The colors of the neon gradient
+ * */
+ neonColors?: NeonColorsProps;
+
+ [key: string]: any;
+}
+
+export const NeonGradientCard: React.FC = ({
+ className,
+ children,
+ borderSize = 2,
+ borderRadius = 20,
+ neonColors = {
+ firstColor: "#ff00aa",
+ secondColor: "#00FFF1",
+ },
+ ...props
+}) => {
+ const containerRef = useRef(null);
+ const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
+
+ useEffect(() => {
+ const updateDimensions = () => {
+ if (containerRef.current) {
+ const { offsetWidth, offsetHeight } = containerRef.current;
+ setDimensions({ width: offsetWidth, height: offsetHeight });
+ }
+ };
+
+ updateDimensions();
+ window.addEventListener("resize", updateDimensions);
+
+ return () => {
+ window.removeEventListener("resize", updateDimensions);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (containerRef.current) {
+ const { offsetWidth, offsetHeight } = containerRef.current;
+ setDimensions({ width: offsetWidth, height: offsetHeight });
+ }
+ }, [children]);
+
+ return (
+
+ );
+};
diff --git a/components/magicui/number-ticker.tsx b/components/magicui/number-ticker.tsx
new file mode 100644
index 0000000..1b9bf39
--- /dev/null
+++ b/components/magicui/number-ticker.tsx
@@ -0,0 +1,67 @@
+"use client";
+
+import { useInView, useMotionValue, useSpring } from "motion/react";
+import { ComponentPropsWithoutRef, useEffect, useRef } from "react";
+
+import { cn } from "@/lib/utils";
+
+interface NumberTickerProps extends ComponentPropsWithoutRef<"span"> {
+ value: number;
+ startValue?: number;
+ direction?: "up" | "down";
+ delay?: number;
+ decimalPlaces?: number;
+}
+
+export function NumberTicker({
+ value,
+ startValue = 0,
+ direction = "up",
+ delay = 0,
+ className,
+ decimalPlaces = 0,
+ ...props
+}: NumberTickerProps) {
+ const ref = useRef(null);
+ const motionValue = useMotionValue(direction === "down" ? value : startValue);
+ const springValue = useSpring(motionValue, {
+ damping: 60,
+ stiffness: 100,
+ });
+ const isInView = useInView(ref, { once: true, margin: "0px" });
+
+ useEffect(() => {
+ if (isInView) {
+ const timer = setTimeout(() => {
+ motionValue.set(direction === "down" ? startValue : value);
+ }, delay * 1000);
+ return () => clearTimeout(timer);
+ }
+ }, [motionValue, isInView, delay, value, direction, startValue]);
+
+ useEffect(
+ () =>
+ springValue.on("change", (latest) => {
+ if (ref.current) {
+ ref.current.textContent = Intl.NumberFormat("en-US", {
+ minimumFractionDigits: decimalPlaces,
+ maximumFractionDigits: decimalPlaces,
+ }).format(Number(latest.toFixed(decimalPlaces)));
+ }
+ }),
+ [springValue, decimalPlaces],
+ );
+
+ return (
+
+ {startValue}
+
+ );
+}
diff --git a/components/magicui/pointer.tsx b/components/magicui/pointer.tsx
new file mode 100644
index 0000000..be75d4e
--- /dev/null
+++ b/components/magicui/pointer.tsx
@@ -0,0 +1,118 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import {
+ AnimatePresence,
+ HTMLMotionProps,
+ motion,
+ useMotionValue,
+} from "motion/react";
+import { useEffect, useRef, useState } from "react";
+
+interface PointerProps extends Omit, "ref"> {}
+
+/**
+ * A custom pointer component that displays an animated cursor.
+ * Add this as a child to any component to enable a custom pointer when hovering.
+ * You can pass custom children to render as the pointer.
+ *
+ * @component
+ * @param {PointerProps} props - The component props
+ */
+export function Pointer({
+ className,
+ style,
+ children,
+ ...props
+}: PointerProps): JSX.Element {
+ const x = useMotionValue(0);
+ const y = useMotionValue(0);
+ const [isActive, setIsActive] = useState(false);
+ const containerRef = useRef(null);
+
+ useEffect(() => {
+ if (typeof window !== "undefined" && containerRef.current) {
+ // Get the parent element directly from the ref
+ const parentElement = containerRef.current.parentElement;
+
+ if (parentElement) {
+ // Add cursor-none to parent
+ parentElement.style.cursor = "none";
+
+ // Add event listeners to parent
+ const handleMouseMove = (e: MouseEvent) => {
+ x.set(e.clientX);
+ y.set(e.clientY);
+ };
+
+ const handleMouseEnter = () => {
+ setIsActive(true);
+ };
+
+ const handleMouseLeave = () => {
+ setIsActive(false);
+ };
+
+ parentElement.addEventListener("mousemove", handleMouseMove);
+ parentElement.addEventListener("mouseenter", handleMouseEnter);
+ parentElement.addEventListener("mouseleave", handleMouseLeave);
+
+ return () => {
+ parentElement.style.cursor = "";
+ parentElement.removeEventListener("mousemove", handleMouseMove);
+ parentElement.removeEventListener("mouseenter", handleMouseEnter);
+ parentElement.removeEventListener("mouseleave", handleMouseLeave);
+ };
+ }
+ }
+ }, [x, y]);
+
+ return (
+ <>
+
+
+ {isActive && (
+
+ {children || (
+
+
+
+ )}
+
+ )}
+
+ >
+ );
+}
diff --git a/components/magicui/scroll-progress.tsx b/components/magicui/scroll-progress.tsx
new file mode 100644
index 0000000..3edfe6f
--- /dev/null
+++ b/components/magicui/scroll-progress.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { motion, MotionProps, useScroll } from "motion/react";
+import React from "react";
+interface ScrollProgressProps
+ extends Omit, keyof MotionProps> {}
+
+export const ScrollProgress = React.forwardRef<
+ HTMLDivElement,
+ ScrollProgressProps
+>(({ className, ...props }, ref) => {
+ const { scrollYProgress } = useScroll();
+
+ return (
+
+ );
+});
+
+ScrollProgress.displayName = "ScrollProgress";
diff --git a/components/magicui/shine-border.tsx b/components/magicui/shine-border.tsx
new file mode 100644
index 0000000..45b1c41
--- /dev/null
+++ b/components/magicui/shine-border.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+interface ShineBorderProps extends React.HTMLAttributes {
+ /**
+ * Width of the border in pixels
+ * @default 1
+ */
+ borderWidth?: number;
+ /**
+ * Duration of the animation in seconds
+ * @default 14
+ */
+ duration?: number;
+ /**
+ * Color of the border, can be a single color or an array of colors
+ * @default "#000000"
+ */
+ shineColor?: string | string[];
+}
+
+/**
+ * Shine Border
+ *
+ * An animated background border effect component with configurable properties.
+ */
+export function ShineBorder({
+ borderWidth = 1,
+ duration = 14,
+ shineColor = "#000000",
+ className,
+ style,
+ ...props
+}: ShineBorderProps) {
+ return (
+
+ );
+}
diff --git a/components/magicui/text-animate.tsx b/components/magicui/text-animate.tsx
new file mode 100644
index 0000000..dc988dd
--- /dev/null
+++ b/components/magicui/text-animate.tsx
@@ -0,0 +1,410 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { AnimatePresence, motion, MotionProps, Variants } from "motion/react";
+import { ElementType, memo } from "react";
+
+type AnimationType = "text" | "word" | "character" | "line";
+type AnimationVariant =
+ | "fadeIn"
+ | "blurIn"
+ | "blurInUp"
+ | "blurInDown"
+ | "slideUp"
+ | "slideDown"
+ | "slideLeft"
+ | "slideRight"
+ | "scaleUp"
+ | "scaleDown";
+
+interface TextAnimateProps extends MotionProps {
+ /**
+ * The text content to animate
+ */
+ children: string;
+ /**
+ * The class name to be applied to the component
+ */
+ className?: string;
+ /**
+ * The class name to be applied to each segment
+ */
+ segmentClassName?: string;
+ /**
+ * The delay before the animation starts
+ */
+ delay?: number;
+ /**
+ * The duration of the animation
+ */
+ duration?: number;
+ /**
+ * Custom motion variants for the animation
+ */
+ variants?: Variants;
+ /**
+ * The element type to render
+ */
+ as?: ElementType;
+ /**
+ * How to split the text ("text", "word", "character")
+ */
+ by?: AnimationType;
+ /**
+ * Whether to start animation when component enters viewport
+ */
+ startOnView?: boolean;
+ /**
+ * Whether to animate only once
+ */
+ once?: boolean;
+ /**
+ * The animation preset to use
+ */
+ animation?: AnimationVariant;
+}
+
+const staggerTimings: Record = {
+ text: 0.06,
+ word: 0.05,
+ character: 0.03,
+ line: 0.06,
+};
+
+const defaultContainerVariants = {
+ hidden: { opacity: 1 },
+ show: {
+ opacity: 1,
+ transition: {
+ delayChildren: 0,
+ staggerChildren: 0.05,
+ },
+ },
+ exit: {
+ opacity: 0,
+ transition: {
+ staggerChildren: 0.05,
+ staggerDirection: -1,
+ },
+ },
+};
+
+const defaultItemVariants: Variants = {
+ hidden: { opacity: 0 },
+ show: {
+ opacity: 1,
+ },
+ exit: {
+ opacity: 0,
+ },
+};
+
+const defaultItemAnimationVariants: Record<
+ AnimationVariant,
+ { container: Variants; item: Variants }
+> = {
+ fadeIn: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0, y: 20 },
+ show: {
+ opacity: 1,
+ y: 0,
+ transition: {
+ duration: 0.3,
+ },
+ },
+ exit: {
+ opacity: 0,
+ y: 20,
+ transition: { duration: 0.3 },
+ },
+ },
+ },
+ blurIn: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0, filter: "blur(10px)" },
+ show: {
+ opacity: 1,
+ filter: "blur(0px)",
+ transition: {
+ duration: 0.3,
+ },
+ },
+ exit: {
+ opacity: 0,
+ filter: "blur(10px)",
+ transition: { duration: 0.3 },
+ },
+ },
+ },
+ blurInUp: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0, filter: "blur(10px)", y: 20 },
+ show: {
+ opacity: 1,
+ filter: "blur(0px)",
+ y: 0,
+ transition: {
+ y: { duration: 0.3 },
+ opacity: { duration: 0.4 },
+ filter: { duration: 0.3 },
+ },
+ },
+ exit: {
+ opacity: 0,
+ filter: "blur(10px)",
+ y: 20,
+ transition: {
+ y: { duration: 0.3 },
+ opacity: { duration: 0.4 },
+ filter: { duration: 0.3 },
+ },
+ },
+ },
+ },
+ blurInDown: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { opacity: 0, filter: "blur(10px)", y: -20 },
+ show: {
+ opacity: 1,
+ filter: "blur(0px)",
+ y: 0,
+ transition: {
+ y: { duration: 0.3 },
+ opacity: { duration: 0.4 },
+ filter: { duration: 0.3 },
+ },
+ },
+ },
+ },
+ slideUp: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { y: 20, opacity: 0 },
+ show: {
+ y: 0,
+ opacity: 1,
+ transition: {
+ duration: 0.3,
+ },
+ },
+ exit: {
+ y: -20,
+ opacity: 0,
+ transition: {
+ duration: 0.3,
+ },
+ },
+ },
+ },
+ slideDown: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { y: -20, opacity: 0 },
+ show: {
+ y: 0,
+ opacity: 1,
+ transition: { duration: 0.3 },
+ },
+ exit: {
+ y: 20,
+ opacity: 0,
+ transition: { duration: 0.3 },
+ },
+ },
+ },
+ slideLeft: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { x: 20, opacity: 0 },
+ show: {
+ x: 0,
+ opacity: 1,
+ transition: { duration: 0.3 },
+ },
+ exit: {
+ x: -20,
+ opacity: 0,
+ transition: { duration: 0.3 },
+ },
+ },
+ },
+ slideRight: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { x: -20, opacity: 0 },
+ show: {
+ x: 0,
+ opacity: 1,
+ transition: { duration: 0.3 },
+ },
+ exit: {
+ x: 20,
+ opacity: 0,
+ transition: { duration: 0.3 },
+ },
+ },
+ },
+ scaleUp: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { scale: 0.5, opacity: 0 },
+ show: {
+ scale: 1,
+ opacity: 1,
+ transition: {
+ duration: 0.3,
+ scale: {
+ type: "spring",
+ damping: 15,
+ stiffness: 300,
+ },
+ },
+ },
+ exit: {
+ scale: 0.5,
+ opacity: 0,
+ transition: { duration: 0.3 },
+ },
+ },
+ },
+ scaleDown: {
+ container: defaultContainerVariants,
+ item: {
+ hidden: { scale: 1.5, opacity: 0 },
+ show: {
+ scale: 1,
+ opacity: 1,
+ transition: {
+ duration: 0.3,
+ scale: {
+ type: "spring",
+ damping: 15,
+ stiffness: 300,
+ },
+ },
+ },
+ exit: {
+ scale: 1.5,
+ opacity: 0,
+ transition: { duration: 0.3 },
+ },
+ },
+ },
+};
+
+const TextAnimateBase = ({
+ children,
+ delay = 0,
+ duration = 0.3,
+ variants,
+ className,
+ segmentClassName,
+ as: Component = "p",
+ startOnView = true,
+ once = false,
+ by = "word",
+ animation = "fadeIn",
+ ...props
+}: TextAnimateProps) => {
+ const MotionComponent = motion.create(Component);
+
+ let segments: string[] = [];
+ switch (by) {
+ case "word":
+ segments = children.split(/(\s+)/);
+ break;
+ case "character":
+ segments = children.split("");
+ break;
+ case "line":
+ segments = children.split("\n");
+ break;
+ case "text":
+ default:
+ segments = [children];
+ break;
+ }
+
+ const finalVariants = variants
+ ? {
+ container: {
+ hidden: { opacity: 0 },
+ show: {
+ opacity: 1,
+ transition: {
+ opacity: { duration: 0.01, delay },
+ delayChildren: delay,
+ staggerChildren: duration / segments.length,
+ },
+ },
+ exit: {
+ opacity: 0,
+ transition: {
+ staggerChildren: duration / segments.length,
+ staggerDirection: -1,
+ },
+ },
+ },
+ item: variants,
+ }
+ : animation
+ ? {
+ container: {
+ ...defaultItemAnimationVariants[animation].container,
+ show: {
+ ...defaultItemAnimationVariants[animation].container.show,
+ transition: {
+ delayChildren: delay,
+ staggerChildren: duration / segments.length,
+ },
+ },
+ exit: {
+ ...defaultItemAnimationVariants[animation].container.exit,
+ transition: {
+ staggerChildren: duration / segments.length,
+ staggerDirection: -1,
+ },
+ },
+ },
+ item: defaultItemAnimationVariants[animation].item,
+ }
+ : { container: defaultContainerVariants, item: defaultItemVariants };
+
+ return (
+
+
+ {segments.map((segment, i) => (
+
+ {segment}
+
+ ))}
+
+
+ );
+};
+
+// Export the memoized version
+export const TextAnimate = memo(TextAnimateBase);
diff --git a/components/magicui/text-reveal.tsx b/components/magicui/text-reveal.tsx
new file mode 100644
index 0000000..31127b6
--- /dev/null
+++ b/components/magicui/text-reveal.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { motion, MotionValue, useScroll, useTransform } from "motion/react";
+import { ComponentPropsWithoutRef, FC, ReactNode, useRef } from "react";
+
+import { cn } from "@/lib/utils";
+
+export interface TextRevealProps extends ComponentPropsWithoutRef<"div"> {
+ children: string;
+}
+
+export const TextReveal: FC = ({ children, className }) => {
+ const targetRef = useRef(null);
+ const { scrollYProgress } = useScroll({
+ target: targetRef,
+ });
+
+ if (typeof children !== "string") {
+ throw new Error("TextReveal: children must be a string");
+ }
+
+ const words = children.split(" ");
+
+ return (
+
+
+
+ {words.map((word, i) => {
+ const start = i / words.length;
+ const end = start + 1 / words.length;
+ return (
+
+ {word}
+
+ );
+ })}
+
+
+
+ );
+};
+
+interface WordProps {
+ children: ReactNode;
+ progress: MotionValue;
+ range: [number, number];
+}
+
+const Word: FC = ({ children, progress, range }) => {
+ const opacity = useTransform(progress, range, [0, 1]);
+ return (
+
+ {children}
+
+ {children}
+
+
+ );
+};
diff --git a/components/theme-provider.tsx b/components/theme-provider.tsx
new file mode 100644
index 0000000..7afe882
--- /dev/null
+++ b/components/theme-provider.tsx
@@ -0,0 +1,9 @@
+"use client";
+
+import * as React from "react";
+import { ThemeProvider as NextThemesProvider } from "next-themes";
+import { type ThemeProviderProps } from "next-themes/dist/types";
+
+export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
+ return {children} ;
+}
\ No newline at end of file
diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx
new file mode 100644
index 0000000..f002e4c
--- /dev/null
+++ b/components/ui/alert.tsx
@@ -0,0 +1,59 @@
+import * as React from "react";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+);
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes & VariantProps
+>(({ className, variant, ...props }, ref) => (
+
+));
+Alert.displayName = "Alert";
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertTitle.displayName = "AlertTitle";
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes
+>(({ className, ...props }, ref) => (
+
+));
+AlertDescription.displayName = "AlertDescription";
+
+export { Alert, AlertTitle, AlertDescription };
\ No newline at end of file
diff --git a/components/ui/input.tsx b/components/ui/input.tsx
new file mode 100644
index 0000000..9548be5
--- /dev/null
+++ b/components/ui/input.tsx
@@ -0,0 +1,24 @@
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+export type InputProps = React.InputHTMLAttributes
+
+const Input = React.forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+Input.displayName = "Input";
+
+export { Input };
\ No newline at end of file
diff --git a/components/ui/label.tsx b/components/ui/label.tsx
new file mode 100644
index 0000000..240a496
--- /dev/null
+++ b/components/ui/label.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import * as React from "react";
+import * as LabelPrimitive from "@radix-ui/react-label";
+import { cva, type VariantProps } from "class-variance-authority";
+
+import { cn } from "@/lib/utils";
+
+const labelVariants = cva(
+ "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+);
+
+const Label = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef &
+ VariantProps
+>(({ className, ...props }, ref) => (
+
+));
+Label.displayName = LabelPrimitive.Root.displayName;
+
+export { Label };
\ No newline at end of file
diff --git a/components/ui/metric-card.tsx b/components/ui/metric-card.tsx
index 8cac8c9..693fd36 100644
--- a/components/ui/metric-card.tsx
+++ b/components/ui/metric-card.tsx
@@ -102,7 +102,7 @@ export default function MetricCard({
return (
- {icon}
+ {icon}
)}
@@ -137,7 +137,9 @@ export default function MetricCard({
-
{value ?? "—"}
+
+ {value ?? "—"}
+
{trend && (
;
+
+const Toaster = ({ ...props }: ToasterProps) => {
+ const { theme = "system" } = useTheme();
+
+ return (
+
+ );
+};
+
+export { Toaster };
\ No newline at end of file
diff --git a/components/ui/theme-toggle.tsx b/components/ui/theme-toggle.tsx
new file mode 100644
index 0000000..8f77901
--- /dev/null
+++ b/components/ui/theme-toggle.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import * as React from "react";
+import { Moon, Sun } from "lucide-react";
+import { useTheme } from "next-themes";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+export function ThemeToggle() {
+ const { setTheme } = useTheme();
+
+ return (
+
+
+
+
+
+ Toggle theme
+
+
+
+ setTheme("light")}>
+ Light
+
+ setTheme("dark")}>
+ Dark
+
+ setTheme("system")}>
+ System
+
+
+
+ );
+}
+
+export function SimpleThemeToggle() {
+ const { theme, setTheme } = useTheme();
+ const [mounted, setMounted] = React.useState(false);
+
+ React.useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ if (!mounted) {
+ return (
+
+
+ Toggle theme
+
+ );
+ }
+
+ return (
+ setTheme(theme === "dark" ? "light" : "dark")}
+ >
+
+
+ Toggle theme
+
+ );
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index a98be83..e42f992 100644
--- a/package.json
+++ b/package.json
@@ -30,10 +30,12 @@
"@prisma/adapter-pg": "^6.10.1",
"@prisma/client": "^6.10.1",
"@radix-ui/react-dropdown-menu": "^2.1.15",
+ "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@rapideditor/country-coder": "^5.4.0",
+ "@types/canvas-confetti": "^1.9.0",
"@types/d3": "^7.4.3",
"@types/d3-cloud": "^1.2.9",
"@types/d3-selection": "^3.0.11",
@@ -41,6 +43,7 @@
"@types/leaflet": "^1.9.19",
"@types/node-fetch": "^2.6.12",
"bcryptjs": "^3.0.2",
+ "canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"csv-parse": "^5.6.0",
@@ -51,8 +54,10 @@
"iso-639-1": "^3.1.5",
"leaflet": "^1.9.4",
"lucide-react": "^0.525.0",
+ "motion": "^12.19.2",
"next": "^15.3.4",
"next-auth": "^4.24.11",
+ "next-themes": "^0.4.6",
"node-cron": "^4.1.1",
"node-fetch": "^3.3.2",
"react": "^19.1.0",
@@ -61,6 +66,7 @@
"react-markdown": "^10.1.0",
"recharts": "^3.0.2",
"rehype-raw": "^7.0.0",
+ "sonner": "^2.0.5",
"tailwind-merge": "^3.3.1",
"zod": "^3.25.67"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 032cd89..3392c75 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -17,6 +17,9 @@ importers:
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.15
version: 2.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@radix-ui/react-label':
+ specifier: ^2.1.7
+ version: 2.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
'@radix-ui/react-separator':
specifier: ^1.1.7
version: 1.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@@ -29,6 +32,9 @@ importers:
'@rapideditor/country-coder':
specifier: ^5.4.0
version: 5.4.0
+ '@types/canvas-confetti':
+ specifier: ^1.9.0
+ version: 1.9.0
'@types/d3':
specifier: ^7.4.3
version: 7.4.3
@@ -50,6 +56,9 @@ importers:
bcryptjs:
specifier: ^3.0.2
version: 3.0.2
+ canvas-confetti:
+ specifier: ^1.9.3
+ version: 1.9.3
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -80,12 +89,18 @@ importers:
lucide-react:
specifier: ^0.525.0
version: 0.525.0(react@19.1.0)
+ motion:
+ specifier: ^12.19.2
+ version: 12.19.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next:
specifier: ^15.3.4
version: 15.3.4(@babel/core@7.27.7)(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
next-auth:
specifier: ^4.24.11
version: 4.24.11(next@15.3.4(@babel/core@7.27.7)(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ next-themes:
+ specifier: ^0.4.6
+ version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
node-cron:
specifier: ^4.1.1
version: 4.1.1
@@ -110,6 +125,9 @@ importers:
rehype-raw:
specifier: ^7.0.0
version: 7.0.0
+ sonner:
+ specifier: ^2.0.5
+ version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
tailwind-merge:
specifier: ^3.3.1
version: 3.3.1
@@ -968,6 +986,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-label@2.1.7':
+ resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==}
+ 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-menu@2.1.15':
resolution: {integrity: sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==}
peerDependencies:
@@ -1444,6 +1475,9 @@ packages:
'@types/babel__traverse@7.20.7':
resolution: {integrity: sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==}
+ '@types/canvas-confetti@1.9.0':
+ resolution: {integrity: sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==}
+
'@types/chai@5.2.2':
resolution: {integrity: sha512-8kB30R7Hwqf40JPiKhVzodJs2Qc1ZJ5zuT3uzw5Hq/dhNCl3G3l83jfpdI1e20BP348+fV7VIL/+FxaXkqBmWg==}
@@ -1980,6 +2014,9 @@ packages:
caniuse-lite@1.0.30001726:
resolution: {integrity: sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==}
+ canvas-confetti@1.9.3:
+ resolution: {integrity: sha512-rFfTURMvmVEX1gyXFgn5QMn81bYk70qa0HLzcIOSVEyl57n6o9ItHeBtUSWdvKAPY0xlvBHno4/v3QPrT83q9g==}
+
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -2613,6 +2650,20 @@ packages:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
+ framer-motion@12.19.2:
+ resolution: {integrity: sha512-0cWMLkYr+i0emeXC4hkLF+5aYpzo32nRdQ0D/5DI460B3O7biQ3l2BpDzIGsAHYuZ0fpBP0DC8XBkVf6RPAlZw==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -3335,6 +3386,26 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ motion-dom@12.19.0:
+ resolution: {integrity: sha512-m96uqq8VbwxFLU0mtmlsIVe8NGGSdpBvBSHbnnOJQxniPaabvVdGgxSamhuDwBsRhwX7xPxdICgVJlOpzn/5bw==}
+
+ motion-utils@12.19.0:
+ resolution: {integrity: sha512-BuFTHINYmV07pdWs6lj6aI63vr2N4dg0vR+td0rtrdpWOhBzIkEklZyLcvKBoEtwSqx8Jg06vUB5RS0xDiUybw==}
+
+ motion@12.19.2:
+ resolution: {integrity: sha512-Yb69HXE4ryhVd1xwpgWMMQAQgqEGMSGWG+NOumans2fvSCtT8gsj8JK7jhcGnc410CLT3BFPgquP67zmjbA5Jw==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -3365,6 +3436,12 @@ packages:
nodemailer:
optional: true
+ next-themes@0.4.6:
+ resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
+ peerDependencies:
+ react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
+
next@15.3.4:
resolution: {integrity: sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
@@ -3905,6 +3982,12 @@ packages:
resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==}
engines: {node: '>=14.16'}
+ sonner@2.0.5:
+ resolution: {integrity: sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ==}
+ peerDependencies:
+ react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+ react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -5098,6 +5181,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.8
+ '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
+ dependencies:
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(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.1.8
+ '@types/react-dom': 19.1.6(@types/react@19.1.8)
+
'@radix-ui/react-menu@2.1.15(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)':
dependencies:
'@radix-ui/primitive': 1.1.2
@@ -5512,6 +5604,8 @@ snapshots:
dependencies:
'@babel/types': 7.27.7
+ '@types/canvas-confetti@1.9.0': {}
+
'@types/chai@5.2.2':
dependencies:
'@types/deep-eql': 4.0.2
@@ -6111,6 +6205,8 @@ snapshots:
caniuse-lite@1.0.30001726: {}
+ canvas-confetti@1.9.3: {}
+
ccount@2.0.1: {}
chai@5.2.0:
@@ -6910,6 +7006,15 @@ snapshots:
dependencies:
fetch-blob: 3.2.0
+ framer-motion@12.19.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ dependencies:
+ motion-dom: 12.19.0
+ motion-utils: 12.19.0
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+
fsevents@2.3.2:
optional: true
@@ -7852,6 +7957,20 @@ snapshots:
mkdirp@3.0.1: {}
+ motion-dom@12.19.0:
+ dependencies:
+ motion-utils: 12.19.0
+
+ motion-utils@12.19.0: {}
+
+ motion@12.19.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ dependencies:
+ framer-motion: 12.19.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+
ms@2.1.3: {}
nanoid@3.3.11: {}
@@ -7875,6 +7994,11 @@ snapshots:
react-dom: 19.1.0(react@19.1.0)
uuid: 8.3.2
+ next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ dependencies:
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+
next@15.3.4(@babel/core@7.27.7)(@playwright/test@1.53.1)(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@next/env': 15.3.4
@@ -8495,6 +8619,11 @@ snapshots:
slash@5.1.0: {}
+ sonner@2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
+ dependencies:
+ react: 19.1.0
+ react-dom: 19.1.0(react@19.1.0)
+
source-map-js@1.2.1: {}
space-separated-tokens@2.0.2: {}
diff --git a/tests-examples/demo-todo-app.spec.ts b/tests-examples/demo-todo-app.spec.ts
index 8641cb5..3280d0a 100644
--- a/tests-examples/demo-todo-app.spec.ts
+++ b/tests-examples/demo-todo-app.spec.ts
@@ -56,7 +56,7 @@ test.describe('New Todo', () => {
// create a todo count locator
const todoCount = page.getByTestId('todo-count')
-
+
// Check test using different methods.
await expect(page.getByText('3 items left')).toBeVisible();
await expect(todoCount).toHaveText('3 items left');
@@ -260,7 +260,7 @@ test.describe('Counter', () => {
test('should display the current number of todo items', async ({ page }) => {
// create a new todo locator
const newTodo = page.getByPlaceholder('What needs to be done?');
-
+
// create a todo count locator
const todoCount = page.getByTestId('todo-count')
@@ -350,7 +350,7 @@ test.describe('Routing', () => {
});
test('should respect the back button', async ({ page }) => {
- const todoItem = page.getByTestId('todo-item');
+ const todoItem = page.getByTestId('todo-item');
await page.getByTestId('todo-item').nth(1).getByRole('checkbox').check();
await checkNumberOfCompletedTodosInLocalStorage(page, 1);
@@ -393,7 +393,7 @@ test.describe('Routing', () => {
test('should highlight the currently applied filter', async ({ page }) => {
await expect(page.getByRole('link', { name: 'All' })).toHaveClass('selected');
-
+
//create locators for active and completed links
const activeLink = page.getByRole('link', { name: 'Active' });
const completedLink = page.getByRole('link', { name: 'Completed' });