feat: initialize reactjs project using vite

This commit is contained in:
Melvin Ragusa
2026-01-21 22:38:10 +01:00
parent 95ca6f57e7
commit eccc359782
52 changed files with 9556 additions and 116 deletions

View File

@@ -1,33 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Page Not Found</title>
<style media="screen">
body { background: #ECEFF1; color: rgba(0,0,0,0.87); font-family: Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; }
#message { background: white; max-width: 360px; margin: 100px auto 16px; padding: 32px 24px 16px; border-radius: 3px; }
#message h3 { color: #888; font-weight: normal; font-size: 16px; margin: 16px 0 12px; }
#message h2 { color: #ffa100; font-weight: bold; font-size: 16px; margin: 0 0 8px; }
#message h1 { font-size: 22px; font-weight: 300; color: rgba(0,0,0,0.6); margin: 0 0 16px;}
#message p { line-height: 140%; margin: 16px 0 24px; font-size: 14px; }
#message a { display: block; text-align: center; background: #039be5; text-transform: uppercase; text-decoration: none; color: white; padding: 16px; border-radius: 4px; }
#message, #message a { box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); }
#load { color: rgba(0,0,0,0.4); text-align: center; font-size: 13px; }
@media (max-width: 600px) {
body, #message { margin-top: 0; background: white; box-shadow: none; }
body { border-top: 16px solid #ffa100; }
}
</style>
</head>
<body>
<div id="message">
<h2>404</h2>
<h1>Page Not Found</h1>
<p>The specified file was not found on this website. Please check the URL for mistakes and try again.</p>
<h3>Why am I seeing this?</h3>
<p>This page was generated by the Firebase Command-Line Interface. To modify it, edit the <code>404.html</code> file in your project's configured <code>public</code> directory.</p>
</div>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

5
build/favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#0F1410"/>
<text x="50" y="62" font-family="system-ui, sans-serif" font-size="36" font-weight="700" text-anchor="middle" fill="#E1E3DF">R</text>
<text x="68" y="62" font-family="system-ui, sans-serif" font-size="36" font-weight="700" text-anchor="middle" fill="#7FD998">IT</text>
</svg>

After

Width:  |  Height:  |  Size: 401 B

View File

@@ -1,89 +1,21 @@
<!DOCTYPE html>
<html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Welcome to Firebase Hosting</title>
<!-- update the version number as needed -->
<script defer src="/__/firebase/12.8.0/firebase-app-compat.js"></script>
<!-- include only the Firebase features as you need -->
<script defer src="/__/firebase/12.8.0/firebase-auth-compat.js"></script>
<script defer src="/__/firebase/12.8.0/firebase-database-compat.js"></script>
<script defer src="/__/firebase/12.8.0/firebase-firestore-compat.js"></script>
<script defer src="/__/firebase/12.8.0/firebase-functions-compat.js"></script>
<script defer src="/__/firebase/12.8.0/firebase-messaging-compat.js"></script>
<script defer src="/__/firebase/12.8.0/firebase-storage-compat.js"></script>
<script defer src="/__/firebase/12.8.0/firebase-analytics-compat.js"></script>
<script defer src="/__/firebase/12.8.0/firebase-remote-config-compat.js"></script>
<script defer src="/__/firebase/12.8.0/firebase-performance-compat.js"></script>
<!--
initialize the SDK after all desired features are loaded, set useEmulator to false
to avoid connecting the SDK to running emulators.
-->
<script defer src="/__/firebase/init.js?useEmulator=true"></script>
<style media="screen">
body { background: #ECEFF1; color: rgba(0,0,0,0.87); font-family: Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; }
#message { background: white; max-width: 360px; margin: 100px auto 16px; padding: 32px 24px; border-radius: 3px; }
#message h2 { color: #ffa100; font-weight: bold; font-size: 16px; margin: 0 0 8px; }
#message h1 { font-size: 22px; font-weight: 300; color: rgba(0,0,0,0.6); margin: 0 0 16px;}
#message p { line-height: 140%; margin: 16px 0 24px; font-size: 14px; }
#message a { display: block; text-align: center; background: #039be5; text-transform: uppercase; text-decoration: none; color: white; padding: 16px; border-radius: 4px; }
#message, #message a { box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); }
#load { color: rgba(0,0,0,0.4); text-align: center; font-size: 13px; }
@media (max-width: 600px) {
body, #message { margin-top: 0; background: white; box-shadow: none; }
body { border-top: 16px solid #ffa100; }
}
</style>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Ragusa IT-Consulting - Professionelle Webentwicklung, IT-Beratung und technischer Support" />
<meta name="theme-color" content="#0F1410" />
<title>Ragusa IT-Consulting | Webentwicklung & IT-Beratung</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-BdWk3V4v.js"></script>
<link rel="modulepreload" crossorigin href="/assets/three-CMo9PyBJ.js">
<link rel="modulepreload" crossorigin href="/assets/motion-BIOHP8Ul.js">
<link rel="stylesheet" crossorigin href="/assets/index-DBnSnrbo.css">
</head>
<body>
<div id="message">
<h2>Welcome</h2>
<h1>Firebase Hosting Setup Complete</h1>
<p>You're seeing this because you've successfully setup Firebase Hosting. Now it's time to go build something extraordinary!</p>
<a target="_blank" href="https://firebase.google.com/docs/hosting/">Open Hosting Documentation</a>
</div>
<p id="load">Firebase SDK Loading&hellip;</p>
<script>
document.addEventListener('DOMContentLoaded', function() {
const loadEl = document.querySelector('#load');
// // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
// // The Firebase SDK is initialized and available here!
//
// firebase.auth().onAuthStateChanged(user => { });
// firebase.database().ref('/path/to/ref').on('value', snapshot => { });
// firebase.firestore().doc('/foo/bar').get().then(() => { });
// firebase.functions().httpsCallable('yourFunction')().then(() => { });
// firebase.messaging().requestPermission().then(() => { });
// firebase.storage().ref('/path/to/ref').getDownloadURL().then(() => { });
// firebase.analytics(); // call to activate
// firebase.analytics().logEvent('tutorial_completed');
// firebase.performance(); // call to activate
//
// // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
try {
let app = firebase.app();
let features = [
'auth',
'database',
'firestore',
'functions',
'messaging',
'storage',
'analytics',
'remoteConfig',
'performance',
].filter(feature => typeof app[feature] === 'function');
loadEl.textContent = `Firebase SDK loaded with ${features.join(', ')}`;
} catch (e) {
console.error(e);
loadEl.textContent = 'Error loading the Firebase SDK, check the console.';
}
});
</script>
<div id="root"></div>
</body>
</html>

18
index.html Normal file
View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Ragusa IT-Consulting - Professionelle Webentwicklung, IT-Beratung und technischer Support" />
<meta name="theme-color" content="#0F1410" />
<title>Ragusa IT-Consulting | Webentwicklung & IT-Beratung</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2660
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "ragusaitweb",
"version": "1.0.0",
"description": "Ragusa IT-Consulting - Web Development & IT Support",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"repository": {
"type": "git",
"url": "git+https://github.com/ragusa-it/ragusaitweb.git"
},
"author": "Ragusa IT-Consulting",
"license": "ISC",
"dependencies": {
"@emailjs/browser": "^4.4.1",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0",
"motion": "^12.28.1",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-router-dom": "^7.12.0",
"three": "^0.182.0"
},
"devDependencies": {
"@types/react": "^19.2.9",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.182.0",
"@vitejs/plugin-react": "^5.1.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}

5
public/favicon.svg Normal file
View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<rect width="100" height="100" rx="20" fill="#0F1410"/>
<text x="50" y="62" font-family="system-ui, sans-serif" font-size="36" font-weight="700" text-anchor="middle" fill="#E1E3DF">R</text>
<text x="68" y="62" font-family="system-ui, sans-serif" font-size="36" font-weight="700" text-anchor="middle" fill="#7FD998">IT</text>
</svg>

After

Width:  |  Height:  |  Size: 401 B

22
src/App.tsx Normal file
View File

@@ -0,0 +1,22 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { LanguageProvider } from './i18n';
import { Navbar, Footer, CustomCursor } from './components/layout';
import { Home, About, Contact } from './pages';
import './styles/global.css';
export function App() {
return (
<LanguageProvider>
<BrowserRouter>
<CustomCursor />
<Navbar />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
</Routes>
<Footer />
</BrowserRouter>
</LanguageProvider>
);
}

View File

@@ -0,0 +1,20 @@
.container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
}
.container canvas {
touch-action: none;
}
/* Fade out on smaller screens */
@media (max-width: 768px) {
.container {
opacity: 0.5;
}
}

View File

@@ -0,0 +1,96 @@
import { useRef, useMemo } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { Float, MeshDistortMaterial } from '@react-three/drei';
import * as THREE from 'three';
import styles from './Scene3D.module.css';
function FloatingShape() {
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
if (meshRef.current) {
meshRef.current.rotation.x = state.clock.elapsedTime * 0.1;
meshRef.current.rotation.y = state.clock.elapsedTime * 0.15;
}
});
return (
<Float
speed={2}
rotationIntensity={0.5}
floatIntensity={1}
>
<mesh ref={meshRef} scale={2.5}>
<icosahedronGeometry args={[1, 1]} />
<MeshDistortMaterial
color="#7FD998"
emissive="#004D2A"
emissiveIntensity={0.3}
roughness={0.4}
metalness={0.8}
distort={0.3}
speed={2}
/>
</mesh>
</Float>
);
}
function ParticleField() {
const count = 100;
const positions = useMemo(() => {
const pos = new Float32Array(count * 3);
for (let i = 0; i < count; i++) {
pos[i * 3] = (Math.random() - 0.5) * 20;
pos[i * 3 + 1] = (Math.random() - 0.5) * 20;
pos[i * 3 + 2] = (Math.random() - 0.5) * 20;
}
return pos;
}, []);
const pointsRef = useRef<THREE.Points>(null);
useFrame((state) => {
if (pointsRef.current) {
pointsRef.current.rotation.y = state.clock.elapsedTime * 0.02;
}
});
return (
<points ref={pointsRef}>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
args={[positions, 3]}
/>
</bufferGeometry>
<pointsMaterial
size={0.05}
color="#7FD998"
transparent
opacity={0.6}
sizeAttenuation
/>
</points>
);
}
export function Scene3D() {
return (
<div className={styles.container}>
<Canvas
camera={{ position: [0, 0, 8], fov: 45 }}
dpr={[1, 2]}
gl={{ antialias: true, alpha: true }}
>
<ambientLight intensity={0.5} />
<directionalLight position={[10, 10, 5]} intensity={1} />
<pointLight position={[-10, -10, -5]} intensity={0.5} color="#7FD998" />
<FloatingShape />
<ParticleField />
</Canvas>
</div>
);
}

View File

@@ -0,0 +1 @@
export { Scene3D } from './Scene3D';

View File

@@ -0,0 +1,67 @@
.cursor {
position: fixed;
width: 40px;
height: 40px;
border: 2px solid var(--md-sys-color-primary);
border-radius: 50%;
pointer-events: none;
z-index: 9999;
transform: translate(-50%, -50%);
transition:
width 0.15s ease-out,
height 0.15s ease-out,
border-color 0.15s ease-out,
background-color 0.15s ease-out,
opacity 0.15s ease-out;
mix-blend-mode: difference;
}
.cursorDot {
position: fixed;
width: 6px;
height: 6px;
background-color: var(--md-sys-color-primary);
border-radius: 50%;
pointer-events: none;
z-index: 10000;
transform: translate(-50%, -50%);
transition: opacity 0.15s ease-out;
}
.cursor.pointer {
width: 60px;
height: 60px;
border-color: var(--md-sys-color-primary);
background-color: rgba(127, 217, 152, 0.1);
}
.cursor.clicking {
width: 35px;
height: 35px;
background-color: rgba(127, 217, 152, 0.2);
}
.cursor.hidden,
.cursorDot.hidden {
opacity: 0;
}
/* Hide on touch devices and when reduced motion is preferred */
@media (hover: none), (prefers-reduced-motion: reduce) {
.cursor,
.cursorDot {
display: none;
}
}
/* Add global cursor hide for devices with custom cursor */
@media (hover: hover) {
:global(body) {
cursor: none;
}
:global(a),
:global(button) {
cursor: none;
}
}

View File

@@ -0,0 +1,74 @@
import { useState, useEffect, useCallback } from 'react';
import styles from './CustomCursor.module.css';
export function CustomCursor() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const [isPointer, setIsPointer] = useState(false);
const [isHidden, setIsHidden] = useState(true);
const [isClicking, setIsClicking] = useState(false);
const handleMouseMove = useCallback((e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
setIsHidden(false);
const target = e.target as HTMLElement;
const isClickable =
target.tagName === 'A' ||
target.tagName === 'BUTTON' ||
!!target.closest('a') ||
!!target.closest('button') ||
window.getComputedStyle(target).cursor === 'pointer';
setIsPointer(isClickable);
}, []);
const handleMouseDown = useCallback(() => setIsClicking(true), []);
const handleMouseUp = useCallback(() => setIsClicking(false), []);
const handleMouseLeave = useCallback(() => setIsHidden(true), []);
const handleMouseEnter = useCallback(() => setIsHidden(false), []);
useEffect(() => {
// Don't show custom cursor on touch devices
if (window.matchMedia('(hover: none)').matches) {
return;
}
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('mouseleave', handleMouseLeave);
document.addEventListener('mouseenter', handleMouseEnter);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mousedown', handleMouseDown);
document.removeEventListener('mouseup', handleMouseUp);
document.removeEventListener('mouseleave', handleMouseLeave);
document.removeEventListener('mouseenter', handleMouseEnter);
};
}, [handleMouseMove, handleMouseDown, handleMouseUp, handleMouseLeave, handleMouseEnter]);
// Don't render on touch devices
if (typeof window !== 'undefined' && window.matchMedia('(hover: none)').matches) {
return null;
}
return (
<>
<div
className={`${styles.cursor} ${isPointer ? styles.pointer : ''} ${isHidden ? styles.hidden : ''} ${isClicking ? styles.clicking : ''}`}
style={{
left: position.x,
top: position.y,
}}
/>
<div
className={`${styles.cursorDot} ${isHidden ? styles.hidden : ''}`}
style={{
left: position.x,
top: position.y,
}}
/>
</>
);
}

View File

@@ -0,0 +1,88 @@
.footer {
margin-top: auto;
padding: var(--space-2xl) 0;
background-color: var(--md-sys-color-surface-container-lowest);
border-top: 1px solid var(--md-sys-color-outline-variant);
}
.content {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
}
.brand {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.logo {
display: flex;
align-items: center;
gap: 0.25em;
font-size: 1.25rem;
font-weight: 700;
}
.logoText {
color: var(--md-sys-color-on-surface);
}
.logoAccent {
color: var(--md-sys-color-primary);
}
.copyright {
font-size: 0.875rem;
color: var(--md-sys-color-on-surface);
opacity: 0.6;
}
.links {
display: flex;
align-items: center;
gap: var(--space-md);
}
.socialLink {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
color: var(--md-sys-color-on-surface);
background-color: var(--md-sys-color-surface-container);
border-radius: var(--radius-full);
transition: color var(--transition-fast), background-color var(--transition-fast), transform var(--transition-fast);
}
.socialLink:hover {
color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-surface-container-high);
transform: translateY(-2px);
}
.credit {
font-size: 0.875rem;
color: var(--md-sys-color-on-surface);
opacity: 0.6;
}
.heart {
color: var(--md-sys-color-primary);
font-weight: 500;
}
@media (max-width: 768px) {
.content {
flex-direction: column;
text-align: center;
}
.brand {
align-items: center;
}
}

View File

@@ -0,0 +1,52 @@
import { useTranslation } from '../../i18n';
import styles from './Footer.module.css';
export function Footer() {
const { t } = useTranslation();
const currentYear = new Date().getFullYear();
return (
<footer className={styles.footer}>
<div className={`${styles.content} container`}>
<div className={styles.brand}>
<span className={styles.logo}>
<span className={styles.logoText}>Ragusa</span>
<span className={styles.logoAccent}>IT</span>
</span>
<p className={styles.copyright}>
{t.footer.copyright.replace('{year}', String(currentYear))}
</p>
</div>
<div className={styles.links}>
<a
href="https://github.com/ragusa-it"
target="_blank"
rel="noopener noreferrer"
className={styles.socialLink}
aria-label="GitHub"
>
<svg
viewBox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
aria-hidden="true"
>
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
</a>
</div>
<div className={styles.credit}>
<p>
{t.footer.madeWith}{' '}
<span className={styles.heart}>React</span>{' '}
{t.footer.and}{' '}
<span className={styles.heart}>TypeScript</span>
</p>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,204 @@
.header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
padding: var(--space-md) 0;
transition: background-color var(--transition-normal), backdrop-filter var(--transition-normal);
}
.header.scrolled {
background-color: rgba(15, 20, 16, 0.85);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
}
.nav {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-lg);
}
.logo {
display: flex;
align-items: center;
gap: 0.25em;
font-size: 1.5rem;
font-weight: 700;
text-decoration: none;
color: var(--md-sys-color-on-surface);
}
.logoText {
color: var(--md-sys-color-on-surface);
}
.logoAccent {
color: var(--md-sys-color-primary);
}
.navLinks {
display: flex;
align-items: center;
gap: var(--space-xl);
}
.navLink {
position: relative;
padding: var(--space-sm) var(--space-md);
font-size: 0.95rem;
font-weight: 500;
color: var(--md-sys-color-on-surface);
text-decoration: none;
opacity: 0.8;
transition: opacity var(--transition-fast), color var(--transition-fast);
}
.navLink:hover {
opacity: 1;
color: var(--md-sys-color-primary);
}
.navLink.active {
opacity: 1;
color: var(--md-sys-color-primary);
}
.activeIndicator {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--md-sys-color-primary);
}
.actions {
display: flex;
align-items: center;
gap: var(--space-md);
}
.langToggle {
display: flex;
align-items: center;
gap: 0.25em;
padding: var(--space-sm) var(--space-md);
font-size: 0.85rem;
font-weight: 600;
color: var(--md-sys-color-on-surface);
background: transparent;
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--radius-full);
cursor: pointer;
transition: border-color var(--transition-fast), background-color var(--transition-fast);
}
.langToggle:hover {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-surface-container);
}
.langDivider {
opacity: 0.5;
}
.activeLang {
color: var(--md-sys-color-primary);
}
.mobileMenuBtn {
display: none;
width: 40px;
height: 40px;
padding: 0;
background: transparent;
border: none;
cursor: pointer;
}
.hamburger {
position: relative;
display: block;
width: 24px;
height: 2px;
margin: 0 auto;
background-color: var(--md-sys-color-on-surface);
border-radius: 2px;
transition: background-color var(--transition-fast);
}
.hamburger::before,
.hamburger::after {
content: '';
position: absolute;
left: 0;
width: 24px;
height: 2px;
background-color: var(--md-sys-color-on-surface);
border-radius: 2px;
transition: transform var(--transition-normal);
}
.hamburger::before {
top: -7px;
}
.hamburger::after {
bottom: -7px;
}
.hamburger.open {
background-color: transparent;
}
.hamburger.open::before {
transform: translateY(7px) rotate(45deg);
}
.hamburger.open::after {
transform: translateY(-7px) rotate(-45deg);
}
/* Mobile styles */
@media (max-width: 768px) {
.navLinks {
position: fixed;
top: 70px;
left: 0;
right: 0;
flex-direction: column;
gap: 0;
padding: var(--space-lg);
background-color: rgba(15, 20, 16, 0.98);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--md-sys-color-outline-variant);
transform: translateY(-100%);
opacity: 0;
visibility: hidden;
transition: transform var(--transition-normal), opacity var(--transition-normal), visibility var(--transition-normal);
}
.navLinks.open {
transform: translateY(0);
opacity: 1;
visibility: visible;
}
.navLink {
width: 100%;
padding: var(--space-md);
text-align: center;
font-size: 1.1rem;
}
.mobileMenuBtn {
display: flex;
align-items: center;
justify-content: center;
}
}

View File

@@ -0,0 +1,90 @@
import { useState, useEffect } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { motion } from 'motion/react';
import { useTranslation } from '../../i18n';
import styles from './Navbar.module.css';
export function Navbar() {
const { t, language, setLanguage } = useTranslation();
const location = useLocation();
const [isScrolled, setIsScrolled] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
useEffect(() => {
const handleScroll = () => {
setIsScrolled(window.scrollY > 20);
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
setIsMobileMenuOpen(false);
}, [location.pathname]);
const navLinks = [
{ path: '/', label: t.nav.home },
{ path: '/about', label: t.nav.about },
{ path: '/contact', label: t.nav.contact },
];
const toggleLanguage = () => {
setLanguage(language === 'de' ? 'en' : 'de');
};
return (
<motion.header
className={`${styles.header} ${isScrolled ? styles.scrolled : ''}`}
initial={{ y: -100 }}
animate={{ y: 0 }}
transition={{ duration: 0.5, ease: 'easeOut' }}
>
<nav className={`${styles.nav} container`}>
<Link to="/" className={styles.logo}>
<span className={styles.logoText}>Ragusa</span>
<span className={styles.logoAccent}>IT</span>
</Link>
<div className={`${styles.navLinks} ${isMobileMenuOpen ? styles.open : ''}`}>
{navLinks.map((link) => (
<Link
key={link.path}
to={link.path}
className={`${styles.navLink} ${location.pathname === link.path ? styles.active : ''}`}
>
{link.label}
{location.pathname === link.path && (
<motion.div
className={styles.activeIndicator}
layoutId="activeNav"
transition={{ type: 'spring', stiffness: 380, damping: 30 }}
/>
)}
</Link>
))}
</div>
<div className={styles.actions}>
<button
onClick={toggleLanguage}
className={styles.langToggle}
aria-label={`Switch to ${language === 'de' ? 'English' : 'German'}`}
>
<span className={language === 'de' ? styles.activeLang : ''}>DE</span>
<span className={styles.langDivider}>/</span>
<span className={language === 'en' ? styles.activeLang : ''}>EN</span>
</button>
<button
className={styles.mobileMenuBtn}
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
aria-label="Toggle menu"
aria-expanded={isMobileMenuOpen}
>
<span className={`${styles.hamburger} ${isMobileMenuOpen ? styles.open : ''}`} />
</button>
</div>
</nav>
</motion.header>
);
}

View File

@@ -0,0 +1,3 @@
export { Navbar } from './Navbar';
export { Footer } from './Footer';
export { CustomCursor } from './CustomCursor';

View File

@@ -0,0 +1,146 @@
.hero {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: var(--space-3xl) 0;
overflow: hidden;
}
.content {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
}
.text {
max-width: 800px;
text-align: center;
}
.greeting {
font-size: 1.125rem;
font-weight: 500;
color: var(--md-sys-color-primary);
margin-bottom: var(--space-md);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.title {
font-size: clamp(2.5rem, 6vw, 5rem);
font-weight: 700;
line-height: 1.1;
margin-bottom: var(--space-lg);
background: linear-gradient(
135deg,
var(--md-sys-color-on-surface) 0%,
var(--md-sys-color-primary) 50%,
var(--md-sys-color-on-surface) 100%
);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: shimmer 3s ease-in-out infinite;
}
@keyframes shimmer {
0%, 100% {
background-position: 0% center;
}
50% {
background-position: 100% center;
}
}
.tagline {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 0.5em;
font-size: clamp(1.25rem, 3vw, 2rem);
color: var(--md-sys-color-on-surface);
opacity: 0.9;
margin-bottom: var(--space-2xl);
}
.typed {
color: var(--md-sys-color-primary);
font-weight: 600;
min-width: 200px;
text-align: left;
}
.cursor {
display: inline-block;
margin-left: 2px;
animation: blink 1s step-end infinite;
}
@keyframes blink {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.cta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--space-md);
}
.scrollIndicator {
position: absolute;
bottom: var(--space-2xl);
left: 50%;
transform: translateX(-50%);
z-index: 1;
}
.scrollMouse {
width: 26px;
height: 42px;
border: 2px solid var(--md-sys-color-outline);
border-radius: 13px;
display: flex;
justify-content: center;
padding-top: 8px;
}
.scrollWheel {
width: 4px;
height: 8px;
background-color: var(--md-sys-color-primary);
border-radius: 2px;
}
@media (max-width: 768px) {
.typed {
min-width: auto;
display: block;
width: 100%;
text-align: center;
}
.cta {
flex-direction: column;
}
.cta a {
width: 100%;
}
.cta button {
width: 100%;
}
}

View File

@@ -0,0 +1,92 @@
import { Link } from 'react-router-dom';
import { motion } from 'motion/react';
import { useTranslation } from '../../i18n';
import { useTypingEffect } from '../../hooks';
import { Scene3D } from '../effects';
import { Button } from '../ui';
import styles from './Hero.module.css';
export function Hero() {
const { t } = useTranslation();
const { text } = useTypingEffect({
words: t.hero.rotatingWords,
typingSpeed: 80,
deletingSpeed: 40,
pauseDuration: 2500,
});
return (
<section className={styles.hero}>
<Scene3D />
<div className={`${styles.content} container`}>
<motion.div
className={styles.text}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: 'easeOut' }}
>
<motion.p
className={styles.greeting}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
{t.hero.greeting}
</motion.p>
<motion.h1
className={styles.title}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.5 }}
>
{t.hero.company}
</motion.h1>
<motion.div
className={styles.tagline}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.5 }}
>
<span>{t.hero.tagline}</span>
<span className={styles.typed}>
{text}
<span className={styles.cursor}>|</span>
</span>
</motion.div>
<motion.div
className={styles.cta}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7, duration: 0.5 }}
>
<Link to="/contact">
<Button variant="primary" size="lg">
{t.hero.cta}
</Button>
</Link>
<Link to="/about">
<Button variant="outline" size="lg">
{t.hero.ctaSecondary}
</Button>
</Link>
</motion.div>
</motion.div>
</div>
<div className={styles.scrollIndicator}>
<motion.div
className={styles.scrollMouse}
animate={{ y: [0, 8, 0] }}
transition={{ repeat: Infinity, duration: 1.5, ease: 'easeInOut' }}
>
<span className={styles.scrollWheel} />
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,36 @@
.services {
padding: var(--space-3xl) 0;
background-color: var(--md-sys-color-surface-container-lowest);
}
.header {
text-align: center;
margin-bottom: var(--space-3xl);
}
.title {
font-size: clamp(2rem, 4vw, 3rem);
font-weight: 700;
margin-bottom: var(--space-md);
color: var(--md-sys-color-on-surface);
}
.subtitle {
font-size: 1.125rem;
color: var(--md-sys-color-on-surface);
opacity: 0.75;
max-width: 600px;
margin: 0 auto;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: var(--space-xl);
}
@media (min-width: 1024px) {
.grid {
grid-template-columns: repeat(4, 1fr);
}
}

View File

@@ -0,0 +1,95 @@
import { motion, type Variants } from 'motion/react';
import { useTranslation } from '../../i18n';
import { Card } from '../ui';
import styles from './Services.module.css';
const icons = {
code: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="16 18 22 12 16 6" />
<polyline points="8 6 2 12 8 18" />
</svg>
),
support: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 1-9 9m9-9a9 9 0 0 0-9-9m9 9H3m9 9a9 9 0 0 1-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 0 1 9-9" />
</svg>
),
consulting: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
),
hosting: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2" />
<rect x="2" y="14" width="20" height="8" rx="2" ry="2" />
<line x1="6" y1="6" x2="6.01" y2="6" />
<line x1="6" y1="18" x2="6.01" y2="18" />
</svg>
),
};
const containerVariants: Variants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.15,
},
},
};
const itemVariants: Variants = {
hidden: { opacity: 0, y: 30 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.5,
ease: [0.4, 0, 0.2, 1],
},
},
};
export function Services() {
const { t } = useTranslation();
return (
<section className={styles.services}>
<div className="container">
<motion.div
className={styles.header}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: '-100px' }}
transition={{ duration: 0.5 }}
>
<h2 className={styles.title}>{t.services.title}</h2>
<p className={styles.subtitle}>{t.services.subtitle}</p>
</motion.div>
<motion.div
className={styles.grid}
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true, margin: '-50px' }}
>
{t.services.items.map((service, index) => (
<motion.div key={index} variants={itemVariants}>
<Card>
<Card.Icon>
{icons[service.icon as keyof typeof icons]}
</Card.Icon>
<Card.Title>{service.title}</Card.Title>
<Card.Description>{service.description}</Card.Description>
</Card>
</motion.div>
))}
</motion.div>
</div>
</section>
);
}

View File

@@ -0,0 +1,2 @@
export { Hero } from './Hero';
export { Services } from './Services';

View File

@@ -0,0 +1,82 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
font-family: var(--md-sys-typescale-body-font);
font-weight: 600;
border: none;
border-radius: var(--radius-full);
cursor: pointer;
transition:
background-color var(--transition-fast),
color var(--transition-fast),
box-shadow var(--transition-fast);
}
.button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Sizes */
.sm {
padding: var(--space-sm) var(--space-md);
font-size: 0.875rem;
}
.md {
padding: var(--space-md) var(--space-xl);
font-size: 1rem;
}
.lg {
padding: var(--space-lg) var(--space-2xl);
font-size: 1.125rem;
}
/* Variants */
.primary {
background-color: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
.primary:hover:not(:disabled) {
background-color: var(--md-sys-color-on-primary-container);
box-shadow: 0 4px 20px rgba(127, 217, 152, 0.3);
}
.secondary {
background-color: var(--md-sys-color-surface-container-high);
color: var(--md-sys-color-on-surface);
}
.secondary:hover:not(:disabled) {
background-color: var(--md-sys-color-surface-container-highest);
}
.outline {
background-color: transparent;
color: var(--md-sys-color-primary);
border: 2px solid var(--md-sys-color-primary);
}
.outline:hover:not(:disabled) {
background-color: rgba(127, 217, 152, 0.1);
}
/* Loader */
.loader {
width: 20px;
height: 20px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View File

@@ -0,0 +1,42 @@
import { type ReactNode } from 'react';
import { motion } from 'motion/react';
import styles from './Button.module.css';
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'outline';
size?: 'sm' | 'md' | 'lg';
children: ReactNode;
isLoading?: boolean;
disabled?: boolean;
className?: string;
type?: 'button' | 'submit' | 'reset';
onClick?: () => void;
}
export function Button({
variant = 'primary',
size = 'md',
children,
isLoading,
disabled,
className,
type = 'button',
onClick,
}: ButtonProps) {
return (
<motion.button
type={type}
className={`${styles.button} ${styles[variant]} ${styles[size]} ${className || ''}`}
disabled={disabled || isLoading}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
onClick={onClick}
>
{isLoading ? (
<span className={styles.loader} />
) : (
children
)}
</motion.button>
);
}

View File

@@ -0,0 +1,51 @@
.card {
position: relative;
padding: var(--space-xl);
background-color: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--radius-lg);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
background-color var(--transition-fast);
}
.card.hoverable:hover {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-surface-container-high);
box-shadow:
0 8px 32px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(127, 217, 152, 0.1),
inset 0 1px 0 rgba(127, 217, 152, 0.1);
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: var(--space-lg);
background-color: var(--md-sys-color-primary-container);
border-radius: var(--radius-md);
color: var(--md-sys-color-on-primary-container);
}
.icon svg {
width: 28px;
height: 28px;
}
.title {
margin-bottom: var(--space-sm);
font-size: 1.25rem;
font-weight: 600;
color: var(--md-sys-color-on-surface);
}
.description {
font-size: 0.95rem;
line-height: 1.6;
color: var(--md-sys-color-on-surface);
opacity: 0.75;
}

View File

@@ -0,0 +1,49 @@
import { type ReactNode } from 'react';
import { motion } from 'motion/react';
import styles from './Card.module.css';
interface CardProps {
children: ReactNode;
className?: string;
hover?: boolean;
}
export function Card({ children, className, hover = true }: CardProps) {
return (
<motion.div
className={`${styles.card} ${hover ? styles.hoverable : ''} ${className || ''}`}
whileHover={hover ? { y: -4 } : undefined}
transition={{ duration: 0.2 }}
>
{children}
</motion.div>
);
}
interface CardIconProps {
children: ReactNode;
}
export function CardIcon({ children }: CardIconProps) {
return <div className={styles.icon}>{children}</div>;
}
interface CardTitleProps {
children: ReactNode;
}
export function CardTitle({ children }: CardTitleProps) {
return <h3 className={styles.title}>{children}</h3>;
}
interface CardDescriptionProps {
children: ReactNode;
}
export function CardDescription({ children }: CardDescriptionProps) {
return <p className={styles.description}>{children}</p>;
}
Card.Icon = CardIcon;
Card.Title = CardTitle;
Card.Description = CardDescription;

View File

@@ -0,0 +1,58 @@
.field {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.label {
font-size: 0.875rem;
font-weight: 500;
color: var(--md-sys-color-on-surface);
}
.input {
padding: var(--space-md);
font-family: var(--md-sys-typescale-body-font);
font-size: 1rem;
color: var(--md-sys-color-on-surface);
background-color: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--radius-md);
transition:
border-color var(--transition-fast),
box-shadow var(--transition-fast),
background-color var(--transition-fast);
}
.input::placeholder {
color: var(--md-sys-color-outline);
}
.input:hover {
border-color: var(--md-sys-color-outline);
background-color: var(--md-sys-color-surface-container-high);
}
.input:focus {
outline: none;
border-color: var(--md-sys-color-primary);
box-shadow: 0 0 0 3px rgba(127, 217, 152, 0.15);
}
.textarea {
min-height: 150px;
resize: vertical;
}
.hasError .input {
border-color: var(--md-sys-color-error);
}
.hasError .input:focus {
box-shadow: 0 0 0 3px rgba(255, 180, 171, 0.15);
}
.error {
font-size: 0.8125rem;
color: var(--md-sys-color-error);
}

View File

@@ -0,0 +1,58 @@
import { type InputHTMLAttributes, type TextareaHTMLAttributes, forwardRef } from 'react';
import styles from './Input.module.css';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, id, className, ...props }, ref) => {
const inputId = id || label.toLowerCase().replace(/\s+/g, '-');
return (
<div className={`${styles.field} ${error ? styles.hasError : ''} ${className || ''}`}>
<label htmlFor={inputId} className={styles.label}>
{label}
</label>
<input
ref={ref}
id={inputId}
className={styles.input}
{...props}
/>
{error && <span className={styles.error}>{error}</span>}
</div>
);
}
);
Input.displayName = 'Input';
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label: string;
error?: string;
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ label, error, id, className, ...props }, ref) => {
const inputId = id || label.toLowerCase().replace(/\s+/g, '-');
return (
<div className={`${styles.field} ${error ? styles.hasError : ''} ${className || ''}`}>
<label htmlFor={inputId} className={styles.label}>
{label}
</label>
<textarea
ref={ref}
id={inputId}
className={`${styles.input} ${styles.textarea}`}
{...props}
/>
{error && <span className={styles.error}>{error}</span>}
</div>
);
}
);
Textarea.displayName = 'Textarea';

View File

@@ -0,0 +1,3 @@
export { Button } from './Button';
export { Card } from './Card';
export { Input, Textarea } from './Input';

1
src/hooks/index.ts Normal file
View File

@@ -0,0 +1 @@
export { useTypingEffect } from './useTypingEffect';

View File

@@ -0,0 +1,60 @@
import { useState, useEffect, useCallback } from 'react';
interface UseTypingEffectOptions {
words: string[];
typingSpeed?: number;
deletingSpeed?: number;
pauseDuration?: number;
}
export function useTypingEffect({
words,
typingSpeed = 100,
deletingSpeed = 50,
pauseDuration = 2000,
}: UseTypingEffectOptions) {
const [currentWordIndex, setCurrentWordIndex] = useState(0);
const [currentText, setCurrentText] = useState('');
const [isDeleting, setIsDeleting] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const tick = useCallback(() => {
const currentWord = words[currentWordIndex];
if (isPaused) {
return;
}
if (isDeleting) {
setCurrentText(currentWord.substring(0, currentText.length - 1));
if (currentText.length === 0) {
setIsDeleting(false);
setCurrentWordIndex((prev) => (prev + 1) % words.length);
}
} else {
setCurrentText(currentWord.substring(0, currentText.length + 1));
if (currentText === currentWord) {
setIsPaused(true);
setTimeout(() => {
setIsPaused(false);
setIsDeleting(true);
}, pauseDuration);
}
}
}, [currentText, currentWordIndex, isDeleting, isPaused, words, pauseDuration]);
useEffect(() => {
const speed = isDeleting ? deletingSpeed : typingSpeed;
const timer = setTimeout(tick, speed);
return () => clearTimeout(timer);
}, [tick, isDeleting, typingSpeed, deletingSpeed]);
return {
text: currentText,
isTyping: !isDeleting && !isPaused,
isDeleting,
currentWordIndex,
};
}

115
src/i18n/de.ts Normal file
View File

@@ -0,0 +1,115 @@
export const de = {
// Navigation
nav: {
home: 'Startseite',
about: 'Über uns',
contact: 'Kontakt',
},
// Hero Section
hero: {
greeting: 'Willkommen bei',
company: 'Ragusa IT-Consulting',
tagline: 'Ihr Partner für',
rotatingWords: ['Webentwicklung', 'IT-Support', 'Digitale Lösungen', 'Tech-Beratung'],
cta: 'Projekt starten',
ctaSecondary: 'Mehr erfahren',
},
// Services
services: {
title: 'Unsere Leistungen',
subtitle: 'Professionelle IT-Lösungen für Ihr Unternehmen',
items: [
{
title: 'Webentwicklung',
description: 'Moderne, responsive Websites und Web-Applikationen mit React, TypeScript und aktuellen Technologien.',
icon: 'code',
},
{
title: 'Tech-Support',
description: 'Schnelle und zuverlässige technische Unterstützung für Hardware und Software vor Ort.',
icon: 'support',
},
{
title: 'IT-Beratung',
description: 'Strategische Beratung für Ihre IT-Infrastruktur und digitale Transformation.',
icon: 'consulting',
},
{
title: 'Hosting & Wartung',
description: 'Zuverlässiges Webhosting, regelmäßige Updates und proaktive Wartung Ihrer Systeme.',
icon: 'hosting',
},
],
},
// About
about: {
title: 'Über uns',
subtitle: 'Ihr lokaler IT-Partner',
intro: 'Ragusa IT-Consulting bietet professionelle IT-Dienstleistungen mit persönlichem Service. Wir kombinieren technische Expertise mit einem tiefen Verständnis für die Bedürfnisse unserer Kunden.',
experience: 'Mit jahrelanger Erfahrung in der Webentwicklung und IT-Beratung unterstützen wir Unternehmen und Privatpersonen dabei, ihre technischen Herausforderungen zu meistern.',
skills: {
title: 'Technologien',
subtitle: 'Moderne Tools für moderne Lösungen',
},
values: {
title: 'Warum Ragusa IT?',
items: [
{
title: 'Persönlicher Service',
description: 'Direkter Ansprechpartner für alle Ihre IT-Anliegen.',
},
{
title: 'Lokale Präsenz',
description: 'Vor-Ort-Support und persönliche Beratungsgespräche.',
},
{
title: 'Faire Preise',
description: 'Transparente Preisgestaltung ohne versteckte Kosten.',
},
{
title: 'Schnelle Reaktion',
description: 'Kurze Reaktionszeiten bei Anfragen und Problemen.',
},
],
},
},
// Contact
contact: {
title: 'Kontakt',
subtitle: 'Lassen Sie uns zusammenarbeiten',
intro: 'Haben Sie ein Projekt im Sinn oder benötigen Sie technische Unterstützung? Ich freue mich auf Ihre Nachricht!',
form: {
name: 'Name',
namePlaceholder: 'Ihr Name',
email: 'E-Mail',
emailPlaceholder: 'ihre@email.de',
subject: 'Betreff',
subjectPlaceholder: 'Worum geht es?',
message: 'Nachricht',
messagePlaceholder: 'Ihre Nachricht...',
submit: 'Nachricht senden',
sending: 'Wird gesendet...',
success: 'Nachricht erfolgreich gesendet! Ich melde mich bald bei Ihnen.',
error: 'Fehler beim Senden. Bitte versuchen Sie es erneut oder kontaktieren Sie mich direkt.',
},
info: {
title: 'Kontaktdaten',
email: 'E-Mail',
location: 'Standort',
github: 'GitHub',
},
},
// Footer
footer: {
copyright: '© {year} Ragusa IT-Consulting. Alle Rechte vorbehalten.',
madeWith: 'Entwickelt mit',
and: 'und',
},
};
export type Translations = typeof de;

115
src/i18n/en.ts Normal file
View File

@@ -0,0 +1,115 @@
import type { Translations } from './de';
export const en: Translations = {
// Navigation
nav: {
home: 'Home',
about: 'About',
contact: 'Contact',
},
// Hero Section
hero: {
greeting: 'Welcome to',
company: 'Ragusa IT-Consulting',
tagline: 'Your partner for',
rotatingWords: ['Web Development', 'IT Support', 'Digital Solutions', 'Tech Consulting'],
cta: 'Start Project',
ctaSecondary: 'Learn More',
},
// Services
services: {
title: 'Our Services',
subtitle: 'Professional IT solutions for your business',
items: [
{
title: 'Web Development',
description: 'Modern, responsive websites and web applications built with React, TypeScript, and cutting-edge technologies.',
icon: 'code',
},
{
title: 'Tech Support',
description: 'Fast and reliable technical support for hardware and software, available on-site.',
icon: 'support',
},
{
title: 'IT Consulting',
description: 'Strategic consulting for your IT infrastructure and digital transformation.',
icon: 'consulting',
},
{
title: 'Hosting & Maintenance',
description: 'Reliable web hosting, regular updates, and proactive maintenance of your systems.',
icon: 'hosting',
},
],
},
// About
about: {
title: 'About Us',
subtitle: 'Your local IT partner',
intro: 'Ragusa IT-Consulting provides professional IT services with a personal touch. We combine technical expertise with a deep understanding of our clients\' needs.',
experience: 'With years of experience in web development and IT consulting, we help businesses and individuals overcome their technical challenges.',
skills: {
title: 'Technologies',
subtitle: 'Modern tools for modern solutions',
},
values: {
title: 'Why Ragusa IT?',
items: [
{
title: 'Personal Service',
description: 'Direct point of contact for all your IT needs.',
},
{
title: 'Local Presence',
description: 'On-site support and in-person consultations.',
},
{
title: 'Fair Pricing',
description: 'Transparent pricing with no hidden costs.',
},
{
title: 'Quick Response',
description: 'Short response times for inquiries and issues.',
},
],
},
},
// Contact
contact: {
title: 'Contact',
subtitle: 'Let\'s work together',
intro: 'Have a project in mind or need technical support? I look forward to hearing from you!',
form: {
name: 'Name',
namePlaceholder: 'Your name',
email: 'Email',
emailPlaceholder: 'your@email.com',
subject: 'Subject',
subjectPlaceholder: 'What is it about?',
message: 'Message',
messagePlaceholder: 'Your message...',
submit: 'Send Message',
sending: 'Sending...',
success: 'Message sent successfully! I\'ll get back to you soon.',
error: 'Error sending message. Please try again or contact me directly.',
},
info: {
title: 'Contact Info',
email: 'Email',
location: 'Location',
github: 'GitHub',
},
},
// Footer
footer: {
copyright: '© {year} Ragusa IT-Consulting. All rights reserved.',
madeWith: 'Built with',
and: 'and',
},
};

71
src/i18n/index.tsx Normal file
View File

@@ -0,0 +1,71 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { de, type Translations } from './de';
import { en } from './en';
type Language = 'de' | 'en';
interface LanguageContextType {
language: Language;
setLanguage: (lang: Language) => void;
t: Translations;
}
const translations: Record<Language, Translations> = { de, en };
const LanguageContext = createContext<LanguageContextType | undefined>(undefined);
const STORAGE_KEY = 'ragusa-it-lang';
function getInitialLanguage(): Language {
if (typeof window === 'undefined') return 'de';
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'de' || stored === 'en') return stored;
const browserLang = navigator.language.split('-')[0];
return browserLang === 'en' ? 'en' : 'de';
}
export function LanguageProvider({ children }: { children: ReactNode }) {
const [language, setLanguageState] = useState<Language>('de');
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
setLanguageState(getInitialLanguage());
setIsInitialized(true);
}, []);
const setLanguage = (lang: Language) => {
setLanguageState(lang);
localStorage.setItem(STORAGE_KEY, lang);
document.documentElement.lang = lang;
};
useEffect(() => {
if (isInitialized) {
document.documentElement.lang = language;
}
}, [language, isInitialized]);
const value: LanguageContextType = {
language,
setLanguage,
t: translations[language],
};
return (
<LanguageContext.Provider value={value}>
{children}
</LanguageContext.Provider>
);
}
export function useTranslation() {
const context = useContext(LanguageContext);
if (!context) {
throw new Error('useTranslation must be used within a LanguageProvider');
}
return context;
}
export type { Language, Translations };

9
src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);

144
src/pages/About.module.css Normal file
View File

@@ -0,0 +1,144 @@
.about {
padding-top: 80px;
}
/* Hero */
.hero {
padding: var(--space-3xl) 0;
text-align: center;
background: linear-gradient(
180deg,
var(--md-sys-color-surface-container-lowest) 0%,
var(--md-sys-color-background) 100%
);
}
.title {
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 700;
margin-bottom: var(--space-md);
}
.subtitle {
font-size: 1.25rem;
color: var(--md-sys-color-primary);
font-weight: 500;
}
/* Intro */
.intro {
padding: var(--space-3xl) 0;
}
.introContent {
max-width: 800px;
margin: 0 auto;
}
.introText {
font-size: 1.125rem;
line-height: 1.8;
margin-bottom: var(--space-lg);
color: var(--md-sys-color-on-surface);
opacity: 0.9;
}
/* Section Header */
.sectionHeader {
text-align: center;
margin-bottom: var(--space-2xl);
}
.sectionHeader h2 {
font-size: clamp(1.75rem, 3vw, 2.5rem);
margin-bottom: var(--space-sm);
}
.sectionHeader p {
font-size: 1rem;
opacity: 0.75;
}
/* Skills */
.skills {
padding: var(--space-3xl) 0;
background-color: var(--md-sys-color-surface-container-lowest);
}
.techGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: var(--space-md);
max-width: 800px;
margin: 0 auto;
}
.techCard {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-sm);
padding: var(--space-lg);
background-color: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--radius-lg);
transition: border-color var(--transition-fast), background-color var(--transition-fast);
}
.techCard:hover {
border-color: var(--md-sys-color-primary);
background-color: var(--md-sys-color-surface-container-high);
}
.techIcon {
font-size: 2rem;
}
.techName {
font-size: 0.875rem;
font-weight: 500;
color: var(--md-sys-color-on-surface);
}
/* Values */
.values {
padding: var(--space-3xl) 0;
}
.valuesGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: var(--space-xl);
}
.valueCard {
padding: var(--space-xl);
background-color: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--radius-lg);
transition: border-color var(--transition-fast);
}
.valueCard:hover {
border-color: var(--md-sys-color-primary);
}
.valueNumber {
font-size: 2rem;
font-weight: 700;
color: var(--md-sys-color-primary);
opacity: 0.5;
margin-bottom: var(--space-sm);
}
.valueTitle {
font-size: 1.25rem;
font-weight: 600;
margin-bottom: var(--space-sm);
}
.valueDescription {
font-size: 0.95rem;
opacity: 0.75;
line-height: 1.6;
}

140
src/pages/About.tsx Normal file
View File

@@ -0,0 +1,140 @@
import { motion } from 'motion/react';
import { useTranslation } from '../i18n';
import styles from './About.module.css';
const techStack = [
{ name: 'React', icon: '⚛️' },
{ name: 'TypeScript', icon: '📘' },
{ name: 'Node.js', icon: '🟢' },
{ name: 'Python', icon: '🐍' },
{ name: 'Firebase', icon: '🔥' },
{ name: 'PostgreSQL', icon: '🐘' },
{ name: 'Docker', icon: '🐳' },
{ name: 'Git', icon: '📦' },
];
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.1 },
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4 },
},
};
export function About() {
const { t } = useTranslation();
return (
<main className={styles.about}>
{/* Hero Section */}
<section className={styles.hero}>
<div className="container">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<h1 className={styles.title}>{t.about.title}</h1>
<p className={styles.subtitle}>{t.about.subtitle}</p>
</motion.div>
</div>
</section>
{/* Intro Section */}
<section className={styles.intro}>
<div className="container">
<motion.div
className={styles.introContent}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<p className={styles.introText}>{t.about.intro}</p>
<p className={styles.introText}>{t.about.experience}</p>
</motion.div>
</div>
</section>
{/* Skills Section */}
<section className={styles.skills}>
<div className="container">
<motion.div
className={styles.sectionHeader}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<h2>{t.about.skills.title}</h2>
<p>{t.about.skills.subtitle}</p>
</motion.div>
<motion.div
className={styles.techGrid}
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
{techStack.map((tech) => (
<motion.div
key={tech.name}
className={styles.techCard}
variants={itemVariants}
whileHover={{ scale: 1.05, y: -4 }}
>
<span className={styles.techIcon}>{tech.icon}</span>
<span className={styles.techName}>{tech.name}</span>
</motion.div>
))}
</motion.div>
</div>
</section>
{/* Values Section */}
<section className={styles.values}>
<div className="container">
<motion.div
className={styles.sectionHeader}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.5 }}
>
<h2>{t.about.values.title}</h2>
</motion.div>
<motion.div
className={styles.valuesGrid}
variants={containerVariants}
initial="hidden"
whileInView="visible"
viewport={{ once: true }}
>
{t.about.values.items.map((value, index) => (
<motion.div
key={index}
className={styles.valueCard}
variants={itemVariants}
>
<div className={styles.valueNumber}>0{index + 1}</div>
<h3 className={styles.valueTitle}>{value.title}</h3>
<p className={styles.valueDescription}>{value.description}</p>
</motion.div>
))}
</motion.div>
</div>
</section>
</main>
);
}

View File

@@ -0,0 +1,141 @@
.contact {
padding-top: 80px;
}
/* Hero */
.hero {
padding: var(--space-3xl) 0;
text-align: center;
background: linear-gradient(
180deg,
var(--md-sys-color-surface-container-lowest) 0%,
var(--md-sys-color-background) 100%
);
}
.title {
font-size: clamp(2.5rem, 5vw, 4rem);
font-weight: 700;
margin-bottom: var(--space-md);
}
.subtitle {
font-size: 1.25rem;
color: var(--md-sys-color-primary);
font-weight: 500;
}
/* Content */
.content {
padding: var(--space-3xl) 0;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: var(--space-3xl);
}
@media (min-width: 768px) {
.grid {
grid-template-columns: 1.5fr 1fr;
}
}
/* Form Section */
.formSection {
max-width: 600px;
}
.intro {
font-size: 1.125rem;
line-height: 1.7;
margin-bottom: var(--space-2xl);
opacity: 0.9;
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.success {
padding: var(--space-md);
background-color: rgba(127, 217, 152, 0.1);
border: 1px solid var(--md-sys-color-primary);
border-radius: var(--radius-md);
color: var(--md-sys-color-primary);
font-size: 0.95rem;
}
.error {
padding: var(--space-md);
background-color: rgba(255, 180, 171, 0.1);
border: 1px solid var(--md-sys-color-error);
border-radius: var(--radius-md);
color: var(--md-sys-color-error);
font-size: 0.95rem;
}
/* Info Section */
.infoSection {
padding: var(--space-xl);
background-color: var(--md-sys-color-surface-container);
border: 1px solid var(--md-sys-color-outline-variant);
border-radius: var(--radius-lg);
height: fit-content;
}
.infoTitle {
font-size: 1.5rem;
font-weight: 600;
margin-bottom: var(--space-xl);
}
.infoList {
display: flex;
flex-direction: column;
gap: var(--space-xl);
}
.infoItem {
display: flex;
align-items: flex-start;
gap: var(--space-md);
}
.infoIcon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
background-color: var(--md-sys-color-primary-container);
border-radius: var(--radius-md);
color: var(--md-sys-color-on-primary-container);
flex-shrink: 0;
}
.infoIcon svg {
width: 22px;
height: 22px;
}
.infoItem h3 {
font-size: 0.875rem;
font-weight: 500;
color: var(--md-sys-color-on-surface);
opacity: 0.7;
margin-bottom: var(--space-xs);
}
.infoItem a {
font-size: 1rem;
color: var(--md-sys-color-primary);
word-break: break-all;
}
.infoItem a:hover {
text-decoration: underline;
}

244
src/pages/Contact.tsx Normal file
View File

@@ -0,0 +1,244 @@
import { useState, type FormEvent } from 'react';
import { motion } from 'motion/react';
import emailjs from '@emailjs/browser';
import { useTranslation } from '../i18n';
import { Button, Input, Textarea } from '../components/ui';
import styles from './Contact.module.css';
// EmailJS configuration - replace these with your actual IDs
const EMAILJS_SERVICE_ID = 'YOUR_SERVICE_ID';
const EMAILJS_TEMPLATE_ID = 'YOUR_TEMPLATE_ID';
const EMAILJS_PUBLIC_KEY = 'YOUR_PUBLIC_KEY';
interface FormData {
name: string;
email: string;
subject: string;
message: string;
}
interface FormErrors {
name?: string;
email?: string;
subject?: string;
message?: string;
}
export function Contact() {
const { t } = useTranslation();
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
subject: '',
message: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<'idle' | 'success' | 'error'>('idle');
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Required';
}
if (!formData.email.trim()) {
newErrors.email = 'Required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Invalid email';
}
if (!formData.subject.trim()) {
newErrors.subject = 'Required';
}
if (!formData.message.trim()) {
newErrors.message = 'Required';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!validateForm()) return;
setIsSubmitting(true);
setSubmitStatus('idle');
try {
await emailjs.send(
EMAILJS_SERVICE_ID,
EMAILJS_TEMPLATE_ID,
{
from_name: formData.name,
from_email: formData.email,
subject: formData.subject,
message: formData.message,
},
EMAILJS_PUBLIC_KEY
);
setSubmitStatus('success');
setFormData({ name: '', email: '', subject: '', message: '' });
} catch {
setSubmitStatus('error');
} finally {
setIsSubmitting(false);
}
};
const handleChange = (field: keyof FormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors((prev) => ({ ...prev, [field]: undefined }));
}
};
return (
<main className={styles.contact}>
{/* Hero */}
<section className={styles.hero}>
<div className="container">
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<h1 className={styles.title}>{t.contact.title}</h1>
<p className={styles.subtitle}>{t.contact.subtitle}</p>
</motion.div>
</div>
</section>
{/* Content */}
<section className={styles.content}>
<div className="container">
<div className={styles.grid}>
{/* Form */}
<motion.div
className={styles.formSection}
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<p className={styles.intro}>{t.contact.intro}</p>
<form onSubmit={handleSubmit} className={styles.form}>
<Input
label={t.contact.form.name}
placeholder={t.contact.form.namePlaceholder}
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
error={errors.name}
/>
<Input
label={t.contact.form.email}
type="email"
placeholder={t.contact.form.emailPlaceholder}
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
error={errors.email}
/>
<Input
label={t.contact.form.subject}
placeholder={t.contact.form.subjectPlaceholder}
value={formData.subject}
onChange={(e) => handleChange('subject', e.target.value)}
error={errors.subject}
/>
<Textarea
label={t.contact.form.message}
placeholder={t.contact.form.messagePlaceholder}
value={formData.message}
onChange={(e) => handleChange('message', e.target.value)}
error={errors.message}
rows={6}
/>
<Button
type="submit"
variant="primary"
size="lg"
isLoading={isSubmitting}
disabled={isSubmitting}
>
{isSubmitting ? t.contact.form.sending : t.contact.form.submit}
</Button>
{submitStatus === 'success' && (
<motion.p
className={styles.success}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
{t.contact.form.success}
</motion.p>
)}
{submitStatus === 'error' && (
<motion.p
className={styles.error}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
>
{t.contact.form.error}
</motion.p>
)}
</form>
</motion.div>
{/* Info */}
<motion.div
className={styles.infoSection}
initial={{ opacity: 0, x: 30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<h2 className={styles.infoTitle}>{t.contact.info.title}</h2>
<div className={styles.infoList}>
<div className={styles.infoItem}>
<div className={styles.infoIcon}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
<polyline points="22,6 12,13 2,6" />
</svg>
</div>
<div>
<h3>{t.contact.info.email}</h3>
<a href="mailto:info@ragusa-it.dev">info@ragusa-it.dev</a>
</div>
</div>
<div className={styles.infoItem}>
<div className={styles.infoIcon}>
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
</div>
<div>
<h3>{t.contact.info.github}</h3>
<a
href="https://github.com/ragusa-it"
target="_blank"
rel="noopener noreferrer"
>
github.com/ragusa-it
</a>
</div>
</div>
</div>
</motion.div>
</div>
</div>
</section>
</main>
);
}

10
src/pages/Home.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { Hero, Services } from '../components/sections';
export function Home() {
return (
<main>
<Hero />
<Services />
</main>
);
}

3
src/pages/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { Home } from './Home';
export { About } from './About';
export { Contact } from './Contact';

140
src/styles/global.css Normal file
View File

@@ -0,0 +1,140 @@
@import './theme.css';
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
scroll-behavior: smooth;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
font-family: var(--md-sys-typescale-body-font);
background-color: var(--md-sys-color-background);
color: var(--md-sys-color-on-background);
line-height: 1.6;
min-height: 100vh;
overflow-x: hidden;
}
#root {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Typography */
h1, h2, h3, h4, h5, h6 {
font-family: var(--md-sys-typescale-display-font);
font-weight: 600;
line-height: 1.2;
color: var(--md-sys-color-on-surface);
}
h1 { font-size: clamp(2.5rem, 5vw, 4rem); }
h2 { font-size: clamp(2rem, 4vw, 3rem); }
h3 { font-size: clamp(1.5rem, 3vw, 2rem); }
h4 { font-size: clamp(1.25rem, 2vw, 1.5rem); }
p {
color: var(--md-sys-color-on-surface);
opacity: 0.87;
}
a {
color: var(--md-sys-color-primary);
text-decoration: none;
transition: color var(--transition-fast);
}
a:hover {
color: var(--md-sys-color-on-primary-container);
}
code {
font-family: var(--md-sys-typescale-code-font);
background: var(--md-sys-color-surface-container);
padding: 0.2em 0.4em;
border-radius: var(--radius-sm);
font-size: 0.9em;
}
/* Focus styles */
:focus-visible {
outline: 2px solid var(--md-sys-color-primary);
outline-offset: 2px;
}
/* Selection */
::selection {
background: var(--md-sys-color-primary);
color: var(--md-sys-color-on-primary);
}
/* Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--md-sys-color-surface-container);
}
::-webkit-scrollbar-thumb {
background: var(--md-sys-color-outline);
border-radius: var(--radius-full);
}
::-webkit-scrollbar-thumb:hover {
background: var(--md-sys-color-outline-variant);
}
/* Container utility */
.container {
width: 100%;
max-width: var(--container-xl);
margin: 0 auto;
padding: 0 var(--space-lg);
}
/* Section spacing */
section {
padding: var(--space-3xl) 0;
}
/* Smooth page transitions */
.page-enter {
opacity: 0;
transform: translateY(20px);
}
.page-enter-active {
opacity: 1;
transform: translateY(0);
transition: opacity 300ms ease-out, transform 300ms ease-out;
}
/* Hide custom cursor on touch devices */
@media (hover: none) and (pointer: coarse) {
.custom-cursor {
display: none !important;
}
}
/* Reduced motion */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
html {
scroll-behavior: auto;
}
}

86
src/styles/theme.css Normal file
View File

@@ -0,0 +1,86 @@
/* Material 3 Expressive Dark Theme - Green Tonal Palette */
:root {
/* Primary Green Tonal */
--md-sys-color-primary: #7FD998;
--md-sys-color-on-primary: #003919;
--md-sys-color-primary-container: #005227;
--md-sys-color-on-primary-container: #9AF6B2;
/* Secondary - Muted Green */
--md-sys-color-secondary: #B4CCB9;
--md-sys-color-on-secondary: #202D24;
--md-sys-color-secondary-container: #364339;
--md-sys-color-on-secondary-container: #D0E8D4;
/* Tertiary - Accent (subtle red from logo) */
--md-sys-color-tertiary: #F2B8B5;
--md-sys-color-on-tertiary: #4C2524;
--md-sys-color-tertiary-container: #6B3A39;
--md-sys-color-on-tertiary-container: #FFDAD7;
/* Error */
--md-sys-color-error: #FFB4AB;
--md-sys-color-on-error: #690005;
--md-sys-color-error-container: #93000A;
--md-sys-color-on-error-container: #FFDAD6;
/* Background & Surface - Dark with green undertone */
--md-sys-color-background: #0F1410;
--md-sys-color-on-background: #E1E3DF;
--md-sys-color-surface: #0F1410;
--md-sys-color-on-surface: #E1E3DF;
/* Surface Variants */
--md-sys-color-surface-dim: #0F1410;
--md-sys-color-surface-bright: #353A36;
--md-sys-color-surface-container-lowest: #0A0F0B;
--md-sys-color-surface-container-low: #171D18;
--md-sys-color-surface-container: #1B211C;
--md-sys-color-surface-container-high: #252B26;
--md-sys-color-surface-container-highest: #303631;
/* Outline */
--md-sys-color-outline: #8B9389;
--md-sys-color-outline-variant: #414942;
/* Inverse */
--md-sys-color-inverse-surface: #E1E3DF;
--md-sys-color-inverse-on-surface: #2E332F;
--md-sys-color-inverse-primary: #006D36;
/* Shadows & Elevation */
--md-sys-color-shadow: #000000;
--md-sys-color-scrim: #000000;
/* Typography */
--md-sys-typescale-display-font: 'Inter', sans-serif;
--md-sys-typescale-body-font: 'Inter', sans-serif;
--md-sys-typescale-code-font: 'JetBrains Mono', monospace;
/* Spacing */
--space-xs: 0.25rem;
--space-sm: 0.5rem;
--space-md: 1rem;
--space-lg: 1.5rem;
--space-xl: 2rem;
--space-2xl: 3rem;
--space-3xl: 4rem;
/* Border Radius - M3 Expressive uses larger radii */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-normal: 250ms cubic-bezier(0.4, 0, 0.2, 1);
--transition-slow: 400ms cubic-bezier(0.4, 0, 0.2, 1);
/* Container widths */
--container-sm: 640px;
--container-md: 768px;
--container-lg: 1024px;
--container-xl: 1280px;
}

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

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.module.css' {
const classes: { [key: string]: string };
export default classes;
}

23
tsconfig.json Normal file
View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

11
tsconfig.node.json Normal file
View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}

22
vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'build',
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks: {
'three': ['three', '@react-three/fiber', '@react-three/drei'],
'motion': ['motion'],
},
},
},
},
server: {
port: 3000,
open: true
}
})