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:
1 parent
cc9a8bcc0e
commit
01405ca8cf
68 files changed
+4429
-433
No files matched your search
+13
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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: [],
|
||||
// };
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "请求失败"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 →
|
||||
{t('packages.checkPricing')} →
|
||||
</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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 })} →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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">© {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>
|
||||
);
|
||||
}
|
||||
@@ -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 })} →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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">© {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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
<<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>
|
||||
);
|
||||
}
|
||||
@@ -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">© {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">
|
||||
<<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 →
|
||||
{t('home.viewAllModels', { count: enabledModels.length })} →
|
||||
</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>
|
||||
</>
|
||||
)}
|
||||
@@ -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">
|
||||
© {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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user