feat: 完整分销系统 — 后端 + 主站管理 + 分站前端模板
分销商后端: - 分销商注册/审核/CRUD API - 分站用户注册/登录/登出 (session cookie auth) - 分站 Token CRUD、充值兑换、套餐订阅 - 分站域名/子域名自动识别中间件 (DistIdentify) - 分销商收益异步记账 + markup 加价计费 - 模型价格快照、渠道路由配置 - 管理员审核/管理分销商 主站前端 (web/): - 分销商注册申请、设置、客户管理(分页)、套餐管理(含速率限制) - 收益管理(含汇总卡片+分页列表)、模型管理 - 管理员分销商审核面板 - DistributorLayout 标签页布局 + 路由 分站前端模板 (dist-site/): - React + Vite + Tailwind 暗色主题 glassmorphism 设计 - react-bits 动画组件 (GradientText, BlurText, CountUp, SpotlightCard 等) - Session cookie 认证 + New-Api-User header - 首页/登录/注册/Dashboard/API Keys/定价/套餐 7 个页面 - Token 创建后一次性密钥展示、余额检查订阅确认 Bug 修复: - 修复 DistUserRegister 密码双重哈希导致用户无法登录 - 修复 401 响应不清除 React user 状态 - 修复订阅确认弹窗不显示余额且提前关闭 - 修复 Home 页 Subscribe 按钮跳转错误 (/dashboard → /packages) - 修复 Login/Register 组件卸载后 setState 警告 - 修复 DistributorEarnings 引用不存在的 username 字段 - 修复 DistributorCustomers 引用不存在的 created_at 字段 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
9dfc5e79bc
29 files changed
+5540
No files matched your search
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import { Navigate, Outlet, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function AuthGuard() {
|
||||
const { user, loading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="w-8 h-8 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useSite } from '../context/SiteContext';
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const { site } = useSite();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const siteName = site?.name || 'SubRouter';
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Home', auth: false },
|
||||
{ to: '/pricing', label: 'Pricing', auth: false },
|
||||
{ to: '/packages', label: 'Packages', auth: false },
|
||||
{ to: '/dashboard', label: 'Dashboard', auth: true },
|
||||
{ to: '/tokens', label: 'API Keys', auth: true },
|
||||
];
|
||||
|
||||
const visibleNavItems = navItems.filter((n) => !n.auth || user);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Announcement Banner */}
|
||||
{site?.announcement && (
|
||||
<div className="bg-brand-600/20 border-b border-brand-500/20 text-center py-2 px-4">
|
||||
<p className="text-sm text-brand-200">{site.announcement}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-50 backdrop-blur-xl bg-black/60 border-b border-white/[0.06]">
|
||||
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-3 group">
|
||||
{site?.logo ? (
|
||||
<img src={site.logo} alt={siteName} className="h-8 w-auto" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-brand-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
|
||||
{siteName.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-lg font-semibold text-white group-hover:text-brand-400 transition-colors">
|
||||
{siteName}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<nav className="hidden md:flex items-center gap-1">
|
||||
{visibleNavItems.map((n) => (
|
||||
<NavLink key={n.to} to={n.to} active={location.pathname === n.to}>
|
||||
{n.label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Auth + Mobile toggle */}
|
||||
<div className="flex items-center gap-3">
|
||||
{user ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-neutral-400 hidden sm:block">
|
||||
{user.display_name || user.username}
|
||||
</span>
|
||||
<button
|
||||
onClick={async () => { await logout(); navigate('/'); }}
|
||||
className="text-sm text-neutral-400 hover:text-white transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/login" className="text-sm text-neutral-300 hover:text-white transition-colors px-3 py-1.5 hidden sm:block">
|
||||
Login
|
||||
</Link>
|
||||
<Link to="/register" className="btn-primary text-sm !px-4 !py-1.5">
|
||||
Sign Up
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Mobile hamburger */}
|
||||
<button
|
||||
className="md:hidden p-1.5 rounded-lg hover:bg-white/5 transition-colors"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
<svg className="w-5 h-5 text-neutral-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
{mobileMenuOpen ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Nav Dropdown */}
|
||||
{mobileMenuOpen && (
|
||||
<div className="md:hidden border-t border-white/[0.06] bg-black/90 backdrop-blur-xl">
|
||||
<nav className="max-w-7xl mx-auto px-6 py-3 flex flex-col gap-1">
|
||||
{visibleNavItems.map((n) => (
|
||||
<Link
|
||||
key={n.to}
|
||||
to={n.to}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={`px-3 py-2 text-sm rounded-lg transition-colors ${
|
||||
location.pathname === n.to
|
||||
? 'text-brand-400 bg-brand-500/10'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{n.label}
|
||||
</Link>
|
||||
))}
|
||||
{!user && (
|
||||
<Link
|
||||
to="/login"
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className="px-3 py-2 text-sm text-neutral-400 hover:text-white rounded-lg hover:bg-white/5 transition-colors sm:hidden"
|
||||
>
|
||||
Login
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-white/[0.06] mt-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-neutral-500">
|
||||
© {new Date().getFullYear()} {siteName}. Powered by SubRouter.
|
||||
</p>
|
||||
<div className="flex items-center gap-4">
|
||||
{site?.contact_email && (
|
||||
<a href={`mailto:${site.contact_email}`} className="text-sm text-neutral-500 hover:text-neutral-300 transition-colors">
|
||||
Contact
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLink({ to, children, active }) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg transition-all ${
|
||||
active
|
||||
? 'text-brand-400 bg-brand-500/10'
|
||||
: 'text-neutral-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { motion } from 'motion/react';
|
||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||
|
||||
const buildKeyframes = (from, steps) => {
|
||||
const keys = new Set([...Object.keys(from), ...steps.flatMap(s => Object.keys(s))]);
|
||||
|
||||
const keyframes = {};
|
||||
keys.forEach(k => {
|
||||
keyframes[k] = [from[k], ...steps.map(s => s[k])];
|
||||
});
|
||||
return keyframes;
|
||||
};
|
||||
|
||||
const BlurText = ({
|
||||
text = '',
|
||||
delay = 200,
|
||||
className = '',
|
||||
animateBy = 'words',
|
||||
direction = 'top',
|
||||
threshold = 0.1,
|
||||
rootMargin = '0px',
|
||||
animationFrom,
|
||||
animationTo,
|
||||
easing = t => t,
|
||||
onAnimationComplete,
|
||||
stepDuration = 0.35
|
||||
}) => {
|
||||
const elements = animateBy === 'words' ? text.split(' ') : text.split('');
|
||||
const [inView, setInView] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) return;
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setInView(true);
|
||||
observer.unobserve(ref.current);
|
||||
}
|
||||
},
|
||||
{ threshold, rootMargin }
|
||||
);
|
||||
observer.observe(ref.current);
|
||||
return () => observer.disconnect();
|
||||
}, [threshold, rootMargin]);
|
||||
|
||||
const defaultFrom = useMemo(
|
||||
() =>
|
||||
direction === 'top' ? { filter: 'blur(10px)', opacity: 0, y: -50 } : { filter: 'blur(10px)', opacity: 0, y: 50 },
|
||||
[direction]
|
||||
);
|
||||
|
||||
const defaultTo = useMemo(
|
||||
() => [
|
||||
{
|
||||
filter: 'blur(5px)',
|
||||
opacity: 0.5,
|
||||
y: direction === 'top' ? 5 : -5
|
||||
},
|
||||
{ filter: 'blur(0px)', opacity: 1, y: 0 }
|
||||
],
|
||||
[direction]
|
||||
);
|
||||
|
||||
const fromSnapshot = animationFrom ?? defaultFrom;
|
||||
const toSnapshots = animationTo ?? defaultTo;
|
||||
|
||||
const stepCount = toSnapshots.length + 1;
|
||||
const totalDuration = stepDuration * (stepCount - 1);
|
||||
const times = Array.from({ length: stepCount }, (_, i) => (stepCount === 1 ? 0 : i / (stepCount - 1)));
|
||||
|
||||
return (
|
||||
<p ref={ref} className={`blur-text ${className} flex flex-wrap`}>
|
||||
{elements.map((segment, index) => {
|
||||
const animateKeyframes = buildKeyframes(fromSnapshot, toSnapshots);
|
||||
|
||||
const spanTransition = {
|
||||
duration: totalDuration,
|
||||
times,
|
||||
delay: (index * delay) / 1000
|
||||
};
|
||||
spanTransition.ease = easing;
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className="inline-block will-change-[transform,filter,opacity]"
|
||||
key={index}
|
||||
initial={fromSnapshot}
|
||||
animate={inView ? animateKeyframes : fromSnapshot}
|
||||
transition={spanTransition}
|
||||
onAnimationComplete={index === elements.length - 1 ? onAnimationComplete : undefined}
|
||||
>
|
||||
{segment === ' ' ? '\u00A0' : segment}
|
||||
{animateBy === 'words' && index < elements.length - 1 && '\u00A0'}
|
||||
</motion.span>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlurText;
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useInView, useMotionValue, useSpring } from 'motion/react';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
|
||||
export default function CountUp({
|
||||
to,
|
||||
from = 0,
|
||||
direction = 'up',
|
||||
delay = 0,
|
||||
duration = 2,
|
||||
className = '',
|
||||
startWhen = true,
|
||||
separator = '',
|
||||
onStart,
|
||||
onEnd
|
||||
}) {
|
||||
const ref = useRef(null);
|
||||
const motionValue = useMotionValue(direction === 'down' ? to : from);
|
||||
|
||||
const damping = 20 + 40 * (1 / duration);
|
||||
const stiffness = 100 * (1 / duration);
|
||||
|
||||
const springValue = useSpring(motionValue, {
|
||||
damping,
|
||||
stiffness
|
||||
});
|
||||
|
||||
const isInView = useInView(ref, { once: true, margin: '0px' });
|
||||
|
||||
const getDecimalPlaces = num => {
|
||||
const str = num.toString();
|
||||
|
||||
if (str.includes('.')) {
|
||||
const decimals = str.split('.')[1];
|
||||
|
||||
if (parseInt(decimals) !== 0) {
|
||||
return decimals.length;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
const maxDecimals = Math.max(getDecimalPlaces(from), getDecimalPlaces(to));
|
||||
|
||||
const formatValue = useCallback(
|
||||
latest => {
|
||||
const hasDecimals = maxDecimals > 0;
|
||||
|
||||
const options = {
|
||||
useGrouping: !!separator,
|
||||
minimumFractionDigits: hasDecimals ? maxDecimals : 0,
|
||||
maximumFractionDigits: hasDecimals ? maxDecimals : 0
|
||||
};
|
||||
|
||||
const formattedNumber = Intl.NumberFormat('en-US', options).format(latest);
|
||||
|
||||
return separator ? formattedNumber.replace(/,/g, separator) : formattedNumber;
|
||||
},
|
||||
[maxDecimals, separator]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatValue(direction === 'down' ? to : from);
|
||||
}
|
||||
}, [from, to, direction, formatValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isInView && startWhen) {
|
||||
if (typeof onStart === 'function') onStart();
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
motionValue.set(direction === 'down' ? from : to);
|
||||
}, delay * 1000);
|
||||
|
||||
const durationTimeoutId = setTimeout(
|
||||
() => {
|
||||
if (typeof onEnd === 'function') onEnd();
|
||||
},
|
||||
delay * 1000 + duration * 1000
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(durationTimeoutId);
|
||||
};
|
||||
}
|
||||
}, [isInView, startWhen, motionValue, direction, from, to, delay, onStart, onEnd, duration]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = springValue.on('change', latest => {
|
||||
if (ref.current) {
|
||||
ref.current.textContent = formatValue(latest);
|
||||
}
|
||||
});
|
||||
|
||||
return () => unsubscribe();
|
||||
}, [springValue, formatValue]);
|
||||
|
||||
return <span className={className} ref={ref} />;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { motion, useMotionValue, useAnimationFrame, useTransform } from 'motion/react';
|
||||
|
||||
export default function GradientText({
|
||||
children,
|
||||
className = '',
|
||||
colors = ['#5227FF', '#FF9FFC', '#B19EEF'],
|
||||
animationSpeed = 8,
|
||||
showBorder = false,
|
||||
direction = 'horizontal',
|
||||
pauseOnHover = false,
|
||||
yoyo = true
|
||||
}) {
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const progress = useMotionValue(0);
|
||||
const elapsedRef = useRef(0);
|
||||
const lastTimeRef = useRef(null);
|
||||
|
||||
const animationDuration = animationSpeed * 1000;
|
||||
|
||||
useAnimationFrame(time => {
|
||||
if (isPaused) {
|
||||
lastTimeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastTimeRef.current === null) {
|
||||
lastTimeRef.current = time;
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaTime = time - lastTimeRef.current;
|
||||
lastTimeRef.current = time;
|
||||
elapsedRef.current += deltaTime;
|
||||
|
||||
if (yoyo) {
|
||||
const fullCycle = animationDuration * 2;
|
||||
const cycleTime = elapsedRef.current % fullCycle;
|
||||
|
||||
if (cycleTime < animationDuration) {
|
||||
progress.set((cycleTime / animationDuration) * 100);
|
||||
} else {
|
||||
progress.set(100 - ((cycleTime - animationDuration) / animationDuration) * 100);
|
||||
}
|
||||
} else {
|
||||
// Continuously increase position for seamless looping
|
||||
progress.set((elapsedRef.current / animationDuration) * 100);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
elapsedRef.current = 0;
|
||||
progress.set(0);
|
||||
}, [animationSpeed, progress, yoyo]);
|
||||
|
||||
const backgroundPosition = useTransform(progress, p => {
|
||||
if (direction === 'horizontal') {
|
||||
return `${p}% 50%`;
|
||||
} else if (direction === 'vertical') {
|
||||
return `50% ${p}%`;
|
||||
} else {
|
||||
// For diagonal, move only horizontally to avoid interference patterns
|
||||
return `${p}% 50%`;
|
||||
}
|
||||
});
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (pauseOnHover) setIsPaused(true);
|
||||
}, [pauseOnHover]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (pauseOnHover) setIsPaused(false);
|
||||
}, [pauseOnHover]);
|
||||
|
||||
const gradientAngle =
|
||||
direction === 'horizontal' ? 'to right' : direction === 'vertical' ? 'to bottom' : 'to bottom right';
|
||||
// Duplicate first color at the end for seamless looping
|
||||
const gradientColors = [...colors, colors[0]].join(', ');
|
||||
|
||||
const gradientStyle = {
|
||||
backgroundImage: `linear-gradient(${gradientAngle}, ${gradientColors})`,
|
||||
backgroundSize: direction === 'horizontal' ? '300% 100%' : direction === 'vertical' ? '100% 300%' : '300% 300%',
|
||||
backgroundRepeat: 'repeat'
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={`relative mx-auto flex max-w-fit flex-row items-center justify-center rounded-[1.25rem] font-medium backdrop-blur transition-shadow duration-500 overflow-hidden cursor-pointer ${showBorder ? 'py-1 px-2' : ''} ${className}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{showBorder && (
|
||||
<motion.div
|
||||
className="absolute inset-0 z-0 pointer-events-none rounded-[1.25rem]"
|
||||
style={{ ...gradientStyle, backgroundPosition }}
|
||||
>
|
||||
<div
|
||||
className="absolute bg-black rounded-[1.25rem] z-[-1]"
|
||||
style={{
|
||||
width: 'calc(100% - 2px)',
|
||||
height: 'calc(100% - 2px)',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)'
|
||||
}}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div
|
||||
className="inline-block relative z-2 text-transparent bg-clip-text"
|
||||
style={{ ...gradientStyle, backgroundPosition, WebkitBackgroundClip: 'text' }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { motion, useMotionValue, useAnimationFrame, useTransform } from 'motion/react';
|
||||
|
||||
const ShinyText = ({
|
||||
text,
|
||||
disabled = false,
|
||||
speed = 2,
|
||||
className = '',
|
||||
color = '#b5b5b5',
|
||||
shineColor = '#ffffff',
|
||||
spread = 120,
|
||||
yoyo = false,
|
||||
pauseOnHover = false,
|
||||
direction = 'left',
|
||||
delay = 0
|
||||
}) => {
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const progress = useMotionValue(0);
|
||||
const elapsedRef = useRef(0);
|
||||
const lastTimeRef = useRef(null);
|
||||
const directionRef = useRef(direction === 'left' ? 1 : -1);
|
||||
|
||||
const animationDuration = speed * 1000;
|
||||
const delayDuration = delay * 1000;
|
||||
|
||||
useAnimationFrame(time => {
|
||||
if (disabled || isPaused) {
|
||||
lastTimeRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastTimeRef.current === null) {
|
||||
lastTimeRef.current = time;
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaTime = time - lastTimeRef.current;
|
||||
lastTimeRef.current = time;
|
||||
|
||||
elapsedRef.current += deltaTime;
|
||||
|
||||
// Animation goes from 0 to 100
|
||||
if (yoyo) {
|
||||
const cycleDuration = animationDuration + delayDuration;
|
||||
const fullCycle = cycleDuration * 2;
|
||||
const cycleTime = elapsedRef.current % fullCycle;
|
||||
|
||||
if (cycleTime < animationDuration) {
|
||||
// Forward animation: 0 -> 100
|
||||
const p = (cycleTime / animationDuration) * 100;
|
||||
progress.set(directionRef.current === 1 ? p : 100 - p);
|
||||
} else if (cycleTime < cycleDuration) {
|
||||
// Delay at end
|
||||
progress.set(directionRef.current === 1 ? 100 : 0);
|
||||
} else if (cycleTime < cycleDuration + animationDuration) {
|
||||
// Reverse animation: 100 -> 0
|
||||
const reverseTime = cycleTime - cycleDuration;
|
||||
const p = 100 - (reverseTime / animationDuration) * 100;
|
||||
progress.set(directionRef.current === 1 ? p : 100 - p);
|
||||
} else {
|
||||
// Delay at start
|
||||
progress.set(directionRef.current === 1 ? 0 : 100);
|
||||
}
|
||||
} else {
|
||||
const cycleDuration = animationDuration + delayDuration;
|
||||
const cycleTime = elapsedRef.current % cycleDuration;
|
||||
|
||||
if (cycleTime < animationDuration) {
|
||||
// Animation phase: 0 -> 100
|
||||
const p = (cycleTime / animationDuration) * 100;
|
||||
progress.set(directionRef.current === 1 ? p : 100 - p);
|
||||
} else {
|
||||
// Delay phase - hold at end (shine off-screen)
|
||||
progress.set(directionRef.current === 1 ? 100 : 0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
directionRef.current = direction === 'left' ? 1 : -1;
|
||||
elapsedRef.current = 0;
|
||||
progress.set(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [direction]);
|
||||
|
||||
// Transform: p=0 -> 150% (shine off right), p=100 -> -50% (shine off left)
|
||||
const backgroundPosition = useTransform(progress, p => `${150 - p * 2}% center`);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
if (pauseOnHover) setIsPaused(true);
|
||||
}, [pauseOnHover]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
if (pauseOnHover) setIsPaused(false);
|
||||
}, [pauseOnHover]);
|
||||
|
||||
const gradientStyle = {
|
||||
backgroundImage: `linear-gradient(${spread}deg, ${color} 0%, ${color} 35%, ${shineColor} 50%, ${color} 65%, ${color} 100%)`,
|
||||
backgroundSize: '200% auto',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent'
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.span
|
||||
className={`inline-block ${className}`}
|
||||
style={{ ...gradientStyle, backgroundPosition }}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{text}
|
||||
</motion.span>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShinyText;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useRef, useState } from 'react';
|
||||
|
||||
const SpotlightCard = ({ children, className = '', spotlightColor = 'rgba(255, 255, 255, 0.25)' }) => {
|
||||
const divRef = useRef(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [opacity, setOpacity] = useState(0);
|
||||
|
||||
const handleMouseMove = e => {
|
||||
if (!divRef.current || isFocused) return;
|
||||
|
||||
const rect = divRef.current.getBoundingClientRect();
|
||||
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
setOpacity(0.6);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
setOpacity(0);
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setOpacity(0.6);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setOpacity(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={divRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
className={`relative rounded-3xl border border-neutral-800 bg-neutral-900 overflow-hidden p-8 ${className}`}
|
||||
>
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-500 ease-in-out"
|
||||
style={{
|
||||
opacity,
|
||||
background: `radial-gradient(circle at ${position.x}px ${position.y}px, ${spotlightColor}, transparent 80%)`
|
||||
}}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SpotlightCard;
|
||||
@@ -0,0 +1,62 @@
|
||||
const StarBorder = ({
|
||||
as: Component = 'button',
|
||||
className = '',
|
||||
color = 'white',
|
||||
speed = '6s',
|
||||
thickness = 1,
|
||||
children,
|
||||
...rest
|
||||
}) => {
|
||||
return (
|
||||
<Component
|
||||
className={`relative inline-block overflow-hidden rounded-[20px] ${className}`}
|
||||
style={{
|
||||
padding: `${thickness}px 0`,
|
||||
...rest.style
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
<div
|
||||
className="absolute w-[300%] h-[50%] opacity-70 bottom-[-11px] right-[-250%] rounded-full animate-star-movement-bottom z-0"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${color}, transparent 10%)`,
|
||||
animationDuration: speed
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className="absolute w-[300%] h-[50%] opacity-70 top-[-10px] left-[-250%] rounded-full animate-star-movement-top z-0"
|
||||
style={{
|
||||
background: `radial-gradient(circle, ${color}, transparent 10%)`,
|
||||
animationDuration: speed
|
||||
}}
|
||||
></div>
|
||||
<div className="relative z-1 bg-gradient-to-b from-black to-gray-900 border border-gray-800 text-white text-center text-[16px] py-[16px] px-[26px] rounded-[20px]">
|
||||
{children}
|
||||
</div>
|
||||
</Component>
|
||||
);
|
||||
};
|
||||
|
||||
export default StarBorder;
|
||||
|
||||
// tailwind.config.js
|
||||
// module.exports = {
|
||||
// theme: {
|
||||
// extend: {
|
||||
// animation: {
|
||||
// 'star-movement-bottom': 'star-movement-bottom linear infinite alternate',
|
||||
// 'star-movement-top': 'star-movement-top linear infinite alternate',
|
||||
// },
|
||||
// keyframes: {
|
||||
// 'star-movement-bottom': {
|
||||
// '0%': { transform: 'translate(0%, 0%)', opacity: '1' },
|
||||
// '100%': { transform: 'translate(-100%, 0%)', opacity: '0' },
|
||||
// },
|
||||
// 'star-movement-top': {
|
||||
// '0%': { transform: 'translate(0%, 0%)', opacity: '1' },
|
||||
// '100%': { transform: 'translate(100%, 0%)', opacity: '0' },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
Reference in new issue
Block a user