feat: dist-site 多主题模板 + 中英双语 i18n

- 新增 4 套主题模板:starter/default/dark/minimal
  - Starter: 毛玻璃 + GradientText + SpotlightCard (原有风格)
  - Default: Aurora 极光背景 + SplitText + TiltedCard
  - Dark: Particles 粒子 + DecryptedText 赛博朋克风格
  - Minimal: 极简优雅 + FadeContent 淡入动画
- 新增 i18n 中英双语支持 (i18next + browser-languagedetector)
- 新增 ThemeProvider 运行时主题切换 (根据 theme_template 字段)
- 新增 11 个 react-bits 动画组件
- 所有页面文案国际化 (Login/Register/Dashboard/Tokens/Pricing/Packages)
- 新增 README.md + README.zh.md 含主题切换指南

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
abingyydsandClaude Sonnet 4.6 committed 2026-03-21 09:04:25 +09:00
1 parent cc9a8bcc0e
commit 01405ca8cf
68 files changed
+4429 -433

No files matched your search

+13 -4
View File
@@ -1,9 +1,8 @@
import React, { lazy, Suspense } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import Layout from './components/Layout';
import AuthGuard from './components/AuthGuard';
import { ThemeProvider, useTheme } from './context/ThemeContext';
const Home = lazy(() => import('./pages/Home'));
const Login = lazy(() => import('./pages/Login'));
const Register = lazy(() => import('./pages/Register'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
@@ -17,11 +16,13 @@ const Loading = () => (
</div>
);
export default function App() {
function ThemedRoutes() {
const { Home, Layout } = useTheme();
return (
<Suspense fallback={<Loading />}>
<Routes>
{/* Public pages with layout */}
{/* Public pages with themed layout */}
<Route element={<Layout />}>
<Route path="/" element={<Home />} />
<Route path="/pricing" element={<Pricing />} />
@@ -41,3 +42,11 @@ export default function App() {
</Suspense>
);
}
export default function App() {
return (
<ThemeProvider>
<ThemedRoutes />
</ThemeProvider>
);
}
+200
View File
@@ -0,0 +1,200 @@
import { Renderer, Program, Mesh, Color, Triangle } from 'ogl';
import { useEffect, useRef } from 'react';
const VERT = `#version 300 es
in vec2 position;
void main() {
gl_Position = vec4(position, 0.0, 1.0);
}
`;
const FRAG = `#version 300 es
precision highp float;
uniform float uTime;
uniform float uAmplitude;
uniform vec3 uColorStops[3];
uniform vec2 uResolution;
uniform float uBlend;
out vec4 fragColor;
vec3 permute(vec3 x) {
return mod(((x * 34.0) + 1.0) * x, 289.0);
}
float snoise(vec2 v){
const vec4 C = vec4(
0.211324865405187, 0.366025403784439,
-0.577350269189626, 0.024390243902439
);
vec2 i = floor(v + dot(v, C.yy));
vec2 x0 = v - i + dot(i, C.xx);
vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
vec4 x12 = x0.xyxy + C.xxzz;
x12.xy -= i1;
i = mod(i, 289.0);
vec3 p = permute(
permute(i.y + vec3(0.0, i1.y, 1.0))
+ i.x + vec3(0.0, i1.x, 1.0)
);
vec3 m = max(
0.5 - vec3(
dot(x0, x0),
dot(x12.xy, x12.xy),
dot(x12.zw, x12.zw)
),
0.0
);
m = m * m;
m = m * m;
vec3 x = 2.0 * fract(p * C.www) - 1.0;
vec3 h = abs(x) - 0.5;
vec3 ox = floor(x + 0.5);
vec3 a0 = x - ox;
m *= 1.79284291400159 - 0.85373472095314 * (a0*a0 + h*h);
vec3 g;
g.x = a0.x * x0.x + h.x * x0.y;
g.yz = a0.yz * x12.xz + h.yz * x12.yw;
return 130.0 * dot(m, g);
}
struct ColorStop {
vec3 color;
float position;
};
#define COLOR_RAMP(colors, factor, finalColor) { \
int index = 0; \
for (int i = 0; i < 2; i++) { \
ColorStop currentColor = colors[i]; \
bool isInBetween = currentColor.position <= factor; \
index = int(mix(float(index), float(i), float(isInBetween))); \
} \
ColorStop currentColor = colors[index]; \
ColorStop nextColor = colors[index + 1]; \
float range = nextColor.position - currentColor.position; \
float lerpFactor = (factor - currentColor.position) / range; \
finalColor = mix(currentColor.color, nextColor.color, lerpFactor); \
}
void main() {
vec2 uv = gl_FragCoord.xy / uResolution;
ColorStop colors[3];
colors[0] = ColorStop(uColorStops[0], 0.0);
colors[1] = ColorStop(uColorStops[1], 0.5);
colors[2] = ColorStop(uColorStops[2], 1.0);
vec3 rampColor;
COLOR_RAMP(colors, uv.x, rampColor);
float height = snoise(vec2(uv.x * 2.0 + uTime * 0.1, uTime * 0.25)) * 0.5 * uAmplitude;
height = exp(height);
height = (uv.y * 2.0 - height + 0.2);
float intensity = 0.6 * height;
float midPoint = 0.20;
float auroraAlpha = smoothstep(midPoint - uBlend * 0.5, midPoint + uBlend * 0.5, intensity);
vec3 auroraColor = intensity * rampColor;
fragColor = vec4(auroraColor * auroraAlpha, auroraAlpha);
}
`;
export default function Aurora(props) {
const { colorStops = ['#5227FF', '#7cff67', '#5227FF'], amplitude = 1.0, blend = 0.5 } = props;
const propsRef = useRef(props);
propsRef.current = props;
const ctnDom = useRef(null);
useEffect(() => {
const ctn = ctnDom.current;
if (!ctn) return;
const renderer = new Renderer({
alpha: true,
premultipliedAlpha: true,
antialias: true
});
const gl = renderer.gl;
gl.clearColor(0, 0, 0, 0);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.canvas.style.backgroundColor = 'transparent';
let program;
function resize() {
if (!ctn) return;
const width = ctn.offsetWidth;
const height = ctn.offsetHeight;
renderer.setSize(width, height);
if (program) {
program.uniforms.uResolution.value = [width, height];
}
}
window.addEventListener('resize', resize);
const geometry = new Triangle(gl);
if (geometry.attributes.uv) {
delete geometry.attributes.uv;
}
const colorStopsArray = colorStops.map(hex => {
const c = new Color(hex);
return [c.r, c.g, c.b];
});
program = new Program(gl, {
vertex: VERT,
fragment: FRAG,
uniforms: {
uTime: { value: 0 },
uAmplitude: { value: amplitude },
uColorStops: { value: colorStopsArray },
uResolution: { value: [ctn.offsetWidth, ctn.offsetHeight] },
uBlend: { value: blend }
}
});
const mesh = new Mesh(gl, { geometry, program });
ctn.appendChild(gl.canvas);
let animateId = 0;
const update = t => {
animateId = requestAnimationFrame(update);
const { time = t * 0.01, speed = 1.0 } = propsRef.current;
program.uniforms.uTime.value = time * speed * 0.1;
program.uniforms.uAmplitude.value = propsRef.current.amplitude ?? 1.0;
program.uniforms.uBlend.value = propsRef.current.blend ?? blend;
const stops = propsRef.current.colorStops ?? colorStops;
program.uniforms.uColorStops.value = stops.map(hex => {
const c = new Color(hex);
return [c.r, c.g, c.b];
});
renderer.render({ scene: mesh });
};
animateId = requestAnimationFrame(update);
resize();
return () => {
cancelAnimationFrame(animateId);
window.removeEventListener('resize', resize);
if (ctn && gl.canvas.parentNode === ctn) {
ctn.removeChild(gl.canvas);
}
gl.getExtension('WEBGL_lose_context')?.loseContext();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [amplitude]);
return <div ref={ctnDom} className="w-full h-full" />;
}
+370
View File
@@ -0,0 +1,370 @@
import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
import { motion } from 'motion/react';
export default function DecryptedText({
text,
speed = 50,
maxIterations = 10,
sequential = false,
revealDirection = 'start',
useOriginalCharsOnly = false,
characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!@#$%^&*()_+',
className = '',
parentClassName = '',
encryptedClassName = '',
animateOn = 'hover',
clickMode = 'once',
...props
}) {
const [displayText, setDisplayText] = useState(text);
const [isAnimating, setIsAnimating] = useState(false);
const [revealedIndices, setRevealedIndices] = useState(new Set());
const [hasAnimated, setHasAnimated] = useState(false);
const [isDecrypted, setIsDecrypted] = useState(animateOn !== 'click');
const [direction, setDirection] = useState('forward');
const containerRef = useRef(null);
const orderRef = useRef([]);
const pointerRef = useRef(0);
const availableChars = useMemo(() => {
return useOriginalCharsOnly
? Array.from(new Set(text.split(''))).filter(char => char !== ' ')
: characters.split('');
}, [useOriginalCharsOnly, text, characters]);
const shuffleText = useCallback(
(originalText, currentRevealed) => {
return originalText
.split('')
.map((char, i) => {
if (char === ' ') return ' ';
if (currentRevealed.has(i)) return originalText[i];
return availableChars[Math.floor(Math.random() * availableChars.length)];
})
.join('');
},
[availableChars]
);
const computeOrder = useCallback(
len => {
const order = [];
if (len <= 0) return order;
if (revealDirection === 'start') {
for (let i = 0; i < len; i++) order.push(i);
return order;
}
if (revealDirection === 'end') {
for (let i = len - 1; i >= 0; i--) order.push(i);
return order;
}
// center
const middle = Math.floor(len / 2);
let offset = 0;
while (order.length < len) {
if (offset % 2 === 0) {
const idx = middle + offset / 2;
if (idx >= 0 && idx < len) order.push(idx);
} else {
const idx = middle - Math.ceil(offset / 2);
if (idx >= 0 && idx < len) order.push(idx);
}
offset++;
}
return order.slice(0, len);
},
[revealDirection]
);
const fillAllIndices = useCallback(() => {
const s = new Set();
for (let i = 0; i < text.length; i++) s.add(i);
return s;
}, [text]);
const removeRandomIndices = useCallback((set, count) => {
const arr = Array.from(set);
for (let i = 0; i < count && arr.length > 0; i++) {
const idx = Math.floor(Math.random() * arr.length);
arr.splice(idx, 1);
}
return new Set(arr);
}, []);
const encryptInstantly = useCallback(() => {
const emptySet = new Set();
setRevealedIndices(emptySet);
setDisplayText(shuffleText(text, emptySet));
setIsDecrypted(false);
}, [text, shuffleText]);
const triggerDecrypt = useCallback(() => {
if (sequential) {
orderRef.current = computeOrder(text.length);
pointerRef.current = 0;
setRevealedIndices(new Set());
} else {
setRevealedIndices(new Set());
}
setDirection('forward');
setIsAnimating(true);
}, [sequential, computeOrder, text.length]);
const triggerReverse = useCallback(() => {
if (sequential) {
// compute forward order then reverse it: we'll remove indices in that order
orderRef.current = computeOrder(text.length).slice().reverse();
pointerRef.current = 0;
setRevealedIndices(fillAllIndices()); // start fully revealed
setDisplayText(shuffleText(text, fillAllIndices()));
} else {
// non-seq: start from fully revealed as well
setRevealedIndices(fillAllIndices());
setDisplayText(shuffleText(text, fillAllIndices()));
}
setDirection('reverse');
setIsAnimating(true);
}, [sequential, computeOrder, fillAllIndices, shuffleText, text]);
useEffect(() => {
if (!isAnimating) return;
let interval;
let currentIteration = 0;
const getNextIndex = revealedSet => {
const textLength = text.length;
switch (revealDirection) {
case 'start':
return revealedSet.size;
case 'end':
return textLength - 1 - revealedSet.size;
case 'center': {
const middle = Math.floor(textLength / 2);
const offset = Math.floor(revealedSet.size / 2);
const nextIndex = revealedSet.size % 2 === 0 ? middle + offset : middle - offset - 1;
if (nextIndex >= 0 && nextIndex < textLength && !revealedSet.has(nextIndex)) {
return nextIndex;
}
for (let i = 0; i < textLength; i++) {
if (!revealedSet.has(i)) return i;
}
return 0;
}
default:
return revealedSet.size;
}
};
interval = setInterval(() => {
setRevealedIndices(prevRevealed => {
if (sequential) {
// Forward
if (direction === 'forward') {
if (prevRevealed.size < text.length) {
const nextIndex = getNextIndex(prevRevealed);
const newRevealed = new Set(prevRevealed);
newRevealed.add(nextIndex);
setDisplayText(shuffleText(text, newRevealed));
return newRevealed;
} else {
clearInterval(interval);
setIsAnimating(false);
setIsDecrypted(true);
return prevRevealed;
}
}
// Reverse
if (direction === 'reverse') {
if (pointerRef.current < orderRef.current.length) {
const idxToRemove = orderRef.current[pointerRef.current++];
const newRevealed = new Set(prevRevealed);
newRevealed.delete(idxToRemove);
setDisplayText(shuffleText(text, newRevealed));
if (newRevealed.size === 0) {
clearInterval(interval);
setIsAnimating(false);
setIsDecrypted(false);
}
return newRevealed;
} else {
clearInterval(interval);
setIsAnimating(false);
setIsDecrypted(false);
return prevRevealed;
}
}
} else {
// Non-Sequential
if (direction === 'forward') {
setDisplayText(shuffleText(text, prevRevealed));
currentIteration++;
if (currentIteration >= maxIterations) {
clearInterval(interval);
setIsAnimating(false);
setDisplayText(text);
setIsDecrypted(true);
}
return prevRevealed;
}
// Non-Sequential Reverse
if (direction === 'reverse') {
let currentSet = prevRevealed;
if (currentSet.size === 0) {
currentSet = fillAllIndices();
}
const removeCount = Math.max(1, Math.ceil(text.length / Math.max(1, maxIterations)));
const nextSet = removeRandomIndices(currentSet, removeCount);
setDisplayText(shuffleText(text, nextSet));
currentIteration++;
if (nextSet.size === 0 || currentIteration >= maxIterations) {
clearInterval(interval);
setIsAnimating(false);
setIsDecrypted(false);
// ensure final scrambled state
setDisplayText(shuffleText(text, new Set()));
return new Set();
}
return nextSet;
}
}
return prevRevealed;
});
}, speed);
return () => clearInterval(interval);
}, [
isAnimating,
text,
speed,
maxIterations,
sequential,
revealDirection,
shuffleText,
direction,
fillAllIndices,
removeRandomIndices,
characters,
useOriginalCharsOnly
]);
/* Click Behaviour */
const handleClick = () => {
if (animateOn !== 'click') return;
if (clickMode === 'once') {
if (isDecrypted) return;
setDirection('forward');
triggerDecrypt();
}
if (clickMode === 'toggle') {
if (isDecrypted) {
triggerReverse();
} else {
setDirection('forward');
triggerDecrypt();
}
}
};
/* Hover Behaviour */
const triggerHoverDecrypt = useCallback(() => {
if (isAnimating) return;
// Reset animation state cleanly
setRevealedIndices(new Set());
setIsDecrypted(false);
setDisplayText(text);
setDirection('forward');
setIsAnimating(true);
}, [isAnimating, text]);
const resetToPlainText = useCallback(() => {
setIsAnimating(false);
setRevealedIndices(new Set());
setDisplayText(text);
setIsDecrypted(true);
setDirection('forward');
}, [text]);
useEffect(() => {
if (animateOn !== 'view' && animateOn !== 'inViewHover') return;
const observerCallback = entries => {
entries.forEach(entry => {
if (entry.isIntersecting && !hasAnimated) {
triggerDecrypt();
setHasAnimated(true);
}
});
};
const observerOptions = {
root: null,
rootMargin: '0px',
threshold: 0.1
};
const observer = new IntersectionObserver(observerCallback, observerOptions);
const currentRef = containerRef.current;
if (currentRef) {
observer.observe(currentRef);
}
return () => {
if (currentRef) observer.unobserve(currentRef);
};
}, [animateOn, hasAnimated, triggerDecrypt]);
useEffect(() => {
if (animateOn === 'click') {
encryptInstantly();
} else {
setDisplayText(text);
setIsDecrypted(true);
}
setRevealedIndices(new Set());
setDirection('forward');
}, [animateOn, text, encryptInstantly]);
const animateProps =
animateOn === 'hover' || animateOn === 'inViewHover'
? {
onMouseEnter: triggerHoverDecrypt,
onMouseLeave: resetToPlainText
}
: animateOn === 'click'
? {
onClick: handleClick
}
: {};
return (
<motion.span
ref={containerRef}
className={`inline-block whitespace-pre-wrap ${parentClassName}`}
{...animateProps}
{...props}
>
<span className="sr-only">{displayText}</span>
<span aria-hidden="true">
{displayText.split('').map((char, index) => {
const isRevealedOrDone = revealedIndices.has(index) || (!isAnimating && isDecrypted);
return (
<span key={index} className={isRevealedOrDone ? className : encryptedClassName}>
{char}
</span>
);
})}
</span>
</motion.span>
);
}
+52
View File
@@ -0,0 +1,52 @@
import { useRef, useEffect, useState } from 'react';
const FadeContent = ({
children,
blur = false,
duration = 800,
delay = 0,
threshold = 0.1,
initialOpacity = 0,
className = '',
...props
}) => {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold]);
return (
<div
ref={ref}
className={className}
style={{
opacity: visible ? 1 : initialOpacity,
filter: blur ? (visible ? 'blur(0px)' : 'blur(10px)') : undefined,
transform: visible ? 'translateY(0)' : 'translateY(20px)',
transition: `opacity ${duration}ms ease-out ${delay}ms, filter ${duration}ms ease-out ${delay}ms, transform ${duration}ms ease-out ${delay}ms`,
willChange: 'opacity, filter, transform',
}}
{...props}
>
{children}
</div>
);
};
export default FadeContent;
+66
View File
@@ -0,0 +1,66 @@
const GlitchText = ({ children, speed = 0.5, enableShadows = true, enableOnHover = false, className = '' }) => {
const inlineStyles = {
'--after-duration': `${speed * 3}s`,
'--before-duration': `${speed * 2}s`,
'--after-shadow': enableShadows ? '-5px 0 red' : 'none',
'--before-shadow': enableShadows ? '5px 0 cyan' : 'none'
};
const baseClasses = 'text-white text-[clamp(2rem,10vw,8rem)] font-black relative mx-auto select-none cursor-pointer';
const pseudoClasses = !enableOnHover
? 'after:content-[attr(data-text)] after:absolute after:top-0 after:left-[10px] after:text-white after:bg-[#060010] after:overflow-hidden after:[clip-path:inset(0_0_0_0)] after:[text-shadow:var(--after-shadow)] after:animate-glitch-after ' +
'before:content-[attr(data-text)] before:absolute before:top-0 before:left-[-10px] before:text-white before:bg-[#060010] before:overflow-hidden before:[clip-path:inset(0_0_0_0)] before:[text-shadow:var(--before-shadow)] before:animate-glitch-before'
: "after:content-[''] after:absolute after:top-0 after:left-[10px] after:text-white after:bg-[#060010] after:overflow-hidden after:[clip-path:inset(0_0_0_0)] after:opacity-0 " +
"before:content-[''] before:absolute before:top-0 before:left-[-10px] before:text-white before:bg-[#060010] before:overflow-hidden before:[clip-path:inset(0_0_0_0)] before:opacity-0 " +
'hover:after:content-[attr(data-text)] hover:after:opacity-100 hover:after:[text-shadow:var(--after-shadow)] hover:after:animate-glitch-after ' +
'hover:before:content-[attr(data-text)] hover:before:opacity-100 hover:before:[text-shadow:var(--before-shadow)] hover:before:animate-glitch-before';
const combinedClasses = `${baseClasses} ${pseudoClasses} ${className}`;
return (
<div style={inlineStyles} data-text={children} className={combinedClasses}>
{children}
</div>
);
};
export default GlitchText;
// tailwind.config.js
// module.exports = {
// theme: {
// extend: {
// keyframes: {
// glitch: {
// "0%": { "clip-path": "inset(20% 0 50% 0)" },
// "5%": { "clip-path": "inset(10% 0 60% 0)" },
// "10%": { "clip-path": "inset(15% 0 55% 0)" },
// "15%": { "clip-path": "inset(25% 0 35% 0)" },
// "20%": { "clip-path": "inset(30% 0 40% 0)" },
// "25%": { "clip-path": "inset(40% 0 20% 0)" },
// "30%": { "clip-path": "inset(10% 0 60% 0)" },
// "35%": { "clip-path": "inset(15% 0 55% 0)" },
// "40%": { "clip-path": "inset(25% 0 35% 0)" },
// "45%": { "clip-path": "inset(30% 0 40% 0)" },
// "50%": { "clip-path": "inset(20% 0 50% 0)" },
// "55%": { "clip-path": "inset(10% 0 60% 0)" },
// "60%": { "clip-path": "inset(15% 0 55% 0)" },
// "65%": { "clip-path": "inset(25% 0 35% 0)" },
// "70%": { "clip-path": "inset(30% 0 40% 0)" },
// "75%": { "clip-path": "inset(40% 0 20% 0)" },
// "80%": { "clip-path": "inset(20% 0 50% 0)" },
// "85%": { "clip-path": "inset(10% 0 60% 0)" },
// "90%": { "clip-path": "inset(15% 0 55% 0)" },
// "95%": { "clip-path": "inset(25% 0 35% 0)" },
// "100%": { "clip-path": "inset(30% 0 40% 0)" },
// },
// },
// animation: {
// "glitch-after": "glitch var(--after-duration) infinite linear alternate-reverse",
// "glitch-before": "glitch var(--before-duration) infinite linear alternate-reverse",
// },
// },
// },
// plugins: [],
// };
+242
View File
@@ -0,0 +1,242 @@
import { useEffect, useRef } from 'react';
import { Renderer, Camera, Geometry, Program, Mesh } from 'ogl';
const defaultColors = ['#ffffff', '#ffffff', '#ffffff'];
const hexToRgb = hex => {
hex = hex.replace(/^#/, '');
if (hex.length === 3) {
hex = hex
.split('')
.map(c => c + c)
.join('');
}
const int = parseInt(hex, 16);
const r = ((int >> 16) & 255) / 255;
const g = ((int >> 8) & 255) / 255;
const b = (int & 255) / 255;
return [r, g, b];
};
const vertex = /* glsl */ `
attribute vec3 position;
attribute vec4 random;
attribute vec3 color;
uniform mat4 modelMatrix;
uniform mat4 viewMatrix;
uniform mat4 projectionMatrix;
uniform float uTime;
uniform float uSpread;
uniform float uBaseSize;
uniform float uSizeRandomness;
varying vec4 vRandom;
varying vec3 vColor;
void main() {
vRandom = random;
vColor = color;
vec3 pos = position * uSpread;
pos.z *= 10.0;
vec4 mPos = modelMatrix * vec4(pos, 1.0);
float t = uTime;
mPos.x += sin(t * random.z + 6.28 * random.w) * mix(0.1, 1.5, random.x);
mPos.y += sin(t * random.y + 6.28 * random.x) * mix(0.1, 1.5, random.w);
mPos.z += sin(t * random.w + 6.28 * random.y) * mix(0.1, 1.5, random.z);
vec4 mvPos = viewMatrix * mPos;
if (uSizeRandomness == 0.0) {
gl_PointSize = uBaseSize;
} else {
gl_PointSize = (uBaseSize * (1.0 + uSizeRandomness * (random.x - 0.5))) / length(mvPos.xyz);
}
gl_Position = projectionMatrix * mvPos;
}
`;
const fragment = /* glsl */ `
precision highp float;
uniform float uTime;
uniform float uAlphaParticles;
varying vec4 vRandom;
varying vec3 vColor;
void main() {
vec2 uv = gl_PointCoord.xy;
float d = length(uv - vec2(0.5));
if(uAlphaParticles < 0.5) {
if(d > 0.5) {
discard;
}
gl_FragColor = vec4(vColor + 0.2 * sin(uv.yxx + uTime + vRandom.y * 6.28), 1.0);
} else {
float circle = smoothstep(0.5, 0.4, d) * 0.8;
gl_FragColor = vec4(vColor + 0.2 * sin(uv.yxx + uTime + vRandom.y * 6.28), circle);
}
}
`;
const Particles = ({
particleCount = 200,
particleSpread = 10,
speed = 0.1,
particleColors,
moveParticlesOnHover = false,
particleHoverFactor = 1,
alphaParticles = false,
particleBaseSize = 100,
sizeRandomness = 1,
cameraDistance = 20,
disableRotation = false,
pixelRatio = 1,
className
}) => {
const containerRef = useRef(null);
const mouseRef = useRef({ x: 0, y: 0 });
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const renderer = new Renderer({
dpr: pixelRatio,
depth: false,
alpha: true
});
const gl = renderer.gl;
container.appendChild(gl.canvas);
gl.clearColor(0, 0, 0, 0);
const camera = new Camera(gl, { fov: 15 });
camera.position.set(0, 0, cameraDistance);
const resize = () => {
const width = container.clientWidth;
const height = container.clientHeight;
renderer.setSize(width, height);
camera.perspective({ aspect: gl.canvas.width / gl.canvas.height });
};
window.addEventListener('resize', resize, false);
resize();
const handleMouseMove = e => {
const rect = container.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -(((e.clientY - rect.top) / rect.height) * 2 - 1);
mouseRef.current = { x, y };
};
if (moveParticlesOnHover) {
container.addEventListener('mousemove', handleMouseMove);
}
const count = particleCount;
const positions = new Float32Array(count * 3);
const randoms = new Float32Array(count * 4);
const colors = new Float32Array(count * 3);
const palette = particleColors && particleColors.length > 0 ? particleColors : defaultColors;
for (let i = 0; i < count; i++) {
let x, y, z, len;
do {
x = Math.random() * 2 - 1;
y = Math.random() * 2 - 1;
z = Math.random() * 2 - 1;
len = x * x + y * y + z * z;
} while (len > 1 || len === 0);
const r = Math.cbrt(Math.random());
positions.set([x * r, y * r, z * r], i * 3);
randoms.set([Math.random(), Math.random(), Math.random(), Math.random()], i * 4);
const col = hexToRgb(palette[Math.floor(Math.random() * palette.length)]);
colors.set(col, i * 3);
}
const geometry = new Geometry(gl, {
position: { size: 3, data: positions },
random: { size: 4, data: randoms },
color: { size: 3, data: colors }
});
const program = new Program(gl, {
vertex,
fragment,
uniforms: {
uTime: { value: 0 },
uSpread: { value: particleSpread },
uBaseSize: { value: particleBaseSize * pixelRatio },
uSizeRandomness: { value: sizeRandomness },
uAlphaParticles: { value: alphaParticles ? 1 : 0 }
},
transparent: true,
depthTest: false
});
const particles = new Mesh(gl, { mode: gl.POINTS, geometry, program });
let animationFrameId;
let lastTime = performance.now();
let elapsed = 0;
const update = t => {
animationFrameId = requestAnimationFrame(update);
const delta = t - lastTime;
lastTime = t;
elapsed += delta * speed;
program.uniforms.uTime.value = elapsed * 0.001;
if (moveParticlesOnHover) {
particles.position.x = -mouseRef.current.x * particleHoverFactor;
particles.position.y = -mouseRef.current.y * particleHoverFactor;
} else {
particles.position.x = 0;
particles.position.y = 0;
}
if (!disableRotation) {
particles.rotation.x = Math.sin(elapsed * 0.0002) * 0.1;
particles.rotation.y = Math.cos(elapsed * 0.0005) * 0.15;
particles.rotation.z += 0.01 * speed;
}
renderer.render({ scene: particles, camera });
};
animationFrameId = requestAnimationFrame(update);
return () => {
window.removeEventListener('resize', resize);
if (moveParticlesOnHover) {
container.removeEventListener('mousemove', handleMouseMove);
}
cancelAnimationFrame(animationFrameId);
if (container.contains(gl.canvas)) {
container.removeChild(gl.canvas);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
particleCount,
particleSpread,
speed,
moveParticlesOnHover,
particleHoverFactor,
alphaParticles,
particleBaseSize,
sizeRandomness,
cameraDistance,
disableRotation,
pixelRatio
]);
return <div ref={containerRef} className={`relative w-full h-full ${className}`} />;
};
export default Particles;
+240
View File
@@ -0,0 +1,240 @@
import { useEffect, useRef } from 'react';
class Pixel {
constructor(canvas, context, x, y, color, speed, delay) {
this.width = canvas.width;
this.height = canvas.height;
this.ctx = context;
this.x = x;
this.y = y;
this.color = color;
this.speed = this.getRandomValue(0.1, 0.9) * speed;
this.size = 0;
this.sizeStep = Math.random() * 0.4;
this.minSize = 0.5;
this.maxSizeInteger = 2;
this.maxSize = this.getRandomValue(this.minSize, this.maxSizeInteger);
this.delay = delay;
this.counter = 0;
this.counterStep = Math.random() * 4 + (this.width + this.height) * 0.01;
this.isIdle = false;
this.isReverse = false;
this.isShimmer = false;
}
getRandomValue(min, max) {
return Math.random() * (max - min) + min;
}
draw() {
const centerOffset = this.maxSizeInteger * 0.5 - this.size * 0.5;
this.ctx.fillStyle = this.color;
this.ctx.fillRect(this.x + centerOffset, this.y + centerOffset, this.size, this.size);
}
appear() {
this.isIdle = false;
if (this.counter <= this.delay) {
this.counter += this.counterStep;
return;
}
if (this.size >= this.maxSize) {
this.isShimmer = true;
}
if (this.isShimmer) {
this.shimmer();
} else {
this.size += this.sizeStep;
}
this.draw();
}
disappear() {
this.isShimmer = false;
this.counter = 0;
if (this.size <= 0) {
this.isIdle = true;
return;
} else {
this.size -= 0.1;
}
this.draw();
}
shimmer() {
if (this.size >= this.maxSize) {
this.isReverse = true;
} else if (this.size <= this.minSize) {
this.isReverse = false;
}
if (this.isReverse) {
this.size -= this.speed;
} else {
this.size += this.speed;
}
}
}
function getEffectiveSpeed(value, reducedMotion) {
const min = 0;
const max = 100;
const throttle = 0.001;
const parsed = parseInt(value, 10);
if (parsed <= min || reducedMotion) {
return min;
} else if (parsed >= max) {
return max * throttle;
} else {
return parsed * throttle;
}
}
const VARIANTS = {
default: {
activeColor: null,
gap: 5,
speed: 35,
colors: '#f8fafc,#f1f5f9,#cbd5e1',
noFocus: false
},
blue: {
activeColor: '#e0f2fe',
gap: 10,
speed: 25,
colors: '#e0f2fe,#7dd3fc,#0ea5e9',
noFocus: false
},
yellow: {
activeColor: '#fef08a',
gap: 3,
speed: 20,
colors: '#fef08a,#fde047,#eab308',
noFocus: false
},
pink: {
activeColor: '#fecdd3',
gap: 6,
speed: 80,
colors: '#fecdd3,#fda4af,#e11d48',
noFocus: true
}
};
export default function PixelCard({ variant = 'default', gap, speed, colors, noFocus, className = '', children }) {
const containerRef = useRef(null);
const canvasRef = useRef(null);
const pixelsRef = useRef([]);
const animationRef = useRef(null);
const timePreviousRef = useRef(performance.now());
const reducedMotion = useRef(window.matchMedia('(prefers-reduced-motion: reduce)').matches).current;
const variantCfg = VARIANTS[variant] || VARIANTS.default;
const finalGap = gap ?? variantCfg.gap;
const finalSpeed = speed ?? variantCfg.speed;
const finalColors = colors ?? variantCfg.colors;
const finalNoFocus = noFocus ?? variantCfg.noFocus;
const initPixels = () => {
if (!containerRef.current || !canvasRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const width = Math.floor(rect.width);
const height = Math.floor(rect.height);
const ctx = canvasRef.current.getContext('2d');
canvasRef.current.width = width;
canvasRef.current.height = height;
canvasRef.current.style.width = `${width}px`;
canvasRef.current.style.height = `${height}px`;
const colorsArray = finalColors.split(',');
const pxs = [];
for (let x = 0; x < width; x += parseInt(finalGap, 10)) {
for (let y = 0; y < height; y += parseInt(finalGap, 10)) {
const color = colorsArray[Math.floor(Math.random() * colorsArray.length)];
const dx = x - width / 2;
const dy = y - height / 2;
const distance = Math.sqrt(dx * dx + dy * dy);
const delay = reducedMotion ? 0 : distance;
pxs.push(new Pixel(canvasRef.current, ctx, x, y, color, getEffectiveSpeed(finalSpeed, reducedMotion), delay));
}
}
pixelsRef.current = pxs;
};
const doAnimate = fnName => {
animationRef.current = requestAnimationFrame(() => doAnimate(fnName));
const timeNow = performance.now();
const timePassed = timeNow - timePreviousRef.current;
const timeInterval = 1000 / 60;
if (timePassed < timeInterval) return;
timePreviousRef.current = timeNow - (timePassed % timeInterval);
const ctx = canvasRef.current?.getContext('2d');
if (!ctx || !canvasRef.current) return;
ctx.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height);
let allIdle = true;
for (let i = 0; i < pixelsRef.current.length; i++) {
const pixel = pixelsRef.current[i];
pixel[fnName]();
if (!pixel.isIdle) {
allIdle = false;
}
}
if (allIdle) {
cancelAnimationFrame(animationRef.current);
}
};
const handleAnimation = name => {
cancelAnimationFrame(animationRef.current);
animationRef.current = requestAnimationFrame(() => doAnimate(name));
};
const onMouseEnter = () => handleAnimation('appear');
const onMouseLeave = () => handleAnimation('disappear');
const onFocus = e => {
if (e.currentTarget.contains(e.relatedTarget)) return;
handleAnimation('appear');
};
const onBlur = e => {
if (e.currentTarget.contains(e.relatedTarget)) return;
handleAnimation('disappear');
};
useEffect(() => {
initPixels();
const observer = new ResizeObserver(() => {
initPixels();
});
if (containerRef.current) {
observer.observe(containerRef.current);
}
return () => {
observer.disconnect();
cancelAnimationFrame(animationRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [finalGap, finalSpeed, finalColors, finalNoFocus]);
return (
<div
ref={containerRef}
className={`h-[400px] w-[300px] relative overflow-hidden grid place-items-center aspect-[4/5] border border-[#27272a] rounded-[25px] isolate transition-colors duration-200 ease-[cubic-bezier(0.5,1,0.89,1)] select-none ${className}`}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onFocus={finalNoFocus ? undefined : onFocus}
onBlur={finalNoFocus ? undefined : onBlur}
tabIndex={finalNoFocus ? -1 : 0}
>
<canvas className="w-full h-full block" ref={canvasRef} />
{children}
</div>
);
}
+191
View File
@@ -0,0 +1,191 @@
'use client';
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useState } from 'react';
import { motion, AnimatePresence } from 'motion/react';
function cn(...classes) {
return classes.filter(Boolean).join(' ');
}
const RotatingText = forwardRef((props, ref) => {
const {
texts,
transition = { type: 'spring', damping: 25, stiffness: 300 },
initial = { y: '100%', opacity: 0 },
animate = { y: 0, opacity: 1 },
exit = { y: '-120%', opacity: 0 },
animatePresenceMode = 'wait',
animatePresenceInitial = false,
rotationInterval = 2000,
staggerDuration = 0,
staggerFrom = 'first',
loop = true,
auto = true,
splitBy = 'characters',
onNext,
mainClassName,
splitLevelClassName,
elementLevelClassName,
...rest
} = props;
const [currentTextIndex, setCurrentTextIndex] = useState(0);
const splitIntoCharacters = text => {
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
return Array.from(segmenter.segment(text), segment => segment.segment);
}
return Array.from(text);
};
const elements = useMemo(() => {
const currentText = texts[currentTextIndex];
if (splitBy === 'characters') {
const words = currentText.split(' ');
return words.map((word, i) => ({
characters: splitIntoCharacters(word),
needsSpace: i !== words.length - 1
}));
}
if (splitBy === 'words') {
return currentText.split(' ').map((word, i, arr) => ({
characters: [word],
needsSpace: i !== arr.length - 1
}));
}
if (splitBy === 'lines') {
return currentText.split('\n').map((line, i, arr) => ({
characters: [line],
needsSpace: i !== arr.length - 1
}));
}
return currentText.split(splitBy).map((part, i, arr) => ({
characters: [part],
needsSpace: i !== arr.length - 1
}));
}, [texts, currentTextIndex, splitBy]);
const getStaggerDelay = useCallback(
(index, totalChars) => {
const total = totalChars;
if (staggerFrom === 'first') return index * staggerDuration;
if (staggerFrom === 'last') return (total - 1 - index) * staggerDuration;
if (staggerFrom === 'center') {
const center = Math.floor(total / 2);
return Math.abs(center - index) * staggerDuration;
}
if (staggerFrom === 'random') {
const randomIndex = Math.floor(Math.random() * total);
return Math.abs(randomIndex - index) * staggerDuration;
}
return Math.abs(staggerFrom - index) * staggerDuration;
},
[staggerFrom, staggerDuration]
);
const handleIndexChange = useCallback(
newIndex => {
setCurrentTextIndex(newIndex);
if (onNext) onNext(newIndex);
},
[onNext]
);
const next = useCallback(() => {
const nextIndex = currentTextIndex === texts.length - 1 ? (loop ? 0 : currentTextIndex) : currentTextIndex + 1;
if (nextIndex !== currentTextIndex) {
handleIndexChange(nextIndex);
}
}, [currentTextIndex, texts.length, loop, handleIndexChange]);
const previous = useCallback(() => {
const prevIndex = currentTextIndex === 0 ? (loop ? texts.length - 1 : currentTextIndex) : currentTextIndex - 1;
if (prevIndex !== currentTextIndex) {
handleIndexChange(prevIndex);
}
}, [currentTextIndex, texts.length, loop, handleIndexChange]);
const jumpTo = useCallback(
index => {
const validIndex = Math.max(0, Math.min(index, texts.length - 1));
if (validIndex !== currentTextIndex) {
handleIndexChange(validIndex);
}
},
[texts.length, currentTextIndex, handleIndexChange]
);
const reset = useCallback(() => {
if (currentTextIndex !== 0) {
handleIndexChange(0);
}
}, [currentTextIndex, handleIndexChange]);
useImperativeHandle(
ref,
() => ({
next,
previous,
jumpTo,
reset
}),
[next, previous, jumpTo, reset]
);
useEffect(() => {
if (!auto) return;
const intervalId = setInterval(next, rotationInterval);
return () => clearInterval(intervalId);
}, [next, rotationInterval, auto]);
return (
<motion.span
className={cn('flex flex-wrap whitespace-pre-wrap relative', mainClassName)}
{...rest}
layout
transition={transition}
>
<span className="sr-only">{texts[currentTextIndex]}</span>
<AnimatePresence mode={animatePresenceMode} initial={animatePresenceInitial}>
<motion.span
key={currentTextIndex}
className={cn(splitBy === 'lines' ? 'flex flex-col w-full' : 'flex flex-wrap whitespace-pre-wrap relative')}
layout
aria-hidden="true"
>
{elements.map((wordObj, wordIndex, array) => {
const previousCharsCount = array.slice(0, wordIndex).reduce((sum, word) => sum + word.characters.length, 0);
return (
<span key={wordIndex} className={cn('inline-flex', splitLevelClassName)}>
{wordObj.characters.map((char, charIndex) => (
<motion.span
key={charIndex}
initial={initial}
animate={animate}
exit={exit}
transition={{
...transition,
delay: getStaggerDelay(
previousCharsCount + charIndex,
array.reduce((sum, word) => sum + word.characters.length, 0)
)
}}
className={cn('inline-block', elementLevelClassName)}
>
{char}
</motion.span>
))}
{wordObj.needsSpace && <span className="whitespace-pre"> </span>}
</span>
);
})}
</motion.span>
</AnimatePresence>
</motion.span>
);
});
RotatingText.displayName = 'RotatingText';
export default RotatingText;
+48
View File
@@ -0,0 +1,48 @@
import { useRef, useEffect, useState } from 'react';
const ScrollFloat = ({
children,
className = '',
scrollContainerRef,
animationDuration = 1,
ease = 'ease-out',
stagger = 0.03,
}) => {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ threshold: 0.1, root: scrollContainerRef?.current || null }
);
observer.observe(el);
return () => observer.disconnect();
}, [scrollContainerRef]);
return (
<span
ref={ref}
className={className}
style={{
display: 'inline-block',
opacity: visible ? 1 : 0,
transform: visible ? 'translateY(0)' : 'translateY(20px)',
transition: `opacity ${animationDuration}s ${ease}, transform ${animationDuration}s ${ease}`,
}}
>
{children}
</span>
);
};
export default ScrollFloat;
+32
View File
@@ -0,0 +1,32 @@
import { useMemo } from 'react';
import { motion } from 'motion/react';
const SplitText = ({
text = '',
className = '',
delay = 50,
}) => {
const chars = useMemo(() => text.split(''), [text]);
return (
<span className={className} aria-label={text}>
{chars.map((char, i) => (
<motion.span
key={`${char}-${i}`}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
delay: (i * delay) / 1000,
ease: [0.25, 0.46, 0.45, 0.94],
}}
style={{ display: 'inline-block', whiteSpace: char === ' ' ? 'pre' : 'normal' }}
>
{char}
</motion.span>
))}
</span>
);
};
export default SplitText;
+134
View File
@@ -0,0 +1,134 @@
import { useRef, useState } from 'react';
import { motion, useMotionValue, useSpring } from 'motion/react';
const springValues = {
damping: 30,
stiffness: 100,
mass: 2
};
export default function TiltedCard({
imageSrc,
altText = 'Tilted card image',
captionText = '',
containerHeight = '300px',
containerWidth = '100%',
imageHeight = '300px',
imageWidth = '300px',
scaleOnHover = 1.1,
rotateAmplitude = 14,
showMobileWarning = true,
showTooltip = true,
overlayContent = null,
displayOverlayContent = false
}) {
const ref = useRef(null);
const x = useMotionValue(0);
const y = useMotionValue(0);
const rotateX = useSpring(useMotionValue(0), springValues);
const rotateY = useSpring(useMotionValue(0), springValues);
const scale = useSpring(1, springValues);
const opacity = useSpring(0);
const rotateFigcaption = useSpring(0, {
stiffness: 350,
damping: 30,
mass: 1
});
const [lastY, setLastY] = useState(0);
function handleMouse(e) {
if (!ref.current) return;
const rect = ref.current.getBoundingClientRect();
const offsetX = e.clientX - rect.left - rect.width / 2;
const offsetY = e.clientY - rect.top - rect.height / 2;
const rotationX = (offsetY / (rect.height / 2)) * -rotateAmplitude;
const rotationY = (offsetX / (rect.width / 2)) * rotateAmplitude;
rotateX.set(rotationX);
rotateY.set(rotationY);
x.set(e.clientX - rect.left);
y.set(e.clientY - rect.top);
const velocityY = offsetY - lastY;
rotateFigcaption.set(-velocityY * 0.6);
setLastY(offsetY);
}
function handleMouseEnter() {
scale.set(scaleOnHover);
opacity.set(1);
}
function handleMouseLeave() {
opacity.set(0);
scale.set(1);
rotateX.set(0);
rotateY.set(0);
rotateFigcaption.set(0);
}
return (
<figure
ref={ref}
className="relative w-full h-full [perspective:800px] flex flex-col items-center justify-center"
style={{
height: containerHeight,
width: containerWidth
}}
onMouseMove={handleMouse}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{showMobileWarning && (
<div className="absolute top-4 text-center text-sm block sm:hidden">
This effect is not optimized for mobile. Check on desktop.
</div>
)}
<motion.div
className="relative [transform-style:preserve-3d]"
style={{
width: imageWidth,
height: imageHeight,
rotateX,
rotateY,
scale
}}
>
<motion.img
src={imageSrc}
alt={altText}
className="absolute top-0 left-0 object-cover rounded-[15px] will-change-transform [transform:translateZ(0)]"
style={{
width: imageWidth,
height: imageHeight
}}
/>
{displayOverlayContent && overlayContent && (
<motion.div className="absolute top-0 left-0 z-[2] will-change-transform [transform:translateZ(30px)]">
{overlayContent}
</motion.div>
)}
</motion.div>
{showTooltip && (
<motion.figcaption
className="pointer-events-none absolute left-0 top-0 rounded-[4px] bg-white px-[10px] py-[4px] text-[10px] text-[#2d2d2d] opacity-0 z-[3] hidden sm:block"
style={{
x,
y,
opacity,
rotate: rotateFigcaption
}}
>
{captionText}
</motion.figcaption>
)}
</figure>
);
}
+319
View File
@@ -0,0 +1,319 @@
import { useRef, useEffect } from 'react';
class Grad {
constructor(x, y, z) {
this.x = x;
this.y = y;
this.z = z;
}
dot2(x, y) {
return this.x * x + this.y * y;
}
}
class Noise {
constructor(seed = 0) {
this.grad3 = [
new Grad(1, 1, 0),
new Grad(-1, 1, 0),
new Grad(1, -1, 0),
new Grad(-1, -1, 0),
new Grad(1, 0, 1),
new Grad(-1, 0, 1),
new Grad(1, 0, -1),
new Grad(-1, 0, -1),
new Grad(0, 1, 1),
new Grad(0, -1, 1),
new Grad(0, 1, -1),
new Grad(0, -1, -1)
];
this.p = [
151, 160, 137, 91, 90, 15, 131, 13, 201, 95, 96, 53, 194, 233, 7, 225, 140, 36, 103, 30, 69, 142, 8, 99, 37, 240,
21, 10, 23, 190, 6, 148, 247, 120, 234, 75, 0, 26, 197, 62, 94, 252, 219, 203, 117, 35, 11, 32, 57, 177, 33, 88,
237, 149, 56, 87, 174, 20, 125, 136, 171, 168, 68, 175, 74, 165, 71, 134, 139, 48, 27, 166, 77, 146, 158, 231, 83,
111, 229, 122, 60, 211, 133, 230, 220, 105, 92, 41, 55, 46, 245, 40, 244, 102, 143, 54, 65, 25, 63, 161, 1, 216,
80, 73, 209, 76, 132, 187, 208, 89, 18, 169, 200, 196, 135, 130, 116, 188, 159, 86, 164, 100, 109, 198, 173, 186,
3, 64, 52, 217, 226, 250, 124, 123, 5, 202, 38, 147, 118, 126, 255, 82, 85, 212, 207, 206, 59, 227, 47, 16, 58,
17, 182, 189, 28, 42, 223, 183, 170, 213, 119, 248, 152, 2, 44, 154, 163, 70, 221, 153, 101, 155, 167, 43, 172, 9,
129, 22, 39, 253, 19, 98, 108, 110, 79, 113, 224, 232, 178, 185, 112, 104, 218, 246, 97, 228, 251, 34, 242, 193,
238, 210, 144, 12, 191, 179, 162, 241, 81, 51, 145, 235, 249, 14, 239, 107, 49, 192, 214, 31, 181, 199, 106, 157,
184, 84, 204, 176, 115, 121, 50, 45, 127, 4, 150, 254, 138, 236, 205, 93, 222, 114, 67, 29, 24, 72, 243, 141, 128,
195, 78, 66, 215, 61, 156, 180
];
this.perm = new Array(512);
this.gradP = new Array(512);
this.seed(seed);
}
seed(seed) {
if (seed > 0 && seed < 1) seed *= 65536;
seed = Math.floor(seed);
if (seed < 256) seed |= seed << 8;
for (let i = 0; i < 256; i++) {
let v = i & 1 ? this.p[i] ^ (seed & 255) : this.p[i] ^ ((seed >> 8) & 255);
this.perm[i] = this.perm[i + 256] = v;
this.gradP[i] = this.gradP[i + 256] = this.grad3[v % 12];
}
}
fade(t) {
return t * t * t * (t * (t * 6 - 15) + 10);
}
lerp(a, b, t) {
return (1 - t) * a + t * b;
}
perlin2(x, y) {
let X = Math.floor(x),
Y = Math.floor(y);
x -= X;
y -= Y;
X &= 255;
Y &= 255;
const n00 = this.gradP[X + this.perm[Y]].dot2(x, y);
const n01 = this.gradP[X + this.perm[Y + 1]].dot2(x, y - 1);
const n10 = this.gradP[X + 1 + this.perm[Y]].dot2(x - 1, y);
const n11 = this.gradP[X + 1 + this.perm[Y + 1]].dot2(x - 1, y - 1);
const u = this.fade(x);
return this.lerp(this.lerp(n00, n10, u), this.lerp(n01, n11, u), this.fade(y));
}
}
const Waves = ({
lineColor = 'black',
backgroundColor = 'transparent',
waveSpeedX = 0.0125,
waveSpeedY = 0.005,
waveAmpX = 32,
waveAmpY = 16,
xGap = 10,
yGap = 32,
friction = 0.925,
tension = 0.005,
maxCursorMove = 100,
style = {},
className = ''
}) => {
const containerRef = useRef(null);
const canvasRef = useRef(null);
const ctxRef = useRef(null);
const boundingRef = useRef({ width: 0, height: 0, left: 0, top: 0 });
const noiseRef = useRef(new Noise(Math.random()));
const linesRef = useRef([]);
const mouseRef = useRef({
x: -10,
y: 0,
lx: 0,
ly: 0,
sx: 0,
sy: 0,
v: 0,
vs: 0,
a: 0,
set: false
});
const configRef = useRef({
lineColor,
waveSpeedX,
waveSpeedY,
waveAmpX,
waveAmpY,
friction,
tension,
maxCursorMove,
xGap,
yGap
});
const frameIdRef = useRef(null);
useEffect(() => {
configRef.current = {
lineColor,
waveSpeedX,
waveSpeedY,
waveAmpX,
waveAmpY,
friction,
tension,
maxCursorMove,
xGap,
yGap
};
}, [lineColor, waveSpeedX, waveSpeedY, waveAmpX, waveAmpY, friction, tension, maxCursorMove, xGap, yGap]);
useEffect(() => {
const canvas = canvasRef.current;
const container = containerRef.current;
ctxRef.current = canvas.getContext('2d');
function setSize() {
boundingRef.current = container.getBoundingClientRect();
canvas.width = boundingRef.current.width;
canvas.height = boundingRef.current.height;
}
function setLines() {
const { width, height } = boundingRef.current;
linesRef.current = [];
const oWidth = width + 200,
oHeight = height + 30;
const { xGap, yGap } = configRef.current;
const totalLines = Math.ceil(oWidth / xGap);
const totalPoints = Math.ceil(oHeight / yGap);
const xStart = (width - xGap * totalLines) / 2;
const yStart = (height - yGap * totalPoints) / 2;
for (let i = 0; i <= totalLines; i++) {
const pts = [];
for (let j = 0; j <= totalPoints; j++) {
pts.push({
x: xStart + xGap * i,
y: yStart + yGap * j,
wave: { x: 0, y: 0 },
cursor: { x: 0, y: 0, vx: 0, vy: 0 }
});
}
linesRef.current.push(pts);
}
}
function movePoints(time) {
const lines = linesRef.current,
mouse = mouseRef.current,
noise = noiseRef.current;
const { waveSpeedX, waveSpeedY, waveAmpX, waveAmpY, friction, tension, maxCursorMove } = configRef.current;
lines.forEach(pts => {
pts.forEach(p => {
const move = noise.perlin2((p.x + time * waveSpeedX) * 0.002, (p.y + time * waveSpeedY) * 0.0015) * 12;
p.wave.x = Math.cos(move) * waveAmpX;
p.wave.y = Math.sin(move) * waveAmpY;
const dx = p.x - mouse.sx,
dy = p.y - mouse.sy;
const dist = Math.hypot(dx, dy),
l = Math.max(175, mouse.vs);
if (dist < l) {
const s = 1 - dist / l;
const f = Math.cos(dist * 0.001) * s;
p.cursor.vx += Math.cos(mouse.a) * f * l * mouse.vs * 0.00065;
p.cursor.vy += Math.sin(mouse.a) * f * l * mouse.vs * 0.00065;
}
p.cursor.vx += (0 - p.cursor.x) * tension;
p.cursor.vy += (0 - p.cursor.y) * tension;
p.cursor.vx *= friction;
p.cursor.vy *= friction;
p.cursor.x += p.cursor.vx * 2;
p.cursor.y += p.cursor.vy * 2;
p.cursor.x = Math.min(maxCursorMove, Math.max(-maxCursorMove, p.cursor.x));
p.cursor.y = Math.min(maxCursorMove, Math.max(-maxCursorMove, p.cursor.y));
});
});
}
function moved(point, withCursor = true) {
const x = point.x + point.wave.x + (withCursor ? point.cursor.x : 0);
const y = point.y + point.wave.y + (withCursor ? point.cursor.y : 0);
return { x: Math.round(x * 10) / 10, y: Math.round(y * 10) / 10 };
}
function drawLines() {
const { width, height } = boundingRef.current;
const ctx = ctxRef.current;
ctx.clearRect(0, 0, width, height);
ctx.beginPath();
ctx.strokeStyle = configRef.current.lineColor;
linesRef.current.forEach(points => {
let p1 = moved(points[0], false);
ctx.moveTo(p1.x, p1.y);
points.forEach((p, idx) => {
const isLast = idx === points.length - 1;
p1 = moved(p, !isLast);
const p2 = moved(points[idx + 1] || points[points.length - 1], !isLast);
ctx.lineTo(p1.x, p1.y);
if (isLast) ctx.moveTo(p2.x, p2.y);
});
});
ctx.stroke();
}
function tick(t) {
const mouse = mouseRef.current;
mouse.sx += (mouse.x - mouse.sx) * 0.1;
mouse.sy += (mouse.y - mouse.sy) * 0.1;
const dx = mouse.x - mouse.lx,
dy = mouse.y - mouse.ly;
const d = Math.hypot(dx, dy);
mouse.v = d;
mouse.vs += (d - mouse.vs) * 0.1;
mouse.vs = Math.min(100, mouse.vs);
mouse.lx = mouse.x;
mouse.ly = mouse.y;
mouse.a = Math.atan2(dy, dx);
container.style.setProperty('--x', `${mouse.sx}px`);
container.style.setProperty('--y', `${mouse.sy}px`);
movePoints(t);
drawLines();
frameIdRef.current = requestAnimationFrame(tick);
}
function onResize() {
setSize();
setLines();
}
function onMouseMove(e) {
updateMouse(e.clientX, e.clientY);
}
function onTouchMove(e) {
const touch = e.touches[0];
updateMouse(touch.clientX, touch.clientY);
}
function updateMouse(x, y) {
const mouse = mouseRef.current,
b = boundingRef.current;
mouse.x = x - b.left;
mouse.y = y - b.top;
if (!mouse.set) {
mouse.sx = mouse.x;
mouse.sy = mouse.y;
mouse.lx = mouse.x;
mouse.ly = mouse.y;
mouse.set = true;
}
}
setSize();
setLines();
frameIdRef.current = requestAnimationFrame(tick);
window.addEventListener('resize', onResize);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('touchmove', onTouchMove, { passive: false });
return () => {
window.removeEventListener('resize', onResize);
window.removeEventListener('mousemove', onMouseMove);
window.removeEventListener('touchmove', onTouchMove);
cancelAnimationFrame(frameIdRef.current);
};
}, []);
return (
<div
ref={containerRef}
style={{
backgroundColor,
...style
}}
className={`absolute top-0 left-0 w-full h-full overflow-hidden ${className}`}
>
<div
className="absolute top-0 left-0 bg-[#160000] rounded-full w-[0.5rem] h-[0.5rem]"
style={{
transform: 'translate3d(calc(var(--x) - 50%), calc(var(--y) - 50%), 0)',
willChange: 'transform'
}}
/>
<canvas ref={canvasRef} className="block w-full h-full" />
</div>
);
};
export default Waves;
+46
View File
@@ -0,0 +1,46 @@
import React, { createContext, useContext, useMemo } from 'react';
import { useSite } from './SiteContext';
const ThemeContext = createContext(null);
// Theme registry — lazy-loaded per-theme Home & Layout components
const themeRegistry = {
starter: {
Home: React.lazy(() => import('../themes/starter/Home')),
Layout: React.lazy(() => import('../themes/starter/Layout')),
},
default: {
Home: React.lazy(() => import('../themes/default/Home')),
Layout: React.lazy(() => import('../themes/default/Layout')),
},
dark: {
Home: React.lazy(() => import('../themes/dark/Home')),
Layout: React.lazy(() => import('../themes/dark/Layout')),
},
minimal: {
Home: React.lazy(() => import('../themes/minimal/Home')),
Layout: React.lazy(() => import('../themes/minimal/Layout')),
},
};
export function ThemeProvider({ children }) {
const { site } = useSite();
const themeName = site?.theme_template || 'starter';
const theme = useMemo(() => {
const t = themeRegistry[themeName] || themeRegistry.starter;
return { name: themeName, ...t };
}, [themeName]);
return (
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
+21
View File
@@ -0,0 +1,21 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en.json';
import zh from './locales/zh.json';
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
load: 'languageOnly',
resources: { en, zh },
fallbackLng: 'en',
interpolation: { escapeValue: false },
detection: {
order: ['navigator', 'htmlTag', 'querystring'],
caches: ['localStorage'],
},
});
export default i18n;
+159
View File
@@ -0,0 +1,159 @@
{
"translation": {
"nav.home": "Home",
"nav.pricing": "Pricing",
"nav.packages": "Packages",
"nav.dashboard": "Dashboard",
"nav.apiKeys": "API Keys",
"nav.login": "Login",
"nav.signUp": "Sign Up",
"nav.logout": "Logout",
"nav.contact": "Contact",
"home.heroSubtitle": "Access the world's most powerful AI models through a single API endpoint. Simple, affordable, reliable.",
"home.getStarted": "Get Started Free",
"home.viewPricing": "View Pricing",
"home.goToDashboard": "Go to Dashboard",
"home.aiModels": "AI Models",
"home.uptime": "Uptime",
"home.latency": "Latency",
"home.whyChooseUs": "Why Choose Us",
"home.whyChooseUsDesc": "Everything you need to build AI-powered applications",
"home.lightningFast": "Lightning Fast",
"home.lightningFastDesc": "Optimized routing and caching deliver responses with minimal latency. Load-balanced across multiple providers for maximum reliability.",
"home.securePrivate": "Secure & Private",
"home.securePrivateDesc": "Enterprise-grade security with encrypted API keys. No request logging by default. Your data stays your data.",
"home.payAsYouGo": "Pay As You Go",
"home.payAsYouGoDesc": "Transparent pricing with no hidden fees. Only pay for what you use. Multiple top-up options and subscription packages available.",
"home.availableModels": "Available Models",
"home.availableModelsDesc": "Access {{count}} AI models through a unified API",
"home.viewAllModels": "View all {{count}} models",
"home.plansPackages": "Plans & Packages",
"home.choosePlan": "Choose a plan that fits your needs",
"home.subscribe": "Subscribe",
"home.days": "{{count}} days",
"home.readyToStart": "Ready to Get Started?",
"home.readyToStartDesc": "Create your account and start building with AI in minutes. No credit card required.",
"home.createFreeAccount": "Create Free Account",
"home.signIn": "Sign In",
"home.defaultHeroTitle": "AI API Platform",
"login.welcomeBack": "Welcome Back",
"login.signInTo": "Sign in to {{name}}",
"login.signInToDefault": "Sign in to your account",
"login.username": "Username",
"login.password": "Password",
"login.enterUsername": "Enter your username",
"login.enterPassword": "Enter your password",
"login.signingIn": "Signing in...",
"login.signInBtn": "Sign In",
"login.noAccount": "Don't have an account?",
"login.createOne": "Create one",
"login.fillAllFields": "Please fill in all fields",
"login.welcomeBackToast": "Welcome back!",
"register.createAccount": "Create Account",
"register.getStartedWith": "Get started with {{name}}",
"register.getStartedDefault": "Get started with our platform",
"register.username": "Username",
"register.email": "Email",
"register.password": "Password",
"register.confirmPassword": "Confirm Password",
"register.chooseUsername": "Choose a username",
"register.emailPlaceholder": "your@email.com (optional)",
"register.passwordPlaceholder": "8-20 characters",
"register.repeatPassword": "Repeat your password",
"register.creating": "Creating...",
"register.createAccountBtn": "Create Account",
"register.hasAccount": "Already have an account?",
"register.signIn": "Sign in",
"register.fillRequired": "Please fill in all required fields",
"register.passwordMismatch": "Passwords do not match",
"register.passwordLength": "Password must be 8-20 characters",
"register.accountCreated": "Account created! Please sign in.",
"dashboard.welcome": "Welcome,",
"dashboard.manageDesc": "Manage your API usage, keys, and account",
"dashboard.balance": "Balance",
"dashboard.quotaUnits": "{{count}} quota units",
"dashboard.used": "Used",
"dashboard.totalRequests": "Total Requests",
"dashboard.redeemCode": "Redeem Code",
"dashboard.enterCode": "Enter redemption code",
"dashboard.redeeming": "Redeeming...",
"dashboard.redeem": "Redeem",
"dashboard.redeemSuccess": "Code redeemed successfully!",
"dashboard.quickLinks": "Quick Links",
"dashboard.apiKeys": "API Keys",
"dashboard.manageKeys": "Manage your keys",
"dashboard.packages": "Packages",
"dashboard.viewPlans": "View plans",
"dashboard.pricing": "Pricing",
"dashboard.modelPrices": "Model prices",
"dashboard.apiDocs": "API Docs",
"dashboard.comingSoon": "Coming soon",
"tokens.title": "API Keys",
"tokens.subtitle": "Manage your API access tokens",
"tokens.newKey": "+ New Key",
"tokens.createApiKey": "Create API Key",
"tokens.name": "Name",
"tokens.namePlaceholder": "e.g. my-app-key",
"tokens.cancel": "Cancel",
"tokens.creating": "Creating...",
"tokens.create": "Create",
"tokens.newApiKey": "Your New API Key",
"tokens.keyWarning": "This is the only time you'll see this key. Copy it now and store it securely.",
"tokens.copied": "Copied!",
"tokens.copy": "Copy",
"tokens.copiedToClipboard": "Copied to clipboard!",
"tokens.savedKey": "I've saved my key",
"tokens.deleteToken": "Delete Token",
"tokens.deleteConfirm": "Delete \"{{name}}\"? This cannot be undone.",
"tokens.delete": "Delete",
"tokens.noKeys": "No API keys yet",
"tokens.createFirst": "Create Your First Key",
"tokens.enabled": "Enabled",
"tokens.disabled": "Disabled",
"tokens.tokenDisabled": "Token disabled",
"tokens.tokenEnabled": "Token enabled",
"tokens.tokenDeleted": "Token deleted",
"tokens.enterName": "Please enter a name",
"tokens.quickStart": "Quick Start",
"tokens.quickStartComment": "# Use your API key with OpenAI-compatible endpoint",
"pricing.title": "Model Pricing",
"pricing.subtitle": "Transparent, pay-as-you-go pricing for all available models. Prices shown per 1M tokens.",
"pricing.searchPlaceholder": "Search models...",
"pricing.noMatch": "No models match your search",
"pricing.noModels": "No models available",
"pricing.model": "Model",
"pricing.inputPrice": "Input / 1M tokens",
"pricing.outputPrice": "Output / 1M tokens",
"pricing.status": "Status",
"pricing.online": "Online",
"pricing.unknown": "Unknown",
"packages.title": "Packages & Plans",
"packages.subtitle": "Choose a package that fits your usage. All plans include access to all available models.",
"packages.noPackages": "No packages available at the moment.",
"packages.checkPricing": "Check model pricing",
"packages.daysAccess": "{{count}} days access",
"packages.creditIncluded": "${{amount}} credit included",
"packages.requestsMin": "{{count}} requests/min",
"packages.allModels": "All models access",
"packages.openaiApi": "OpenAI-compatible API",
"packages.subscribeNow": "Subscribe Now",
"packages.signUpToSubscribe": "Sign Up to Subscribe",
"packages.processing": "Processing...",
"packages.confirmTitle": "Confirm Subscription",
"packages.confirmDesc": "Subscribe to {{name}} for ${{price}}?",
"packages.yourBalance": "Your balance:",
"packages.insufficientBalance": "Insufficient balance. Please top up before subscribing.",
"packages.confirm": "Confirm",
"packages.subscribedSuccess": "Subscribed successfully!",
"common.sessionExpired": "Session expired, please log in again",
"common.requestFailed": "Request failed"
}
}
+159
View File
@@ -0,0 +1,159 @@
{
"translation": {
"nav.home": "首页",
"nav.pricing": "定价",
"nav.packages": "套餐",
"nav.dashboard": "控制台",
"nav.apiKeys": "API 密钥",
"nav.login": "登录",
"nav.signUp": "注册",
"nav.logout": "退出",
"nav.contact": "联系我们",
"home.heroSubtitle": "通过单一 API 端点访问全球最强大的 AI 模型。简单、实惠、可靠。",
"home.getStarted": "免费开始",
"home.viewPricing": "查看定价",
"home.goToDashboard": "进入控制台",
"home.aiModels": "AI 模型",
"home.uptime": "在线率",
"home.latency": "延迟",
"home.whyChooseUs": "为什么选择我们",
"home.whyChooseUsDesc": "构建 AI 应用所需的一切",
"home.lightningFast": "极速响应",
"home.lightningFastDesc": "优化的路由和缓存以最低延迟交付响应。跨多个供应商负载均衡,确保最大可靠性。",
"home.securePrivate": "安全可靠",
"home.securePrivateDesc": "企业级安全性,API 密钥加密存储。默认不记录请求日志。您的数据由您掌控。",
"home.payAsYouGo": "按量付费",
"home.payAsYouGoDesc": "透明定价,无隐藏费用。按使用量付费。提供多种充值方式和订阅套餐。",
"home.availableModels": "可用模型",
"home.availableModelsDesc": "通过统一 API 访问 {{count}} 个 AI 模型",
"home.viewAllModels": "查看全部 {{count}} 个模型",
"home.plansPackages": "套餐方案",
"home.choosePlan": "选择适合您需求的方案",
"home.subscribe": "订阅",
"home.days": "{{count}} 天",
"home.readyToStart": "准备好了吗?",
"home.readyToStartDesc": "创建账户,几分钟内即可开始使用 AI 构建应用。无需信用卡。",
"home.createFreeAccount": "免费创建账户",
"home.signIn": "登录",
"home.defaultHeroTitle": "AI API 平台",
"login.welcomeBack": "欢迎回来",
"login.signInTo": "登录到 {{name}}",
"login.signInToDefault": "登录您的账户",
"login.username": "用户名",
"login.password": "密码",
"login.enterUsername": "请输入用户名",
"login.enterPassword": "请输入密码",
"login.signingIn": "登录中...",
"login.signInBtn": "登录",
"login.noAccount": "还没有账户?",
"login.createOne": "立即注册",
"login.fillAllFields": "请填写所有字段",
"login.welcomeBackToast": "欢迎回来!",
"register.createAccount": "创建账户",
"register.getStartedWith": "开始使用 {{name}}",
"register.getStartedDefault": "开始使用我们的平台",
"register.username": "用户名",
"register.email": "邮箱",
"register.password": "密码",
"register.confirmPassword": "确认密码",
"register.chooseUsername": "请选择用户名",
"register.emailPlaceholder": "your@email.com(可选)",
"register.passwordPlaceholder": "8-20 个字符",
"register.repeatPassword": "请再次输入密码",
"register.creating": "创建中...",
"register.createAccountBtn": "创建账户",
"register.hasAccount": "已有账户?",
"register.signIn": "立即登录",
"register.fillRequired": "请填写所有必填字段",
"register.passwordMismatch": "两次密码不一致",
"register.passwordLength": "密码长度需为 8-20 个字符",
"register.accountCreated": "账户创建成功!请登录。",
"dashboard.welcome": "欢迎,",
"dashboard.manageDesc": "管理您的 API 用量、密钥和账户",
"dashboard.balance": "余额",
"dashboard.quotaUnits": "{{count}} 额度单位",
"dashboard.used": "已使用",
"dashboard.totalRequests": "总请求数",
"dashboard.redeemCode": "兑换码",
"dashboard.enterCode": "请输入兑换码",
"dashboard.redeeming": "兑换中...",
"dashboard.redeem": "兑换",
"dashboard.redeemSuccess": "兑换码兑换成功!",
"dashboard.quickLinks": "快捷入口",
"dashboard.apiKeys": "API 密钥",
"dashboard.manageKeys": "管理您的密钥",
"dashboard.packages": "套餐",
"dashboard.viewPlans": "查看方案",
"dashboard.pricing": "定价",
"dashboard.modelPrices": "模型价格",
"dashboard.apiDocs": "API 文档",
"dashboard.comingSoon": "即将推出",
"tokens.title": "API 密钥",
"tokens.subtitle": "管理您的 API 访问令牌",
"tokens.newKey": "+ 新建密钥",
"tokens.createApiKey": "创建 API 密钥",
"tokens.name": "名称",
"tokens.namePlaceholder": "例如 my-app-key",
"tokens.cancel": "取消",
"tokens.creating": "创建中...",
"tokens.create": "创建",
"tokens.newApiKey": "您的新 API 密钥",
"tokens.keyWarning": "这是您唯一一次看到此密钥的机会。请立即复制并安全保存。",
"tokens.copied": "已复制!",
"tokens.copy": "复制",
"tokens.copiedToClipboard": "已复制到剪贴板!",
"tokens.savedKey": "我已保存密钥",
"tokens.deleteToken": "删除令牌",
"tokens.deleteConfirm": "删除 \"{{name}}\"?此操作不可撤销。",
"tokens.delete": "删除",
"tokens.noKeys": "暂无 API 密钥",
"tokens.createFirst": "创建您的第一个密钥",
"tokens.enabled": "已启用",
"tokens.disabled": "已禁用",
"tokens.tokenDisabled": "令牌已禁用",
"tokens.tokenEnabled": "令牌已启用",
"tokens.tokenDeleted": "令牌已删除",
"tokens.enterName": "请输入名称",
"tokens.quickStart": "快速开始",
"tokens.quickStartComment": "# 使用 OpenAI 兼容端点调用 API",
"pricing.title": "模型定价",
"pricing.subtitle": "透明的按量付费定价。价格以每百万 token 计。",
"pricing.searchPlaceholder": "搜索模型...",
"pricing.noMatch": "没有匹配的模型",
"pricing.noModels": "暂无可用模型",
"pricing.model": "模型",
"pricing.inputPrice": "输入 / 百万 token",
"pricing.outputPrice": "输出 / 百万 token",
"pricing.status": "状态",
"pricing.online": "在线",
"pricing.unknown": "未知",
"packages.title": "套餐方案",
"packages.subtitle": "选择适合您用量的套餐。所有方案均可访问全部可用模型。",
"packages.noPackages": "暂无可用套餐。",
"packages.checkPricing": "查看模型定价",
"packages.daysAccess": "{{count}} 天有效期",
"packages.creditIncluded": "含 ${{amount}} 额度",
"packages.requestsMin": "{{count}} 次请求/分钟",
"packages.allModels": "全部模型可用",
"packages.openaiApi": "OpenAI 兼容 API",
"packages.subscribeNow": "立即订阅",
"packages.signUpToSubscribe": "注册后订阅",
"packages.processing": "处理中...",
"packages.confirmTitle": "确认订阅",
"packages.confirmDesc": "订阅 {{name}},价格 ${{price}}?",
"packages.yourBalance": "您的余额:",
"packages.insufficientBalance": "余额不足,请先充值。",
"packages.confirm": "确认",
"packages.subscribedSuccess": "订阅成功!",
"common.sessionExpired": "会话已过期,请重新登录",
"common.requestFailed": "请求失败"
}
}
+1
View File
@@ -5,6 +5,7 @@ import { Toaster } from 'react-hot-toast';
import App from './App';
import { AuthProvider } from './context/AuthContext';
import { SiteProvider } from './context/SiteContext';
import './i18n';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
+22 -20
View File
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { getUserUsage, redeemCode, Q, quotaToDollar } from '../api';
import SpotlightCard from '../components/bits/SpotlightCard';
@@ -8,6 +9,7 @@ import ShinyText from '../components/bits/ShinyText';
import toast from 'react-hot-toast';
export default function Dashboard() {
const { t } = useTranslation();
const { user, refreshUser } = useAuth();
const [usage, setUsage] = useState(null);
const [redeemInput, setRedeemInput] = useState('');
@@ -29,7 +31,7 @@ export default function Dashboard() {
try {
const res = await redeemCode(redeemInput.trim());
if (res.data.success) {
toast.success('Code redeemed successfully!');
toast.success(t('dashboard.redeemSuccess'));
setRedeemInput('');
// Refresh both usage AND user data to update balance
await Promise.all([loadUsage(), refreshUser()]);
@@ -49,31 +51,31 @@ export default function Dashboard() {
{/* 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" />
{t('dashboard.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>
<p className="text-sm text-neutral-400">{t('dashboard.manageDesc')}</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>
<p className="text-sm text-neutral-400 mb-2">{t('dashboard.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>
<p className="text-xs text-neutral-500 mt-1">{t('dashboard.quotaUnits', { count: quota.toLocaleString() })}</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>
<p className="text-sm text-neutral-400 mb-2">{t('dashboard.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>
<p className="text-xs text-neutral-500 mt-1">{t('dashboard.quotaUnits', { count: usedQuota.toLocaleString() })}</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>
<p className="text-sm text-neutral-400 mb-2">{t('dashboard.totalRequests')}</p>
<div className="text-3xl font-bold text-white">
<CountUp from={0} to={requestCount} duration={1.5} />
</div>
@@ -84,40 +86,40 @@ export default function Dashboard() {
<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>
<h2 className="text-lg font-semibold text-white mb-4">{t('dashboard.redeemCode')}</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"
placeholder={t('dashboard.enterCode')}
/>
<button type="submit" disabled={redeeming} className="btn-primary whitespace-nowrap">
{redeeming ? 'Redeeming...' : 'Redeem'}
{redeeming ? t('dashboard.redeeming') : t('dashboard.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>
<h2 className="text-lg font-semibold text-white mb-4">{t('dashboard.quickLinks')}</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>
<p className="text-sm font-medium text-white group-hover:text-brand-400 transition-colors">{t('dashboard.apiKeys')}</p>
<p className="text-xs text-neutral-500">{t('dashboard.manageKeys')}</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>
<p className="text-sm font-medium text-white group-hover:text-brand-400 transition-colors">{t('dashboard.packages')}</p>
<p className="text-xs text-neutral-500">{t('dashboard.viewPlans')}</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>
<p className="text-sm font-medium text-white group-hover:text-brand-400 transition-colors">{t('dashboard.pricing')}</p>
<p className="text-xs text-neutral-500">{t('dashboard.modelPrices')}</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>
<p className="text-sm font-medium text-white">{t('dashboard.apiDocs')}</p>
<p className="text-xs text-neutral-500">{t('dashboard.comingSoon')}</p>
</div>
</div>
</div>
+13 -11
View File
@@ -1,10 +1,12 @@
import React, { useState } from 'react';
import { Link, Navigate, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useSite } from '../context/SiteContext';
import toast from 'react-hot-toast';
export default function Login() {
const { t } = useTranslation();
const { login, user } = useAuth();
const { site } = useSite();
const navigate = useNavigate();
@@ -21,14 +23,14 @@ export default function Login() {
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.username || !form.password) {
toast.error('Please fill in all fields');
toast.error(t('login.fillAllFields'));
return;
}
setLoading(true);
try {
const result = await login(form.username, form.password);
if (result.success) {
toast.success('Welcome back!');
toast.success(t('login.welcomeBackToast'));
const from = location.state?.from?.pathname || '/dashboard';
navigate(from, { replace: true });
return; // component may unmount — skip setLoading
@@ -45,34 +47,34 @@ export default function Login() {
<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>
<h1 className="text-2xl font-heading font-bold text-white mb-2">{t('login.welcomeBack')}</h1>
<p className="text-sm text-neutral-400">
Sign in to {site?.name || 'your account'}
{site?.name ? t('login.signInTo', { name: site.name }) : t('login.signInToDefault')}
</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>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">{t('login.username')}</label>
<input
type="text"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className="input"
placeholder="Enter your username"
placeholder={t('login.enterUsername')}
autoComplete="username"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Password</label>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">{t('login.password')}</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="input"
placeholder="Enter your password"
placeholder={t('login.enterPassword')}
autoComplete="current-password"
required
/>
@@ -86,15 +88,15 @@ export default function Login() {
{loading && (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
)}
{loading ? 'Signing in...' : 'Sign In'}
{loading ? t('login.signingIn') : t('login.signInBtn')}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-neutral-400">
Don't have an account?{' '}
{t('login.noAccount')}{' '}
<Link to="/register" className="text-brand-400 hover:text-brand-300 transition-colors font-medium">
Create one
{t('login.createOne')}
</Link>
</p>
</div>
+19 -18
View File
@@ -1,11 +1,13 @@
import React, { useState, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
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 { t } = useTranslation();
const { user, refreshUser } = useAuth();
const navigate = useNavigate();
const [packages, setPackages] = useState([]);
@@ -36,7 +38,7 @@ export default function Packages() {
try {
const res = await subscribePackage(pkgId);
if (res.data.success) {
toast.success('Subscribed successfully!');
toast.success(t('packages.subscribedSuccess'));
setConfirmPkg(null);
// Refresh user data to update balance
await refreshUser();
@@ -65,17 +67,17 @@ export default function Packages() {
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>
<h1 className="text-3xl font-heading font-bold text-white mb-3">{t('packages.title')}</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.
{t('packages.subtitle')}
</p>
</div>
{enabled.length === 0 ? (
<div className="text-center py-12 text-neutral-400">
<p>No packages available at the moment.</p>
<p>{t('packages.noPackages')}</p>
<Link to="/pricing" className="text-brand-400 hover:text-brand-300 transition-colors mt-2 inline-block">
Check model pricing &rarr;
{t('packages.checkPricing')} &rarr;
</Link>
</div>
) : (
@@ -104,7 +106,7 @@ export default function Packages() {
)}
</div>
{pkg.duration > 0 && (
<p className="text-sm text-neutral-500 mt-1">{pkg.duration} days access</p>
<p className="text-sm text-neutral-500 mt-1">{t('packages.daysAccess', { count: pkg.duration })}</p>
)}
</div>
@@ -115,7 +117,7 @@ export default function Packages() {
<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
{t('packages.creditIncluded', { amount: (pkg.quota_amount / Q).toFixed(2) })}
</li>
)}
{pkg.rate_limit > 0 && (
@@ -123,20 +125,20 @@ export default function Packages() {
<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
{t('packages.requestsMin', { count: pkg.rate_limit })}
</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
{t('packages.allModels')}
</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
{t('packages.openaiApi')}
</li>
</ul>
@@ -146,7 +148,7 @@ export default function Packages() {
disabled={subscribing === pkg.id}
className="btn-primary w-full text-center"
>
{subscribing === pkg.id ? 'Processing...' : user ? 'Subscribe Now' : 'Sign Up to Subscribe'}
{subscribing === pkg.id ? t('packages.processing') : user ? t('packages.subscribeNow') : t('packages.signUpToSubscribe')}
</button>
</div>
</SpotlightCard>
@@ -162,23 +164,22 @@ export default function Packages() {
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>
<h2 className="text-lg font-semibold text-white mb-3">{t('packages.confirmTitle')}</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>?
{t('packages.confirmDesc', { name: confirmPkg.name, price: pkgPrice.toFixed(2) })}
</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>
{t('packages.yourBalance')} <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>
<p className="text-sm text-red-300">{t('packages.insufficientBalance')}</p>
</div>
)}
<div className="flex justify-end gap-3">
<button onClick={() => setConfirmPkg(null)} disabled={subscribing} className="btn-secondary">Cancel</button>
<button onClick={() => setConfirmPkg(null)} disabled={subscribing} className="btn-secondary">{t('tokens.cancel')}</button>
<button onClick={confirmSubscribe} disabled={insufficient || subscribing} className="btn-primary">
{subscribing ? 'Processing...' : 'Confirm'}
{subscribing ? t('packages.processing') : t('packages.confirm')}
</button>
</div>
</div>
+11 -10
View File
@@ -1,13 +1,14 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { getSiteModels } from '../api';
export default function Pricing() {
const { t } = useTranslation();
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 || []);
@@ -34,9 +35,9 @@ export default function Pricing() {
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>
<h1 className="text-3xl font-heading font-bold text-white mb-3">{t('pricing.title')}</h1>
<p className="text-neutral-400 max-w-xl mx-auto">
Transparent, pay-as-you-go pricing for all available models. Prices shown per 1M tokens.
{t('pricing.subtitle')}
</p>
</div>
@@ -51,24 +52,24 @@ export default function Pricing() {
value={search}
onChange={(e) => setSearch(e.target.value)}
className="input !pl-10"
placeholder="Search models..."
placeholder={t('pricing.searchPlaceholder')}
/>
</div>
</div>
{filtered.length === 0 ? (
<div className="text-center py-12 text-neutral-400">
{search ? 'No models match your search' : 'No models available'}
{search ? t('pricing.noMatch') : t('pricing.noModels')}
</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 / 1M tokens</th>
<th className="text-right px-5 py-3.5 font-medium text-neutral-400">Output / 1M tokens</th>
<th className="text-center px-5 py-3.5 font-medium text-neutral-400">Status</th>
<th className="text-left px-5 py-3.5 font-medium text-neutral-400">{t('pricing.model')}</th>
<th className="text-right px-5 py-3.5 font-medium text-neutral-400">{t('pricing.inputPrice')}</th>
<th className="text-right px-5 py-3.5 font-medium text-neutral-400">{t('pricing.outputPrice')}</th>
<th className="text-center px-5 py-3.5 font-medium text-neutral-400">{t('pricing.status')}</th>
</tr>
</thead>
<tbody>
@@ -90,7 +91,7 @@ export default function Pricing() {
: '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'}
{m.status === 'healthy' ? t('pricing.online') : t('pricing.unknown')}
</span>
</td>
</tr>
+20 -18
View File
@@ -1,10 +1,12 @@
import React, { useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../context/AuthContext';
import { useSite } from '../context/SiteContext';
import toast from 'react-hot-toast';
export default function Register() {
const { t } = useTranslation();
const { register, user } = useAuth();
const { site } = useSite();
const navigate = useNavigate();
@@ -19,19 +21,19 @@ export default function Register() {
const handleSubmit = async (e) => {
e.preventDefault();
if (!form.username || !form.password) {
toast.error('Please fill in all required fields');
toast.error(t('register.fillRequired'));
return;
}
if (form.password !== form.password2) {
toast.error('Passwords do not match');
toast.error(t('register.passwordMismatch'));
return;
}
if (form.password.length < 8) {
toast.error('Password must be 8-20 characters');
toast.error(t('register.passwordLength'));
return;
}
if (form.password.length > 20) {
toast.error('Password must be 8-20 characters');
toast.error(t('register.passwordLength'));
return;
}
setLoading(true);
@@ -42,7 +44,7 @@ export default function Register() {
email: form.email || undefined,
});
if (result.success) {
toast.success('Account created! Please sign in.');
toast.success(t('register.accountCreated'));
navigate('/login', { replace: true });
return; // component may unmount — skip setLoading
}
@@ -58,59 +60,59 @@ export default function Register() {
<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>
<h1 className="text-2xl font-heading font-bold text-white mb-2">{t('register.createAccount')}</h1>
<p className="text-sm text-neutral-400">
Get started with {site?.name || 'our platform'}
{site?.name ? t('register.getStartedWith', { name: site.name }) : t('register.getStartedDefault')}
</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>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">{t('register.username')} *</label>
<input
type="text"
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
className="input"
placeholder="Choose a username"
placeholder={t('register.chooseUsername')}
autoComplete="username"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Email</label>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">{t('register.email')}</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="input"
placeholder="your@email.com (optional)"
placeholder={t('register.emailPlaceholder')}
autoComplete="email"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Password *</label>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">{t('register.password')} *</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="input"
placeholder="8-20 characters"
placeholder={t('register.passwordPlaceholder')}
autoComplete="new-password"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Confirm Password *</label>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">{t('register.confirmPassword')} *</label>
<input
type="password"
value={form.password2}
onChange={(e) => setForm({ ...form, password2: e.target.value })}
className="input"
placeholder="Repeat your password"
placeholder={t('register.repeatPassword')}
autoComplete="new-password"
required
/>
@@ -124,15 +126,15 @@ export default function Register() {
{loading && (
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
)}
{loading ? 'Creating...' : 'Create Account'}
{loading ? t('register.creating') : t('register.createAccountBtn')}
</button>
</form>
<div className="mt-6 text-center">
<p className="text-sm text-neutral-400">
Already have an account?{' '}
{t('register.hasAccount')}{' '}
<Link to="/login" className="text-brand-400 hover:text-brand-300 transition-colors font-medium">
Sign in
{t('register.signIn')}
</Link>
</p>
</div>
+28 -26
View File
@@ -1,8 +1,10 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { getTokens, createToken, updateToken, deleteToken } from '../api';
import toast from 'react-hot-toast';
export default function Tokens() {
const { t } = useTranslation();
const [tokens, setTokens] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
@@ -27,7 +29,7 @@ export default function Tokens() {
const handleCreate = async (e) => {
e.preventDefault();
if (!createName.trim()) {
toast.error('Please enter a name');
toast.error(t('tokens.enterName'));
return;
}
setCreating(true);
@@ -53,7 +55,7 @@ export default function Tokens() {
status: token.status === 1 ? 2 : 1,
});
if (res.data.success) {
toast.success(token.status === 1 ? 'Token disabled' : 'Token enabled');
toast.success(token.status === 1 ? t('tokens.tokenDisabled') : t('tokens.tokenEnabled'));
await load();
}
} catch (e) { /* interceptor */ }
@@ -64,7 +66,7 @@ export default function Tokens() {
try {
const res = await deleteToken(deleteConfirm.id);
if (res.data.success) {
toast.success('Token deleted');
toast.success(t('tokens.tokenDeleted'));
setDeleteConfirm(null);
await load();
}
@@ -85,7 +87,7 @@ export default function Tokens() {
document.body.removeChild(ta);
}
setCopiedId(text);
toast.success('Copied to clipboard!');
toast.success(t('tokens.copiedToClipboard'));
setTimeout(() => setCopiedId(null), 2000);
};
@@ -101,11 +103,11 @@ export default function Tokens() {
<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>
<h1 className="text-2xl font-heading font-bold text-white">{t('tokens.title')}</h1>
<p className="text-sm text-neutral-400 mt-1">{t('tokens.subtitle')}</p>
</div>
<button onClick={() => setShowCreate(true)} className="btn-primary">
+ New Key
{t('tokens.newKey')}
</button>
</div>
@@ -113,26 +115,26 @@ export default function Tokens() {
{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>
<h2 className="text-lg font-semibold text-white mb-4">{t('tokens.createApiKey')}</h2>
<form onSubmit={handleCreate} className="space-y-4">
<div>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">Name</label>
<label className="block text-sm font-medium text-neutral-300 mb-1.5">{t('tokens.name')}</label>
<input
type="text"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
className="input"
placeholder="e.g. my-app-key"
placeholder={t('tokens.namePlaceholder')}
autoFocus
required
/>
</div>
<div className="flex justify-end gap-3">
<button type="button" onClick={() => setShowCreate(false)} className="btn-secondary">
Cancel
{t('tokens.cancel')}
</button>
<button type="submit" disabled={creating} className="btn-primary">
{creating ? 'Creating...' : 'Create'}
{creating ? t('tokens.creating') : t('tokens.create')}
</button>
</div>
</form>
@@ -144,10 +146,10 @@ export default function Tokens() {
{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>
<h2 className="text-lg font-semibold text-white mb-2">{t('tokens.newApiKey')}</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.
{t('tokens.keyWarning')}
</p>
</div>
<div className="bg-black/40 rounded-xl p-4 flex items-center gap-3">
@@ -158,12 +160,12 @@ export default function Tokens() {
onClick={() => handleCopy(newKey)}
className="btn-primary !px-4 !py-1.5 flex-shrink-0"
>
{copiedId === newKey ? 'Copied!' : 'Copy'}
{copiedId === newKey ? t('tokens.copied') : t('tokens.copy')}
</button>
</div>
<div className="flex justify-end mt-4">
<button onClick={() => setNewKey(null)} className="btn-secondary">
I've saved my key
{t('tokens.savedKey')}
</button>
</div>
</div>
@@ -174,14 +176,14 @@ export default function Tokens() {
{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>
<h2 className="text-lg font-semibold text-white mb-3">{t('tokens.deleteToken')}</h2>
<p className="text-sm text-neutral-400 mb-4">
Delete <span className="text-white font-medium">"{deleteConfirm.name}"</span>? This cannot be undone.
{t('tokens.deleteConfirm', { name: deleteConfirm.name })}
</p>
<div className="flex justify-end gap-3">
<button onClick={() => setDeleteConfirm(null)} className="btn-secondary">Cancel</button>
<button onClick={() => setDeleteConfirm(null)} className="btn-secondary">{t('tokens.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
{t('tokens.delete')}
</button>
</div>
</div>
@@ -196,9 +198,9 @@ export default function Tokens() {
<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>
<p className="text-neutral-400 mb-4">{t('tokens.noKeys')}</p>
<button onClick={() => setShowCreate(true)} className="btn-primary">
Create Your First Key
{t('tokens.createFirst')}
</button>
</div>
) : (
@@ -231,13 +233,13 @@ export default function Tokens() {
: 'border-neutral-600 text-neutral-400 hover:bg-neutral-800'
}`}
>
{token.status === 1 ? 'Enabled' : 'Disabled'}
{token.status === 1 ? t('tokens.enabled') : t('tokens.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
{t('tokens.delete')}
</button>
</div>
</div>
@@ -247,9 +249,9 @@ export default function Tokens() {
{/* 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>
<h3 className="text-sm font-semibold text-white mb-3">{t('tokens.quickStart')}</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-neutral-500">{t('tokens.quickStartComment')}</p>
<p className="text-green-400 mt-2">
curl {window.location.origin}/v1/chat/completions \
</p>
+209
View File
@@ -0,0 +1,209 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import { getSiteModels, getSitePackages } from '../../api';
import Particles from '../../components/bits/Particles';
import DecryptedText from '../../components/bits/DecryptedText';
import CountUp from '../../components/bits/CountUp';
export default function DarkHome() {
const { t } = useTranslation();
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">
{/* Particles Background */}
<div className="absolute inset-0 h-[800px] overflow-hidden pointer-events-none">
<Particles
particleColors={['#00ff88', '#00ccff', '#ff0055']}
particleCount={80}
particleSpread={15}
speed={0.3}
particleBaseSize={80}
moveParticlesOnHover={true}
alphaParticles={false}
disableRotation={false}
/>
</div>
{/* Hero Section */}
<section className="relative max-w-7xl mx-auto px-6 pt-28 pb-24">
<div className="text-center max-w-4xl mx-auto">
{/* Terminal-style header */}
<div className="inline-flex items-center gap-2 px-4 py-1.5 rounded-full border border-green-500/30 bg-green-500/5 mb-8">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="text-xs font-mono text-green-400 uppercase tracking-wider">System Online</span>
</div>
<h1 className="text-5xl md:text-7xl font-heading font-extrabold text-white leading-tight mb-4">
<DecryptedText
text={site?.name || t('home.defaultHeroTitle')}
speed={60}
maxIterations={10}
revealDirection="start"
className="text-white"
encryptedClassName="text-green-500/40"
/>
</h1>
<p className="text-lg md:text-xl text-neutral-400 max-w-2xl mx-auto mb-10 font-mono">
{'> '}{t('home.heroSubtitle')}
</p>
<div className="flex items-center justify-center gap-4">
{user ? (
<Link to="/dashboard" className="px-8 py-3.5 rounded-lg bg-green-500 text-black font-bold text-base hover:bg-green-400 transition-all font-mono shadow-lg shadow-green-500/20">
{t('home.goToDashboard')}
</Link>
) : (
<>
<Link to="/register" className="px-8 py-3.5 rounded-lg bg-green-500 text-black font-bold text-base hover:bg-green-400 transition-all font-mono shadow-lg shadow-green-500/20">
{t('home.getStarted')}
</Link>
<Link to="/pricing" className="px-8 py-3.5 rounded-lg border border-green-500/30 text-green-400 font-bold text-base hover:bg-green-500/10 transition-all font-mono">
{t('home.viewPricing')}
</Link>
</>
)}
</div>
</div>
{/* Stats — terminal style */}
<div className="grid grid-cols-3 gap-4 max-w-2xl mx-auto mt-20">
{[
{ label: t('home.aiModels'), value: enabledModels.length || 50, suffix: '+', color: 'text-green-400 border-green-500/20' },
{ label: t('home.uptime'), value: 99.9, suffix: '%', color: 'text-cyan-400 border-cyan-500/20' },
{ label: t('home.latency'), value: 50, suffix: 'ms', prefix: '<', color: 'text-pink-400 border-pink-500/20' },
].map((s, i) => (
<div key={i} className={`text-center p-4 rounded-lg bg-black/60 border ${s.color} font-mono`}>
<div className={`text-3xl font-bold ${s.color.split(' ')[0]}`}>
{s.prefix}<CountUp from={0} to={s.value} duration={2} />{s.suffix}
</div>
<p className="text-xs text-neutral-500 mt-1 uppercase tracking-wider">{s.label}</p>
</div>
))}
</div>
</section>
{/* Features Section */}
<section className="relative max-w-7xl mx-auto px-6 py-20">
<div className="text-center mb-14">
<h2 className="text-3xl font-heading font-bold text-white mb-3">{t('home.whyChooseUs')}</h2>
<p className="text-neutral-500 font-mono">{t('home.whyChooseUsDesc')}</p>
</div>
<div className="grid md:grid-cols-3 gap-6">
{[
{ icon: '⚡', title: t('home.lightningFast'), desc: t('home.lightningFastDesc'), accent: 'green' },
{ icon: '🛡️', title: t('home.securePrivate'), desc: t('home.securePrivateDesc'), accent: 'cyan' },
{ icon: '📊', title: t('home.payAsYouGo'), desc: t('home.payAsYouGoDesc'), accent: 'pink' },
].map((f, i) => (
<div key={i} className={`p-6 rounded-xl bg-black/40 border border-${f.accent}-500/20 hover:border-${f.accent}-500/40 transition-all group`}>
<div className="text-2xl mb-3">{f.icon}</div>
<h3 className={`text-lg font-semibold text-${f.accent}-400 mb-2 font-mono`}>{f.title}</h3>
<p className="text-sm text-neutral-400 leading-relaxed">{f.desc}</p>
<div className={`h-0.5 w-0 group-hover:w-full bg-${f.accent}-500/50 transition-all duration-500 mt-4`} />
</div>
))}
</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">{t('home.availableModels')}</h2>
<p className="text-neutral-500 font-mono">{t('home.availableModelsDesc', { count: enabledModels.length })}</p>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
{enabledModels.slice(0, 12).map((m, i) => (
<div key={m.id || i} className="px-4 py-3 rounded-lg bg-black/40 border border-green-500/10 flex items-center gap-3 hover:border-green-500/30 transition-colors group font-mono">
<span className="text-green-500 text-xs">$</span>
<span className="text-sm text-neutral-400 group-hover:text-green-400 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-green-400 hover:text-green-300 transition-colors font-mono">
{t('home.viewAllModels', { count: enabledModels.length })} &rarr;
</Link>
</div>
)}
</section>
)}
{/* Packages */}
{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">{t('home.plansPackages')}</h2>
<p className="text-neutral-500 font-mono">{t('home.choosePlan')}</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="rounded-xl p-6 flex flex-col bg-black/40 border border-green-500/10 hover:border-green-500/30 transition-all">
<h3 className="text-lg font-semibold text-white mb-1 font-mono">{pkg.name}</h3>
{pkg.description && <p className="text-sm text-neutral-400 mb-4">{pkg.description}</p>}
<div className="mt-auto pt-4">
<span className="text-3xl font-bold text-green-400 font-mono">${pkg.price}</span>
{pkg.original_price > pkg.price && (
<span className="text-sm text-neutral-600 line-through ml-2">${pkg.original_price}</span>
)}
{pkg.duration > 0 && <p className="text-xs text-neutral-600 mt-1 font-mono">{t('home.days', { count: pkg.duration })}</p>}
</div>
<Link to={user ? '/packages' : '/register'} className="mt-4 py-2.5 rounded-lg bg-green-500/10 border border-green-500/30 text-green-400 font-medium text-sm text-center hover:bg-green-500/20 transition-colors font-mono">
{user ? t('home.subscribe') : t('home.getStarted')}
</Link>
</div>
))}
</div>
</section>
)}
{/* CTA */}
<section className="relative max-w-7xl mx-auto px-6 py-24">
<div className="rounded-xl p-12 text-center bg-black/60 border border-green-500/20 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-green-500/5 via-transparent to-cyan-500/5" />
<div className="relative z-10">
<h2 className="text-3xl font-heading font-bold text-white mb-4 font-mono">{t('home.readyToStart')}</h2>
<p className="text-neutral-400 mb-8 max-w-md mx-auto">{t('home.readyToStartDesc')}</p>
<div className="flex items-center justify-center gap-4">
{user ? (
<Link to="/dashboard" className="px-8 py-3 rounded-lg bg-green-500 text-black font-bold hover:bg-green-400 transition-all font-mono">
{t('home.goToDashboard')}
</Link>
) : (
<>
<Link to="/register" className="px-8 py-3 rounded-lg bg-green-500 text-black font-bold hover:bg-green-400 transition-all font-mono">
{t('home.createFreeAccount')}
</Link>
<Link to="/login" className="px-8 py-3 rounded-lg border border-green-500/30 text-green-400 font-bold hover:bg-green-500/10 transition-all font-mono">
{t('home.signIn')}
</Link>
</>
)}
</div>
</div>
</div>
</section>
</div>
);
}
+122
View File
@@ -0,0 +1,122 @@
import React, { useState } from 'react';
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
export default function DarkLayout() {
const { t } = useTranslation();
const { user, logout } = useAuth();
const { site } = useSite();
const navigate = useNavigate();
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const siteName = site?.name || 'AI Platform';
const navItems = [
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
];
const visibleNavItems = navItems.filter((n) => !n.auth || user);
return (
<div className="min-h-screen flex flex-col bg-black text-white">
{/* Announcement Banner */}
{site?.announcement && (
<div className="bg-green-500/10 border-b border-green-500/20 text-center py-2 px-4">
<p className="text-sm text-green-300 font-mono">{site.announcement}</p>
</div>
)}
{/* Header — cyberpunk style */}
<header className="sticky top-0 z-50 backdrop-blur-xl bg-black/80 border-b border-green-500/10">
<div className="max-w-7xl mx-auto px-6 h-14 flex items-center justify-between">
<Link to="/" className="flex items-center gap-3 group">
{site?.logo ? (
<img src={site.logo} alt={siteName} className="h-7 w-auto" />
) : (
<div className="w-8 h-8 rounded-md bg-green-500/20 border border-green-500/30 flex items-center justify-center text-green-400 font-bold font-mono text-sm">
{siteName.charAt(0)}
</div>
)}
<span className="text-lg font-semibold text-green-400 font-mono group-hover:text-green-300 transition-colors">
{siteName}
</span>
</Link>
<nav className="hidden md:flex items-center gap-1">
{visibleNavItems.map((n) => (
<Link
key={n.to}
to={n.to}
className={`px-3 py-1.5 text-sm font-mono rounded transition-all ${
location.pathname === n.to
? 'text-green-400 bg-green-500/10'
: 'text-neutral-500 hover:text-green-400'
}`}
>
{n.label}
</Link>
))}
</nav>
<div className="flex items-center gap-3">
{user ? (
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-500 font-mono hidden sm:block">{user.display_name || user.username}</span>
<button onClick={async () => { await logout(); navigate('/'); }} className="text-sm text-neutral-500 hover:text-green-400 font-mono transition-colors">
{t('nav.logout')}
</button>
</div>
) : (
<>
<Link to="/login" className="text-sm text-neutral-500 hover:text-green-400 font-mono transition-colors hidden sm:block">
{t('nav.login')}
</Link>
<Link to="/register" className="px-4 py-1.5 rounded-md bg-green-500/10 border border-green-500/30 text-green-400 text-sm font-mono hover:bg-green-500/20 transition-all">
{t('nav.signUp')}
</Link>
</>
)}
<button className="md:hidden p-1.5 rounded hover:bg-white/5" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
<svg className="w-5 h-5 text-green-400" 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>
{mobileMenuOpen && (
<div className="md:hidden border-t border-green-500/10 bg-black/95 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 font-mono rounded transition-colors ${location.pathname === n.to ? 'text-green-400 bg-green-500/10' : 'text-neutral-500 hover:text-green-400'}`}>
{n.label}
</Link>
))}
</nav>
</div>
)}
</header>
<main className="flex-1"><Outlet /></main>
<footer className="border-t border-green-500/10 mt-auto">
<div className="max-w-7xl mx-auto px-6 py-6 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-xs text-neutral-600 font-mono">&copy; {new Date().getFullYear()} {siteName}</p>
{site?.contact_email && (
<a href={`mailto:${site.contact_email}`} className="text-xs text-neutral-600 hover:text-green-400 font-mono transition-colors">
{t('nav.contact')}
</a>
)}
</div>
</footer>
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import { getSiteModels, getSitePackages } from '../../api';
import Aurora from '../../components/bits/Aurora';
import SplitText from '../../components/bits/SplitText';
import RotatingText from '../../components/bits/RotatingText';
import TiltedCard from '../../components/bits/TiltedCard';
import CountUp from '../../components/bits/CountUp';
export default function DefaultHome() {
const { t } = useTranslation();
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">
{/* Aurora Background */}
<div className="absolute inset-0 h-[700px] overflow-hidden pointer-events-none">
<Aurora colorStops={["#3A29FF", "#FF94B4", "#FF3232"]} blend={0.5} amplitude={1.0} speed={0.5} />
</div>
{/* Hero Section */}
<section className="relative max-w-7xl mx-auto px-6 pt-28 pb-24">
<div className="text-center max-w-4xl mx-auto">
<div className="mb-4">
<h1 className="text-5xl md:text-7xl font-heading font-extrabold text-white leading-tight">
<SplitText text={site?.name || t('home.defaultHeroTitle')} delay={80} className="inline" />
</h1>
</div>
<div className="flex items-center justify-center gap-2 text-xl md:text-2xl text-neutral-300 mb-10">
<span>{t('home.heroSubtitle').split('.')[0]}.</span>
</div>
<div className="flex flex-wrap items-center justify-center gap-3 mb-6">
<RotatingText
texts={[t('home.lightningFast'), t('home.securePrivate'), t('home.payAsYouGo')]}
mainClassName="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white text-sm font-medium overflow-hidden"
staggerFrom="last"
rotationInterval={3000}
/>
</div>
<div className="flex items-center justify-center gap-4 mt-8">
{user ? (
<Link to="/dashboard" className="px-8 py-3.5 rounded-2xl bg-white text-black font-semibold text-base hover:bg-neutral-100 transition-all shadow-xl shadow-white/10">
{t('home.goToDashboard')}
</Link>
) : (
<>
<Link to="/register" className="px-8 py-3.5 rounded-2xl bg-white text-black font-semibold text-base hover:bg-neutral-100 transition-all shadow-xl shadow-white/10">
{t('home.getStarted')}
</Link>
<Link to="/pricing" className="px-8 py-3.5 rounded-2xl border border-white/20 text-white font-semibold text-base hover:bg-white/10 transition-all">
{t('home.viewPricing')}
</Link>
</>
)}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-8 max-w-2xl mx-auto mt-24">
{[
{ value: enabledModels.length || 50, suffix: '+', label: t('home.aiModels') },
{ value: 99.9, suffix: '%', label: t('home.uptime') },
{ value: 50, suffix: 'ms', label: t('home.latency'), prefix: '<' },
].map((stat, i) => (
<div key={i} className="text-center p-4 rounded-2xl bg-white/5 backdrop-blur-sm border border-white/10">
<div className="text-3xl font-bold text-white">
{stat.prefix}<CountUp from={0} to={stat.value} duration={2} />{stat.suffix}
</div>
<p className="text-sm text-neutral-400 mt-1">{stat.label}</p>
</div>
))}
</div>
</section>
{/* Features Section */}
<section className="relative max-w-7xl mx-auto px-6 py-20">
<div className="text-center mb-14">
<h2 className="text-3xl font-heading font-bold text-white mb-3">{t('home.whyChooseUs')}</h2>
<p className="text-neutral-400">{t('home.whyChooseUsDesc')}</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
{[
{ icon: '⚡', title: t('home.lightningFast'), desc: t('home.lightningFastDesc'), color: 'from-yellow-500/20 to-orange-500/20' },
{ icon: '🔒', title: t('home.securePrivate'), desc: t('home.securePrivateDesc'), color: 'from-blue-500/20 to-cyan-500/20' },
{ icon: '💰', title: t('home.payAsYouGo'), desc: t('home.payAsYouGoDesc'), color: 'from-green-500/20 to-emerald-500/20' },
].map((f, i) => (
<TiltedCard
key={i}
rotateAmplitude={8}
scaleOnHover={1.03}
borderRadius="16px"
className="w-full"
>
<div className={`p-6 rounded-2xl bg-gradient-to-br ${f.color} border border-white/10 h-full`}>
<div className="text-3xl mb-4">{f.icon}</div>
<h3 className="text-lg font-semibold text-white mb-2">{f.title}</h3>
<p className="text-sm text-neutral-300 leading-relaxed">{f.desc}</p>
</div>
</TiltedCard>
))}
</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">{t('home.availableModels')}</h2>
<p className="text-neutral-400">{t('home.availableModelsDesc', { count: enabledModels.length })}</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="px-4 py-3 rounded-xl bg-white/5 border border-white/10 flex items-center gap-3 hover:bg-white/10 transition-colors group">
<div className="w-2 h-2 rounded-full bg-green-400 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-blue-400 hover:text-blue-300 transition-colors">
{t('home.viewAllModels', { count: enabledModels.length })} &rarr;
</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">{t('home.plansPackages')}</h2>
<p className="text-neutral-400">{t('home.choosePlan')}</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, i) => (
<div key={pkg.id} className={`rounded-2xl p-6 flex flex-col border transition-all hover:scale-[1.02] ${
i === 1 ? 'bg-white/10 border-white/20 shadow-xl' : 'bg-white/5 border-white/10'
}`}>
<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">{t('home.days', { count: pkg.duration })}</p>}
</div>
<Link to={user ? '/packages' : '/register'} className="mt-4 py-2.5 rounded-xl bg-white text-black font-medium text-sm text-center hover:bg-neutral-100 transition-colors">
{user ? t('home.subscribe') : t('home.getStarted')}
</Link>
</div>
))}
</div>
</section>
)}
{/* CTA Section */}
<section className="relative max-w-7xl mx-auto px-6 py-24">
<div className="rounded-3xl p-12 text-center relative overflow-hidden bg-gradient-to-br from-blue-600/20 via-purple-600/20 to-pink-600/20 border border-white/10">
<div className="relative z-10">
<h2 className="text-3xl font-heading font-bold text-white mb-4">{t('home.readyToStart')}</h2>
<p className="text-neutral-300 mb-8 max-w-md mx-auto">{t('home.readyToStartDesc')}</p>
<div className="flex items-center justify-center gap-4">
{user ? (
<Link to="/dashboard" className="px-8 py-3 rounded-2xl bg-white text-black font-semibold hover:bg-neutral-100 transition-all">
{t('home.goToDashboard')}
</Link>
) : (
<>
<Link to="/register" className="px-8 py-3 rounded-2xl bg-white text-black font-semibold hover:bg-neutral-100 transition-all">
{t('home.createFreeAccount')}
</Link>
<Link to="/login" className="px-8 py-3 rounded-2xl border border-white/20 text-white font-semibold hover:bg-white/10 transition-all">
{t('home.signIn')}
</Link>
</>
)}
</div>
</div>
</div>
</section>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import React, { useState } from 'react';
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
export default function DefaultLayout() {
const { t } = useTranslation();
const { user, logout } = useAuth();
const { site } = useSite();
const navigate = useNavigate();
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const siteName = site?.name || 'AI Platform';
const navItems = [
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
];
const visibleNavItems = navItems.filter((n) => !n.auth || user);
return (
<div className="min-h-screen flex flex-col bg-[#0a0a1a]">
{/* Announcement Banner */}
{site?.announcement && (
<div className="bg-gradient-to-r from-blue-600/20 via-purple-600/20 to-pink-600/20 border-b border-white/10 text-center py-2.5 px-4">
<p className="text-sm text-neutral-200">{site.announcement}</p>
</div>
)}
{/* Header */}
<header className="sticky top-0 z-50 backdrop-blur-2xl bg-[#0a0a1a]/80 border-b border-white/[0.08]">
<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-9 h-9 rounded-xl bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500 flex items-center justify-center text-white font-bold text-sm shadow-lg shadow-purple-500/20">
{siteName.charAt(0)}
</div>
)}
<span className="text-lg font-semibold text-white group-hover:text-blue-400 transition-colors">
{siteName}
</span>
</Link>
{/* Desktop Nav */}
<nav className="hidden md:flex items-center gap-1 bg-white/5 rounded-full px-2 py-1">
{visibleNavItems.map((n) => (
<Link
key={n.to}
to={n.to}
className={`px-4 py-1.5 text-sm rounded-full transition-all ${
location.pathname === n.to
? 'text-white bg-white/10'
: 'text-neutral-400 hover:text-white'
}`}
>
{n.label}
</Link>
))}
</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"
>
{t('nav.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">
{t('nav.login')}
</Link>
<Link to="/register" className="px-5 py-2 rounded-full bg-white text-black text-sm font-medium hover:bg-neutral-100 transition-all">
{t('nav.signUp')}
</Link>
</>
)}
<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>
{mobileMenuOpen && (
<div className="md:hidden border-t border-white/[0.08] bg-[#0a0a1a]/95 backdrop-blur-2xl">
<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-blue-400 bg-blue-500/10'
: 'text-neutral-400 hover:text-white hover:bg-white/5'
}`}
>
{n.label}
</Link>
))}
</nav>
</div>
)}
</header>
<main className="flex-1">
<Outlet />
</main>
<footer className="border-t border-white/[0.08] mt-auto bg-[#0a0a1a]">
<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">&copy; {new Date().getFullYear()} {siteName}.</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">
{t('nav.contact')}
</a>
)}
</div>
</div>
</footer>
</div>
);
}
+188
View File
@@ -0,0 +1,188 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
import { getSiteModels, getSitePackages } from '../../api';
import CountUp from '../../components/bits/CountUp';
import FadeContent from '../../components/bits/FadeContent';
export default function MinimalHome() {
const { t } = useTranslation();
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">
{/* Hero Section — clean and minimal */}
<section className="max-w-5xl mx-auto px-6 pt-32 pb-24">
<FadeContent blur duration={800} delay={100}>
<div className="max-w-3xl">
<h1 className="text-5xl md:text-6xl font-heading font-bold text-white leading-[1.1] tracking-tight">
{site?.name || t('home.defaultHeroTitle')}
</h1>
<p className="text-lg text-neutral-400 mt-6 leading-relaxed max-w-xl">
{t('home.heroSubtitle')}
</p>
<div className="flex items-center gap-4 mt-10">
{user ? (
<Link to="/dashboard" className="px-7 py-3 rounded-lg bg-white text-neutral-900 font-medium text-sm hover:bg-neutral-200 transition-colors">
{t('home.goToDashboard')} →
</Link>
) : (
<>
<Link to="/register" className="px-7 py-3 rounded-lg bg-white text-neutral-900 font-medium text-sm hover:bg-neutral-200 transition-colors">
{t('home.getStarted')} →
</Link>
<Link to="/pricing" className="px-7 py-3 text-neutral-400 font-medium text-sm hover:text-white transition-colors">
{t('home.viewPricing')}
</Link>
</>
)}
</div>
</div>
</FadeContent>
{/* Stats — simple inline */}
<FadeContent blur duration={800} delay={400}>
<div className="flex items-center gap-12 mt-20 border-t border-neutral-800 pt-10">
<div>
<div className="text-2xl font-bold text-white">
<CountUp from={0} to={enabledModels.length || 50} duration={2} />+
</div>
<p className="text-sm text-neutral-500 mt-0.5">{t('home.aiModels')}</p>
</div>
<div>
<div className="text-2xl font-bold text-white">
<CountUp from={0} to={99.9} duration={2.5} />%
</div>
<p className="text-sm text-neutral-500 mt-0.5">{t('home.uptime')}</p>
</div>
<div>
<div className="text-2xl font-bold text-white">
&lt;<CountUp from={200} to={50} duration={2} direction="down" />ms
</div>
<p className="text-sm text-neutral-500 mt-0.5">{t('home.latency')}</p>
</div>
</div>
</FadeContent>
</section>
{/* Features — two-column layout */}
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
<h2 className="text-2xl font-heading font-bold text-white mb-2">{t('home.whyChooseUs')}</h2>
<p className="text-neutral-500 mb-10">{t('home.whyChooseUsDesc')}</p>
<div className="space-y-6">
{[
{ title: t('home.lightningFast'), desc: t('home.lightningFastDesc') },
{ title: t('home.securePrivate'), desc: t('home.securePrivateDesc') },
{ title: t('home.payAsYouGo'), desc: t('home.payAsYouGoDesc') },
].map((f, i) => (
<div key={i} className="flex gap-6 items-start py-6 border-b border-neutral-800/60 last:border-0 group">
<span className="text-sm font-mono text-neutral-600 mt-0.5">0{i + 1}</span>
<div>
<h3 className="text-base font-semibold text-white mb-1 group-hover:text-neutral-300 transition-colors">{f.title}</h3>
<p className="text-sm text-neutral-500 leading-relaxed max-w-lg">{f.desc}</p>
</div>
</div>
))}
</div>
</FadeContent>
</section>
{/* Models — simple list */}
{enabledModels.length > 0 && (
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
<div className="flex items-end justify-between mb-8">
<div>
<h2 className="text-2xl font-heading font-bold text-white mb-2">{t('home.availableModels')}</h2>
<p className="text-neutral-500">{t('home.availableModelsDesc', { count: enabledModels.length })}</p>
</div>
{enabledModels.length > 8 && (
<Link to="/pricing" className="text-sm text-neutral-400 hover:text-white transition-colors">
{t('home.viewAllModels', { count: enabledModels.length })} →
</Link>
)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
{enabledModels.slice(0, 8).map((m, i) => (
<div key={m.id || i} className="px-4 py-3 rounded-lg border border-neutral-800/60 hover:border-neutral-700 transition-colors">
<span className="text-sm text-neutral-300 font-mono">{m.display_name || m.model_name}</span>
</div>
))}
</div>
</FadeContent>
</section>
)}
{/* Packages — clean cards */}
{packages.length > 0 && (
<section className="max-w-5xl mx-auto px-6 py-20">
<FadeContent blur duration={800} delay={100}>
<h2 className="text-2xl font-heading font-bold text-white mb-2">{t('home.plansPackages')}</h2>
<p className="text-neutral-500 mb-10">{t('home.choosePlan')}</p>
<div className="grid md:grid-cols-3 gap-4 max-w-4xl">
{packages.filter(p => p.enabled).slice(0, 3).map((pkg) => (
<div key={pkg.id} className="rounded-xl p-6 flex flex-col border border-neutral-800/60 hover:border-neutral-700 transition-colors">
<h3 className="text-base font-semibold text-white">{pkg.name}</h3>
{pkg.description && <p className="text-sm text-neutral-500 mt-1">{pkg.description}</p>}
<div className="mt-auto pt-6">
<span className="text-3xl font-bold text-white">${pkg.price}</span>
{pkg.original_price > pkg.price && (
<span className="text-sm text-neutral-600 line-through ml-2">${pkg.original_price}</span>
)}
{pkg.duration > 0 && <p className="text-xs text-neutral-600 mt-1">{t('home.days', { count: pkg.duration })}</p>}
</div>
<Link to={user ? '/packages' : '/register'} className="mt-4 py-2.5 rounded-lg bg-neutral-800 text-white font-medium text-sm text-center hover:bg-neutral-700 transition-colors">
{user ? t('home.subscribe') : t('home.getStarted')}
</Link>
</div>
))}
</div>
</FadeContent>
</section>
)}
{/* CTA — simple */}
<section className="max-w-5xl mx-auto px-6 py-24">
<FadeContent blur duration={800} delay={100}>
<div className="border-t border-neutral-800 pt-16 text-center">
<h2 className="text-2xl font-heading font-bold text-white mb-3">{t('home.readyToStart')}</h2>
<p className="text-neutral-500 mb-8 max-w-md mx-auto">{t('home.readyToStartDesc')}</p>
<div className="flex items-center justify-center gap-4">
{user ? (
<Link to="/dashboard" className="px-7 py-3 rounded-lg bg-white text-neutral-900 font-medium text-sm hover:bg-neutral-200 transition-colors">
{t('home.goToDashboard')} →
</Link>
) : (
<>
<Link to="/register" className="px-7 py-3 rounded-lg bg-white text-neutral-900 font-medium text-sm hover:bg-neutral-200 transition-colors">
{t('home.createFreeAccount')} →
</Link>
<Link to="/login" className="px-7 py-3 text-neutral-400 font-medium text-sm hover:text-white transition-colors">
{t('home.signIn')}
</Link>
</>
)}
</div>
</div>
</FadeContent>
</section>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
export default function MinimalLayout() {
const { t } = useTranslation();
const { user, logout } = useAuth();
const { site } = useSite();
const navigate = useNavigate();
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const siteName = site?.name || 'AI Platform';
const navItems = [
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), auth: true },
];
const visibleNavItems = navItems.filter((n) => !n.auth || user);
return (
<div className="min-h-screen flex flex-col bg-neutral-950 text-white">
{/* Announcement */}
{site?.announcement && (
<div className="border-b border-neutral-800/60 text-center py-2 px-4">
<p className="text-sm text-neutral-400">{site.announcement}</p>
</div>
)}
{/* Header — minimal */}
<header className="sticky top-0 z-50 backdrop-blur-lg bg-neutral-950/80 border-b border-neutral-800/40">
<div className="max-w-5xl mx-auto px-6 h-14 flex items-center justify-between">
<Link to="/" className="flex items-center gap-2.5 group">
{site?.logo ? (
<img src={site.logo} alt={siteName} className="h-7 w-auto" />
) : (
<span className="text-base font-semibold text-white">{siteName}</span>
)}
{site?.logo && (
<span className="text-base font-semibold text-white">{siteName}</span>
)}
</Link>
<nav className="hidden md:flex items-center gap-6">
{visibleNavItems.map((n) => (
<Link
key={n.to}
to={n.to}
className={`text-sm transition-colors ${
location.pathname === n.to
? 'text-white'
: 'text-neutral-500 hover:text-white'
}`}
>
{n.label}
</Link>
))}
</nav>
<div className="flex items-center gap-4">
{user ? (
<div className="flex items-center gap-3">
<span className="text-sm text-neutral-500 hidden sm:block">{user.display_name || user.username}</span>
<button onClick={async () => { await logout(); navigate('/'); }} className="text-sm text-neutral-500 hover:text-white transition-colors">
{t('nav.logout')}
</button>
</div>
) : (
<>
<Link to="/login" className="text-sm text-neutral-500 hover:text-white transition-colors hidden sm:block">
{t('nav.login')}
</Link>
<Link to="/register" className="px-4 py-1.5 rounded-lg bg-white text-neutral-900 text-sm font-medium hover:bg-neutral-200 transition-colors">
{t('nav.signUp')}
</Link>
</>
)}
<button className="md:hidden p-1" onClick={() => setMobileMenuOpen(!mobileMenuOpen)}>
<svg className="w-5 h-5 text-neutral-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
{mobileMenuOpen ? <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M6 18L18 6M6 6l12 12" /> : <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h16M4 12h16M4 18h16" />}
</svg>
</button>
</div>
</div>
{mobileMenuOpen && (
<div className="md:hidden border-t border-neutral-800/40 bg-neutral-950/95">
<nav className="max-w-5xl 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 transition-colors ${location.pathname === n.to ? 'text-white' : 'text-neutral-500 hover:text-white'}`}>
{n.label}
</Link>
))}
</nav>
</div>
)}
</header>
<main className="flex-1"><Outlet /></main>
<footer className="border-t border-neutral-800/40 mt-auto">
<div className="max-w-5xl mx-auto px-6 py-6 flex flex-col md:flex-row items-center justify-between gap-3">
<p className="text-xs text-neutral-600">&copy; {new Date().getFullYear()} {siteName}</p>
{site?.contact_email && (
<a href={`mailto:${site.contact_email}`} className="text-xs text-neutral-600 hover:text-white transition-colors">
{t('nav.contact')}
</a>
)}
</div>
</footer>
</div>
);
}
@@ -1,16 +1,18 @@
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';
import { useTranslation } from 'react-i18next';
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() {
export default function StarterHome() {
const { t } = useTranslation();
const { user } = useAuth();
const { site } = useSite();
const [models, setModels] = useState([]);
@@ -40,12 +42,12 @@ export default function Home() {
animationSpeed={6}
className="text-5xl md:text-7xl font-heading font-extrabold leading-tight"
>
{site?.name || 'AI API Platform'}
{site?.name || t('home.defaultHeroTitle')}
</GradientText>
</div>
<BlurText
text="Access the world's most powerful AI models through a single API endpoint. Simple, affordable, reliable."
text={t('home.heroSubtitle')}
className="text-lg md:text-xl text-neutral-400 max-w-2xl mx-auto mb-10"
delay={50}
animateBy="words"
@@ -55,16 +57,16 @@ export default function Home() {
{user ? (
<Link to="/dashboard">
<StarBorder as="div" color="#818cf8" speed="5s">
Go to Dashboard
{t('home.goToDashboard')}
</StarBorder>
</Link>
) : (
<>
<Link to="/register" className="btn-primary text-base !px-8 !py-3">
Get Started Free
{t('home.getStarted')}
</Link>
<Link to="/pricing" className="btn-secondary text-base !px-8 !py-3">
View Pricing
{t('home.viewPricing')}
</Link>
</>
)}
@@ -77,19 +79,19 @@ export default function Home() {
<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>
<p className="text-sm text-neutral-500 mt-1">{t('home.aiModels')}</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>
<p className="text-sm text-neutral-500 mt-1">{t('home.uptime')}</p>
</div>
<div className="text-center">
<div className="text-3xl font-bold text-white">
&lt;<CountUp from={200} to={50} duration={2} direction="down" />ms
</div>
<p className="text-sm text-neutral-500 mt-1">Latency</p>
<p className="text-sm text-neutral-500 mt-1">{t('home.latency')}</p>
</div>
</div>
</section>
@@ -97,8 +99,8 @@ export default function Home() {
{/* 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>
<h2 className="text-3xl font-heading font-bold text-white mb-3">{t('home.whyChooseUs')}</h2>
<p className="text-neutral-400">{t('home.whyChooseUsDesc')}</p>
</div>
<div className="grid md:grid-cols-3 gap-6">
@@ -108,10 +110,8 @@ export default function Home() {
<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>
<h3 className="text-lg font-semibold text-white mb-2">{t('home.lightningFast')}</h3>
<p className="text-sm text-neutral-400 leading-relaxed">{t('home.lightningFastDesc')}</p>
</SpotlightCard>
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60" spotlightColor="rgba(192,132,252,0.15)">
@@ -120,10 +120,8 @@ export default function Home() {
<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>
<h3 className="text-lg font-semibold text-white mb-2">{t('home.securePrivate')}</h3>
<p className="text-sm text-neutral-400 leading-relaxed">{t('home.securePrivateDesc')}</p>
</SpotlightCard>
<SpotlightCard className="!bg-neutral-900/60 !border-neutral-800/60" spotlightColor="rgba(244,114,182,0.15)">
@@ -132,10 +130,8 @@ export default function Home() {
<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>
<h3 className="text-lg font-semibold text-white mb-2">{t('home.payAsYouGo')}</h3>
<p className="text-sm text-neutral-400 leading-relaxed">{t('home.payAsYouGoDesc')}</p>
</SpotlightCard>
</div>
</section>
@@ -144,9 +140,9 @@ export default function Home() {
{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>
<h2 className="text-3xl font-heading font-bold text-white mb-3">{t('home.availableModels')}</h2>
<p className="text-neutral-400">
Access <span className="text-brand-400 font-medium">{enabledModels.length}</span> AI models through a unified API
{t('home.availableModelsDesc', { count: enabledModels.length })}
</p>
</div>
@@ -167,7 +163,7 @@ export default function Home() {
{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 &rarr;
{t('home.viewAllModels', { count: enabledModels.length })} &rarr;
</Link>
</div>
)}
@@ -178,8 +174,8 @@ export default function Home() {
{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>
<h2 className="text-3xl font-heading font-bold text-white mb-3">{t('home.plansPackages')}</h2>
<p className="text-neutral-400">{t('home.choosePlan')}</p>
</div>
<div className="grid md:grid-cols-3 gap-6 max-w-4xl mx-auto">
@@ -197,14 +193,14 @@ export default function Home() {
)}
</div>
{pkg.duration > 0 && (
<p className="text-xs text-neutral-500 mt-1">{pkg.duration} days</p>
<p className="text-xs text-neutral-500 mt-1">{t('home.days', { count: pkg.duration })}</p>
)}
</div>
<Link
to={user ? '/packages' : '/register'}
className="btn-primary text-center mt-4 !py-2"
>
{user ? 'Subscribe' : 'Get Started'}
{user ? t('home.subscribe') : t('home.getStarted')}
</Link>
</div>
))}
@@ -218,23 +214,23 @@ export default function Home() {
<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?
{t('home.readyToStart')}
</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.
{t('home.readyToStartDesc')}
</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
{t('home.goToDashboard')}
</Link>
) : (
<>
<Link to="/register" className="btn-primary text-base !px-8 !py-3">
Create Free Account
{t('home.createFreeAccount')}
</Link>
<Link to="/login" className="btn-secondary text-base !px-8 !py-3">
Sign In
{t('home.signIn')}
</Link>
</>
)}
+173
View File
@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { Outlet, Link, useNavigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '../../context/AuthContext';
import { useSite } from '../../context/SiteContext';
export default function StarterLayout() {
const { t } = useTranslation();
const { user, logout } = useAuth();
const { site } = useSite();
const navigate = useNavigate();
const location = useLocation();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const siteName = site?.name || 'AI Platform';
const navItems = [
{ to: '/', label: t('nav.home'), auth: false },
{ to: '/pricing', label: t('nav.pricing'), auth: false },
{ to: '/packages', label: t('nav.packages'), auth: false },
{ to: '/dashboard', label: t('nav.dashboard'), auth: true },
{ to: '/tokens', label: t('nav.apiKeys'), 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"
>
{t('nav.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">
{t('nav.login')}
</Link>
<Link to="/register" className="btn-primary text-sm !px-4 !py-1.5">
{t('nav.signUp')}
</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"
>
{t('nav.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">
&copy; {new Date().getFullYear()} {siteName}.
</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">
{t('nav.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>
);
}