Add Jack 3D Creator portfolio template (Phase 1) with PRD
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Msa87P1aZQSV9MWhEu3VfJ
This commit is contained in:
23 files changed
+3369
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# PRD: "Jack — 3D Creator" Portfolio Template
|
||||||
|
|
||||||
|
## The restaurant analogy
|
||||||
|
| Stage | Restaurant | This project |
|
||||||
|
|---|---|---|
|
||||||
|
| Phase 1 (today) | Food stand: one great dish, open today | The full single-page site, looks right, animations work |
|
||||||
|
| Phase 2 | Small restaurant: a few tables, a real menu | Make it easy to edit (content in one file), better mobile + accessibility |
|
||||||
|
| Phase 3 | Full restaurant: front of house + back of house | Real contact form, email, project pages, CMS, analytics |
|
||||||
|
|
||||||
|
Front of house = what visitors see. Back of house = forms, email, storage, security.
|
||||||
|
|
||||||
|
## Core features
|
||||||
|
1. Hero: giant "Hi, i'm jack" title, nav, magnetic portrait, Contact button
|
||||||
|
2. Marquee: two rows of GIFs sliding left/right as you scroll
|
||||||
|
3. About: floating 3D icons + words that light up as you scroll
|
||||||
|
4. Services: white panel, 5 numbered services
|
||||||
|
5. Projects: 3 cards that stack and shrink as you scroll
|
||||||
|
6. Reusable parts: ContactButton, LiveProjectButton, FadeIn, Magnet, AnimatedText
|
||||||
|
|
||||||
|
## Phased roadmap
|
||||||
|
**Phase 1 — Food stand (today).** Static site, all 5 sections, all animations, responsive.
|
||||||
|
Done when: builds with no errors; page loads with no console errors; every button/link works; all image URLs load.
|
||||||
|
|
||||||
|
**Phase 2 — Small restaurant.** Nav links smooth-scroll to sections, Contact Me opens a mailto, content moved into editable data files, reduced-motion support, alt text, SEO tags, image fallbacks.
|
||||||
|
|
||||||
|
**Phase 3 — Full restaurant.** Contact form with a backend (server-side validation, spam protection, rate limits), project detail pages, CMS, analytics, hosting.
|
||||||
|
|
||||||
|
## Test ladder (each step must pass before the next)
|
||||||
|
1. Page loads with no errors
|
||||||
|
2. Buttons / links / forms work
|
||||||
|
3. Real external APIs/assets work (images load)
|
||||||
|
4. Security check (no secrets in code, safe external links, dependency audit)
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
Phase 1 has no forms, no logins, no secrets, so risk is low. External links use rel="noopener noreferrer". When Phase 3 adds a form, validation and spam protection must live on the server, and API keys must never be in front-end code.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Jack — a 3D creator crafting striking and unforgettable projects." />
|
||||||
|
<title>Jack -- 3D Creator</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=Kanit:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2744
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "jack-portfolio",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"lucide-react": "^0.344.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.5",
|
||||||
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.5.3",
|
||||||
|
"vite": "^5.4.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default {
|
||||||
|
plugins: { tailwindcss: {}, autoprefixer: {} },
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import HeroSection from './sections/HeroSection';
|
||||||
|
import MarqueeSection from './sections/MarqueeSection';
|
||||||
|
import AboutSection from './sections/AboutSection';
|
||||||
|
import ServicesSection from './sections/ServicesSection';
|
||||||
|
import ProjectsSection from './sections/ProjectsSection';
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<main style={{ background: '#0C0C0C', overflowX: 'clip' }}>
|
||||||
|
<HeroSection />
|
||||||
|
<MarqueeSection />
|
||||||
|
<AboutSection />
|
||||||
|
<ServicesSection />
|
||||||
|
<ProjectsSection />
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||||
|
import type { MotionValue } from 'framer-motion';
|
||||||
|
|
||||||
|
function Char({ char, index, total, progress }: { char: string; index: number; total: number; progress: MotionValue<number> }) {
|
||||||
|
const start = index / total;
|
||||||
|
const end = start + 1 / total;
|
||||||
|
const opacity = useTransform(progress, [start, end], [0.2, 1]);
|
||||||
|
return (
|
||||||
|
<span className="relative">
|
||||||
|
<span className="invisible">{char}</span>
|
||||||
|
<motion.span className="absolute left-0 top-0" style={{ opacity }}>
|
||||||
|
{char}
|
||||||
|
</motion.span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AnimatedText({ text, className, style }: { text: string; className?: string; style?: React.CSSProperties }) {
|
||||||
|
const ref = useRef<HTMLParagraphElement>(null);
|
||||||
|
const { scrollYProgress } = useScroll({ target: ref, offset: ['start 0.8', 'end 0.2'] });
|
||||||
|
const chars = Array.from(text);
|
||||||
|
return (
|
||||||
|
<p ref={ref} className={className} style={style} aria-label={text}>
|
||||||
|
{chars.map((c, i) =>
|
||||||
|
c === ' ' ? ' ' : <Char key={i} char={c} index={i} total={chars.length} progress={scrollYProgress} />
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
export default function ContactButton() {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="mailto:hello@example.com"
|
||||||
|
className="inline-block rounded-full px-8 py-3 text-xs font-medium uppercase tracking-widest text-white sm:px-10 sm:py-3.5 sm:text-sm md:px-12 md:py-4 md:text-base"
|
||||||
|
style={{
|
||||||
|
background: 'linear-gradient(123deg, #18011F 7%, #B600A8 37%, #7621B0 72%, #BE4C00 100%)',
|
||||||
|
boxShadow: '0px 4px 4px rgba(181, 1, 167, 0.25), 4px 4px 12px #7721B1 inset',
|
||||||
|
outline: '2px solid white',
|
||||||
|
outlineOffset: '-3px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Contact Me
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import type { MotionProps } from 'framer-motion';
|
||||||
|
import type { ComponentType, ElementType, ReactNode } from 'react';
|
||||||
|
|
||||||
|
type FadeInProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
delay?: number;
|
||||||
|
duration?: number;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
className?: string;
|
||||||
|
as?: ElementType;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cache = new Map<ElementType, ComponentType<MotionProps & { className?: string }>>();
|
||||||
|
const getMotion = (el: ElementType) => {
|
||||||
|
if (!cache.has(el)) cache.set(el, motion.create(el as 'div') as ComponentType<MotionProps & { className?: string }>);
|
||||||
|
return cache.get(el)!;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FadeIn({
|
||||||
|
children,
|
||||||
|
delay = 0,
|
||||||
|
duration = 0.7,
|
||||||
|
x = 0,
|
||||||
|
y = 30,
|
||||||
|
className,
|
||||||
|
as = 'div',
|
||||||
|
}: FadeInProps) {
|
||||||
|
const Component = getMotion(as);
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
className={className}
|
||||||
|
initial={{ opacity: 0, x, y }}
|
||||||
|
whileInView={{ opacity: 1, x: 0, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: '50px', amount: 0 }}
|
||||||
|
transition={{ duration, delay, ease: [0.25, 0.1, 0.25, 1] }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Component>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export default function LiveProjectButton() {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="rounded-full border-2 border-[#D7E2EA] px-8 py-3 text-sm font-medium uppercase tracking-widest text-[#D7E2EA] transition-colors hover:bg-[#D7E2EA]/10 sm:px-10 sm:py-3.5 sm:text-base"
|
||||||
|
>
|
||||||
|
Live Project
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type MagnetProps = {
|
||||||
|
children: ReactNode;
|
||||||
|
padding?: number;
|
||||||
|
strength?: number;
|
||||||
|
activeTransition?: string;
|
||||||
|
inactiveTransition?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Magnet({
|
||||||
|
children,
|
||||||
|
padding = 100,
|
||||||
|
strength = 2,
|
||||||
|
activeTransition = 'transform 0.3s ease-out',
|
||||||
|
inactiveTransition = 'transform 0.6s ease-in-out',
|
||||||
|
className,
|
||||||
|
}: MagnetProps) {
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
const [pos, setPos] = useState({ x: 0, y: 0 });
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const { left, top, width, height } = ref.current.getBoundingClientRect();
|
||||||
|
const cx = left + width / 2;
|
||||||
|
const cy = top + height / 2;
|
||||||
|
const dx = Math.abs(cx - e.clientX);
|
||||||
|
const dy = Math.abs(cy - e.clientY);
|
||||||
|
if (dx < width / 2 + padding && dy < height / 2 + padding) {
|
||||||
|
setActive(true);
|
||||||
|
setPos({ x: (e.clientX - cx) / strength, y: (e.clientY - cy) / strength });
|
||||||
|
} else {
|
||||||
|
setActive(false);
|
||||||
|
setPos({ x: 0, y: 0 });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
return () => window.removeEventListener('mousemove', onMove);
|
||||||
|
}, [padding, strength]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={className} style={{ position: 'relative', display: 'inline-block' }}>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
transform: `translate3d(${pos.x}px, ${pos.y}px, 0)`,
|
||||||
|
transition: active ? activeTransition : inactiveTransition,
|
||||||
|
willChange: 'transform',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
const FIGMA = 'https://shrug-person-78902957.figma.site/_components/v2';
|
||||||
|
const ABOUT = `${FIGMA}/ebb2b8f25d8e24d5f0a5ca8af4c950de81aa2fd7`;
|
||||||
|
|
||||||
|
export const portrait = `${FIGMA}/d24c01ad3a56fc65e942a1f501eb73db42d7cf9a/Rectangle_40443.81459862.png`;
|
||||||
|
|
||||||
|
export const aboutIcons = {
|
||||||
|
moon: `${ABOUT}/moon_icon.11395d36.png`,
|
||||||
|
object: `${ABOUT}/p59_1.4659672e.png`,
|
||||||
|
lego: `${ABOUT}/lego_icon-1.703bb594.png`,
|
||||||
|
group: `${ABOUT}/Group_134-1.2e04f3ce.png`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const aboutText =
|
||||||
|
"With more than five years of experience in design, i focus on branding, web design, and user experience, i truly enjoy working with businesses that aim to stand out and present their best image. Let's build something incredible together!";
|
||||||
|
|
||||||
|
export const marqueeImages = [
|
||||||
|
'hero-space-voyage-preview-eECLH3Yc',
|
||||||
|
'hero-codenest-preview-Cgppc2qV',
|
||||||
|
'hero-vex-ventures-preview-BczMFIiw',
|
||||||
|
'hero-stellar-ai-v2-preview-DjvxjG3C',
|
||||||
|
'hero-asme-preview-B_nGDnTP',
|
||||||
|
'hero-transform-data-preview-Cx5OU29N',
|
||||||
|
'hero-vitara-preview-Cjz2QYyU',
|
||||||
|
'hero-terra-preview-BFjrCr7T',
|
||||||
|
'hero-skyelite-preview-DHaZIgUv',
|
||||||
|
'hero-aethera-preview-DknSlcTa',
|
||||||
|
'hero-designpro-preview-D8c5_een',
|
||||||
|
'hero-stellar-ai-preview-D3HL6bw1',
|
||||||
|
'hero-xportfolio-preview-D4A8maiC',
|
||||||
|
'hero-orbit-web3-preview-BXt4OttD',
|
||||||
|
'hero-nexora-preview-cx5HmUgo',
|
||||||
|
'hero-evr-ventures-preview-DZxeVFEX',
|
||||||
|
'hero-planet-orbit-preview-DWAP8Z1P',
|
||||||
|
'hero-new-era-preview-CocuDUm9',
|
||||||
|
'hero-wealth-preview-B70idl_u',
|
||||||
|
'hero-luminex-preview-CxOP7ce6',
|
||||||
|
'hero-celestia-preview-0yO3jXO8',
|
||||||
|
].map((n) => `https://motionsites.ai/assets/${n}.gif`);
|
||||||
|
|
||||||
|
export const services = [
|
||||||
|
{ name: '3D Modeling', description: 'Creation of detailed objects, characters, or environments tailored to specific client needs, ideal for games, products, and visualizations.' },
|
||||||
|
{ name: 'Rendering', description: 'High-quality, photorealistic renders that showcase designs with custom lighting, textures, and materials to bring concepts to life.' },
|
||||||
|
{ name: 'Motion Design', description: 'Dynamic animations and motion graphics that add energy and storytelling to brands, products, and digital experiences.' },
|
||||||
|
{ name: 'Branding', description: 'Crafting cohesive visual identities -- from logos to full brand systems -- that communicate a clear and memorable presence.' },
|
||||||
|
{ name: 'Web Design', description: 'Designing clean, modern, and conversion-focused websites with attention to layout, typography, and user experience.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const cf = (file: string) =>
|
||||||
|
`https://images.higgs.ai/?default=1&output=webp&url=${encodeURIComponent(
|
||||||
|
`https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/${file}`
|
||||||
|
)}&w=1280&q=85`;
|
||||||
|
|
||||||
|
export const projects = [
|
||||||
|
{
|
||||||
|
category: 'Client',
|
||||||
|
name: 'Nextlevel Studio',
|
||||||
|
col1: [cf('hf_20260412_055344_5eff02e0-87a5-41ce-b64f-eb08da8f33db.png'), cf('hf_20260412_055431_11d841fd-8b41-46a5-82e4-b04f2407a7d8.png')],
|
||||||
|
col2: cf('hf_20260412_055451_e317bf2d-28d4-48cc-86b0-6f72f25b6327.png'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Personal',
|
||||||
|
name: 'Aura Brand Identity',
|
||||||
|
col1: [cf('hf_20260412_055654_911201c5-36d9-4bc6-bac7-331adfce159f.png'), cf('hf_20260412_055723_5ceda0b8-d9c2-4665-b2e3-83ba19ba76d1.png')],
|
||||||
|
col2: cf('hf_20260412_055753_adc5dcbd-a8e6-49c0-b43a-9b030d835cea.png'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
category: 'Client',
|
||||||
|
name: 'Solaris Digital',
|
||||||
|
col1: [cf('hf_20260412_055759_963cfb0b-4bd1-4b0f-9d0a-09bd6cf95b2f.png'), cf('hf_20260412_060108_438f781a-9846-4dcc-89ab-c4e6cb830f5b.png')],
|
||||||
|
col2: cf('hf_20260412_055818_9d062121-ad7e-46b9-999a-1a6a692ef1ee.png'),
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
|
||||||
|
html, body, #root { background: #0C0C0C; }
|
||||||
|
body { font-family: 'Kanit', sans-serif; }
|
||||||
|
|
||||||
|
.hero-heading {
|
||||||
|
background: linear-gradient(180deg, #646973 0%, #BBCCD7 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import App from './App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import FadeIn from '../components/FadeIn';
|
||||||
|
import AnimatedText from '../components/AnimatedText';
|
||||||
|
import ContactButton from '../components/ContactButton';
|
||||||
|
import { aboutIcons, aboutText } from '../data/content';
|
||||||
|
|
||||||
|
export default function AboutSection() {
|
||||||
|
return (
|
||||||
|
<section id="about" className="relative flex min-h-screen flex-col items-center justify-center gap-16 px-5 py-20 sm:gap-20 sm:px-8 md:gap-24 md:px-10">
|
||||||
|
<FadeIn delay={0.1} x={-80} y={0} duration={0.9} className="absolute left-[1%] top-[4%] w-[120px] sm:left-[2%] sm:w-[160px] md:left-[4%] md:w-[210px]">
|
||||||
|
<img src={aboutIcons.moon} alt="" className="w-full" />
|
||||||
|
</FadeIn>
|
||||||
|
<FadeIn delay={0.25} x={-80} y={0} duration={0.9} className="absolute bottom-[8%] left-[3%] w-[100px] sm:left-[6%] sm:w-[140px] md:left-[10%] md:w-[180px]">
|
||||||
|
<img src={aboutIcons.object} alt="" className="w-full" />
|
||||||
|
</FadeIn>
|
||||||
|
<FadeIn delay={0.15} x={80} y={0} duration={0.9} className="absolute right-[1%] top-[4%] w-[120px] sm:right-[2%] sm:w-[160px] md:right-[4%] md:w-[210px]">
|
||||||
|
<img src={aboutIcons.lego} alt="" className="w-full" />
|
||||||
|
</FadeIn>
|
||||||
|
<FadeIn delay={0.3} x={80} y={0} duration={0.9} className="absolute bottom-[8%] right-[3%] w-[130px] sm:right-[6%] sm:w-[170px] md:right-[10%] md:w-[220px]">
|
||||||
|
<img src={aboutIcons.group} alt="" className="w-full" />
|
||||||
|
</FadeIn>
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col items-center gap-10 sm:gap-14 md:gap-16">
|
||||||
|
<FadeIn delay={0} y={40}>
|
||||||
|
<h2 className="hero-heading text-center font-black uppercase leading-none tracking-tight" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||||
|
About me
|
||||||
|
</h2>
|
||||||
|
</FadeIn>
|
||||||
|
<AnimatedText
|
||||||
|
text={aboutText}
|
||||||
|
className="max-w-[560px] text-center font-medium leading-relaxed text-[#D7E2EA]"
|
||||||
|
style={{ fontSize: 'clamp(1rem, 2vw, 1.35rem)' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative z-10">
|
||||||
|
<ContactButton />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import FadeIn from '../components/FadeIn';
|
||||||
|
import Magnet from '../components/Magnet';
|
||||||
|
import ContactButton from '../components/ContactButton';
|
||||||
|
import { portrait } from '../data/content';
|
||||||
|
|
||||||
|
const links = [
|
||||||
|
{ label: 'About', href: '#about' },
|
||||||
|
{ label: 'Price', href: '#services' },
|
||||||
|
{ label: 'Projects', href: '#projects' },
|
||||||
|
{ label: 'Contact', href: 'mailto:hello@example.com' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function HeroSection() {
|
||||||
|
return (
|
||||||
|
<section className="relative flex h-screen flex-col" style={{ overflowX: 'clip' }}>
|
||||||
|
<FadeIn delay={0} y={-20}>
|
||||||
|
<nav className="flex justify-between px-6 pt-6 md:px-10 md:pt-8">
|
||||||
|
{links.map((l) => (
|
||||||
|
<a
|
||||||
|
key={l.label}
|
||||||
|
href={l.href}
|
||||||
|
className="text-sm font-medium uppercase tracking-wider text-[#D7E2EA] transition-opacity duration-200 hover:opacity-70 md:text-lg lg:text-[1.4rem]"
|
||||||
|
>
|
||||||
|
{l.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</FadeIn>
|
||||||
|
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<FadeIn delay={0.15} y={40}>
|
||||||
|
<h1 className="hero-heading mt-6 w-full whitespace-nowrap text-center text-[14vw] font-black uppercase leading-none tracking-tight sm:mt-4 sm:text-[15vw] md:-mt-5 md:text-[16vw] lg:text-[17.5vw]">
|
||||||
|
Hi, i’m jack
|
||||||
|
</h1>
|
||||||
|
</FadeIn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="absolute left-1/2 top-1/2 z-10 w-[280px] -translate-x-1/2 -translate-y-1/2 sm:bottom-0 sm:top-auto sm:w-[360px] sm:translate-y-0 md:w-[440px] lg:w-[520px]">
|
||||||
|
<FadeIn delay={0.6} y={30}>
|
||||||
|
<Magnet padding={150} strength={3} activeTransition="transform 0.3s ease-out" inactiveTransition="transform 0.6s ease-in-out" className="!block">
|
||||||
|
<img src={portrait} alt="Portrait of Jack" className="w-full" />
|
||||||
|
</Magnet>
|
||||||
|
</FadeIn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto flex items-end justify-between px-6 pb-7 sm:pb-8 md:px-10 md:pb-10">
|
||||||
|
<FadeIn delay={0.35} y={20}>
|
||||||
|
<p
|
||||||
|
className="max-w-[160px] font-light uppercase leading-snug tracking-wide text-[#D7E2EA] sm:max-w-[220px] md:max-w-[260px]"
|
||||||
|
style={{ fontSize: 'clamp(0.75rem, 1.4vw, 1.5rem)' }}
|
||||||
|
>
|
||||||
|
a 3d creator driven by crafting striking and unforgettable projects
|
||||||
|
</p>
|
||||||
|
</FadeIn>
|
||||||
|
<FadeIn delay={0.5} y={20} className="relative z-20">
|
||||||
|
<ContactButton />
|
||||||
|
</FadeIn>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { marqueeImages } from '../data/content';
|
||||||
|
|
||||||
|
const row1 = marqueeImages.slice(0, 11);
|
||||||
|
const row2 = marqueeImages.slice(11);
|
||||||
|
|
||||||
|
function Row({ images, transform }: { images: string[]; transform: string }) {
|
||||||
|
const tripled = [...images, ...images, ...images];
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3" style={{ transform, willChange: 'transform' }}>
|
||||||
|
{tripled.map((src, i) => (
|
||||||
|
<img
|
||||||
|
key={i}
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className="rounded-2xl object-cover"
|
||||||
|
style={{ width: 420, height: 270, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MarqueeSection() {
|
||||||
|
const ref = useRef<HTMLElement>(null);
|
||||||
|
const [offset, setOffset] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => {
|
||||||
|
if (!ref.current) return;
|
||||||
|
const top = ref.current.getBoundingClientRect().top + window.scrollY;
|
||||||
|
setOffset((window.scrollY - top + window.innerHeight) * 0.3);
|
||||||
|
};
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section ref={ref} className="flex flex-col gap-3 bg-[#0C0C0C] pb-10 pt-24 sm:pt-32 md:pt-40" style={{ overflow: 'clip' }}>
|
||||||
|
<Row images={row1} transform={`translateX(${offset - 200}px)`} />
|
||||||
|
<Row images={row2} transform={`translateX(${-(offset - 200)}px)`} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||||
|
import FadeIn from '../components/FadeIn';
|
||||||
|
import LiveProjectButton from '../components/LiveProjectButton';
|
||||||
|
import { projects } from '../data/content';
|
||||||
|
|
||||||
|
type Project = (typeof projects)[number];
|
||||||
|
|
||||||
|
const radius = 'rounded-[40px] sm:rounded-[50px] md:rounded-[60px]';
|
||||||
|
|
||||||
|
function ProjectCard({ project, index, total }: { project: Project; index: number; total: number }) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const { scrollYProgress } = useScroll({ target: ref, offset: ['start end', 'start start'] });
|
||||||
|
const targetScale = 1 - (total - 1 - index) * 0.03;
|
||||||
|
const scale = useTransform(scrollYProgress, [0, 1], [1, targetScale]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="sticky top-24 h-[85vh] md:top-32">
|
||||||
|
<motion.div
|
||||||
|
className={`border-2 border-[#D7E2EA] bg-[#0C0C0C] p-4 sm:p-6 md:p-8 ${radius}`}
|
||||||
|
style={{ scale, top: `${index * 28}px`, position: 'relative', transformOrigin: 'top center' }}
|
||||||
|
>
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-4 sm:mb-6">
|
||||||
|
<div className="flex items-center gap-4 sm:gap-8">
|
||||||
|
<span className="hero-heading font-black leading-none" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
||||||
|
{String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<div className="text-[#D7E2EA]">
|
||||||
|
<p className="text-xs font-light uppercase tracking-widest opacity-70 sm:text-sm">{project.category}</p>
|
||||||
|
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{project.name}</h3>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LiveProjectButton />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 sm:gap-4">
|
||||||
|
<div className="flex w-[40%] flex-col gap-3 sm:gap-4">
|
||||||
|
<img src={project.col1[0]} alt="" loading="lazy" className={`w-full object-cover ${radius}`} style={{ height: 'clamp(130px, 16vw, 230px)' }} />
|
||||||
|
<img src={project.col1[1]} alt="" loading="lazy" className={`w-full object-cover ${radius}`} style={{ height: 'clamp(160px, 22vw, 340px)' }} />
|
||||||
|
</div>
|
||||||
|
<div className="w-[60%]">
|
||||||
|
<img src={project.col2} alt="" loading="lazy" className={`h-full w-full object-cover ${radius}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProjectsSection() {
|
||||||
|
return (
|
||||||
|
<section id="projects" className="relative z-10 -mt-10 rounded-t-[40px] bg-[#0C0C0C] px-5 py-20 sm:-mt-12 sm:rounded-t-[50px] sm:px-8 sm:py-24 md:-mt-14 md:rounded-t-[60px] md:px-10 md:py-32">
|
||||||
|
<FadeIn>
|
||||||
|
<h2 className="hero-heading mb-16 text-center font-black uppercase leading-none tracking-tight sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||||
|
Project
|
||||||
|
</h2>
|
||||||
|
</FadeIn>
|
||||||
|
{projects.map((p, i) => (
|
||||||
|
<ProjectCard key={p.name} project={p} index={i} total={projects.length} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import FadeIn from '../components/FadeIn';
|
||||||
|
import { services } from '../data/content';
|
||||||
|
|
||||||
|
export default function ServicesSection() {
|
||||||
|
return (
|
||||||
|
<section id="services" className="rounded-t-[40px] bg-white px-5 py-20 sm:rounded-t-[50px] sm:px-8 sm:py-24 md:rounded-t-[60px] md:px-10 md:py-32">
|
||||||
|
<h2 className="mb-16 text-center font-black uppercase text-[#0C0C0C] sm:mb-20 md:mb-28" style={{ fontSize: 'clamp(3rem, 12vw, 160px)' }}>
|
||||||
|
Services
|
||||||
|
</h2>
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
{services.map((s, i) => (
|
||||||
|
<FadeIn key={s.name} delay={i * 0.1}>
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-4 py-8 sm:gap-8 sm:py-10 md:gap-12 md:py-12"
|
||||||
|
style={{ borderTop: i === 0 ? undefined : '1px solid rgba(12, 12, 12, 0.15)' }}
|
||||||
|
>
|
||||||
|
<span className="font-black leading-none text-[#0C0C0C]" style={{ fontSize: 'clamp(3rem, 10vw, 140px)' }}>
|
||||||
|
{String(i + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-col gap-3 text-[#0C0C0C]">
|
||||||
|
<h3 className="font-medium uppercase" style={{ fontSize: 'clamp(1rem, 2.2vw, 2.1rem)' }}>{s.name}</h3>
|
||||||
|
<p className="max-w-2xl font-light leading-relaxed opacity-60" style={{ fontSize: 'clamp(0.85rem, 1.6vw, 1.25rem)' }}>
|
||||||
|
{s.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FadeIn>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
theme: { extend: { fontFamily: { sans: ['Kanit', 'sans-serif'] } } },
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"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
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
|
});
|
||||||
Reference in new issue
Block a user