New design v0.2

New design and framework
This commit is contained in:
Oussama Douhou
2026-01-13 10:49:47 +01:00
parent 21161c6554
commit 8977a6fdee
29 changed files with 2434 additions and 58 deletions

31
client/src/App.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
import DeployPage from './pages/DeployPage'
// Lazy load the sign-in page (includes Three.js - large bundle)
const SignInPage = lazy(() => import('./components/ui/sign-in-flow-1').then(m => ({ default: m.SignInPage })))
// Loading fallback for lazy-loaded routes
const PageLoader = () => (
<div className="min-h-screen bg-black flex items-center justify-center">
<div className="animate-pulse text-white/50">Loading...</div>
</div>
)
function App() {
return (
<Routes>
<Route path="/" element={<DeployPage />} />
<Route
path="/auth"
element={
<Suspense fallback={<PageLoader />}>
<SignInPage />
</Suspense>
}
/>
</Routes>
)
}
export default App

View File

@@ -0,0 +1,35 @@
import { TypewriterText } from './TypewriterText';
import type { TranslationKey } from '@/lib/i18n';
interface DeployErrorProps {
t: (key: TranslationKey) => string;
errorMessage: string;
onTryAgain: () => void;
}
export function DeployError({ t, errorMessage, onTryAgain }: DeployErrorProps) {
return (
<div className="backdrop-blur-md bg-black/20 rounded-2xl p-10 border border-white/10 shadow-2xl text-center animate-fadeIn">
<div className="mb-6 animate-shake">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full border-2 border-red-500 text-red-500 text-5xl bg-red-500/10">
</div>
</div>
<h2 className="text-2xl font-bold mb-4 min-h-[2.4rem] text-white">
<TypewriterText text={t('deploymentFailed')} speed={30} />
</h2>
<div className="mb-8 p-4 bg-red-500/10 border border-red-500/20 rounded-xl font-mono text-sm text-red-200">
{errorMessage}
</div>
<button
onClick={onTryAgain}
className="w-full py-3.5 px-6 rounded-xl font-semibold bg-white text-black hover:bg-white/90 transition-colors"
>
{t('tryAgain')}
</button>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useState, useEffect, useRef } from 'react';
import { cn } from '@/lib/utils';
import { validateStackName } from '@/lib/validation';
import { TypewriterText } from './TypewriterText';
import type { TranslationKey } from '@/lib/i18n';
interface DeployFormProps {
t: (key: TranslationKey) => string;
onDeploy: (name: string) => void;
isDeploying: boolean;
}
type ValidationStatus = 'idle' | 'checking' | 'valid' | 'invalid';
export function DeployForm({ t, onDeploy, isDeploying }: DeployFormProps) {
const [name, setName] = useState('');
const [validationStatus, setValidationStatus] = useState<ValidationStatus>('idle');
const [validationMessage, setValidationMessage] = useState('');
const checkTimeoutRef = useRef<NodeJS.Timeout>(undefined);
useEffect(() => {
if (!name) {
setValidationStatus('idle');
setValidationMessage('');
return;
}
const validation = validateStackName(name);
if (!validation.valid && validation.error) {
setValidationStatus('invalid');
setValidationMessage(t(validation.error));
return;
}
setValidationStatus('checking');
setValidationMessage(t('checkingAvailability'));
if (checkTimeoutRef.current) {
clearTimeout(checkTimeoutRef.current);
}
checkTimeoutRef.current = setTimeout(async () => {
try {
const response = await fetch(`/api/check/${validation.name}`);
const data = await response.json();
if (data.available && data.valid) {
setValidationStatus('valid');
setValidationMessage(t('nameAvailable'));
} else {
setValidationStatus('invalid');
setValidationMessage(data.error || t('nameNotAvailable'));
}
} catch {
setValidationStatus('invalid');
setValidationMessage(t('checkFailed'));
}
}, 500);
return () => {
if (checkTimeoutRef.current) {
clearTimeout(checkTimeoutRef.current);
}
};
}, [name, t]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (validationStatus === 'valid') {
onDeploy(name.trim().toLowerCase());
}
};
const previewName = name || t('yournamePlaceholder');
return (
<div className="backdrop-blur-md bg-black/20 rounded-2xl p-10 border border-white/10 shadow-2xl transition-all hover:border-white/20">
<h2 className="text-2xl font-bold mb-4 min-h-[2.4rem] text-white">
<TypewriterText text={t('chooseStackName')} />
</h2>
<p className="text-gray-400 mb-8 text-[0.95rem]">
{t('availableAt')}{' '}
<strong className="text-blue-400 font-medium">
{previewName}.ai.flexinit.nl
</strong>
</p>
<form onSubmit={handleSubmit}>
<div className="mb-6">
<label className="block font-medium mb-2 text-gray-400 text-sm">
{t('stackName')}
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value.toLowerCase())}
placeholder={t('placeholder')}
className={cn(
'w-full py-3 px-4 bg-white/5 border border-white/10 rounded-xl',
'text-xl font-mono text-white placeholder-gray-600',
'focus:outline-none focus:border-white/30 focus:bg-white/10 transition-all',
validationStatus === 'invalid' && 'border-red-500/50 focus:border-red-500',
validationStatus === 'valid' && 'border-green-500/50 focus:border-green-500',
validationStatus === 'checking' && 'border-blue-400/50 focus:border-blue-400'
)}
autoComplete="off"
disabled={isDeploying}
/>
<p className="text-sm text-gray-500 mt-2">{t('inputHint')}</p>
{validationMessage && (
<p
className={cn(
'text-sm mt-2 min-h-[1.25rem]',
validationStatus === 'invalid' && 'text-red-400',
validationStatus === 'valid' && 'text-green-400',
validationStatus === 'checking' && 'text-gray-400'
)}
>
{validationMessage}
</p>
)}
</div>
<button
type="submit"
disabled={validationStatus !== 'valid' || isDeploying}
className={cn(
'w-full flex items-center justify-center gap-2',
'py-3.5 px-6 rounded-xl font-semibold text-base',
'transition-all duration-300',
validationStatus === 'valid' && !isDeploying
? 'bg-white text-black hover:bg-white/90 shadow-[0_0_15px_rgba(255,255,255,0.1)] hover:translate-y-[-2px]'
: 'bg-white/5 text-gray-500 border border-white/5 cursor-not-allowed'
)}
>
<span>{isDeploying ? t('deployingText') : t('deployBtn')}</span>
{!isDeploying && (
<svg className="w-5 h-5 rtl:scale-x-[-1]" viewBox="0 0 20 20" fill="none">
<path d="M10 4V16M4 10H16" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
)}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { cn } from '@/lib/utils';
import type { TranslationKey } from '@/lib/i18n';
interface ProgressData {
progress: number;
message: string;
}
interface DeployProgressProps {
t: (key: TranslationKey) => string;
stackName: string;
deploymentUrl: string;
progressData: ProgressData;
logs: string[];
}
export function DeployProgress({ t, stackName, deploymentUrl, progressData, logs }: DeployProgressProps) {
return (
<div className="backdrop-blur-md bg-black/20 rounded-2xl p-10 border border-white/10 shadow-2xl animate-fadeIn">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-white">{t('deploying')}</h2>
<div className="w-6 h-6 border-3 border-white/10 border-t-white rounded-full animate-spin" />
</div>
<div className="bg-white/5 p-4 rounded-xl mb-6 border border-white/10">
<p className="text-[0.95rem] mb-2 text-gray-300">
{t('stack')}: <strong className="text-white">{stackName}</strong>
</p>
<p className="text-[0.95rem] text-gray-300">
URL: <strong className="text-white">{deploymentUrl}</strong>
</p>
</div>
<div className="flex items-center gap-4 mb-8">
<div className="flex-1 h-1 bg-white/10 rounded overflow-hidden">
<div
className="h-full bg-white transition-all duration-300 rounded"
style={{ width: `${progressData.progress}%` }}
/>
</div>
<span className="font-medium text-gray-400 min-w-[3rem] text-right text-sm">
{progressData.progress}%
</span>
</div>
<div className="flex flex-col gap-4">
<div className={cn(
'flex items-center gap-4 p-3 rounded-lg',
'bg-white/5 border-l-[3px] border-white'
)}>
<span className="text-2xl"></span>
<span className="flex-1 text-[0.95rem] text-gray-300">{progressData.message}</span>
</div>
</div>
{logs.length > 0 && (
<div className="mt-6 p-4 bg-black/50 border border-white/10 rounded-lg font-mono text-sm text-gray-400 max-h-[200px] overflow-y-auto">
{logs.map((log, i) => (
<div key={i} className="animate-fadeInUp">
{log}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
import { TypewriterText } from './TypewriterText';
import type { TranslationKey } from '@/lib/i18n';
interface DeploySuccessProps {
t: (key: TranslationKey) => string;
stackName: string;
deploymentUrl: string;
onDeployAnother: () => void;
}
export function DeploySuccess({ t, stackName, deploymentUrl, onDeployAnother }: DeploySuccessProps) {
return (
<div className="backdrop-blur-md bg-black/20 rounded-2xl p-10 border border-white/10 shadow-2xl text-center animate-fadeIn">
<div className="mb-6 animate-scaleIn">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-full border-2 border-green-500 text-green-500 text-5xl bg-green-500/10">
</div>
</div>
<h2 className="text-2xl font-bold mb-4 min-h-[2.4rem] text-white">
<TypewriterText text={t('deploymentComplete')} />
</h2>
<p className="text-gray-400 mb-8">{t('successMessage')}</p>
<div className="bg-white/5 p-6 rounded-xl mb-8 border border-white/10 text-left">
<div className="flex justify-between mb-4 text-[0.95rem]">
<span className="text-gray-400 font-medium">{t('stackNameLabel')}</span>
<span className="font-semibold font-mono text-white">{stackName}</span>
</div>
<div className="flex justify-between text-[0.95rem]">
<span className="text-gray-400 font-medium">URL:</span>
<a
href={deploymentUrl}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 font-semibold font-mono hover:underline"
>
{deploymentUrl}
</a>
</div>
</div>
<div className="space-y-3">
<a
href={deploymentUrl}
target="_blank"
rel="noopener noreferrer"
className="w-full flex items-center justify-center gap-2 py-3.5 px-6 rounded-xl font-semibold bg-white text-black hover:bg-white/90 transition-all animate-pulse-glow"
>
<span>{t('openStack')}</span>
<svg className="w-5 h-5 rtl:scale-x-[-1]" viewBox="0 0 20 20" fill="none">
<path d="M10 4L16 10L10 16M16 10H4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</a>
<button
onClick={onDeployAnother}
className="w-full py-3.5 px-6 rounded-xl font-semibold bg-transparent text-gray-400 border border-white/10 hover:bg-white/5 hover:border-white/20 hover:text-white transition-colors"
>
{t('deployAnother')}
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,37 @@
import { cn } from '@/lib/utils';
import type { Language } from '@/lib/i18n';
interface LanguageSelectorProps {
currentLang: Language;
onLangChange: (lang: Language) => void;
}
const languages: { code: Language; label: string; title: string }[] = [
{ code: 'nl', label: 'NL', title: 'Nederlands' },
{ code: 'ar', label: 'AR', title: 'العربية' },
{ code: 'en', label: 'EN', title: 'English' },
];
export function LanguageSelector({ currentLang, onLangChange }: LanguageSelectorProps) {
return (
<div className="fixed top-4 right-4 rtl:right-auto rtl:left-4 flex gap-2 z-50">
{languages.map(({ code, label, title }) => (
<button
key={code}
onClick={() => onLangChange(code)}
title={title}
className={cn(
'px-3 py-2 text-xs font-semibold font-mono uppercase tracking-wide',
'backdrop-blur-md bg-black/20 border border-white/10 rounded-xl',
'transition-all duration-200 hover:scale-105',
currentLang === code
? 'border-white text-white shadow-[0_0_15px_rgba(255,255,255,0.2)] bg-white/10'
: 'text-gray-500 hover:border-white/50 hover:text-white hover:bg-white/5'
)}
>
{label}
</button>
))}
</div>
);
}

View File

@@ -0,0 +1,20 @@
import { useTypewriter } from '@/hooks/useTypewriter';
interface TypewriterTextProps {
text: string;
speed?: number;
className?: string;
}
export function TypewriterText({ text, speed = 50, className }: TypewriterTextProps) {
const { displayText, isComplete } = useTypewriter(text, speed);
return (
<span className={className}>
{displayText}
{!isComplete && (
<span className="inline-block w-2.5 h-6 bg-cyan-400 ml-1 rtl:ml-0 rtl:mr-1 animate-blink translate-y-1" />
)}
</span>
);
}

View File

@@ -0,0 +1,822 @@
"use client";
import React, { useState, useMemo, useRef, useEffect } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Link } from "react-router-dom";
import { cn } from "@/lib/utils";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
type Uniforms = {
[key: string]: {
value: number[] | number[][] | number;
type: string;
};
};
interface ShaderProps {
source: string;
uniforms: {
[key: string]: {
value: number[] | number[][] | number;
type: string;
};
};
maxFps?: number;
}
interface SignInPageProps {
className?: string;
}
export const CanvasRevealEffect = ({
animationSpeed = 10,
opacities = [0.3, 0.3, 0.3, 0.5, 0.5, 0.5, 0.8, 0.8, 0.8, 1],
colors = [[0, 255, 255]],
containerClassName,
dotSize,
showGradient = true,
reverse = false,
}: {
animationSpeed?: number;
opacities?: number[];
colors?: number[][];
containerClassName?: string;
dotSize?: number;
showGradient?: boolean;
reverse?: boolean;
}) => {
return (
<div className={cn("h-full relative w-full", containerClassName)}>
<div className="h-full w-full">
<DotMatrix
colors={colors ?? [[0, 255, 255]]}
dotSize={dotSize ?? 3}
opacities={opacities ?? [0.3, 0.3, 0.3, 0.5, 0.5, 0.5, 0.8, 0.8, 0.8, 1]}
shader={`
${reverse ? "u_reverse_active" : "false"}_;
animation_speed_factor_${animationSpeed.toFixed(1)}_;
`}
center={["x", "y"]}
/>
</div>
{showGradient && (
<div className="absolute inset-0 bg-gradient-to-t from-black to-transparent" />
)}
</div>
);
};
interface DotMatrixProps {
colors?: number[][];
opacities?: number[];
totalSize?: number;
dotSize?: number;
shader?: string;
center?: ("x" | "y")[];
}
const DotMatrix: React.FC<DotMatrixProps> = ({
colors = [[0, 0, 0]],
opacities = [0.04, 0.04, 0.04, 0.04, 0.04, 0.08, 0.08, 0.08, 0.08, 0.14],
totalSize = 20,
dotSize = 2,
shader = "",
center = ["x", "y"],
}) => {
const uniforms = React.useMemo(() => {
let colorsArray = [colors[0], colors[0], colors[0], colors[0], colors[0], colors[0]];
if (colors.length === 2) {
colorsArray = [colors[0], colors[0], colors[0], colors[1], colors[1], colors[1]];
} else if (colors.length === 3) {
colorsArray = [colors[0], colors[0], colors[1], colors[1], colors[2], colors[2]];
}
return {
u_colors: {
value: colorsArray.map((color) => [color[0] / 255, color[1] / 255, color[2] / 255]),
type: "uniform3fv",
},
u_opacities: {
value: opacities,
type: "uniform1fv",
},
u_total_size: {
value: totalSize,
type: "uniform1f",
},
u_dot_size: {
value: dotSize,
type: "uniform1f",
},
u_reverse: {
value: shader.includes("u_reverse_active") ? 1 : 0,
type: "uniform1i",
},
};
}, [colors, opacities, totalSize, dotSize, shader]);
return (
<Shader
source={`
precision mediump float;
in vec2 fragCoord;
uniform float u_time;
uniform float u_opacities[10];
uniform vec3 u_colors[6];
uniform float u_total_size;
uniform float u_dot_size;
uniform vec2 u_resolution;
uniform int u_reverse;
out vec4 fragColor;
float PHI = 1.61803398874989484820459;
float random(vec2 xy) {
return fract(tan(distance(xy * PHI, xy) * 0.5) * xy.x);
}
float map(float value, float min1, float max1, float min2, float max2) {
return min2 + (value - min1) * (max2 - min2) / (max1 - min1);
}
void main() {
vec2 st = fragCoord.xy;
${center.includes("x") ? "st.x -= abs(floor((mod(u_resolution.x, u_total_size) - u_dot_size) * 0.5));" : ""}
${center.includes("y") ? "st.y -= abs(floor((mod(u_resolution.y, u_total_size) - u_dot_size) * 0.5));" : ""}
float opacity = step(0.0, st.x);
opacity *= step(0.0, st.y);
vec2 st2 = vec2(int(st.x / u_total_size), int(st.y / u_total_size));
float frequency = 5.0;
float show_offset = random(st2);
float rand = random(st2 * floor((u_time / frequency) + show_offset + frequency));
opacity *= u_opacities[int(rand * 10.0)];
opacity *= 1.0 - step(u_dot_size / u_total_size, fract(st.x / u_total_size));
opacity *= 1.0 - step(u_dot_size / u_total_size, fract(st.y / u_total_size));
vec3 color = u_colors[int(show_offset * 6.0)];
float animation_speed_factor = 0.5;
vec2 center_grid = u_resolution / 2.0 / u_total_size;
float dist_from_center = distance(center_grid, st2);
float timing_offset_intro = dist_from_center * 0.01 + (random(st2) * 0.15);
float max_grid_dist = distance(center_grid, vec2(0.0, 0.0));
float timing_offset_outro = (max_grid_dist - dist_from_center) * 0.02 + (random(st2 + 42.0) * 0.2);
float current_timing_offset;
if (u_reverse == 1) {
current_timing_offset = timing_offset_outro;
opacity *= 1.0 - step(current_timing_offset, u_time * animation_speed_factor);
opacity *= clamp((step(current_timing_offset + 0.1, u_time * animation_speed_factor)) * 1.25, 1.0, 1.25);
} else {
current_timing_offset = timing_offset_intro;
opacity *= step(current_timing_offset, u_time * animation_speed_factor);
opacity *= clamp((1.0 - step(current_timing_offset + 0.1, u_time * animation_speed_factor)) * 1.25, 1.0, 1.25);
}
fragColor = vec4(color, opacity);
fragColor.rgb *= fragColor.a;
}`}
uniforms={uniforms}
maxFps={60}
/>
);
};
const ShaderMaterial = ({
source,
uniforms,
}: {
source: string;
hovered?: boolean;
maxFps?: number;
uniforms: Uniforms;
}) => {
const { size } = useThree();
const ref = useRef<THREE.Mesh>(null);
useFrame(({ clock }) => {
if (!ref.current) return;
const timestamp = clock.getElapsedTime();
const material = ref.current.material as THREE.ShaderMaterial;
material.uniforms.u_time.value = timestamp;
});
const getUniforms = () => {
const preparedUniforms: Record<string, unknown> = {};
for (const uniformName in uniforms) {
const uniform = uniforms[uniformName];
switch (uniform.type) {
case "uniform1f":
preparedUniforms[uniformName] = { value: uniform.value, type: "1f" };
break;
case "uniform1i":
preparedUniforms[uniformName] = { value: uniform.value, type: "1i" };
break;
case "uniform3f":
preparedUniforms[uniformName] = {
value: new THREE.Vector3().fromArray(uniform.value as number[]),
type: "3f",
};
break;
case "uniform1fv":
preparedUniforms[uniformName] = { value: uniform.value, type: "1fv" };
break;
case "uniform3fv":
preparedUniforms[uniformName] = {
value: (uniform.value as number[][]).map((v: number[]) =>
new THREE.Vector3().fromArray(v)
),
type: "3fv",
};
break;
case "uniform2f":
preparedUniforms[uniformName] = {
value: new THREE.Vector2().fromArray(uniform.value as number[]),
type: "2f",
};
break;
default:
console.error(`Invalid uniform type for '${uniformName}'.`);
break;
}
}
preparedUniforms["u_time"] = { value: 0, type: "1f" };
preparedUniforms["u_resolution"] = {
value: new THREE.Vector2(size.width * 2, size.height * 2),
};
return preparedUniforms;
};
const material = useMemo(() => {
const materialObject = new THREE.ShaderMaterial({
vertexShader: `
precision mediump float;
in vec2 coordinates;
uniform vec2 u_resolution;
out vec2 fragCoord;
void main(){
float x = position.x;
float y = position.y;
gl_Position = vec4(x, y, 0.0, 1.0);
fragCoord = (position.xy + vec2(1.0)) * 0.5 * u_resolution;
fragCoord.y = u_resolution.y - fragCoord.y;
}
`,
fragmentShader: source,
uniforms: getUniforms() as Record<string, THREE.IUniform>,
glslVersion: THREE.GLSL3,
blending: THREE.CustomBlending,
blendSrc: THREE.SrcAlphaFactor,
blendDst: THREE.OneFactor,
});
return materialObject;
}, [size.width, size.height, source]);
return (
<mesh ref={ref}>
<planeGeometry args={[2, 2]} />
<primitive object={material} attach="material" />
</mesh>
);
};
const Shader: React.FC<ShaderProps> = ({ source, uniforms, maxFps = 60 }) => {
return (
<Canvas className="absolute inset-0 h-full w-full">
<ShaderMaterial source={source} uniforms={uniforms} maxFps={maxFps} />
</Canvas>
);
};
const AnimatedNavLink = ({ href, children }: { href: string; children: React.ReactNode }) => {
const defaultTextColor = "text-gray-300";
const hoverTextColor = "text-white";
const textSizeClass = "text-sm";
return (
<a
href={href}
className={`group relative inline-block overflow-hidden h-5 flex items-center ${textSizeClass}`}
>
<div className="flex flex-col transition-transform duration-400 ease-out transform group-hover:-translate-y-1/2">
<span className={defaultTextColor}>{children}</span>
<span className={hoverTextColor}>{children}</span>
</div>
</a>
);
};
function MiniNavbar() {
const [isOpen, setIsOpen] = useState(false);
const [headerShapeClass, setHeaderShapeClass] = useState("rounded-full");
const shapeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const toggleMenu = () => {
setIsOpen(!isOpen);
};
useEffect(() => {
if (shapeTimeoutRef.current) {
clearTimeout(shapeTimeoutRef.current);
}
if (isOpen) {
setHeaderShapeClass("rounded-xl");
} else {
shapeTimeoutRef.current = setTimeout(() => {
setHeaderShapeClass("rounded-full");
}, 300);
}
return () => {
if (shapeTimeoutRef.current) {
clearTimeout(shapeTimeoutRef.current);
}
};
}, [isOpen]);
const logoElement = (
<div className="relative w-5 h-5 flex items-center justify-center">
<span className="absolute w-1.5 h-1.5 rounded-full bg-gray-200 top-0 left-1/2 transform -translate-x-1/2 opacity-80"></span>
<span className="absolute w-1.5 h-1.5 rounded-full bg-gray-200 left-0 top-1/2 transform -translate-y-1/2 opacity-80"></span>
<span className="absolute w-1.5 h-1.5 rounded-full bg-gray-200 right-0 top-1/2 transform -translate-y-1/2 opacity-80"></span>
<span className="absolute w-1.5 h-1.5 rounded-full bg-gray-200 bottom-0 left-1/2 transform -translate-x-1/2 opacity-80"></span>
</div>
);
const navLinksData = [
{ label: "Manifesto", href: "#1" },
{ label: "Careers", href: "#2" },
{ label: "Discover", href: "#3" },
];
const loginButtonElement = (
<button className="px-4 py-2 sm:px-3 text-xs sm:text-sm border border-[#333] bg-[rgba(31,31,31,0.62)] text-gray-300 rounded-full hover:border-white/50 hover:text-white transition-colors duration-200 w-full sm:w-auto">
LogIn
</button>
);
const signupButtonElement = (
<div className="relative group w-full sm:w-auto">
<div
className="absolute inset-0 -m-2 rounded-full
hidden sm:block
bg-gray-100
opacity-40 filter blur-lg pointer-events-none
transition-all duration-300 ease-out
group-hover:opacity-60 group-hover:blur-xl group-hover:-m-3"
></div>
<button className="relative z-10 px-4 py-2 sm:px-3 text-xs sm:text-sm font-semibold text-black bg-gradient-to-br from-gray-100 to-gray-300 rounded-full hover:from-gray-200 hover:to-gray-400 transition-all duration-200 w-full sm:w-auto">
Signup
</button>
</div>
);
return (
<header
className={`fixed top-6 left-1/2 transform -translate-x-1/2 z-20
flex flex-col items-center
pl-6 pr-6 py-3 backdrop-blur-sm
${headerShapeClass}
border border-[#333] bg-[#1f1f1f57]
w-[calc(100%-2rem)] sm:w-auto
transition-[border-radius] duration-0 ease-in-out`}
>
<div className="flex items-center justify-between w-full gap-x-6 sm:gap-x-8">
<div className="flex items-center">{logoElement}</div>
<nav className="hidden sm:flex items-center space-x-4 sm:space-x-6 text-sm">
{navLinksData.map((link) => (
<AnimatedNavLink key={link.href} href={link.href}>
{link.label}
</AnimatedNavLink>
))}
</nav>
<div className="hidden sm:flex items-center gap-2 sm:gap-3">
{loginButtonElement}
{signupButtonElement}
</div>
<button
className="sm:hidden flex items-center justify-center w-8 h-8 text-gray-300 focus:outline-none"
onClick={toggleMenu}
aria-label={isOpen ? "Close Menu" : "Open Menu"}
>
{isOpen ? (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M6 18L18 6M6 6l12 12"
></path>
</svg>
) : (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M4 6h16M4 12h16M4 18h16"
></path>
</svg>
)}
</button>
</div>
<div
className={`sm:hidden flex flex-col items-center w-full transition-all ease-in-out duration-300 overflow-hidden
${isOpen ? "max-h-[1000px] opacity-100 pt-4" : "max-h-0 opacity-0 pt-0 pointer-events-none"}`}
>
<nav className="flex flex-col items-center space-y-4 text-base w-full">
{navLinksData.map((link) => (
<a
key={link.href}
href={link.href}
className="text-gray-300 hover:text-white transition-colors w-full text-center"
>
{link.label}
</a>
))}
</nav>
<div className="flex flex-col items-center space-y-4 mt-4 w-full">
{loginButtonElement}
{signupButtonElement}
</div>
</div>
</header>
);
}
export const SignInPage = ({ className }: SignInPageProps) => {
const [email, setEmail] = useState("");
const [step, setStep] = useState<"email" | "code" | "success">("email");
const [code, setCode] = useState(["", "", "", "", "", ""]);
const codeInputRefs = useRef<(HTMLInputElement | null)[]>([]);
const [initialCanvasVisible, setInitialCanvasVisible] = useState(true);
const [reverseCanvasVisible, setReverseCanvasVisible] = useState(false);
const handleEmailSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (email) {
setStep("code");
}
};
useEffect(() => {
if (step === "code") {
setTimeout(() => {
codeInputRefs.current[0]?.focus();
}, 500);
}
}, [step]);
const handleCodeChange = (index: number, value: string) => {
if (value.length <= 1) {
const newCode = [...code];
newCode[index] = value;
setCode(newCode);
if (value && index < 5) {
codeInputRefs.current[index + 1]?.focus();
}
if (index === 5 && value) {
const isComplete = newCode.every((digit) => digit.length === 1);
if (isComplete) {
setReverseCanvasVisible(true);
setTimeout(() => {
setInitialCanvasVisible(false);
}, 50);
setTimeout(() => {
setStep("success");
}, 2000);
}
}
}
};
const handleKeyDown = (index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace" && !code[index] && index > 0) {
codeInputRefs.current[index - 1]?.focus();
}
};
const handleBackClick = () => {
setStep("email");
setCode(["", "", "", "", "", ""]);
setReverseCanvasVisible(false);
setInitialCanvasVisible(true);
};
return (
<div className={cn("flex w-[100%] flex-col min-h-screen bg-black relative", className)}>
<div className="absolute inset-0 z-0">
{initialCanvasVisible && (
<div className="absolute inset-0">
<CanvasRevealEffect
animationSpeed={3}
containerClassName="bg-black"
colors={[
[255, 255, 255],
[255, 255, 255],
]}
dotSize={6}
reverse={false}
/>
</div>
)}
{reverseCanvasVisible && (
<div className="absolute inset-0">
<CanvasRevealEffect
animationSpeed={4}
containerClassName="bg-black"
colors={[
[255, 255, 255],
[255, 255, 255],
]}
dotSize={6}
reverse={true}
/>
</div>
)}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_rgba(0,0,0,1)_0%,_transparent_100%)]" />
<div className="absolute top-0 left-0 right-0 h-1/3 bg-gradient-to-b from-black to-transparent" />
</div>
<div className="relative z-10 flex flex-col flex-1">
<MiniNavbar />
<div className="flex flex-1 flex-col lg:flex-row ">
<div className="flex-1 flex flex-col justify-center items-center">
<div className="w-full mt-[150px] max-w-sm">
<AnimatePresence mode="wait">
{step === "email" ? (
<motion.div
key="email-step"
initial={{ opacity: 0, x: -100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="space-y-6 text-center"
>
<div className="space-y-1">
<h1 className="text-[2.5rem] font-bold leading-[1.1] tracking-tight text-white">
Welcome Developer
</h1>
<p className="text-[1.8rem] text-white/70 font-light">
Your sign in component
</p>
</div>
<div className="space-y-4">
<button className="backdrop-blur-[2px] w-full flex items-center justify-center gap-2 bg-white/5 hover:bg-white/10 text-white border border-white/10 rounded-full py-3 px-4 transition-colors">
<span className="text-lg">G</span>
<span>Sign in with Google</span>
</button>
<div className="flex items-center gap-4">
<div className="h-px bg-white/10 flex-1" />
<span className="text-white/40 text-sm">or</span>
<div className="h-px bg-white/10 flex-1" />
</div>
<form onSubmit={handleEmailSubmit}>
<div className="relative">
<input
type="email"
placeholder="info@gmail.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="w-full backdrop-blur-[1px] text-white border-1 border-white/10 rounded-full py-3 px-4 focus:outline-none focus:border focus:border-white/30 text-center"
required
/>
<button
type="submit"
className="absolute right-1.5 top-1.5 text-white w-9 h-9 flex items-center justify-center rounded-full bg-white/10 hover:bg-white/20 transition-colors group overflow-hidden"
>
<span className="relative w-full h-full block overflow-hidden">
<span className="absolute inset-0 flex items-center justify-center transition-transform duration-300 group-hover:translate-x-full">
</span>
<span className="absolute inset-0 flex items-center justify-center transition-transform duration-300 -translate-x-full group-hover:translate-x-0">
</span>
</span>
</button>
</div>
</form>
</div>
<p className="text-xs text-white/40 pt-10">
By signing up, you agree to the{" "}
<Link to="#" className="underline text-white/40 hover:text-white/60 transition-colors">
MSA
</Link>
,{" "}
<Link to="#" className="underline text-white/40 hover:text-white/60 transition-colors">
Product Terms
</Link>
,{" "}
<Link to="#" className="underline text-white/40 hover:text-white/60 transition-colors">
Policies
</Link>
,{" "}
<Link to="#" className="underline text-white/40 hover:text-white/60 transition-colors">
Privacy Notice
</Link>
, and{" "}
<Link to="#" className="underline text-white/40 hover:text-white/60 transition-colors">
Cookie Notice
</Link>
.
</p>
</motion.div>
) : step === "code" ? (
<motion.div
key="code-step"
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 100 }}
transition={{ duration: 0.4, ease: "easeOut" }}
className="space-y-6 text-center"
>
<div className="space-y-1">
<h1 className="text-[2.5rem] font-bold leading-[1.1] tracking-tight text-white">
We sent you a code
</h1>
<p className="text-[1.25rem] text-white/50 font-light">Please enter it</p>
</div>
<div className="w-full">
<div className="relative rounded-full py-4 px-5 border border-white/10 bg-transparent">
<div className="flex items-center justify-center">
{code.map((digit, i) => (
<div key={i} className="flex items-center">
<div className="relative">
<input
ref={(el) => {
codeInputRefs.current[i] = el;
}}
type="text"
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
value={digit}
onChange={(e) => handleCodeChange(i, e.target.value)}
onKeyDown={(e) => handleKeyDown(i, e)}
className="w-8 text-center text-xl bg-transparent text-white border-none focus:outline-none focus:ring-0 appearance-none"
style={{ caretColor: "transparent" }}
/>
{!digit && (
<div className="absolute top-0 left-0 w-full h-full flex items-center justify-center pointer-events-none">
<span className="text-xl text-white">0</span>
</div>
)}
</div>
{i < 5 && <span className="text-white/20 text-xl">|</span>}
</div>
))}
</div>
</div>
</div>
<div>
<motion.p
className="text-white/50 hover:text-white/70 transition-colors cursor-pointer text-sm"
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.2 }}
>
Resend code
</motion.p>
</div>
<div className="flex w-full gap-3">
<motion.button
onClick={handleBackClick}
className="rounded-full bg-white text-black font-medium px-8 py-3 hover:bg-white/90 transition-colors w-[30%]"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.2 }}
>
Back
</motion.button>
<motion.button
className={`flex-1 rounded-full font-medium py-3 border transition-all duration-300 ${
code.every((d) => d !== "")
? "bg-white text-black border-transparent hover:bg-white/90 cursor-pointer"
: "bg-[#111] text-white/50 border-white/10 cursor-not-allowed"
}`}
disabled={!code.every((d) => d !== "")}
>
Continue
</motion.button>
</div>
<div className="pt-16">
<p className="text-xs text-white/40">
By signing up, you agree to the{" "}
<Link to="#" className="underline text-white/40 hover:text-white/60 transition-colors">
MSA
</Link>
,{" "}
<Link to="#" className="underline text-white/40 hover:text-white/60 transition-colors">
Product Terms
</Link>
,{" "}
<Link to="#" className="underline text-white/40 hover:text-white/60 transition-colors">
Policies
</Link>
,{" "}
<Link to="#" className="underline text-white/40 hover:text-white/60 transition-colors">
Privacy Notice
</Link>
, and{" "}
<Link to="#" className="underline text-white/40 hover:text-white/60 transition-colors">
Cookie Notice
</Link>
.
</p>
</div>
</motion.div>
) : (
<motion.div
key="success-step"
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: "easeOut", delay: 0.3 }}
className="space-y-6 text-center"
>
<div className="space-y-1">
<h1 className="text-[2.5rem] font-bold leading-[1.1] tracking-tight text-white">
You're in!
</h1>
<p className="text-[1.25rem] text-white/50 font-light">Welcome</p>
</div>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.5 }}
className="py-10"
>
<div className="mx-auto w-16 h-16 rounded-full bg-gradient-to-br from-white to-white/70 flex items-center justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-8 w-8 text-black"
viewBox="0 0 20 20"
fill="currentColor"
>
<path
fillRule="evenodd"
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
</svg>
</div>
</motion.div>
<motion.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1 }}
className="w-full rounded-full bg-white text-black font-medium py-3 hover:bg-white/90 transition-colors"
>
Continue to Dashboard
</motion.button>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,24 @@
import { useState, useEffect, useCallback } from 'react';
import { translations, type Language, type TranslationKey, getStoredLanguage, storeLanguage } from '@/lib/i18n';
export function useI18n() {
const [lang, setLangState] = useState<Language>(getStoredLanguage);
const setLang = useCallback((newLang: Language) => {
setLangState(newLang);
storeLanguage(newLang);
document.documentElement.lang = newLang;
document.documentElement.dir = newLang === 'ar' ? 'rtl' : 'ltr';
}, []);
useEffect(() => {
document.documentElement.lang = lang;
document.documentElement.dir = lang === 'ar' ? 'rtl' : 'ltr';
}, [lang]);
const t = useCallback((key: TranslationKey): string => {
return translations[lang][key] || translations.en[key] || key;
}, [lang]);
return { lang, setLang, t, isRtl: lang === 'ar' };
}

View File

@@ -0,0 +1,29 @@
import { useState, useEffect, useRef } from 'react';
export function useTypewriter(text: string, speed: number = 50) {
const [displayText, setDisplayText] = useState('');
const [isComplete, setIsComplete] = useState(false);
const indexRef = useRef(0);
useEffect(() => {
setDisplayText('');
setIsComplete(false);
indexRef.current = 0;
if (!text) return;
const timer = setInterval(() => {
if (indexRef.current < text.length) {
setDisplayText(text.slice(0, indexRef.current + 1));
indexRef.current++;
} else {
setIsComplete(true);
clearInterval(timer);
}
}, speed);
return () => clearInterval(timer);
}, [text, speed]);
return { displayText, isComplete };
}

70
client/src/index.css Normal file
View File

@@ -0,0 +1,70 @@
@import "tailwindcss";
@theme {
--font-sans: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace;
}
/* Base styles */
body {
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #333;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #444;
}
@keyframes blink {
50% { opacity: 0; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes scaleIn {
from { transform: scale(0); opacity: 0; }
to { transform: scale(1); opacity: 1; }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-10px); }
75% { transform: translateX(10px); }
}
@keyframes pulse-glow {
0% { box-shadow: 0 0 15px rgba(0, 229, 255, 0.2); }
50% { box-shadow: 0 0 25px rgba(0, 229, 255, 0.5); }
100% { box-shadow: 0 0 15px rgba(0, 229, 255, 0.2); }
}
.animate-blink { animation: blink 1s steps(1) infinite; }
.animate-fadeIn { animation: fadeIn 0.5s ease; }
.animate-fadeInUp { animation: fadeInUp 0.5s ease; }
.animate-scaleIn { animation: scaleIn 0.5s ease; }
.animate-shake { animation: shake 0.5s ease; }
.animate-pulse-glow { animation: pulse-glow 2s infinite; }

124
client/src/lib/i18n.ts Normal file
View File

@@ -0,0 +1,124 @@
export const translations = {
en: {
title: 'AI Stack Deployer',
subtitle: 'Deploy your personal AI assistant in seconds',
chooseStackName: 'Choose Your Stack Name',
availableAt: 'Your AI assistant will be available at',
stackName: 'Stack Name',
placeholder: 'e.g., john-dev',
inputHint: '3-20 characters, lowercase letters, numbers, and hyphens only',
deployBtn: 'Deploy My AI Stack',
deploying: 'Deploying Your Stack',
stack: 'Stack',
initializing: 'Initializing deployment...',
successMessage: 'Your AI coding assistant is ready to use',
stackNameLabel: 'Stack Name:',
openStack: 'Open My AI Stack',
deployAnother: 'Deploy Another Stack',
tryAgain: 'Try Again',
poweredBy: 'Powered by',
deploymentComplete: 'Deployment Complete',
deploymentFailed: 'Deployment Failed',
nameRequired: 'Name is required',
nameLengthError: 'Name must be between 3 and 20 characters',
nameCharsError: 'Only lowercase letters, numbers, and hyphens allowed',
nameHyphenError: 'Cannot start or end with a hyphen',
nameReserved: 'This name is reserved',
checkingAvailability: 'Checking availability...',
nameAvailable: '✓ Name is available!',
nameNotAvailable: 'Name is not available',
checkFailed: 'Failed to check availability',
connectionLost: 'Connection lost. Please refresh and try again.',
deployingText: 'Deploying...',
yournamePlaceholder: 'yourname'
},
nl: {
title: 'AI Stack Deployer',
subtitle: 'Implementeer je persoonlijke AI in seconden',
chooseStackName: 'Kies Je Stack Naam',
availableAt: 'Je zal AI-assistenten beschikbaar zijn op',
stackName: 'Stack Naam',
placeholder: 'bijv., Oussama',
inputHint: '3-20 tekens, kleine letters, cijfers en koppeltekens',
deployBtn: 'Implementeer Mijn AI Stack',
deploying: 'Stack Wordt Geïmplementeerd',
stack: 'Stack',
initializing: 'Implementatie initialiseren...',
successMessage: 'Je AI programmeerassistent is klaar voor gebruik',
stackNameLabel: 'Stack Naam:',
openStack: 'Open Mijn AI Stack',
deployAnother: 'Implementeer Nog Een Stack',
tryAgain: 'Probeer Opnieuw',
poweredBy: 'Mogelijk gemaakt door',
deploymentComplete: 'Implementatie Voltooid',
deploymentFailed: 'Implementatie Mislukt',
nameRequired: 'Naam is verplicht',
nameLengthError: 'Naam moet tussen 3 en 20 tekens zijn',
nameCharsError: 'Alleen kleine letters, cijfers en koppeltekens toegestaan',
nameHyphenError: 'Kan niet beginnen of eindigen met een koppelteken',
nameReserved: 'Deze naam is gereserveerd',
checkingAvailability: 'Beschikbaarheid controleren...',
nameAvailable: '✓ Naam is beschikbaar!',
nameNotAvailable: 'Naam is niet beschikbaar',
checkFailed: 'Controle mislukt',
connectionLost: 'Verbinding verbroken. Ververs de pagina en probeer opnieuw.',
deployingText: 'Implementeren...',
yournamePlaceholder: 'jouwnaam'
},
ar: {
title: 'AI Stack Deployer',
subtitle: 'انشر مساعد البرمجة الذكي الخاص بك في ثوانٍ',
chooseStackName: 'اختر اسم المشروع',
availableAt: 'سيكون مساعدك الذكي متاحًا على',
stackName: 'اسم المشروع',
placeholder: 'مثال: أحمد-dev',
inputHint: '3-20 حرف، أحرف صغيرة وأرقام وشرطات فقط',
deployBtn: 'انشر مشروعي',
deploying: 'جاري النشر',
stack: 'المشروع',
initializing: 'جاري التهيئة...',
successMessage: 'مساعد البرمجة الذكي جاهز للاستخدام',
stackNameLabel: 'اسم المشروع:',
openStack: 'افتح مشروعي',
deployAnother: 'انشر مشروع آخر',
tryAgain: 'حاول مرة أخرى',
poweredBy: 'مدعوم من',
deploymentComplete: 'تم النشر بنجاح',
deploymentFailed: 'فشل النشر',
nameRequired: 'الاسم مطلوب',
nameLengthError: 'يجب أن يكون الاسم بين 3 و 20 حرفًا',
nameCharsError: 'يُسمح فقط بالأحرف الصغيرة والأرقام والشرطات',
nameHyphenError: 'لا يمكن أن يبدأ أو ينتهي بشرطة',
nameReserved: 'هذا الاسم محجوز',
checkingAvailability: 'جاري التحقق...',
nameAvailable: '✓ الاسم متاح!',
nameNotAvailable: 'الاسم غير متاح',
checkFailed: 'فشل التحقق',
connectionLost: 'انقطع الاتصال. يرجى تحديث الصفحة والمحاولة مرة أخرى.',
deployingText: 'جاري النشر...',
yournamePlaceholder: 'اسمك'
}
} as const;
export type Language = keyof typeof translations;
export type TranslationKey = keyof typeof translations.en;
export function detectLanguage(): Language {
const browserLang = navigator.language?.split('-')[0].toLowerCase();
if (browserLang && browserLang in translations) {
return browserLang as Language;
}
return 'en';
}
export function getStoredLanguage(): Language {
const stored = localStorage.getItem('preferredLanguage');
if (stored && stored in translations) {
return stored as Language;
}
return detectLanguage();
}
export function storeLanguage(lang: Language): void {
localStorage.setItem('preferredLanguage', lang);
}

6
client/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,35 @@
import type { TranslationKey } from './i18n';
export interface ValidationResult {
valid: boolean;
error?: TranslationKey;
name?: string;
}
const RESERVED_NAMES = ['admin', 'api', 'www', 'root', 'system', 'test', 'demo', 'portal'];
export function validateStackName(name: string): ValidationResult {
if (!name) {
return { valid: false, error: 'nameRequired' };
}
const trimmedName = name.trim().toLowerCase();
if (trimmedName.length < 3 || trimmedName.length > 20) {
return { valid: false, error: 'nameLengthError' };
}
if (!/^[a-z0-9-]+$/.test(trimmedName)) {
return { valid: false, error: 'nameCharsError' };
}
if (trimmedName.startsWith('-') || trimmedName.endsWith('-')) {
return { valid: false, error: 'nameHyphenError' };
}
if (RESERVED_NAMES.includes(trimmedName)) {
return { valid: false, error: 'nameReserved' };
}
return { valid: true, name: trimmedName };
}

15
client/src/main.tsx Normal file
View File

@@ -0,0 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/*" element={<App />} />
</Routes>
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,232 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from "framer-motion";
import { useI18n } from '@/hooks/useI18n';
import { LanguageSelector } from '@/components/deploy/LanguageSelector';
import { DeployForm } from '@/components/deploy/DeployForm';
import { DeployProgress } from '@/components/deploy/DeployProgress';
import { DeploySuccess } from '@/components/deploy/DeploySuccess';
import { DeployError } from '@/components/deploy/DeployError';
import { CanvasRevealEffect } from '@/components/ui/sign-in-flow-1';
type DeployState = 'form' | 'progress' | 'success' | 'error';
interface ProgressData {
progress: number;
message: string;
}
export default function DeployPage() {
const { lang, setLang, t } = useI18n();
const [state, setState] = useState<DeployState>('form');
const [stackName, setStackName] = useState('');
const [deploymentUrl, setDeploymentUrl] = useState('');
const [progressData, setProgressData] = useState<ProgressData>({ progress: 0, message: '' });
const [logs, setLogs] = useState<string[]>([]);
const [errorMessage, setErrorMessage] = useState('');
const eventSourceRef = useRef<EventSource | null>(null);
const handleDeploy = useCallback(async (name: string) => {
setStackName(name);
setProgressData({ progress: 0, message: t('initializing') });
setLogs([]);
try {
const response = await fetch('/api/deploy', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Deployment failed');
}
setDeploymentUrl(data.url);
setState('progress');
const es = new EventSource(`/api/status/${data.deploymentId}`);
eventSourceRef.current = es;
es.addEventListener('progress', (event) => {
const eventData = JSON.parse(event.data);
setProgressData({
progress: eventData.progress,
message: eventData.currentStep || eventData.message,
});
setLogs((prev) => [
...prev,
`[${new Date().toLocaleTimeString()}] ${eventData.currentStep || eventData.message}`,
]);
});
es.addEventListener('complete', () => {
es.close();
setState('success');
});
es.addEventListener('error', (event) => {
const eventData = (event as MessageEvent).data
? JSON.parse((event as MessageEvent).data)
: { message: 'Unknown error' };
es.close();
setErrorMessage(eventData.message);
setState('error');
});
es.onerror = () => {
es.close();
setErrorMessage(t('connectionLost'));
setState('error');
};
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : 'Deployment failed');
setState('error');
}
}, [t]);
const handleReset = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setStackName('');
setDeploymentUrl('');
setProgressData({ progress: 0, message: '' });
setLogs([]);
setErrorMessage('');
setState('form');
}, []);
useEffect(() => {
return () => {
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
};
}, []);
return (
<div className="min-h-screen bg-black relative flex flex-col items-center justify-center font-mono overflow-hidden">
<div className="absolute inset-0 z-0">
<div className="absolute inset-0">
<CanvasRevealEffect
animationSpeed={3}
containerClassName="bg-black"
colors={[[255, 255, 255], [255, 255, 255]]}
dotSize={2}
/>
</div>
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_rgba(0,0,0,1)_0%,_transparent_100%)]" />
<div className="absolute top-0 left-0 right-0 h-1/3 bg-gradient-to-b from-black to-transparent" />
</div>
<LanguageSelector currentLang={lang} onLangChange={setLang} />
<div className="relative z-10 w-full max-w-[640px] p-4 md:p-8">
<header className="text-center mb-12">
<motion.h1
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="text-3xl md:text-4xl font-bold mb-4 text-white tracking-tight"
>
{t('title')}
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="text-gray-400"
>
{t('subtitle')}
</motion.p>
</header>
<main className="w-full">
<AnimatePresence mode="wait">
{state === 'form' && (
<motion.div
key="form"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<DeployForm t={t} onDeploy={handleDeploy} isDeploying={false} />
</motion.div>
)}
{state === 'progress' && (
<motion.div
key="progress"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.3 }}
>
<DeployProgress
t={t}
stackName={stackName}
deploymentUrl={deploymentUrl}
progressData={progressData}
logs={logs}
/>
</motion.div>
)}
{state === 'success' && (
<motion.div
key="success"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
<DeploySuccess
t={t}
stackName={stackName}
deploymentUrl={deploymentUrl}
onDeployAnother={handleReset}
/>
</motion.div>
)}
{state === 'error' && (
<motion.div
key="error"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
>
<DeployError t={t} errorMessage={errorMessage} onTryAgain={handleReset} />
</motion.div>
)}
</AnimatePresence>
</main>
<motion.footer
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5, delay: 0.5 }}
className="text-center mt-12 pt-8 text-sm text-gray-600 border-t border-white/5"
>
<p>
{t('poweredBy')}{' '}
<a
href="https://flexinit.nl"
target="_blank"
rel="noopener noreferrer"
className="text-white/60 font-semibold hover:text-white hover:underline transition-colors"
>
FLEXINIT
</a>{' '}
FlexAI Assistant
</p>
</motion.footer>
</div>
</div>
);
}

1
client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />