feat: Rebuild application UI with new Header, Footer, HeroModel, and ProductGrid components, integrating smooth scrolling and GSAP animations.
This commit is contained in:
118
src/App.jsx
118
src/App.jsx
@@ -1,35 +1,95 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Header from './components/Header';
|
||||
import HeroModel from './canvas/HeroModel';
|
||||
import ProductGrid from './components/ProductGrid';
|
||||
import Footer from './components/Footer';
|
||||
import './styles/index.css';
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import Lenis from '@studio-freight/lenis'
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const lenisRef = useRef()
|
||||
|
||||
useEffect(() => {
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
direction: 'vertical',
|
||||
gestureDirection: 'vertical',
|
||||
smooth: true,
|
||||
mouseMultiplier: 1,
|
||||
smoothTouch: false,
|
||||
touchMultiplier: 2,
|
||||
})
|
||||
|
||||
lenisRef.current = lenis
|
||||
|
||||
function raf(time) {
|
||||
lenis.raf(time)
|
||||
requestAnimationFrame(raf)
|
||||
}
|
||||
|
||||
requestAnimationFrame(raf)
|
||||
|
||||
return () => {
|
||||
lenis.destroy()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.jsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
<div className="App">
|
||||
<Header />
|
||||
|
||||
<main>
|
||||
{/* Hero Section */}
|
||||
<section style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
paddingTop: '60px'
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '20%',
|
||||
left: '10%',
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none'
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: '8rem',
|
||||
fontWeight: '900',
|
||||
lineHeight: '0.8',
|
||||
color: '#000',
|
||||
letterSpacing: '-0.05em'
|
||||
}}>
|
||||
OP-1 <br /> FIELD
|
||||
</h1>
|
||||
<p style={{
|
||||
marginTop: '20px',
|
||||
fontSize: '1.2rem',
|
||||
maxWidth: '300px',
|
||||
fontFamily: 'monospace'
|
||||
}}>
|
||||
The portable synthesizer. Refined, reshaped, reborn.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<HeroModel />
|
||||
</section>
|
||||
|
||||
{/* Product Grid Section */}
|
||||
<ProductGrid />
|
||||
|
||||
<Footer />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App
|
||||
export default App;
|
||||
|
||||
81
src/canvas/HeroModel.jsx
Normal file
81
src/canvas/HeroModel.jsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useRef, useState, Suspense } from 'react';
|
||||
import { Canvas, useFrame } from '@react-three/fiber';
|
||||
import { OrthographicCamera, Environment, ContactShadows, Float } from '@react-three/drei';
|
||||
import * as THREE from 'three';
|
||||
|
||||
const Device = (props) => {
|
||||
const mesh = useRef();
|
||||
|
||||
// Rotate based on mouse position (simplified for now, logic can be in global state or event)
|
||||
useFrame((state) => {
|
||||
if (mesh.current) {
|
||||
// Gentle idle rotation
|
||||
mesh.current.rotation.y += 0.002;
|
||||
|
||||
// Mouse interaction (lerp to mouse x/y)
|
||||
const t = state.clock.getElapsedTime();
|
||||
mesh.current.rotation.x = THREE.MathUtils.lerp(mesh.current.rotation.x, Math.cos(t / 2) / 8 + state.mouse.y / 4, 0.1);
|
||||
mesh.current.rotation.z = THREE.MathUtils.lerp(mesh.current.rotation.z, Math.sin(t / 4) / 8, 0.1);
|
||||
mesh.current.rotation.y = THREE.MathUtils.lerp(mesh.current.rotation.y, state.mouse.x / 4, 0.1);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group {...props} dispose={null}>
|
||||
<Float speed={2} rotationIntensity={0.5} floatIntensity={0.5}>
|
||||
{/* Main Body - Industrial Grey Box */}
|
||||
<mesh ref={mesh}>
|
||||
<boxGeometry args={[2, 3, 0.5]} />
|
||||
<meshStandardMaterial color="#d4d4d4" roughness={0.4} metalness={0.1} />
|
||||
|
||||
{/* Screen / Detail */}
|
||||
<mesh position={[0, 0.8, 0.26]}>
|
||||
<planeGeometry args={[1.5, 0.8]} />
|
||||
<meshBasicMaterial color="#111" />
|
||||
</mesh>
|
||||
|
||||
{/* Orange Dial -- Teenage Engineering signature */}
|
||||
<mesh position={[0.5, -0.5, 0.3]} rotation={[1.57, 0, 0]}>
|
||||
<cylinderGeometry args={[0.2, 0.2, 0.2, 32]} />
|
||||
<meshStandardMaterial color="#ff4d00" />
|
||||
</mesh>
|
||||
|
||||
{/* Buttons */}
|
||||
<mesh position={[-0.5, -0.5, 0.3]} rotation={[1.57, 0, 0]}>
|
||||
<cylinderGeometry args={[0.15, 0.15, 0.1, 32]} />
|
||||
<meshStandardMaterial color="#333" />
|
||||
</mesh>
|
||||
<mesh position={[-0.5, -0.9, 0.3]} rotation={[1.57, 0, 0]}>
|
||||
<cylinderGeometry args={[0.15, 0.15, 0.1, 32]} />
|
||||
<meshStandardMaterial color="#333" />
|
||||
</mesh>
|
||||
</mesh>
|
||||
</Float>
|
||||
</group>
|
||||
);
|
||||
};
|
||||
|
||||
const HeroModel = () => {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '80vh', position: 'relative', marginTop: 0, zIndex: 0 }}>
|
||||
<Canvas shadows dpr={[1, 2]}>
|
||||
<Suspense fallback={null}>
|
||||
{/* Orthographic Camera for schematic look */}
|
||||
<OrthographicCamera makeDefault position={[0, 0, 10]} zoom={150} />
|
||||
|
||||
<ambientLight intensity={1.5} />
|
||||
<pointLight position={[10, 10, 10]} intensity={1} />
|
||||
<spotLight position={[-10, 15, 10]} angle={0.3} penumbra={1} castShadow intensity={2} shadow-bias={-0.0001} />
|
||||
|
||||
<Device position={[0, 0, 0]} />
|
||||
|
||||
{/* Environment for reflections */}
|
||||
<Environment preset="city" />
|
||||
<ContactShadows resolution={1024} scale={10} blur={2} opacity={0.25} far={10} color="#000" />
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeroModel;
|
||||
97
src/components/Footer.jsx
Normal file
97
src/components/Footer.jsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
|
||||
const Footer = () => {
|
||||
return (
|
||||
<footer style={{
|
||||
background: '#0a0a0a',
|
||||
color: '#e4e4e4',
|
||||
padding: '80px 20px',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
<div style={{
|
||||
maxWidth: '1400px',
|
||||
margin: '0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: '40px'
|
||||
}}>
|
||||
<div>
|
||||
<h4 className="uppercase" style={{ marginBottom: '20px', color: '#666' }}>Products</h4>
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<li><a href="#">OP-1 Field</a></li>
|
||||
<li><a href="#">TX-6</a></li>
|
||||
<li><a href="#">OP-Z</a></li>
|
||||
<li><a href="#">Pocket Operators</a></li>
|
||||
<li><a href="#">OD-11</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="uppercase" style={{ marginBottom: '20px', color: '#666' }}>Support</h4>
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<li><a href="#">Downloads</a></li>
|
||||
<li><a href="#">Guides</a></li>
|
||||
<li><a href="#">Warranty</a></li>
|
||||
<li><a href="#">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="uppercase" style={{ marginBottom: '20px', color: '#666' }}>Company</h4>
|
||||
<ul style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
<li><a href="#">About</a></li>
|
||||
<li><a href="#">Press</a></li>
|
||||
<li><a href="#">Careers</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div style={{ minWidth: '300px' }}>
|
||||
<h4 className="uppercase" style={{ marginBottom: '20px', color: '#ff4d00' }}>Newsletter</h4>
|
||||
<p style={{ marginBottom: '20px', color: '#888' }}>
|
||||
Sign up for the latest news, product releases, and exclusive offers.
|
||||
</p>
|
||||
<div style={{ display: 'flex', borderBottom: '1px solid #333' }}>
|
||||
<input
|
||||
type="email"
|
||||
placeholder="email address"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
padding: '10px 0',
|
||||
flex: 1,
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
<button style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#ff4d00',
|
||||
cursor: 'pointer',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '0.8rem'
|
||||
}}>
|
||||
Subscribe
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
maxWidth: '1400px',
|
||||
margin: '80px auto 0',
|
||||
paddingTop: '40px',
|
||||
borderTop: '1px solid #222',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
color: '#444'
|
||||
}}>
|
||||
<div className="uppercase" style={{ fontSize: '1.2rem', fontWeight: 'bold' }}>Teenage Engineering</div>
|
||||
<div className="mono" style={{ fontSize: '0.8rem' }}>© 2024</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
};
|
||||
|
||||
export default Footer;
|
||||
47
src/components/Header.jsx
Normal file
47
src/components/Header.jsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import React from 'react';
|
||||
import '../styles/variables.css';
|
||||
|
||||
const Header = () => {
|
||||
const headerStyle = {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
mixBlendMode: 'difference', // Cool effect against white/black backgrounds
|
||||
color: '#fff',
|
||||
textTransform: 'uppercase',
|
||||
fontSize: '12px',
|
||||
letterSpacing: '0.1em',
|
||||
fontWeight: 600
|
||||
};
|
||||
|
||||
const navStyle = {
|
||||
display: 'flex',
|
||||
gap: '30px'
|
||||
};
|
||||
|
||||
return (
|
||||
<header style={headerStyle}>
|
||||
<div className="logo" style={{ fontSize: '18px', fontWeight: 'bold' }}>
|
||||
te
|
||||
</div>
|
||||
|
||||
<nav style={navStyle}>
|
||||
<a href="#products">Products</a>
|
||||
<a href="#about">Now</a>
|
||||
<a href="#store">Store</a>
|
||||
</nav>
|
||||
|
||||
<div className="cart">
|
||||
Cart (0)
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
153
src/components/ProductGrid.jsx
Normal file
153
src/components/ProductGrid.jsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import gsap from 'gsap';
|
||||
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||
import '../styles/variables.css';
|
||||
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const products = [
|
||||
{ id: 1, name: 'TX-6', desc: 'Portable Mixer', price: '$1199' },
|
||||
{ id: 2, name: 'CM-15', desc: 'Field Microphone', price: '$1199' },
|
||||
{ id: 3, name: 'TP-7', desc: 'Field Recorder', price: '$1499' },
|
||||
{ id: 4, name: 'OB-4', desc: 'Magic Radio', price: '$649' },
|
||||
{ id: 5, name: 'OD-11', desc: 'Cloud Speaker', price: '$999' },
|
||||
{ id: 6, name: 'EP-133', desc: 'K.O. II', price: '$299' },
|
||||
];
|
||||
|
||||
const ProductGrid = () => {
|
||||
const gridRef = useRef(null);
|
||||
const titleRef = useRef(null);
|
||||
const itemRefs = useRef([]);
|
||||
|
||||
useEffect(() => {
|
||||
const ctx = gsap.context(() => {
|
||||
// Animate Title
|
||||
gsap.from(titleRef.current, {
|
||||
scrollTrigger: {
|
||||
trigger: titleRef.current,
|
||||
start: "top 80%",
|
||||
},
|
||||
y: 50,
|
||||
opacity: 0,
|
||||
duration: 1,
|
||||
ease: "power3.out"
|
||||
});
|
||||
|
||||
// Animate Grid Items
|
||||
itemRefs.current.forEach((item, i) => {
|
||||
if (!item) return;
|
||||
gsap.from(item, {
|
||||
scrollTrigger: {
|
||||
trigger: item,
|
||||
start: "top 85%",
|
||||
},
|
||||
y: 100,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
delay: i % 2 * 0.1, // Stagger slightly based on column
|
||||
ease: "power3.out"
|
||||
});
|
||||
});
|
||||
|
||||
}, gridRef);
|
||||
|
||||
return () => ctx.revert();
|
||||
}, []);
|
||||
|
||||
const onEnter = ({ currentTarget }) => {
|
||||
gsap.to(currentTarget, { backgroundColor: '#fff', scale: 0.98, duration: 0.3 });
|
||||
gsap.to(currentTarget.querySelector('.product-img'), { scale: 1.1, duration: 0.3 });
|
||||
gsap.to(currentTarget.querySelector('.indicator'), { backgroundColor: '#ff4d00', scale: 1.5, duration: 0.3 });
|
||||
};
|
||||
|
||||
const onLeave = ({ currentTarget }) => {
|
||||
gsap.to(currentTarget, { backgroundColor: '#f5f5f5', scale: 1, duration: 0.3 });
|
||||
gsap.to(currentTarget.querySelector('.product-img'), { scale: 1, duration: 0.3 });
|
||||
gsap.to(currentTarget.querySelector('.indicator'), { backgroundColor: '#ccc', scale: 1, duration: 0.3 });
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="products" ref={gridRef} style={{
|
||||
padding: '100px 20px',
|
||||
background: '#fff',
|
||||
minHeight: '100vh'
|
||||
}}>
|
||||
<div style={{ maxWidth: '1400px', margin: '0 auto' }}>
|
||||
<div style={{
|
||||
marginBottom: '60px',
|
||||
borderBottom: '1px solid #000',
|
||||
paddingBottom: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'baseline'
|
||||
}} ref={titleRef}>
|
||||
<h2 className="uppercase" style={{ fontSize: '2rem', margin: 0 }}>Field System</h2>
|
||||
<span className="mono" style={{ fontSize: '0.9rem', color: '#666' }}>ENGINEERING / AUDIO</span>
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(350px, 1fr))',
|
||||
gap: '2px', // Tight gap for grid lines effect
|
||||
background: '#ccc', // Color of grid lines
|
||||
border: '1px solid #ccc'
|
||||
}}>
|
||||
{products.map((p, i) => (
|
||||
<div
|
||||
key={p.id}
|
||||
ref={el => itemRefs.current[i] = el}
|
||||
className="product-item"
|
||||
style={{
|
||||
background: '#f5f5f5',
|
||||
height: '450px',
|
||||
padding: '30px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
onMouseEnter={onEnter}
|
||||
onMouseLeave={onLeave}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', zIndex: 2 }}>
|
||||
<span className="mono" style={{ fontSize: '0.8rem', color: '#ff4d00' }}>00{p.id}</span>
|
||||
<div className="indicator" style={{
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
background: '#ccc',
|
||||
borderRadius: '50%'
|
||||
}}></div>
|
||||
</div>
|
||||
|
||||
{/* Placeholder for Product Image */}
|
||||
<div className="product-img" style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '5rem',
|
||||
color: '#e0e0e0',
|
||||
fontWeight: 800,
|
||||
userSelect: 'none'
|
||||
}}>
|
||||
TE
|
||||
</div>
|
||||
|
||||
<div style={{ zIndex: 2 }}>
|
||||
<h3 style={{ fontSize: '1.5rem', marginBottom: '5px' }}>{p.name}</h3>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: '0.9rem', color: '#666' }}>
|
||||
<span>{p.desc}</span>
|
||||
<span>{p.price}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductGrid;
|
||||
13
src/main.jsx
13
src/main.jsx
@@ -1,10 +1,11 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './styles/index.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
</React.StrictMode>,
|
||||
)
|
||||
|
||||
|
||||
42
src/styles/index.css
Normal file
42
src/styles/index.css
Normal file
@@ -0,0 +1,42 @@
|
||||
@import './variables.css';
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-main);
|
||||
font-family: var(--font-main);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
overflow-x: hidden;
|
||||
/* Hide scrollbar potentially */
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
canvas {
|
||||
touch-action: none;
|
||||
}
|
||||
15
src/styles/variables.css
Normal file
15
src/styles/variables.css
Normal file
@@ -0,0 +1,15 @@
|
||||
:root {
|
||||
/* Teenage Engineering Palette */
|
||||
--bg-color: #e4e4e4;
|
||||
--text-main: #000000;
|
||||
--text-dim: #666666;
|
||||
--accent-orange: #ff4d00;
|
||||
--grid-line: #cccccc;
|
||||
|
||||
/* Typos */
|
||||
--font-main: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
--spacing-unit: 8px;
|
||||
|
||||
/* Layout */
|
||||
--header-height: 60px;
|
||||
}
|
||||
Reference in New Issue
Block a user