mirror of
https://github.com/kjanat/livedash-node.git
synced 2026-01-16 06:52:11 +01:00
fix: improve dark mode compatibility and chart visibility
- Fix TopQuestionsChart with proper dark mode colors using CSS variables and shadcn/ui components - Enhance ResponseTimeDistribution with thicker bars (maxBarSize: 60) - Replace GeographicMap with dark/light mode compatible CartoDB tiles - Add custom text selection background color with primary theme color - Update all loading states to use proper CSS variables instead of hardcoded colors - Fix dashboard layout background to use bg-background instead of bg-gray-100
This commit is contained in:
22
CLAUDE.md
22
CLAUDE.md
@ -29,9 +29,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
**Testing:**
|
||||
|
||||
- `pnpm test` - Run tests once
|
||||
- `pnpm test:watch` - Run tests in watch mode
|
||||
- `pnpm test:coverage` - Run tests with coverage report
|
||||
- `pnpm test` - Run both Vitest and Playwright tests concurrently
|
||||
- `pnpm test:vitest` - Run Vitest tests only
|
||||
- `pnpm test:vitest:watch` - Run Vitest in watch mode
|
||||
- `pnpm test:vitest:coverage` - Run Vitest with coverage report
|
||||
- `pnpm test:coverage` - Run all tests with coverage
|
||||
|
||||
**Markdown:**
|
||||
|
||||
@ -115,16 +117,28 @@ Environment variables are managed through `lib/env.ts` with .env.local file supp
|
||||
|
||||
- Schedulers are optional and controlled by `SCHEDULER_ENABLED` environment variable
|
||||
- Use `pnpm dev:next-only` to run without schedulers for pure frontend development
|
||||
- Three separate schedulers handle different pipeline stages
|
||||
- Three separate schedulers handle different pipeline stages:
|
||||
- CSV Import Scheduler (`lib/scheduler.ts`)
|
||||
- Import Processing Scheduler (`lib/importProcessor.ts`)
|
||||
- Session Processing Scheduler (`lib/processingScheduler.ts`)
|
||||
|
||||
**Database Migrations:**
|
||||
|
||||
- Always run `pnpm prisma:generate` after schema changes
|
||||
- Use `pnpm prisma:migrate` for production-ready migrations
|
||||
- Use `pnpm prisma:push` for development schema changes
|
||||
- Database uses PostgreSQL with Prisma's driver adapter for connection pooling
|
||||
|
||||
**AI Processing:**
|
||||
|
||||
- All AI requests are tracked for cost analysis
|
||||
- Support for multiple AI models per company
|
||||
- Time-based pricing management for accurate cost calculation
|
||||
- Processing stages can be retried on failure with retry count tracking
|
||||
|
||||
**Code Quality Standards:**
|
||||
|
||||
- Run `pnpm lint` and `pnpm format:check` before committing
|
||||
- TypeScript with ES modules (type: "module" in package.json)
|
||||
- React 19 with Next.js 15 App Router
|
||||
- TailwindCSS 4 for styling
|
||||
|
||||
@ -57,7 +57,7 @@ export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-100">
|
||||
<div className="flex h-screen bg-background">
|
||||
<Sidebar
|
||||
isExpanded={isSidebarExpanded}
|
||||
isMobile={isMobile}
|
||||
|
||||
@ -36,8 +36,11 @@ const DashboardPage: FC = () => {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
|
||||
<p className="text-lg text-muted-foreground">Loading dashboard...</p>
|
||||
<div className="relative">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-2 border-muted border-t-primary mx-auto"></div>
|
||||
<div className="absolute inset-0 animate-ping rounded-full h-12 w-12 border border-primary opacity-20 mx-auto"></div>
|
||||
</div>
|
||||
<p className="text-lg text-muted-foreground animate-pulse">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -121,39 +124,39 @@ const DashboardPage: FC = () => {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Welcome Header */}
|
||||
<Card className="border-0 bg-linear-to-r from-primary/5 via-primary/10 to-primary/5">
|
||||
<CardHeader>
|
||||
<div className="relative overflow-hidden rounded-xl bg-linear-to-r from-primary/10 via-primary/5 to-transparent p-8 border border-primary/10">
|
||||
<div className="absolute inset-0 bg-linear-to-br from-primary/5 to-transparent" />
|
||||
<div className="absolute -top-24 -right-24 h-64 w-64 rounded-full bg-primary/10 blur-3xl" />
|
||||
<div className="relative">
|
||||
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-3xl font-bold tracking-tight">
|
||||
<h1 className="text-4xl font-bold tracking-tight bg-clip-text text-transparent bg-linear-to-r from-foreground to-foreground/70">
|
||||
Welcome back, {session?.user?.name || "User"}!
|
||||
</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Badge variant="secondary" className="text-xs px-3 py-1 bg-primary/10 text-primary border-primary/20">
|
||||
{session?.user?.role}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Choose a section below to explore your analytics dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<Shield className="h-4 w-4" />
|
||||
Secure Dashboard
|
||||
</div>
|
||||
<div className="flex items-center gap-3 px-4 py-2 rounded-full bg-muted/50 backdrop-blur-sm">
|
||||
<Shield className="h-4 w-4 text-green-600" />
|
||||
<span className="text-sm font-medium">Secure Dashboard</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation Cards */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{navigationCards.map((card, index) => (
|
||||
<Card
|
||||
key={index}
|
||||
className={`relative overflow-hidden transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5 cursor-pointer ${getCardClasses(
|
||||
className={`relative overflow-hidden transition-all duration-300 hover:shadow-2xl hover:-translate-y-1 cursor-pointer group ${getCardClasses(
|
||||
card.variant
|
||||
)}`}
|
||||
onClick={() => router.push(card.href)}
|
||||
@ -166,11 +169,11 @@ const DashboardPage: FC = () => {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full border transition-colors ${getIconClasses(
|
||||
className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-full border transition-all duration-300 group-hover:scale-110 ${getIconClasses(
|
||||
card.variant
|
||||
)}`}
|
||||
>
|
||||
{card.icon}
|
||||
<span className="transition-transform duration-300 group-hover:scale-110">{card.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl font-semibold flex items-center gap-2">
|
||||
@ -198,7 +201,7 @@ const DashboardPage: FC = () => {
|
||||
key={featureIndex}
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-current opacity-60" />
|
||||
<Zap className="h-3 w-3 text-primary/60" />
|
||||
<span className="text-muted-foreground">{feature}</span>
|
||||
</div>
|
||||
))}
|
||||
@ -206,7 +209,7 @@ const DashboardPage: FC = () => {
|
||||
|
||||
{/* Action Button */}
|
||||
<Button
|
||||
className="w-full gap-2 mt-4"
|
||||
className="w-full gap-2 mt-4 group-hover:gap-3 transition-all duration-300"
|
||||
variant={card.variant === "primary" ? "default" : "outline"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@ -39,7 +39,58 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
--animate-shine: shine var(--duration) infinite linear;
|
||||
@keyframes shine {
|
||||
0% {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 100%;
|
||||
}
|
||||
to {
|
||||
background-position: 0% 0%;
|
||||
}
|
||||
}
|
||||
--animate-meteor: meteor 5s linear infinite
|
||||
;
|
||||
@keyframes meteor {
|
||||
0% {
|
||||
transform: rotate(var(--angle)) translateX(0);
|
||||
opacity: 1;}
|
||||
70% {
|
||||
opacity: 1;}
|
||||
100% {
|
||||
transform: rotate(var(--angle)) translateX(-500px);
|
||||
opacity: 0;}}
|
||||
--animate-background-position-spin: background-position-spin 3000ms infinite alternate;
|
||||
@keyframes background-position-spin {
|
||||
0% {
|
||||
background-position: top center;}
|
||||
100% {
|
||||
background-position: bottom center;}}
|
||||
--animate-aurora: aurora 8s ease-in-out infinite alternate;
|
||||
@keyframes aurora {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
transform: rotate(-5deg) scale(0.9);}
|
||||
25% {
|
||||
background-position: 50% 100%;
|
||||
transform: rotate(5deg) scale(1.1);}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
transform: rotate(-3deg) scale(0.95);}
|
||||
75% {
|
||||
background-position: 50% 0%;
|
||||
transform: rotate(3deg) scale(1.05);}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
transform: rotate(-5deg) scale(0.9);}}
|
||||
--animate-shiny-text: shiny-text 8s infinite;
|
||||
@keyframes shiny-text {
|
||||
0%, 90%, 100% {
|
||||
background-position: calc(-100% - var(--shiny-width)) 0;}
|
||||
30%, 60% {
|
||||
background-position: calc(100% + var(--shiny-width)) 0;}}}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
@ -49,8 +100,8 @@
|
||||
--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-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.5 0.2 240);
|
||||
--primary-foreground: oklch(0.98 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
@ -83,17 +134,17 @@
|
||||
--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-foreground: oklch(0.205 0 0);
|
||||
--primary: oklch(0.7 0.2 240);
|
||||
--primary-foreground: oklch(0.15 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--muted-foreground: oklch(0.65 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--border: oklch(1 0 0 / 15%);
|
||||
--input: oklch(1 0 0 / 20%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
@ -117,4 +168,15 @@
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom text selection colors */
|
||||
::selection {
|
||||
background-color: hsl(var(--primary) / 0.2);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background-color: hsl(var(--primary) / 0.2);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
import "./globals.css";
|
||||
import { ReactNode } from "react";
|
||||
import { Providers } from "./providers";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
|
||||
export const metadata = {
|
||||
title: "LiveDash-Node",
|
||||
@ -19,9 +20,10 @@ export const metadata = {
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-gray-100 min-h-screen font-sans">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className="bg-background text-foreground min-h-screen font-sans antialiased">
|
||||
<Providers>{children}</Providers>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -2,58 +2,216 @@
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { ThemeToggle } from "@/components/ui/theme-toggle";
|
||||
import { Loader2, Shield, BarChart3, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const res = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
if (res?.ok) router.push("/dashboard");
|
||||
else setError("Invalid credentials.");
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const res = await signIn("credentials", {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (res?.ok) {
|
||||
toast.success("Login successful! Redirecting...");
|
||||
router.push("/dashboard");
|
||||
} else {
|
||||
setError("Invalid email or password. Please try again.");
|
||||
toast.error("Login failed. Please check your credentials.");
|
||||
}
|
||||
} catch {
|
||||
setError("An error occurred. Please try again.");
|
||||
toast.error("An unexpected error occurred.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto mt-24 bg-white rounded-xl p-8 shadow">
|
||||
<h1 className="text-2xl font-bold mb-6">Login</h1>
|
||||
{error && <div className="text-red-600 mb-3">{error}</div>}
|
||||
<form onSubmit={handleLogin} className="flex flex-col gap-4">
|
||||
<input
|
||||
className="border px-3 py-2 rounded"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
className="border px-3 py-2 rounded"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button className="bg-blue-600 text-white rounded py-2" type="submit">
|
||||
Login
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-4 text-center">
|
||||
<a href="/register" className="text-blue-600 underline">
|
||||
Register company
|
||||
</a>
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left side - Branding and Features */}
|
||||
<div className="hidden lg:flex lg:flex-1 bg-linear-to-br from-primary/10 via-primary/5 to-background relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-linear-to-br from-primary/5 to-transparent" />
|
||||
<div className="absolute -top-24 -left-24 h-96 w-96 rounded-full bg-primary/10 blur-3xl" />
|
||||
<div className="absolute -bottom-24 -right-24 h-96 w-96 rounded-full bg-primary/5 blur-3xl" />
|
||||
|
||||
<div className="relative flex flex-col justify-center px-12 py-24">
|
||||
<div className="max-w-md">
|
||||
<Link href="/" className="flex items-center gap-3 mb-8">
|
||||
<div className="relative w-12 h-12">
|
||||
<Image
|
||||
src="/favicon.svg"
|
||||
alt="LiveDash Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-2xl font-bold text-primary">LiveDash</span>
|
||||
</Link>
|
||||
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-6">
|
||||
Welcome back to your analytics dashboard
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8">
|
||||
Monitor, analyze, and optimize your customer conversations with AI-powered insights.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-primary/10 text-primary">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-muted-foreground">Real-time analytics and insights</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/10 text-green-600">
|
||||
<Shield className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-muted-foreground">Enterprise-grade security</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-600">
|
||||
<Zap className="h-5 w-5" />
|
||||
</div>
|
||||
<span className="text-muted-foreground">AI-powered conversation analysis</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<a href="/forgot-password" className="text-blue-600 underline">
|
||||
Forgot password?
|
||||
</a>
|
||||
|
||||
{/* Right side - Login Form */}
|
||||
<div className="flex-1 flex flex-col justify-center px-8 py-12 lg:px-12">
|
||||
<div className="absolute top-4 right-4">
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-sm">
|
||||
{/* Mobile logo */}
|
||||
<div className="lg:hidden flex justify-center mb-8">
|
||||
<Link href="/" className="flex items-center gap-3">
|
||||
<div className="relative w-10 h-10">
|
||||
<Image
|
||||
src="/favicon.svg"
|
||||
alt="LiveDash Logo"
|
||||
fill
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xl font-bold text-primary">LiveDash</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="border-border/50 shadow-xl">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl font-bold">Sign in</CardTitle>
|
||||
<CardDescription>
|
||||
Enter your email and password to access your dashboard
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{error && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="name@company.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={isLoading}
|
||||
required
|
||||
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={isLoading}
|
||||
required
|
||||
className="transition-all duration-200 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full mt-6 h-11 bg-linear-to-r from-primary to-primary/90 hover:from-primary/90 hover:to-primary/80 transition-all duration-200"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
"Sign in"
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/register"
|
||||
className="text-sm text-primary hover:underline transition-colors"
|
||||
>
|
||||
Don't have a company account? Register here
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="mt-8 text-center text-xs text-muted-foreground">
|
||||
By signing in, you agree to our{" "}
|
||||
<Link href="/terms" className="text-primary hover:underline">
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="/privacy" className="text-primary hover:underline">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -2,16 +2,24 @@
|
||||
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { ReactNode } from "react";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
export function Providers({ children }: { children: ReactNode }) {
|
||||
// Including error handling and refetch interval for better user experience
|
||||
return (
|
||||
<SessionProvider
|
||||
// Re-fetch session every 30 minutes (reduced from 10)
|
||||
refetchInterval={30 * 60}
|
||||
refetchOnWindowFocus={false}
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
enableSystem
|
||||
disableTransitionOnChange
|
||||
>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
<SessionProvider
|
||||
// Re-fetch session every 30 minutes (reduced from 10)
|
||||
refetchInterval={30 * 60}
|
||||
refetchOnWindowFocus={false}
|
||||
>
|
||||
{children}
|
||||
</SessionProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -63,7 +63,7 @@ const DEFAULT_COORDINATES = getCountryCoordinates();
|
||||
const Map = dynamic(() => import("./Map"), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
||||
<div className="h-full w-full bg-muted flex items-center justify-center text-muted-foreground">
|
||||
Loading map...
|
||||
</div>
|
||||
),
|
||||
@ -151,7 +151,7 @@ export default function GeographicMap({
|
||||
// Show loading state during SSR or until client-side rendering takes over
|
||||
if (!isClient) {
|
||||
return (
|
||||
<div className="h-full w-full bg-gray-100 flex items-center justify-center">
|
||||
<div className="h-full w-full bg-muted flex items-center justify-center text-muted-foreground">
|
||||
Loading map...
|
||||
</div>
|
||||
);
|
||||
@ -162,7 +162,7 @@ export default function GeographicMap({
|
||||
{countryData.length > 0 ? (
|
||||
<Map countryData={countryData} maxCount={maxCount} />
|
||||
) : (
|
||||
<div className="h-full w-full bg-gray-100 flex items-center justify-center text-gray-500">
|
||||
<div className="h-full w-full bg-muted flex items-center justify-center text-muted-foreground">
|
||||
No geographic data available
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -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 <div className="h-full w-full bg-muted animate-pulse rounded-lg" />;
|
||||
}
|
||||
|
||||
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
|
||||
? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
||||
: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={[30, 0]}
|
||||
@ -25,8 +50,8 @@ const Map = ({ countryData, maxCount }: MapProps) => {
|
||||
style={{ height: "100%", width: "100%", borderRadius: "0.5rem" }}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
attribution={tileLayerAttribution}
|
||||
url={tileLayerUrl}
|
||||
/>
|
||||
{countryData.map((country) => (
|
||||
<CircleMarker
|
||||
@ -34,19 +59,19 @@ const Map = ({ countryData, maxCount }: MapProps) => {
|
||||
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,
|
||||
}}
|
||||
>
|
||||
<Tooltip>
|
||||
<div className="p-1">
|
||||
<div className="font-medium">
|
||||
<div className="p-2 bg-background border border-border rounded-md shadow-md">
|
||||
<div className="font-medium text-foreground">
|
||||
{getLocalizedCountryName(country.code)}
|
||||
</div>
|
||||
<div className="text-sm">Sessions: {country.count}</div>
|
||||
<div className="text-sm text-muted-foreground">Sessions: {country.count}</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</CircleMarker>
|
||||
|
||||
@ -114,7 +114,12 @@ export default function ResponseTimeDistribution({
|
||||
/>
|
||||
<Tooltip content={<CustomTooltip />} />
|
||||
|
||||
<Bar dataKey="value" radius={[4, 4, 0, 0]} fill="hsl(var(--chart-1))">
|
||||
<Bar
|
||||
dataKey="value"
|
||||
radius={[4, 4, 0, 0]}
|
||||
fill="hsl(var(--chart-1))"
|
||||
maxBarSize={60}
|
||||
>
|
||||
{chartData.map((entry, index) => (
|
||||
<Bar key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
|
||||
@ -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<NavItemProps> = ({
|
||||
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<NavItemProps> = ({
|
||||
) : (
|
||||
<div
|
||||
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||
text-white bg-gray-800 z-50
|
||||
text-popover-foreground bg-popover border border-border z-50
|
||||
invisible opacity-0 -translate-x-3 transition-all
|
||||
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||
>
|
||||
@ -202,13 +203,13 @@ export default function Sidebar({
|
||||
{/* Backdrop overlay when sidebar is expanded on mobile */}
|
||||
{isExpanded && isMobile && (
|
||||
<div
|
||||
className="fixed inset-0 bg-gray-900 bg-opacity-50 z-10 transition-opacity duration-300"
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-10 transition-all duration-300"
|
||||
onClick={onToggle}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`fixed md:relative h-screen bg-white shadow-md transition-all duration-300
|
||||
className={`fixed md:relative h-screen bg-card border-r border-border shadow-lg transition-all duration-300
|
||||
${
|
||||
isExpanded ? (isMobile ? "w-full sm:w-80" : "w-56") : "w-16"
|
||||
} flex flex-col overflow-visible z-20`}
|
||||
@ -222,7 +223,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="Expand sidebar"
|
||||
>
|
||||
<MinimalToggleIcon isExpanded={isExpanded} />
|
||||
@ -248,7 +249,7 @@ export default function Sidebar({
|
||||
/>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<span className="text-lg font-bold text-sky-700 mt-1 transition-opacity duration-300">
|
||||
<span className="text-lg font-bold text-primary mt-1 transition-opacity duration-300">
|
||||
LiveDash
|
||||
</span>
|
||||
)}
|
||||
@ -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"
|
||||
>
|
||||
<MinimalToggleIcon isExpanded={isExpanded} />
|
||||
@ -327,10 +328,17 @@ export default function Sidebar({
|
||||
onNavigate={onNavigate}
|
||||
/>
|
||||
</nav>
|
||||
<div className="p-4 border-t mt-auto">
|
||||
<div className="p-4 border-t mt-auto space-y-2">
|
||||
{/* Theme Toggle */}
|
||||
<div className={`flex items-center ${isExpanded ? "justify-between" : "justify-center"}`}>
|
||||
{isExpanded && <span className="text-sm font-medium text-muted-foreground">Theme</span>}
|
||||
<SimpleThemeToggle />
|
||||
</div>
|
||||
|
||||
{/* Logout Button */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className={`relative flex items-center p-3 w-full rounded-lg text-gray-700 hover:bg-gray-100 hover:text-gray-900 transition-all group ${
|
||||
className={`relative flex items-center p-3 w-full rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground transition-all group ${
|
||||
isExpanded ? "" : "justify-center"
|
||||
}`}
|
||||
>
|
||||
@ -342,7 +350,7 @@ export default function Sidebar({
|
||||
) : (
|
||||
<div
|
||||
className="fixed ml-6 w-auto p-2 min-w-max rounded-md shadow-md text-xs font-medium
|
||||
text-white bg-gray-800 z-50
|
||||
text-popover-foreground bg-popover border border-border z-50
|
||||
invisible opacity-0 -translate-x-3 transition-all
|
||||
group-hover:visible group-hover:opacity-100 group-hover:translate-x-0"
|
||||
>
|
||||
|
||||
@ -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 (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
|
||||
<div className="text-center py-8 text-gray-500">
|
||||
No questions data available
|
||||
</div>
|
||||
</div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No questions data available
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@ -27,52 +34,55 @@ export default function TopQuestionsChart({
|
||||
const maxCount = Math.max(...data.map((q) => q.count));
|
||||
|
||||
return (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{title}</h3>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg font-semibold">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{data.map((question, index) => {
|
||||
const percentage =
|
||||
maxCount > 0 ? (question.count / maxCount) * 100 : 0;
|
||||
|
||||
<div className="space-y-4">
|
||||
{data.map((question, index) => {
|
||||
const percentage =
|
||||
maxCount > 0 ? (question.count / maxCount) * 100 : 0;
|
||||
return (
|
||||
<div key={index} className="relative pl-8">
|
||||
{/* Question text */}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<p className="text-sm font-medium leading-tight pr-4 flex-1 text-foreground">
|
||||
{question.question}
|
||||
</p>
|
||||
<Badge variant="secondary" className="whitespace-nowrap">
|
||||
{question.count}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div key={index} className="relative">
|
||||
{/* Question text */}
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<p className="text-sm text-gray-700 font-medium leading-tight pr-4 flex-1">
|
||||
{question.question}
|
||||
</p>
|
||||
<span className="text-sm font-semibold text-gray-900 bg-gray-100 px-2 py-1 rounded-md whitespace-nowrap">
|
||||
{question.count}
|
||||
</span>
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-muted rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary h-2 rounded-full transition-all duration-300 ease-in-out"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Rank indicator */}
|
||||
<div className="absolute -left-1 top-0 w-6 h-6 bg-primary text-primary-foreground text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-in-out"
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Separator className="my-6" />
|
||||
|
||||
{/* Rank indicator */}
|
||||
<div className="absolute -left-2 top-0 w-6 h-6 bg-blue-600 text-white text-xs font-bold rounded-full flex items-center justify-center">
|
||||
{index + 1}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="mt-6 pt-4 border-t border-gray-200">
|
||||
<div className="flex justify-between text-sm text-gray-600">
|
||||
{/* Summary */}
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>Total questions analyzed</span>
|
||||
<span className="font-medium">
|
||||
<span className="font-medium text-foreground">
|
||||
{data.reduce((sum, q) => sum + q.count, 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
188
components/magicui/animated-beam.tsx
Normal file
188
components/magicui/animated-beam.tsx
Normal file
@ -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<HTMLElement | null>; // Container ref
|
||||
fromRef: RefObject<HTMLElement | null>;
|
||||
toRef: RefObject<HTMLElement | null>;
|
||||
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<AnimatedBeamProps> = ({
|
||||
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 (
|
||||
<svg
|
||||
fill="none"
|
||||
width={svgDimensions.width}
|
||||
height={svgDimensions.height}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
"pointer-events-none absolute left-0 top-0 transform-gpu stroke-2",
|
||||
className,
|
||||
)}
|
||||
viewBox={`0 0 ${svgDimensions.width} ${svgDimensions.height}`}
|
||||
>
|
||||
<path
|
||||
d={pathD}
|
||||
stroke={pathColor}
|
||||
strokeWidth={pathWidth}
|
||||
strokeOpacity={pathOpacity}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d={pathD}
|
||||
strokeWidth={pathWidth}
|
||||
stroke={`url(#${id})`}
|
||||
strokeOpacity="1"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<defs>
|
||||
<motion.linearGradient
|
||||
className="transform-gpu"
|
||||
id={id}
|
||||
gradientUnits={"userSpaceOnUse"}
|
||||
initial={{
|
||||
x1: "0%",
|
||||
x2: "0%",
|
||||
y1: "0%",
|
||||
y2: "0%",
|
||||
}}
|
||||
animate={{
|
||||
x1: gradientCoordinates.x1,
|
||||
x2: gradientCoordinates.x2,
|
||||
y1: gradientCoordinates.y1,
|
||||
y2: gradientCoordinates.y2,
|
||||
}}
|
||||
transition={{
|
||||
delay,
|
||||
duration,
|
||||
ease: [0.16, 1, 0.3, 1], // https://easings.net/#easeOutExpo
|
||||
repeat: Infinity,
|
||||
repeatDelay: 0,
|
||||
}}
|
||||
>
|
||||
<stop stopColor={gradientStartColor} stopOpacity="0"></stop>
|
||||
<stop stopColor={gradientStartColor}></stop>
|
||||
<stop offset="32.5%" stopColor={gradientStopColor}></stop>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={gradientStopColor}
|
||||
stopOpacity="0"
|
||||
></stop>
|
||||
</motion.linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
108
components/magicui/animated-circular-progress-bar.tsx
Normal file
108
components/magicui/animated-circular-progress-bar.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className={cn("relative size-40 text-2xl font-semibold", className)}
|
||||
style={
|
||||
{
|
||||
"--circle-size": "100px",
|
||||
"--circumference": circumference,
|
||||
"--percent-to-px": `${percentPx}px`,
|
||||
"--gap-percent": "5",
|
||||
"--offset-factor": "0",
|
||||
"--transition-length": "1s",
|
||||
"--transition-step": "200ms",
|
||||
"--delay": "0s",
|
||||
"--percent-to-deg": "3.6deg",
|
||||
transform: "translateZ(0)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<svg
|
||||
fill="none"
|
||||
className="size-full"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
{currentPercent <= 90 && currentPercent >= 0 && (
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
strokeWidth="10"
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className=" opacity-100"
|
||||
style={
|
||||
{
|
||||
stroke: gaugeSecondaryColor,
|
||||
"--stroke-percent": 90 - currentPercent,
|
||||
"--offset-factor-secondary": "calc(1 - var(--offset-factor))",
|
||||
strokeDasharray:
|
||||
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
|
||||
transform:
|
||||
"rotate(calc(1turn - 90deg - (var(--gap-percent) * var(--percent-to-deg) * var(--offset-factor-secondary)))) scaleY(-1)",
|
||||
transition: "all var(--transition-length) ease var(--delay)",
|
||||
transformOrigin:
|
||||
"calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
strokeWidth="10"
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="opacity-100"
|
||||
style={
|
||||
{
|
||||
stroke: gaugePrimaryColor,
|
||||
"--stroke-percent": currentPercent,
|
||||
strokeDasharray:
|
||||
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
|
||||
transition:
|
||||
"var(--transition-length) ease var(--delay),stroke var(--transition-length) ease var(--delay)",
|
||||
transitionProperty: "stroke-dasharray,transform",
|
||||
transform:
|
||||
"rotate(calc(-90deg + var(--gap-percent) * var(--offset-factor) * var(--percent-to-deg)))",
|
||||
transformOrigin:
|
||||
"calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
</svg>
|
||||
<span
|
||||
data-current-value={currentPercent}
|
||||
className="duration-[var(--transition-length)] delay-[var(--delay)] absolute inset-0 m-auto size-fit ease-linear animate-in fade-in"
|
||||
>
|
||||
{currentPercent}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
components/magicui/animated-shiny-text.tsx
Normal file
39
components/magicui/animated-shiny-text.tsx
Normal file
@ -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<AnimatedShinyTextProps> = ({
|
||||
children,
|
||||
className,
|
||||
shimmerWidth = 100,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<span
|
||||
style={
|
||||
{
|
||||
"--shiny-width": `${shimmerWidth}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"mx-auto max-w-md text-neutral-600/70 dark:text-neutral-400/70",
|
||||
|
||||
// Shine effect
|
||||
"animate-shiny-text bg-clip-text bg-no-repeat [background-position:0_0] [background-size:var(--shiny-width)_100%] [transition:background-position_1s_cubic-bezier(.6,.6,0,1)_infinite]",
|
||||
|
||||
// Shine gradient
|
||||
"bg-gradient-to-r from-transparent via-black/80 via-50% to-transparent dark:via-white/80",
|
||||
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
43
components/magicui/aurora-text.tsx
Normal file
43
components/magicui/aurora-text.tsx
Normal file
@ -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 (
|
||||
<span className={`relative inline-block ${className}`}>
|
||||
<span className="sr-only">{children}</span>
|
||||
<span
|
||||
className="relative animate-aurora bg-[length:200%_auto] bg-clip-text text-transparent"
|
||||
style={gradientStyle}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AuroraText.displayName = "AuroraText";
|
||||
81
components/magicui/blur-fade.tsx
Normal file
81
components/magicui/blur-fade.tsx
Normal file
@ -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 (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
ref={ref}
|
||||
initial="hidden"
|
||||
animate={isInView ? "visible" : "hidden"}
|
||||
exit="hidden"
|
||||
variants={combinedVariants}
|
||||
transition={{
|
||||
delay: 0.04 + delay,
|
||||
duration,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
104
components/magicui/border-beam.tsx
Normal file
104
components/magicui/border-beam.tsx
Normal file
@ -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 (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit] border-transparent [mask-clip:padding-box,border-box] [mask-composite:intersect] [mask-image:linear-gradient(transparent,transparent),linear-gradient(#000,#000)] border-(length:--border-beam-width)"
|
||||
style={{
|
||||
"--border-beam-width": `${borderWidth}px`,
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"absolute aspect-square",
|
||||
"bg-gradient-to-l from-[var(--color-from)] via-[var(--color-to)] to-transparent",
|
||||
className,
|
||||
)}
|
||||
style={
|
||||
{
|
||||
width: size,
|
||||
offsetPath: `rect(0 auto auto 0 round ${size}px)`,
|
||||
"--color-from": colorFrom,
|
||||
"--color-to": colorTo,
|
||||
...style,
|
||||
} as MotionStyle
|
||||
}
|
||||
initial={{ offsetDistance: `${initialOffset}%` }}
|
||||
animate={{
|
||||
offsetDistance: reverse
|
||||
? [`${100 - initialOffset}%`, `${-initialOffset}%`]
|
||||
: [`${initialOffset}%`, `${100 + initialOffset}%`],
|
||||
}}
|
||||
transition={{
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
duration,
|
||||
delay: -delay,
|
||||
...transition,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
149
components/magicui/confetti.tsx
Normal file
149
components/magicui/confetti.tsx
Normal file
@ -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<Api>({} as Api);
|
||||
|
||||
// Define component first
|
||||
const ConfettiComponent = forwardRef<ConfettiRef, Props>((props, ref) => {
|
||||
const {
|
||||
options,
|
||||
globalOptions = { resize: true, useWorker: true },
|
||||
manualstart = false,
|
||||
children,
|
||||
...rest
|
||||
} = props;
|
||||
const instanceRef = useRef<ConfettiInstance | null>(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 (
|
||||
<ConfettiContext.Provider value={api}>
|
||||
<canvas ref={canvasRef} {...rest} />
|
||||
{children}
|
||||
</ConfettiContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
// 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<HTMLButtonElement>) => {
|
||||
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 (
|
||||
<Button onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
ConfettiButtonComponent.displayName = "ConfettiButton";
|
||||
|
||||
export const ConfettiButton = ConfettiButtonComponent;
|
||||
108
components/magicui/magic-card.tsx
Normal file
108
components/magicui/magic-card.tsx
Normal file
@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={cn("group relative rounded-[inherit]", className)}
|
||||
>
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 rounded-[inherit] bg-border duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
background: useMotionTemplate`
|
||||
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px,
|
||||
${gradientFrom},
|
||||
${gradientTo},
|
||||
var(--border) 100%
|
||||
)
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-px rounded-[inherit] bg-background" />
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-px rounded-[inherit] opacity-0 transition-opacity duration-300 group-hover:opacity-100"
|
||||
style={{
|
||||
background: useMotionTemplate`
|
||||
radial-gradient(${gradientSize}px circle at ${mouseX}px ${mouseY}px, ${gradientColor}, transparent 100%)
|
||||
`,
|
||||
opacity: gradientOpacity,
|
||||
}}
|
||||
/>
|
||||
<div className="relative">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
components/magicui/meteors.tsx
Normal file
60
components/magicui/meteors.tsx
Normal file
@ -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<Array<React.CSSProperties>>(
|
||||
[],
|
||||
);
|
||||
|
||||
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
|
||||
<span
|
||||
key={idx}
|
||||
style={{ ...style }}
|
||||
className={cn(
|
||||
"pointer-events-none absolute size-0.5 rotate-[var(--angle)] animate-meteor rounded-full bg-zinc-500 shadow-[0_0_0_1px_#ffffff10]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{/* Meteor Tail */}
|
||||
<div className="pointer-events-none absolute top-1/2 -z-10 h-px w-[50px] -translate-y-1/2 bg-gradient-to-r from-zinc-500 to-transparent" />
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
149
components/magicui/neon-gradient-card.tsx
Normal file
149
components/magicui/neon-gradient-card.tsx
Normal file
@ -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 <div />
|
||||
* @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<NeonGradientCardProps> = ({
|
||||
className,
|
||||
children,
|
||||
borderSize = 2,
|
||||
borderRadius = 20,
|
||||
neonColors = {
|
||||
firstColor: "#ff00aa",
|
||||
secondColor: "#00FFF1",
|
||||
},
|
||||
...props
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
style={
|
||||
{
|
||||
"--border-size": `${borderSize}px`,
|
||||
"--border-radius": `${borderRadius}px`,
|
||||
"--neon-first-color": neonColors.firstColor,
|
||||
"--neon-second-color": neonColors.secondColor,
|
||||
"--card-width": `${dimensions.width}px`,
|
||||
"--card-height": `${dimensions.height}px`,
|
||||
"--card-content-radius": `${borderRadius - borderSize}px`,
|
||||
"--pseudo-element-background-image": `linear-gradient(0deg, ${neonColors.firstColor}, ${neonColors.secondColor})`,
|
||||
"--pseudo-element-width": `${dimensions.width + borderSize * 2}px`,
|
||||
"--pseudo-element-height": `${dimensions.height + borderSize * 2}px`,
|
||||
"--after-blur": `${dimensions.width / 3}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"relative z-10 size-full rounded-[var(--border-radius)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"relative size-full min-h-[inherit] rounded-[var(--card-content-radius)] bg-gray-100 p-6",
|
||||
"before:absolute before:-left-[var(--border-size)] before:-top-[var(--border-size)] before:-z-10 before:block",
|
||||
"before:h-[var(--pseudo-element-height)] before:w-[var(--pseudo-element-width)] before:rounded-[var(--border-radius)] before:content-['']",
|
||||
"before:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] before:bg-[length:100%_200%]",
|
||||
"before:animate-background-position-spin",
|
||||
"after:absolute after:-left-[var(--border-size)] after:-top-[var(--border-size)] after:-z-10 after:block",
|
||||
"after:h-[var(--pseudo-element-height)] after:w-[var(--pseudo-element-width)] after:rounded-[var(--border-radius)] after:blur-[var(--after-blur)] after:content-['']",
|
||||
"after:bg-[linear-gradient(0deg,var(--neon-first-color),var(--neon-second-color))] after:bg-[length:100%_200%] after:opacity-80",
|
||||
"after:animate-background-position-spin",
|
||||
"dark:bg-neutral-900",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
67
components/magicui/number-ticker.tsx
Normal file
67
components/magicui/number-ticker.tsx
Normal file
@ -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<HTMLSpanElement>(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 (
|
||||
<span
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-block tabular-nums tracking-wider text-black dark:text-white",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{startValue}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
118
components/magicui/pointer.tsx
Normal file
118
components/magicui/pointer.tsx
Normal file
@ -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<HTMLMotionProps<"div">, "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<boolean>(false);
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<>
|
||||
<div ref={containerRef} />
|
||||
<AnimatePresence>
|
||||
{isActive && (
|
||||
<motion.div
|
||||
className="transform-[translate(-50%,-50%)] pointer-events-none fixed z-50"
|
||||
style={{
|
||||
top: y,
|
||||
left: x,
|
||||
...style,
|
||||
}}
|
||||
initial={{
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
}}
|
||||
exit={{
|
||||
scale: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children || (
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
strokeWidth="1"
|
||||
viewBox="0 0 16 16"
|
||||
height="24"
|
||||
width="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn(
|
||||
"rotate-[-70deg] stroke-white text-black",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<path d="M14.082 2.182a.5.5 0 0 1 .103.557L8.528 15.467a.5.5 0 0 1-.917-.007L5.57 10.694.803 8.652a.5.5 0 0 1-.006-.916l12.728-5.657a.5.5 0 0 1 .556.103z" />
|
||||
</svg>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
components/magicui/scroll-progress.tsx
Normal file
30
components/magicui/scroll-progress.tsx
Normal file
@ -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<React.HTMLAttributes<HTMLElement>, keyof MotionProps> {}
|
||||
|
||||
export const ScrollProgress = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
ScrollProgressProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { scrollYProgress } = useScroll();
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 top-0 z-50 h-px origin-left bg-gradient-to-r from-[#A97CF8] via-[#F38CB8] to-[#FDCC92]",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
scaleX: scrollYProgress,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
ScrollProgress.displayName = "ScrollProgress";
|
||||
63
components/magicui/shine-border.tsx
Normal file
63
components/magicui/shine-border.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ShineBorderProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/**
|
||||
* 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 (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--border-width": `${borderWidth}px`,
|
||||
"--duration": `${duration}s`,
|
||||
backgroundImage: `radial-gradient(transparent,transparent, ${
|
||||
Array.isArray(shineColor) ? shineColor.join(",") : shineColor
|
||||
},transparent,transparent)`,
|
||||
backgroundSize: "300% 300%",
|
||||
mask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
|
||||
WebkitMask: `linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)`,
|
||||
WebkitMaskComposite: "xor",
|
||||
maskComposite: "exclude",
|
||||
padding: "var(--border-width)",
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 size-full rounded-[inherit] will-change-[background-position] motion-safe:animate-shine",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
410
components/magicui/text-animate.tsx
Normal file
410
components/magicui/text-animate.tsx
Normal file
@ -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<AnimationType, number> = {
|
||||
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 (
|
||||
<AnimatePresence mode="popLayout">
|
||||
<MotionComponent
|
||||
variants={finalVariants.container as Variants}
|
||||
initial="hidden"
|
||||
whileInView={startOnView ? "show" : undefined}
|
||||
animate={startOnView ? undefined : "show"}
|
||||
exit="exit"
|
||||
className={cn("whitespace-pre-wrap", className)}
|
||||
viewport={{ once }}
|
||||
{...props}
|
||||
>
|
||||
{segments.map((segment, i) => (
|
||||
<motion.span
|
||||
key={`${by}-${segment}-${i}`}
|
||||
variants={finalVariants.item}
|
||||
custom={i * staggerTimings[by]}
|
||||
className={cn(
|
||||
by === "line" ? "block" : "inline-block whitespace-pre",
|
||||
by === "character" && "",
|
||||
segmentClassName,
|
||||
)}
|
||||
>
|
||||
{segment}
|
||||
</motion.span>
|
||||
))}
|
||||
</MotionComponent>
|
||||
</AnimatePresence>
|
||||
);
|
||||
};
|
||||
|
||||
// Export the memoized version
|
||||
export const TextAnimate = memo(TextAnimateBase);
|
||||
71
components/magicui/text-reveal.tsx
Normal file
71
components/magicui/text-reveal.tsx
Normal file
@ -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<TextRevealProps> = ({ children, className }) => {
|
||||
const targetRef = useRef<HTMLDivElement | null>(null);
|
||||
const { scrollYProgress } = useScroll({
|
||||
target: targetRef,
|
||||
});
|
||||
|
||||
if (typeof children !== "string") {
|
||||
throw new Error("TextReveal: children must be a string");
|
||||
}
|
||||
|
||||
const words = children.split(" ");
|
||||
|
||||
return (
|
||||
<div ref={targetRef} className={cn("relative z-0 h-[200vh]", className)}>
|
||||
<div
|
||||
className={
|
||||
"sticky top-0 mx-auto flex h-[50%] max-w-4xl items-center bg-transparent px-[1rem] py-[5rem]"
|
||||
}
|
||||
>
|
||||
<span
|
||||
ref={targetRef}
|
||||
className={
|
||||
"flex flex-wrap p-5 text-2xl font-bold text-black/20 dark:text-white/20 md:p-8 md:text-3xl lg:p-10 lg:text-4xl xl:text-5xl"
|
||||
}
|
||||
>
|
||||
{words.map((word, i) => {
|
||||
const start = i / words.length;
|
||||
const end = start + 1 / words.length;
|
||||
return (
|
||||
<Word key={i} progress={scrollYProgress} range={[start, end]}>
|
||||
{word}
|
||||
</Word>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface WordProps {
|
||||
children: ReactNode;
|
||||
progress: MotionValue<number>;
|
||||
range: [number, number];
|
||||
}
|
||||
|
||||
const Word: FC<WordProps> = ({ children, progress, range }) => {
|
||||
const opacity = useTransform(progress, range, [0, 1]);
|
||||
return (
|
||||
<span className="xl:lg-3 relative mx-1 lg:mx-1.5">
|
||||
<span className="absolute opacity-30">{children}</span>
|
||||
<motion.span
|
||||
style={{ opacity: opacity }}
|
||||
className={"text-black dark:text-white"}
|
||||
>
|
||||
{children}
|
||||
</motion.span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
9
components/theme-provider.tsx
Normal file
9
components/theme-provider.tsx
Normal file
@ -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 <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
59
components/ui/alert.tsx
Normal file
59
components/ui/alert.tsx
Normal file
@ -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<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertTitle.displayName = "AlertTitle";
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AlertDescription.displayName = "AlertDescription";
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
24
components/ui/input.tsx
Normal file
24
components/ui/input.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal file
@ -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<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Label.displayName = LabelPrimitive.Root.displayName;
|
||||
|
||||
export { Label };
|
||||
@ -102,7 +102,7 @@ export default function MetricCard({
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
"relative overflow-hidden transition-all duration-200 hover:shadow-lg hover:-translate-y-0.5",
|
||||
"relative overflow-hidden transition-all duration-300 hover:shadow-xl hover:-translate-y-1 group",
|
||||
getVariantClasses(),
|
||||
className
|
||||
)}
|
||||
@ -124,11 +124,11 @@ export default function MetricCard({
|
||||
{icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full border transition-colors",
|
||||
"flex h-10 w-10 shrink-0 items-center justify-center rounded-full border transition-all duration-300 group-hover:scale-110",
|
||||
getIconClasses()
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{icon}</span>
|
||||
<span className="text-lg transition-transform duration-300 group-hover:scale-110">{icon}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -137,7 +137,9 @@ export default function MetricCard({
|
||||
<CardContent className="relative">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xl font-bold tracking-tight">{value ?? "—"}</p>
|
||||
<p className="text-3xl font-bold tracking-tight bg-clip-text text-transparent bg-linear-to-r from-foreground to-foreground/80">
|
||||
{value ?? "—"}
|
||||
</p>
|
||||
|
||||
{trend && (
|
||||
<Badge
|
||||
|
||||
31
components/ui/sonner.tsx
Normal file
31
components/ui/sonner.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster as Sonner } from "sonner";
|
||||
|
||||
type ToasterProps = React.ComponentProps<typeof Sonner>;
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast:
|
||||
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
|
||||
description: "group-[.toast]:text-muted-foreground",
|
||||
actionButton:
|
||||
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
|
||||
cancelButton:
|
||||
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { Toaster };
|
||||
71
components/ui/theme-toggle.tsx
Normal file
71
components/ui/theme-toggle.tsx
Normal file
@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 px-0">
|
||||
<Sun className="rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimpleThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 px-0">
|
||||
<Sun className="h-4 w-4" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 px-0"
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
>
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@ -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"
|
||||
},
|
||||
|
||||
129
pnpm-lock.yaml
generated
129
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user