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
+43
@@ -0,0 +1,43 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import Layout from './components/Layout';
|
||||
import AuthGuard from './components/AuthGuard';
|
||||
|
||||
const Home = lazy(() => import('./pages/Home'));
|
||||
const Login = lazy(() => import('./pages/Login'));
|
||||
const Register = lazy(() => import('./pages/Register'));
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const Tokens = lazy(() => import('./pages/Tokens'));
|
||||
const Packages = lazy(() => import('./pages/Packages'));
|
||||
const Pricing = lazy(() => import('./pages/Pricing'));
|
||||
|
||||
const Loading = () => (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="w-8 h-8 border-2 border-brand-500/30 border-t-brand-500 rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<Routes>
|
||||
{/* Public pages with layout */}
|
||||
<Route element={<Layout />}>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/pricing" element={<Pricing />} />
|
||||
<Route path="/packages" element={<Packages />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
|
||||
{/* Protected pages */}
|
||||
<Route element={<AuthGuard />}>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/tokens" element={<Tokens />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
import axios from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export const Q = 500000; // QuotaPerUnit — single source of truth
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '',
|
||||
timeout: 30000,
|
||||
withCredentials: true, // CRITICAL: send session cookies on every request
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
// Attach New-Api-User header (required by backend auth middleware)
|
||||
api.interceptors.request.use((config) => {
|
||||
const userId = localStorage.getItem('dist_user_id');
|
||||
if (userId) {
|
||||
config.headers['New-Api-User'] = userId;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
api.interceptors.response.use(
|
||||
(res) => {
|
||||
// Handle success:false responses with user-visible errors
|
||||
if (res.data && res.data.success === false && res.data.message) {
|
||||
toast.error(res.data.message);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
(err) => {
|
||||
const msg = err.response?.data?.message || err.message || 'Request failed';
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('dist_user_id');
|
||||
// Emit event so AuthContext can clear React state
|
||||
window.dispatchEvent(new Event('auth:logout'));
|
||||
toast.error('Session expired, please log in again');
|
||||
} else {
|
||||
toast.error(msg);
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
// ===== Public =====
|
||||
export const getSiteInfo = () => api.get('/api/dist/site/info');
|
||||
export const getSiteModels = () => api.get('/api/dist/site/models');
|
||||
export const getSitePricing = () => api.get('/api/dist/site/pricing');
|
||||
export const getSitePackages = () => api.get('/api/dist/site/packages');
|
||||
|
||||
// ===== Auth =====
|
||||
export const register = (data) => api.post('/api/dist/user/register', data);
|
||||
export const login = (data) => api.post('/api/dist/user/login', data);
|
||||
export const logout = () => api.post('/api/dist/user/logout');
|
||||
|
||||
// ===== User =====
|
||||
export const getUserSelf = () => api.get('/api/dist/user/self');
|
||||
export const getUserUsage = () => api.get('/api/dist/user/usage');
|
||||
|
||||
// ===== Tokens =====
|
||||
export const getTokens = () => api.get('/api/dist/token/list');
|
||||
export const createToken = (data) => api.post('/api/dist/token/create', data);
|
||||
export const updateToken = (id, data) => api.put(`/api/dist/token/${id}`, data);
|
||||
export const deleteToken = (id) => api.delete(`/api/dist/token/${id}`);
|
||||
|
||||
// ===== Purchase =====
|
||||
export const redeemCode = (key) => api.post('/api/dist/topup/redeem', { key }); // backend field is "key"
|
||||
export const subscribePackage = (packageId) => api.post('/api/dist/package/subscribe', { package_id: packageId });
|
||||
|
||||
// ===== Helpers =====
|
||||
export const quotaToDollar = (quota) => (quota / Q).toFixed(4);
|
||||
export const quotaToDollar6 = (quota) => (quota / Q).toFixed(6);
|
||||
|
||||
export default api;
|
||||
@@ -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' },
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect } from 'react';
|
||||
import { getUserSelf, login as loginApi, register as registerApi, logout as logoutApi } from '../api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Refresh user data — used after login, redeem, subscribe, etc.
|
||||
const refreshUser = useCallback(async () => {
|
||||
try {
|
||||
const res = await getUserSelf();
|
||||
if (res.data.success) {
|
||||
setUser(res.data.data);
|
||||
return res.data.data;
|
||||
}
|
||||
} catch (e) { /* handled by interceptor */ }
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Try to restore session on mount (server uses cookies, so just try fetching self)
|
||||
useEffect(() => {
|
||||
const userId = localStorage.getItem('dist_user_id');
|
||||
if (userId) {
|
||||
getUserSelf()
|
||||
.then((res) => {
|
||||
if (res.data.success) {
|
||||
setUser(res.data.data);
|
||||
} else {
|
||||
localStorage.removeItem('dist_user_id');
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('dist_user_id');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Listen for 401 events from API interceptor to clear React state
|
||||
useEffect(() => {
|
||||
const handleForceLogout = () => {
|
||||
setUser(null);
|
||||
};
|
||||
window.addEventListener('auth:logout', handleForceLogout);
|
||||
return () => window.removeEventListener('auth:logout', handleForceLogout);
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (username, password) => {
|
||||
const res = await loginApi({ username, password });
|
||||
if (res.data.success) {
|
||||
// Server sets session cookie automatically
|
||||
// Store user ID for New-Api-User header
|
||||
const userData = res.data.data;
|
||||
if (userData?.id) {
|
||||
localStorage.setItem('dist_user_id', String(userData.id));
|
||||
}
|
||||
// Fetch full user info (login response may not have quota/usage)
|
||||
const userRes = await getUserSelf();
|
||||
if (userRes.data.success) {
|
||||
setUser(userRes.data.data);
|
||||
} else {
|
||||
setUser(userData); // fallback to login response data
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
// success:false is already toasted by the response interceptor
|
||||
return { success: false, message: res.data.message };
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (data) => {
|
||||
const res = await registerApi(data);
|
||||
if (res.data.success) {
|
||||
// Registration succeeds — but user needs to login separately
|
||||
// (backend register does NOT set session)
|
||||
return { success: true, needsLogin: true };
|
||||
}
|
||||
return { success: false, message: res.data.message };
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try { await logoutApi(); } catch (e) { /* ok */ }
|
||||
localStorage.removeItem('dist_user_id');
|
||||
setUser(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout, refreshUser }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const ctx = useContext(AuthContext);
|
||||
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { getSiteInfo } from '../api';
|
||||
|
||||
const SiteContext = createContext(null);
|
||||
|
||||
export function SiteProvider({ children }) {
|
||||
const [site, setSite] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
getSiteInfo()
|
||||
.then((res) => {
|
||||
if (res.data.success) {
|
||||
setSite(res.data.data);
|
||||
// Update page title
|
||||
if (res.data.data?.name) {
|
||||
document.title = res.data.data.name;
|
||||
}
|
||||
// Update favicon
|
||||
if (res.data.data?.favicon) {
|
||||
const link = document.querySelector("link[rel~='icon']") || document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
link.href = res.data.data.favicon;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SiteContext.Provider value={{ site, loading }}>
|
||||
{children}
|
||||
</SiteContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSite() {
|
||||
const ctx = useContext(SiteContext);
|
||||
if (!ctx) throw new Error('useSite must be used within SiteProvider');
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--brand: 99 102 241;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-black text-white;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
}
|
||||
|
||||
::selection {
|
||||
@apply bg-brand-500/30;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
@apply w-1.5;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-neutral-700 rounded-full;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass {
|
||||
@apply bg-white/[0.03] backdrop-blur-xl border border-white/[0.06] rounded-2xl;
|
||||
}
|
||||
|
||||
.glass-sm {
|
||||
@apply bg-white/[0.04] backdrop-blur-md border border-white/[0.08] rounded-xl;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply px-6 py-2.5 rounded-xl bg-brand-600 text-white font-medium text-sm
|
||||
hover:bg-brand-500 active:bg-brand-700
|
||||
transition-all duration-200 ease-out
|
||||
shadow-lg shadow-brand-600/20
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply px-6 py-2.5 rounded-xl border border-white/10 text-white font-medium text-sm
|
||||
hover:bg-white/5 active:bg-white/10
|
||||
transition-all duration-200 ease-out
|
||||
disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-4 py-2.5 rounded-xl border border-white/10 bg-white/[0.04]
|
||||
text-white placeholder-neutral-500 text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-brand-500/40 focus:border-brand-500/60
|
||||
transition-all duration-200;
|
||||
}
|
||||
|
||||
.card-glow {
|
||||
@apply relative overflow-hidden;
|
||||
}
|
||||
|
||||
.card-glow::before {
|
||||
content: '';
|
||||
@apply absolute -inset-0.5 bg-gradient-to-r from-brand-500/20 via-purple-500/20 to-pink-500/20 rounded-2xl opacity-0 transition-opacity duration-500;
|
||||
}
|
||||
|
||||
.card-glow:hover::before {
|
||||
@apply opacity-100;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import App from './App';
|
||||
import { AuthProvider } from './context/AuthContext';
|
||||
import { SiteProvider } from './context/SiteContext';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<BrowserRouter>
|
||||
<SiteProvider>
|
||||
<AuthProvider>
|
||||
<App />
|
||||
<Toaster
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
duration: 3000,
|
||||
style: {
|
||||
background: '#1a1a2e',
|
||||
color: '#fff',
|
||||
border: '1px solid rgba(255,255,255,0.08)',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</AuthProvider>
|
||||
</SiteProvider>
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
@@ -0,0 +1,127 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { getUserUsage, redeemCode, Q, quotaToDollar } from '../api';
|
||||
import SpotlightCard from '../components/bits/SpotlightCard';
|
||||
import CountUp from '../components/bits/CountUp';
|
||||
import ShinyText from '../components/bits/ShinyText';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const [usage, setUsage] = useState(null);
|
||||
const [redeemInput, setRedeemInput] = useState('');
|
||||
const [redeeming, setRedeeming] = useState(false);
|
||||
|
||||
const loadUsage = async () => {
|
||||
try {
|
||||
const r = await getUserUsage();
|
||||
if (r.data.success) setUsage(r.data.data);
|
||||
} catch (e) { /* interceptor */ }
|
||||
};
|
||||
|
||||
useEffect(() => { loadUsage(); }, []);
|
||||
|
||||
const handleRedeem = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!redeemInput.trim()) return;
|
||||
setRedeeming(true);
|
||||
try {
|
||||
const res = await redeemCode(redeemInput.trim());
|
||||
if (res.data.success) {
|
||||
toast.success('Code redeemed successfully!');
|
||||
setRedeemInput('');
|
||||
// Refresh both usage AND user data to update balance
|
||||
await Promise.all([loadUsage(), refreshUser()]);
|
||||
}
|
||||
} catch (err) { /* interceptor */ }
|
||||
setRedeeming(false);
|
||||
};
|
||||
|
||||
// Use usage data (fresh from API) for balance/used — NOT stale user object
|
||||
const quota = usage?.quota ?? user?.quota ?? 0;
|
||||
const usedQuota = usage?.used_quota ?? user?.used_quota ?? 0;
|
||||
const requestCount = usage?.request_count ?? user?.request_count ?? 0;
|
||||
const balanceDollars = quota / Q;
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-10">
|
||||
{/* Welcome */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-heading font-bold text-white mb-1">
|
||||
Welcome, <ShinyText text={user?.display_name || user?.username || 'User'} className="!inline" speed={3} color="#a5b4fc" shineColor="#e0e7ff" />
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-400">Manage your API usage, keys, and account</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
|
||||
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60 !p-6" spotlightColor="rgba(34,197,94,0.15)">
|
||||
<p className="text-sm text-neutral-400 mb-2">Balance</p>
|
||||
<div className="text-3xl font-bold text-white">
|
||||
$<CountUp from={0} to={balanceDollars} duration={1.5} />
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">{quota.toLocaleString()} quota units</p>
|
||||
</SpotlightCard>
|
||||
|
||||
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60 !p-6" spotlightColor="rgba(129,140,248,0.15)">
|
||||
<p className="text-sm text-neutral-400 mb-2">Used</p>
|
||||
<div className="text-3xl font-bold text-white">
|
||||
$<CountUp from={0} to={usedQuota / Q} duration={1.5} />
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 mt-1">{usedQuota.toLocaleString()} quota units</p>
|
||||
</SpotlightCard>
|
||||
|
||||
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60 !p-6" spotlightColor="rgba(244,114,182,0.15)">
|
||||
<p className="text-sm text-neutral-400 mb-2">Total Requests</p>
|
||||
<div className="text-3xl font-bold text-white">
|
||||
<CountUp from={0} to={requestCount} duration={1.5} />
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Redeem Code */}
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Redeem Code</h2>
|
||||
<form onSubmit={handleRedeem} className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={redeemInput}
|
||||
onChange={(e) => setRedeemInput(e.target.value)}
|
||||
className="input flex-1"
|
||||
placeholder="Enter redemption code"
|
||||
/>
|
||||
<button type="submit" disabled={redeeming} className="btn-primary whitespace-nowrap">
|
||||
{redeeming ? 'Redeeming...' : 'Redeem'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Quick Links</h2>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Link to="/tokens" className="glass-sm !rounded-xl px-4 py-3 hover:bg-white/[0.06] transition-colors group">
|
||||
<p className="text-sm font-medium text-white group-hover:text-brand-400 transition-colors">API Keys</p>
|
||||
<p className="text-xs text-neutral-500">Manage your keys</p>
|
||||
</Link>
|
||||
<Link to="/packages" className="glass-sm !rounded-xl px-4 py-3 hover:bg-white/[0.06] transition-colors group">
|
||||
<p className="text-sm font-medium text-white group-hover:text-brand-400 transition-colors">Packages</p>
|
||||
<p className="text-xs text-neutral-500">View plans</p>
|
||||
</Link>
|
||||
<Link to="/pricing" className="glass-sm !rounded-xl px-4 py-3 hover:bg-white/[0.06] transition-colors group">
|
||||
<p className="text-sm font-medium text-white group-hover:text-brand-400 transition-colors">Pricing</p>
|
||||
<p className="text-xs text-neutral-500">Model prices</p>
|
||||
</Link>
|
||||
<div className="glass-sm !rounded-xl px-4 py-3 opacity-50">
|
||||
<p className="text-sm font-medium text-white">API Docs</p>
|
||||
<p className="text-xs text-neutral-500">Coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useSite } from '../context/SiteContext';
|
||||
import { getSiteModels, getSitePackages } from '../api';
|
||||
import GradientText from '../components/bits/GradientText';
|
||||
import BlurText from '../components/bits/BlurText';
|
||||
import SpotlightCard from '../components/bits/SpotlightCard';
|
||||
import CountUp from '../components/bits/CountUp';
|
||||
import StarBorder from '../components/bits/StarBorder';
|
||||
import ShinyText from '../components/bits/ShinyText';
|
||||
|
||||
export default function Home() {
|
||||
const { user } = useAuth();
|
||||
const { site } = useSite();
|
||||
const [models, setModels] = useState([]);
|
||||
const [packages, setPackages] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
getSiteModels().then(r => { if (r.data.success) setModels(r.data.data || []); }).catch(() => {});
|
||||
getSitePackages().then(r => { if (r.data.success) setPackages(r.data.data || []); }).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const enabledModels = models.filter(m => m.enabled !== false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Gradient Background */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[600px] bg-gradient-radial from-brand-600/20 via-purple-600/5 to-transparent blur-3xl" />
|
||||
<div className="absolute top-[400px] right-0 w-[500px] h-[500px] bg-gradient-radial from-pink-600/10 to-transparent blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative max-w-7xl mx-auto px-6 pt-24 pb-20">
|
||||
<div className="text-center max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<GradientText
|
||||
colors={['#818cf8', '#c084fc', '#f472b6', '#818cf8']}
|
||||
animationSpeed={6}
|
||||
className="text-5xl md:text-7xl font-heading font-extrabold leading-tight"
|
||||
>
|
||||
{site?.name || 'AI API Platform'}
|
||||
</GradientText>
|
||||
</div>
|
||||
|
||||
<BlurText
|
||||
text="Access the world's most powerful AI models through a single API endpoint. Simple, affordable, reliable."
|
||||
className="text-lg md:text-xl text-neutral-400 max-w-2xl mx-auto mb-10"
|
||||
delay={50}
|
||||
animateBy="words"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{user ? (
|
||||
<Link to="/dashboard">
|
||||
<StarBorder as="div" color="#818cf8" speed="5s">
|
||||
Go to Dashboard
|
||||
</StarBorder>
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/register" className="btn-primary text-base !px-8 !py-3">
|
||||
Get Started Free
|
||||
</Link>
|
||||
<Link to="/pricing" className="btn-secondary text-base !px-8 !py-3">
|
||||
View Pricing
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-6 max-w-xl mx-auto mt-20">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-white">
|
||||
<CountUp from={0} to={enabledModels.length || 50} duration={2} separator="" />+
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 mt-1">AI Models</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-white">
|
||||
<CountUp from={0} to={99.9} duration={2.5} />%
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 mt-1">Uptime</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-white">
|
||||
<<CountUp from={200} to={50} duration={2} direction="down" />ms
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 mt-1">Latency</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="relative max-w-7xl mx-auto px-6 py-20">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-heading font-bold text-white mb-3">Why Choose Us</h2>
|
||||
<p className="text-neutral-400">Everything you need to build AI-powered applications</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60" spotlightColor="rgba(129,140,248,0.15)">
|
||||
<div className="w-12 h-12 rounded-xl bg-brand-500/10 flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-brand-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Lightning Fast</h3>
|
||||
<p className="text-sm text-neutral-400 leading-relaxed">
|
||||
Optimized routing and caching deliver responses with minimal latency. Load-balanced across multiple providers for maximum reliability.
|
||||
</p>
|
||||
</SpotlightCard>
|
||||
|
||||
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60" spotlightColor="rgba(192,132,252,0.15)">
|
||||
<div className="w-12 h-12 rounded-xl bg-purple-500/10 flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Secure & Private</h3>
|
||||
<p className="text-sm text-neutral-400 leading-relaxed">
|
||||
Enterprise-grade security with encrypted API keys. No request logging by default. Your data stays your data.
|
||||
</p>
|
||||
</SpotlightCard>
|
||||
|
||||
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60" spotlightColor="rgba(244,114,182,0.15)">
|
||||
<div className="w-12 h-12 rounded-xl bg-pink-500/10 flex items-center justify-center mb-4">
|
||||
<svg className="w-6 h-6 text-pink-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-white mb-2">Pay As You Go</h3>
|
||||
<p className="text-sm text-neutral-400 leading-relaxed">
|
||||
Transparent pricing with no hidden fees. Only pay for what you use. Multiple top-up options and subscription packages available.
|
||||
</p>
|
||||
</SpotlightCard>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Models Preview */}
|
||||
{enabledModels.length > 0 && (
|
||||
<section className="relative max-w-7xl mx-auto px-6 py-20">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-heading font-bold text-white mb-3">Available Models</h2>
|
||||
<p className="text-neutral-400">
|
||||
Access <span className="text-brand-400 font-medium">{enabledModels.length}</span> AI models through a unified API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{enabledModels.slice(0, 12).map((m, i) => (
|
||||
<div
|
||||
key={m.id || i}
|
||||
className="glass-sm px-4 py-3 flex items-center gap-3 hover:bg-white/[0.06] transition-colors group"
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 flex-shrink-0" />
|
||||
<span className="text-sm font-mono text-neutral-300 group-hover:text-white transition-colors truncate">
|
||||
{m.display_name || m.model_name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{enabledModels.length > 12 && (
|
||||
<div className="text-center mt-6">
|
||||
<Link to="/pricing" className="text-sm text-brand-400 hover:text-brand-300 transition-colors">
|
||||
View all {enabledModels.length} models →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Packages Preview */}
|
||||
{packages.length > 0 && (
|
||||
<section className="relative max-w-7xl mx-auto px-6 py-20">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl font-heading font-bold text-white mb-3">Plans & Packages</h2>
|
||||
<p className="text-neutral-400">Choose a plan that fits your needs</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto">
|
||||
{packages.filter(p => p.enabled).slice(0, 3).map((pkg) => (
|
||||
<div key={pkg.id} className="glass rounded-2xl p-6 flex flex-col hover:border-brand-500/30 transition-colors">
|
||||
<h3 className="text-lg font-semibold text-white mb-1">{pkg.name}</h3>
|
||||
{pkg.description && (
|
||||
<p className="text-sm text-neutral-400 mb-4">{pkg.description}</p>
|
||||
)}
|
||||
<div className="mt-auto pt-4">
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold text-white">${pkg.price}</span>
|
||||
{pkg.original_price > pkg.price && (
|
||||
<span className="text-sm text-neutral-500 line-through">${pkg.original_price}</span>
|
||||
)}
|
||||
</div>
|
||||
{pkg.duration > 0 && (
|
||||
<p className="text-xs text-neutral-500 mt-1">{pkg.duration} days</p>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
to={user ? '/packages' : '/register'}
|
||||
className="btn-primary text-center mt-4 !py-2"
|
||||
>
|
||||
{user ? 'Subscribe' : 'Get Started'}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="relative max-w-7xl mx-auto px-6 py-24">
|
||||
<div className="glass rounded-3xl p-12 text-center relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-brand-600/10 via-transparent to-purple-600/10" />
|
||||
<div className="relative z-10">
|
||||
<h2 className="text-3xl font-heading font-bold text-white mb-4">
|
||||
Ready to Get Started?
|
||||
</h2>
|
||||
<p className="text-neutral-400 mb-8 max-w-md mx-auto">
|
||||
Create your account and start building with AI in minutes. No credit card required.
|
||||
</p>
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
{user ? (
|
||||
<Link to="/dashboard" className="btn-primary text-base !px-8 !py-3">
|
||||
Go to Dashboard
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
<Link to="/register" className="btn-primary text-base !px-8 !py-3">
|
||||
Create Free Account
|
||||
</Link>
|
||||
<Link to="/login" className="btn-secondary text-base !px-8 !py-3">
|
||||
Sign In
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, Navigate, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useSite } from '../context/SiteContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Login() {
|
||||
const { login, user } = useAuth();
|
||||
const { site } = useSite();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [form, setForm] = useState({ username: '', password: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// If already logged in, redirect via component (not navigate in render)
|
||||
if (user) {
|
||||
const from = location.state?.from?.pathname || '/dashboard';
|
||||
return <Navigate to={from} replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.username || !form.password) {
|
||||
toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await login(form.username, form.password);
|
||||
if (result.success) {
|
||||
toast.success('Welcome back!');
|
||||
const from = location.state?.from?.pathname || '/dashboard';
|
||||
navigate(from, { replace: true });
|
||||
return; // component may unmount — skip setLoading
|
||||
}
|
||||
// error toast is handled by api interceptor for success:false
|
||||
} catch (err) {
|
||||
// Network error handled by interceptor
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="glass rounded-2xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-heading font-bold text-white mb-2">Welcome Back</h1>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Sign in to {site?.name || 'your account'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
className="input"
|
||||
placeholder="Enter your username"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="input"
|
||||
placeholder="Enter your password"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
)}
|
||||
{loading ? 'Signing in...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-neutral-400">
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-brand-400 hover:text-brand-300 transition-colors font-medium">
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { getSitePackages, subscribePackage, Q } from '../api';
|
||||
import SpotlightCard from '../components/bits/SpotlightCard';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Packages() {
|
||||
const { user, refreshUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [packages, setPackages] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [subscribing, setSubscribing] = useState(null);
|
||||
|
||||
const [confirmPkg, setConfirmPkg] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
getSitePackages()
|
||||
.then((r) => { if (r.data.success) setPackages(r.data.data || []); })
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleSubscribe = async (pkg) => {
|
||||
if (!user) {
|
||||
navigate('/register');
|
||||
return;
|
||||
}
|
||||
setConfirmPkg(pkg);
|
||||
};
|
||||
|
||||
const confirmSubscribe = async () => {
|
||||
if (!confirmPkg) return;
|
||||
const pkgId = confirmPkg.id;
|
||||
setSubscribing(pkgId);
|
||||
try {
|
||||
const res = await subscribePackage(pkgId);
|
||||
if (res.data.success) {
|
||||
toast.success('Subscribed successfully!');
|
||||
setConfirmPkg(null);
|
||||
// Refresh user data to update balance
|
||||
await refreshUser();
|
||||
}
|
||||
} catch (e) { /* interceptor */ }
|
||||
setSubscribing(null);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const enabled = packages.filter((p) => p.enabled);
|
||||
|
||||
const spotlightColors = [
|
||||
'rgba(129,140,248,0.15)',
|
||||
'rgba(192,132,252,0.15)',
|
||||
'rgba(244,114,182,0.15)',
|
||||
'rgba(34,197,94,0.15)',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-10">
|
||||
<div className="text-center mb-12">
|
||||
<h1 className="text-3xl font-heading font-bold text-white mb-3">Packages & Plans</h1>
|
||||
<p className="text-neutral-400 max-w-xl mx-auto">
|
||||
Choose a package that fits your usage. All plans include access to all available models.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{enabled.length === 0 ? (
|
||||
<div className="text-center py-12 text-neutral-400">
|
||||
<p>No packages available at the moment.</p>
|
||||
<Link to="/pricing" className="text-brand-400 hover:text-brand-300 transition-colors mt-2 inline-block">
|
||||
Check model pricing →
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 max-w-5xl mx-auto">
|
||||
{enabled.map((pkg, i) => (
|
||||
<SpotlightCard
|
||||
key={pkg.id}
|
||||
className="!bg-neutral-900/60 !border-neutral-800/60 !p-0 !rounded-2xl flex flex-col"
|
||||
spotlightColor={spotlightColors[i % spotlightColors.length]}
|
||||
>
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xl font-semibold text-white">{pkg.name}</h3>
|
||||
{pkg.description && (
|
||||
<p className="text-sm text-neutral-400 mt-1">{pkg.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-4xl font-bold text-white">${Number(pkg.price).toFixed(2)}</span>
|
||||
{pkg.original_price > 0 && pkg.original_price > pkg.price && (
|
||||
<span className="text-lg text-neutral-500 line-through">${Number(pkg.original_price).toFixed(2)}</span>
|
||||
)}
|
||||
</div>
|
||||
{pkg.duration > 0 && (
|
||||
<p className="text-sm text-neutral-500 mt-1">{pkg.duration} days access</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<ul className="space-y-2 mb-6 flex-1">
|
||||
{pkg.quota_amount > 0 && (
|
||||
<li className="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
${(pkg.quota_amount / Q).toFixed(2)} credit included
|
||||
</li>
|
||||
)}
|
||||
{pkg.rate_limit > 0 && (
|
||||
<li className="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
{pkg.rate_limit} requests/min
|
||||
</li>
|
||||
)}
|
||||
<li className="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
All models access
|
||||
</li>
|
||||
<li className="flex items-center gap-2 text-sm text-neutral-300">
|
||||
<svg className="w-4 h-4 text-green-500 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
OpenAI-compatible API
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
{/* CTA */}
|
||||
<button
|
||||
onClick={() => handleSubscribe(pkg)}
|
||||
disabled={subscribing === pkg.id}
|
||||
className="btn-primary w-full text-center"
|
||||
>
|
||||
{subscribing === pkg.id ? 'Processing...' : user ? 'Subscribe Now' : 'Sign Up to Subscribe'}
|
||||
</button>
|
||||
</div>
|
||||
</SpotlightCard>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmation Modal */}
|
||||
{confirmPkg && (() => {
|
||||
const userBalance = (user?.quota || 0) / Q;
|
||||
const pkgPrice = Number(confirmPkg.price);
|
||||
const insufficient = userBalance < pkgPrice;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => !subscribing && setConfirmPkg(null)}>
|
||||
<div className="glass rounded-2xl p-6 w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-white mb-3">Confirm Subscription</h2>
|
||||
<p className="text-sm text-neutral-400 mb-2">
|
||||
Subscribe to <span className="text-white font-medium">{confirmPkg.name}</span> for{' '}
|
||||
<span className="text-white font-medium">${pkgPrice.toFixed(2)}</span>?
|
||||
</p>
|
||||
<p className="text-sm text-neutral-400 mb-4">
|
||||
Your balance: <span className={`font-medium ${insufficient ? 'text-red-400' : 'text-green-400'}`}>${userBalance.toFixed(2)}</span>
|
||||
</p>
|
||||
{insufficient && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-xl p-3 mb-4">
|
||||
<p className="text-sm text-red-300">Insufficient balance. Please top up before subscribing.</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setConfirmPkg(null)} disabled={subscribing} className="btn-secondary">Cancel</button>
|
||||
<button onClick={confirmSubscribe} disabled={insufficient || subscribing} className="btn-primary">
|
||||
{subscribing ? 'Processing...' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { getSiteModels } from '../api';
|
||||
|
||||
export default function Pricing() {
|
||||
const [models, setModels] = useState([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// /api/dist/site/pricing returns same data as /api/dist/site/models — only need one call
|
||||
getSiteModels()
|
||||
.then((r) => {
|
||||
if (r.data.success) setModels(r.data.data || []);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const enabledModels = models.filter((m) => m.enabled !== false);
|
||||
|
||||
const filtered = enabledModels.filter((m) =>
|
||||
!search ||
|
||||
(m.display_name || m.model_name || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-6 py-10">
|
||||
<div className="text-center mb-10">
|
||||
<h1 className="text-3xl font-heading font-bold text-white mb-3">Model Pricing</h1>
|
||||
<p className="text-neutral-400 max-w-xl mx-auto">
|
||||
Transparent, pay-as-you-go pricing for all available models. Prices shown per 1K tokens.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="max-w-md mx-auto mb-8">
|
||||
<div className="relative">
|
||||
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-neutral-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="input !pl-10"
|
||||
placeholder="Search models..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-neutral-400">
|
||||
{search ? 'No models match your search' : 'No models available'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="glass-sm rounded-xl overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06]">
|
||||
<th className="text-left px-5 py-3.5 font-medium text-neutral-400">Model</th>
|
||||
<th className="text-right px-5 py-3.5 font-medium text-neutral-400">Input / 1K tokens</th>
|
||||
<th className="text-right px-5 py-3.5 font-medium text-neutral-400">Output / 1K tokens</th>
|
||||
<th className="text-center px-5 py-3.5 font-medium text-neutral-400">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((m, i) => (
|
||||
<tr key={m.model_name || i} className="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.02] transition-colors">
|
||||
<td className="px-5 py-3.5">
|
||||
<span className="font-mono text-white">{m.display_name || m.model_name}</span>
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right font-mono text-neutral-300">
|
||||
{m.input_price != null ? `$${Number(m.input_price).toFixed(6)}` : '-'}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-right font-mono text-neutral-300">
|
||||
{m.output_price != null ? `$${Number(m.output_price).toFixed(6)}` : '-'}
|
||||
</td>
|
||||
<td className="px-5 py-3.5 text-center">
|
||||
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs border ${
|
||||
m.status === 'healthy'
|
||||
? 'bg-green-500/10 text-green-400 border-green-500/20'
|
||||
: 'bg-neutral-500/10 text-neutral-400 border-neutral-500/20'
|
||||
}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${m.status === 'healthy' ? 'bg-green-500' : 'bg-neutral-500'}`} />
|
||||
{m.status === 'healthy' ? 'Online' : 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, Navigate, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useSite } from '../context/SiteContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Register() {
|
||||
const { register, user } = useAuth();
|
||||
const { site } = useSite();
|
||||
const navigate = useNavigate();
|
||||
const [form, setForm] = useState({ username: '', password: '', password2: '', email: '' });
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// If already logged in, redirect via component
|
||||
if (user) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!form.username || !form.password) {
|
||||
toast.error('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
if (form.password !== form.password2) {
|
||||
toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
if (form.password.length < 8) {
|
||||
toast.error('Password must be 8-20 characters');
|
||||
return;
|
||||
}
|
||||
if (form.password.length > 20) {
|
||||
toast.error('Password must be 8-20 characters');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await register({
|
||||
username: form.username,
|
||||
password: form.password,
|
||||
email: form.email || undefined,
|
||||
});
|
||||
if (result.success) {
|
||||
toast.success('Account created! Please sign in.');
|
||||
navigate('/login', { replace: true });
|
||||
return; // component may unmount — skip setLoading
|
||||
}
|
||||
// error toast is handled by api interceptor for success:false
|
||||
} catch (err) {
|
||||
// Network error handled by interceptor
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-[70vh] flex items-center justify-center px-6">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="glass rounded-2xl p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-2xl font-heading font-bold text-white mb-2">Create Account</h1>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Get started with {site?.name || 'our platform'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Username *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.username}
|
||||
onChange={(e) => setForm({ ...form, username: e.target.value })}
|
||||
className="input"
|
||||
placeholder="Choose a username"
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Email</label>
|
||||
<input
|
||||
type="email"
|
||||
value={form.email}
|
||||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||
className="input"
|
||||
placeholder="your@email.com (optional)"
|
||||
autoComplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password}
|
||||
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||
className="input"
|
||||
placeholder="8-20 characters"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Confirm Password *</label>
|
||||
<input
|
||||
type="password"
|
||||
value={form.password2}
|
||||
onChange={(e) => setForm({ ...form, password2: e.target.value })}
|
||||
className="input"
|
||||
placeholder="Repeat your password"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading && (
|
||||
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
)}
|
||||
{loading ? 'Creating...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-neutral-400">
|
||||
Already have an account?{' '}
|
||||
<Link to="/login" className="text-brand-400 hover:text-brand-300 transition-colors font-medium">
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { getTokens, createToken, updateToken, deleteToken } from '../api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function Tokens() {
|
||||
const [tokens, setTokens] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState(null);
|
||||
// Newly created key — shown only once
|
||||
const [newKey, setNewKey] = useState(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await getTokens();
|
||||
if (res.data.success) setTokens(res.data.data || []);
|
||||
} catch (e) { /* interceptor */ }
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleCreate = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!createName.trim()) {
|
||||
toast.error('Please enter a name');
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const res = await createToken({ name: createName.trim() });
|
||||
if (res.data.success) {
|
||||
setCreateName('');
|
||||
setShowCreate(false);
|
||||
// Show the key — it's only visible at creation time!
|
||||
const createdKey = res.data.data?.key;
|
||||
if (createdKey) {
|
||||
setNewKey(createdKey);
|
||||
}
|
||||
await load();
|
||||
}
|
||||
} catch (e) { /* interceptor */ }
|
||||
setCreating(false);
|
||||
};
|
||||
|
||||
const handleToggle = async (token) => {
|
||||
try {
|
||||
const res = await updateToken(token.id, {
|
||||
status: token.status === 1 ? 2 : 1,
|
||||
});
|
||||
if (res.data.success) {
|
||||
toast.success(token.status === 1 ? 'Token disabled' : 'Token enabled');
|
||||
await load();
|
||||
}
|
||||
} catch (e) { /* interceptor */ }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteConfirm) return;
|
||||
try {
|
||||
const res = await deleteToken(deleteConfirm.id);
|
||||
if (res.data.success) {
|
||||
toast.success('Token deleted');
|
||||
setDeleteConfirm(null);
|
||||
await load();
|
||||
}
|
||||
} catch (e) { /* interceptor */ }
|
||||
};
|
||||
|
||||
const handleCopy = async (text) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (e) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopiedId(text);
|
||||
toast.success('Copied to clipboard!');
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto px-6 py-10">
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-heading font-bold text-white">API Keys</h1>
|
||||
<p className="text-sm text-neutral-400 mt-1">Manage your API access tokens</p>
|
||||
</div>
|
||||
<button onClick={() => setShowCreate(true)} className="btn-primary">
|
||||
+ New Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowCreate(false)}>
|
||||
<div className="glass rounded-2xl p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-white mb-4">Create API Key</h2>
|
||||
<form onSubmit={handleCreate} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
className="input"
|
||||
placeholder="e.g. my-app-key"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button type="button" onClick={() => setShowCreate(false)} className="btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={creating} className="btn-primary">
|
||||
{creating ? 'Creating...' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New Key Reveal Modal — shown only once after creation */}
|
||||
{newKey && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="glass rounded-2xl p-6 w-full max-w-lg">
|
||||
<h2 className="text-lg font-semibold text-white mb-2">Your New API Key</h2>
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-xl p-3 mb-4">
|
||||
<p className="text-sm text-yellow-300">
|
||||
This is the only time you'll see this key. Copy it now and store it securely.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-black/40 rounded-xl p-4 flex items-center gap-3">
|
||||
<code className="text-sm font-mono text-green-400 flex-1 break-all select-all">
|
||||
{newKey}
|
||||
</code>
|
||||
<button
|
||||
onClick={() => handleCopy(newKey)}
|
||||
className="btn-primary !px-4 !py-1.5 flex-shrink-0"
|
||||
>
|
||||
{copiedId === newKey ? 'Copied!' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<button onClick={() => setNewKey(null)} className="btn-secondary">
|
||||
I've saved my key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{deleteConfirm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setDeleteConfirm(null)}>
|
||||
<div className="glass rounded-2xl p-6 w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
|
||||
<h2 className="text-lg font-semibold text-white mb-3">Delete Token</h2>
|
||||
<p className="text-sm text-neutral-400 mb-4">
|
||||
Delete <span className="text-white font-medium">"{deleteConfirm.name}"</span>? This cannot be undone.
|
||||
</p>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button onClick={() => setDeleteConfirm(null)} className="btn-secondary">Cancel</button>
|
||||
<button onClick={handleDelete} className="px-6 py-2.5 rounded-xl bg-red-600 text-white text-sm font-medium hover:bg-red-500 transition-colors">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Token List */}
|
||||
{tokens.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-12 text-center">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-2xl bg-neutral-800/50 flex items-center justify-center">
|
||||
<svg className="w-8 h-8 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-neutral-400 mb-4">No API keys yet</p>
|
||||
<button onClick={() => setShowCreate(true)} className="btn-primary">
|
||||
Create Your First Key
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{tokens.map((token) => (
|
||||
<div key={token.id} className="glass-sm rounded-xl p-5 flex items-center gap-4">
|
||||
{/* Status dot */}
|
||||
<div className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${token.status === 1 ? 'bg-green-500' : 'bg-neutral-600'}`} />
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-white">{token.name}</p>
|
||||
<p className="text-xs font-mono text-neutral-500 mt-1">
|
||||
sk-***
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Created time */}
|
||||
<span className="text-xs text-neutral-500 hidden md:block">
|
||||
{token.created_time ? new Date(token.created_time * 1000).toLocaleDateString() : ''}
|
||||
</span>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handleToggle(token)}
|
||||
className={`px-3 py-1 text-xs rounded-lg border transition-colors ${
|
||||
token.status === 1
|
||||
? 'border-green-500/30 text-green-400 hover:bg-green-500/10'
|
||||
: 'border-neutral-600 text-neutral-400 hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
{token.status === 1 ? 'Enabled' : 'Disabled'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(token)}
|
||||
className="px-3 py-1 text-xs rounded-lg border border-red-500/20 text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Usage Note */}
|
||||
<div className="glass rounded-2xl p-6 mt-8">
|
||||
<h3 className="text-sm font-semibold text-white mb-3">Quick Start</h3>
|
||||
<div className="bg-black/40 rounded-xl p-4 font-mono text-sm">
|
||||
<p className="text-neutral-500"># Use your API key with OpenAI-compatible endpoint</p>
|
||||
<p className="text-green-400 mt-2">
|
||||
curl {window.location.origin}/v1/chat/completions \
|
||||
</p>
|
||||
<p className="text-neutral-300 pl-4">
|
||||
-H "Authorization: Bearer sk-your-key" \
|
||||
</p>
|
||||
<p className="text-neutral-300 pl-4">
|
||||
-H "Content-Type: application/json" \
|
||||
</p>
|
||||
<p className="text-neutral-300 pl-4">
|
||||
-d '{`{"model":"gpt-4o","messages":[{"role":"user","content":"Hello!"}]}`}'
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user