portfolio tech
This commit is contained in:
@@ -4,6 +4,7 @@ import { HeroSection } from './components/hero/HeroSection'
|
||||
import { TechStackSection } from './components/tech/TechStackSection'
|
||||
import { ArchitectureSection } from './components/architecture/ArchitectureSection'
|
||||
import { ProjectsSection } from './components/projects/ProjectsSection'
|
||||
import { DeliverSection } from './components/deliver/DeliverSection'
|
||||
import { ChatWidget } from './components/chat/ChatWidget'
|
||||
|
||||
function App() {
|
||||
@@ -15,6 +16,7 @@ function App() {
|
||||
<TechStackSection />
|
||||
<ArchitectureSection />
|
||||
<ProjectsSection />
|
||||
<DeliverSection />
|
||||
</main>
|
||||
<Footer />
|
||||
<ChatWidget />
|
||||
|
||||
@@ -9,12 +9,20 @@ type BoxProps = { x: number; y: number; w: number; h: number; label: string; sub
|
||||
function ServiceBox({ x, y, w, h, label, sub }: BoxProps) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={w} height={h} rx={8} fill={BOX_BG} stroke={BOX_BORDER} strokeWidth={1.5} />
|
||||
<text x={x + w / 2} y={sub ? y + h / 2 - 6 : y + h / 2 + 5} textAnchor="middle" fill={TEXT} fontSize={13} fontFamily="JetBrains Mono, monospace" fontWeight={600}>
|
||||
<rect x={x} y={y} width={w} height={h} rx={7} fill={BOX_BG} stroke={BOX_BORDER} strokeWidth={1.5} />
|
||||
<text
|
||||
x={x + w / 2} y={sub ? y + h / 2 - 5 : y + h / 2 + 5}
|
||||
textAnchor="middle" fill={TEXT} fontSize={12}
|
||||
fontFamily="JetBrains Mono, monospace" fontWeight={600}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
{sub && (
|
||||
<text x={x + w / 2} y={y + h / 2 + 12} textAnchor="middle" fill={TEXT_MUTED} fontSize={10} fontFamily="JetBrains Mono, monospace">
|
||||
<text
|
||||
x={x + w / 2} y={y + h / 2 + 10}
|
||||
textAnchor="middle" fill={TEXT_MUTED} fontSize={10}
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
>
|
||||
{sub}
|
||||
</text>
|
||||
)}
|
||||
@@ -25,12 +33,20 @@ function ServiceBox({ x, y, w, h, label, sub }: BoxProps) {
|
||||
function InfraBox({ x, y, w, h, label, sub }: BoxProps) {
|
||||
return (
|
||||
<g>
|
||||
<rect x={x} y={y} width={w} height={h} rx={8} fill={BOX_BG} stroke={ACCENT} strokeWidth={1} strokeDasharray="4 3" opacity={0.85} />
|
||||
<text x={x + w / 2} y={sub ? y + h / 2 - 6 : y + h / 2 + 5} textAnchor="middle" fill={TEXT} fontSize={12} fontFamily="JetBrains Mono, monospace" fontWeight={600}>
|
||||
<rect x={x} y={y} width={w} height={h} rx={7} fill={BOX_BG} stroke={ACCENT} strokeWidth={1} strokeDasharray="4 3" opacity={0.9} />
|
||||
<text
|
||||
x={x + w / 2} y={sub ? y + h / 2 - 5 : y + h / 2 + 5}
|
||||
textAnchor="middle" fill={TEXT} fontSize={12}
|
||||
fontFamily="JetBrains Mono, monospace" fontWeight={600}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
{sub && (
|
||||
<text x={x + w / 2} y={y + h / 2 + 12} textAnchor="middle" fill={TEXT_MUTED} fontSize={10} fontFamily="JetBrains Mono, monospace">
|
||||
<text
|
||||
x={x + w / 2} y={y + h / 2 + 10}
|
||||
textAnchor="middle" fill={TEXT_MUTED} fontSize={10}
|
||||
fontFamily="JetBrains Mono, monospace"
|
||||
>
|
||||
{sub}
|
||||
</text>
|
||||
)}
|
||||
@@ -39,35 +55,75 @@ function InfraBox({ x, y, w, h, label, sub }: BoxProps) {
|
||||
}
|
||||
|
||||
export function ArchitectureSection() {
|
||||
// Layout constants
|
||||
const W = 760
|
||||
const H = 440
|
||||
const W = 1000
|
||||
const H = 530
|
||||
|
||||
// Gateway
|
||||
const gw = { x: 280, y: 30, w: 200, h: 52 }
|
||||
const gwCx = gw.x + gw.w / 2
|
||||
const gwCy = gw.y + gw.h
|
||||
// ── Row Y positions (top edge) ──────────────────────────────
|
||||
const Y_BROWSER = 18
|
||||
const Y_NGINX = 88
|
||||
const Y_GATEWAY = 158
|
||||
const Y_SVC = 252
|
||||
const Y_INFRA = 372
|
||||
|
||||
// Services row — evenly spaced
|
||||
const svcY = 145
|
||||
const svcH = 52
|
||||
const svcW = 140
|
||||
const services = [
|
||||
{ label: 'auth-service', sub: 'JWT / OAuth2', x: 20 },
|
||||
{ label: 'post-service', sub: 'CRUD / feed', x: 185 },
|
||||
{ label: 'rag-service', sub: 'AI / RAG', x: 350 },
|
||||
{ label: 'analytics-service', sub: 'metrics', x: 515 },
|
||||
const H_SM = 38 // Browser, nginx
|
||||
const H_MD = 44 // Gateway, services, infra
|
||||
|
||||
const CX = 490 // horizontal centre of the canvas
|
||||
|
||||
// ── Derived bottom edges ────────────────────────────────────
|
||||
const browserBot = Y_BROWSER + H_SM
|
||||
const nginxBot = Y_NGINX + H_SM
|
||||
const gwBot = Y_GATEWAY + H_MD
|
||||
const svcBot = Y_SVC + H_MD
|
||||
const infraMidY = svcBot + (Y_INFRA - svcBot) / 2 // ≈ 332
|
||||
|
||||
// ── nginx box ───────────────────────────────────────────────
|
||||
const nginxW = 150
|
||||
const nginxX = CX - nginxW / 2 // 415
|
||||
const nginxMidY = Y_NGINX + H_SM / 2
|
||||
|
||||
// ── Gateway box ─────────────────────────────────────────────
|
||||
const gwW = 225
|
||||
const gwX = CX - gwW / 2 // 378
|
||||
const gwRight = gwX + gwW // 603
|
||||
|
||||
// ── Frontend views (left cluster, row 4) ───────────────────
|
||||
const FE_W = 115
|
||||
const feViews = [
|
||||
{ label: 'auth-view', sub: '/auth/', x: 15 },
|
||||
{ label: 'rag-view', sub: '/ragview/', x: 140 },
|
||||
{ label: 'analytics-view', sub: '/analytics/', x: 265 },
|
||||
]
|
||||
const feCx = (i: number) => feViews[i].x + FE_W / 2
|
||||
|
||||
// Infra row
|
||||
const infraY = 310
|
||||
const infraH = 52
|
||||
const infraW = 160
|
||||
// ── Backend services (right cluster, row 4) ─────────────────
|
||||
const BE_W = 130
|
||||
const beServices = [
|
||||
{ label: 'auth-service', sub: 'JWT / OAuth2', x: 400 },
|
||||
{ label: 'post-service', sub: 'CRUD / feed', x: 540 },
|
||||
{ label: 'rag-service', sub: 'AI / RAG', x: 680 },
|
||||
{ label: 'analytics-service', sub: 'metrics', x: 820 },
|
||||
]
|
||||
const beCx = (i: number) => beServices[i].x + BE_W / 2
|
||||
// beCx: 465, 605, 745, 885
|
||||
|
||||
// ── Infra row (centred under backend cluster) ───────────────
|
||||
// Backend spans x=400 → 950 (width=550)
|
||||
// 3 × 148 + 2 × 14 = 472 → start = 400 + (550-472)/2 = 439
|
||||
const INF_W = 148
|
||||
const infra = [
|
||||
{ label: 'PostgreSQL', sub: 'persistence', x: 60 },
|
||||
{ label: 'Kafka', sub: 'event bus', x: 295 },
|
||||
{ label: 'Consul', sub: 'service mesh',x: 530 },
|
||||
{ label: 'PostgreSQL', sub: 'persistence', x: 439 }, // cx = 513
|
||||
{ label: 'Kafka', sub: 'event bus', x: 601 }, // cx = 675
|
||||
{ label: 'Consul', sub: 'service mesh', x: 763 }, // cx = 837
|
||||
]
|
||||
const infCx = (i: number) => infra[i].x + INF_W / 2
|
||||
// infCx: 513, 675, 837
|
||||
|
||||
// ── Consul right edge (for the L-path endpoint) ─────────────
|
||||
const consulRight = infra[2].x + INF_W // 763+148 = 911
|
||||
|
||||
// Routing rail on the far right (clear of all service boxes, rightmost=950)
|
||||
const RAIL_X = 972
|
||||
|
||||
return (
|
||||
<section id="architecture" className="py-24 px-6">
|
||||
@@ -91,75 +147,151 @@ export function ArchitectureSection() {
|
||||
aria-label="Microservices architecture diagram"
|
||||
>
|
||||
<defs>
|
||||
<marker id="arrow" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
|
||||
<marker id="arr" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto">
|
||||
<path d="M0,0 L0,6 L8,3 z" fill={ACCENT} />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* ── Gateway → Services connections ── */}
|
||||
{services.map((s) => {
|
||||
const sx = s.x + svcW / 2
|
||||
const sy = svcY
|
||||
return (
|
||||
<line
|
||||
key={s.label}
|
||||
x1={gwCx} y1={gwCy}
|
||||
x2={sx} y2={sy - 2}
|
||||
stroke={ACCENT}
|
||||
strokeWidth={1.5}
|
||||
strokeOpacity={0.7}
|
||||
markerEnd="url(#arrow)"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{/* ═══════════════════════════════════════════════════════
|
||||
SOLID ROUTING ARROWS (request routing)
|
||||
═══════════════════════════════════════════════════════ */}
|
||||
|
||||
{/* ── Services → Infra connections ── */}
|
||||
{services.map((s) => {
|
||||
const sx = s.x + svcW / 2
|
||||
const sy = svcY + svcH
|
||||
// Each service connects to the nearest infra node
|
||||
const targets = infra.map((inf) => inf.x + infraW / 2)
|
||||
const nearest = targets.reduce((prev, cur) =>
|
||||
Math.abs(cur - sx) < Math.abs(prev - sx) ? cur : prev
|
||||
)
|
||||
return (
|
||||
<line
|
||||
key={s.label + '-infra'}
|
||||
x1={sx} y1={sy}
|
||||
x2={nearest} y2={infraY - 2}
|
||||
stroke={ACCENT}
|
||||
strokeWidth={1}
|
||||
strokeOpacity={0.35}
|
||||
strokeDasharray="5 4"
|
||||
markerEnd="url(#arrow)"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
{/* Browser → nginx */}
|
||||
<line x1={CX} y1={browserBot} x2={CX} y2={Y_NGINX - 2}
|
||||
stroke={ACCENT} strokeWidth={1.5} strokeOpacity={0.85} markerEnd="url(#arr)" />
|
||||
|
||||
{/* ── Gateway box ── */}
|
||||
<ServiceBox {...gw} label="Spring Cloud Gateway" />
|
||||
{/* nginx → Spring Cloud Gateway */}
|
||||
<line x1={CX} y1={nginxBot} x2={CX} y2={Y_GATEWAY - 2}
|
||||
stroke={ACCENT} strokeWidth={1.5} strokeOpacity={0.85} markerEnd="url(#arr)" />
|
||||
|
||||
{/* ── Service boxes ── */}
|
||||
{services.map((s) => (
|
||||
<ServiceBox key={s.label} x={s.x} y={svcY} w={svcW} h={svcH} label={s.label} sub={s.sub} />
|
||||
{/* nginx → frontend views (from nginx LEFT side) */}
|
||||
{feViews.map((fv, i) => (
|
||||
<line key={fv.label + '-route'}
|
||||
x1={nginxX} y1={nginxMidY}
|
||||
x2={feCx(i)} y2={Y_SVC - 2}
|
||||
stroke={ACCENT} strokeWidth={1.2} strokeOpacity={0.65} markerEnd="url(#arr)" />
|
||||
))}
|
||||
|
||||
{/* ── Section label: Shared Infrastructure ── */}
|
||||
<text x={W / 2} y={infraY - 18} textAnchor="middle" fill={TEXT_MUTED} fontSize={10} fontFamily="JetBrains Mono, monospace" letterSpacing={2}>
|
||||
{/* Gateway → backend services */}
|
||||
{beServices.map((bs, i) => (
|
||||
<line key={bs.label + '-route'}
|
||||
x1={CX} y1={gwBot}
|
||||
x2={beCx(i)} y2={Y_SVC - 2}
|
||||
stroke={ACCENT} strokeWidth={1.5} strokeOpacity={0.75} markerEnd="url(#arr)" />
|
||||
))}
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════
|
||||
DASHED INFRA DEPENDENCY ARROWS
|
||||
═══════════════════════════════════════════════════════ */}
|
||||
|
||||
{/* ── All 4 services → PostgreSQL ── */}
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<line key={`svc${i}-pg`}
|
||||
x1={beCx(i)} y1={svcBot}
|
||||
x2={infCx(0)} y2={Y_INFRA - 2}
|
||||
stroke={ACCENT} strokeWidth={0.9} strokeOpacity={0.45} strokeDasharray="5 4" markerEnd="url(#arr)" />
|
||||
))}
|
||||
|
||||
{/* ── post-service, rag-service, analytics-service → Kafka ── */}
|
||||
{[1, 2, 3].map(i => (
|
||||
<line key={`svc${i}-kafka`}
|
||||
x1={beCx(i)} y1={svcBot}
|
||||
x2={infCx(1)} y2={Y_INFRA - 2}
|
||||
stroke={ACCENT} strokeWidth={0.9} strokeOpacity={0.45} strokeDasharray="5 4" markerEnd="url(#arr)" />
|
||||
))}
|
||||
|
||||
{/* ── All 4 backend services → Consul (faint — many lines) ── */}
|
||||
{[0, 1, 2, 3].map(i => (
|
||||
<line key={`svc${i}-consul`}
|
||||
x1={beCx(i)} y1={svcBot}
|
||||
x2={infCx(2)} y2={Y_INFRA - 2}
|
||||
stroke={ACCENT} strokeWidth={0.7} strokeOpacity={0.22} strokeDasharray="5 4" markerEnd="url(#arr)" />
|
||||
))}
|
||||
|
||||
{/* ── Gateway → Consul (L-path along right rail, avoids all boxes) ──
|
||||
M gwRight, gwMid → RAIL_X, gwMid → RAIL_X, consulMidY → consulRight, consulMidY */}
|
||||
<path
|
||||
d={`M ${gwRight} ${Y_GATEWAY + H_MD / 2}
|
||||
L ${RAIL_X} ${Y_GATEWAY + H_MD / 2}
|
||||
L ${RAIL_X} ${Y_INFRA + H_MD / 2}
|
||||
L ${consulRight + 2} ${Y_INFRA + H_MD / 2}`}
|
||||
stroke={ACCENT} strokeWidth={1} strokeOpacity={0.5} strokeDasharray="5 4" fill="none" markerEnd="url(#arr)" />
|
||||
|
||||
{/* ═══════════════════════════════════════════════════════
|
||||
BOXES (rendered on top of arrows)
|
||||
═══════════════════════════════════════════════════════ */}
|
||||
|
||||
{/* Browser */}
|
||||
<ServiceBox
|
||||
x={CX - 50} y={Y_BROWSER} w={100} h={H_SM}
|
||||
label="Browser" />
|
||||
|
||||
{/* nginx */}
|
||||
<ServiceBox
|
||||
x={nginxX} y={Y_NGINX} w={nginxW} h={H_SM}
|
||||
label="nginx" sub="reverse proxy" />
|
||||
|
||||
{/* Spring Cloud Gateway */}
|
||||
<ServiceBox
|
||||
x={gwX} y={Y_GATEWAY} w={gwW} h={H_MD}
|
||||
label="Spring Cloud Gateway" sub="port 8080" />
|
||||
|
||||
{/* Cluster labels row 4 */}
|
||||
<text
|
||||
x={feViews[1].x + FE_W / 2} y={Y_SVC - 12}
|
||||
textAnchor="middle" fill={TEXT_MUTED} fontSize={9}
|
||||
fontFamily="JetBrains Mono, monospace" letterSpacing={1.5}
|
||||
>
|
||||
FRONTEND
|
||||
</text>
|
||||
<text
|
||||
x={(beServices[0].x + beServices[3].x + BE_W) / 2} y={Y_SVC - 12}
|
||||
textAnchor="middle" fill={TEXT_MUTED} fontSize={9}
|
||||
fontFamily="JetBrains Mono, monospace" letterSpacing={1.5}
|
||||
>
|
||||
BACKEND SERVICES
|
||||
</text>
|
||||
|
||||
{/* Subtle divider between clusters */}
|
||||
<line x1={388} y1={Y_SVC - 18} x2={388} y2={Y_SVC + H_MD + 4}
|
||||
stroke={BOX_BORDER} strokeWidth={1} strokeOpacity={0.5} strokeDasharray="3 4" />
|
||||
|
||||
{/* Frontend view boxes */}
|
||||
{feViews.map(fv => (
|
||||
<ServiceBox key={fv.label} x={fv.x} y={Y_SVC} w={FE_W} h={H_MD} label={fv.label} sub={fv.sub} />
|
||||
))}
|
||||
|
||||
{/* Backend service boxes */}
|
||||
{beServices.map(bs => (
|
||||
<ServiceBox key={bs.label} x={bs.x} y={Y_SVC} w={BE_W} h={H_MD} label={bs.label} sub={bs.sub} />
|
||||
))}
|
||||
|
||||
{/* "SHARED INFRASTRUCTURE" label */}
|
||||
<text
|
||||
x={W / 2} y={Y_INFRA - 11}
|
||||
textAnchor="middle" fill={TEXT_MUTED} fontSize={9}
|
||||
fontFamily="JetBrains Mono, monospace" letterSpacing={2}
|
||||
>
|
||||
SHARED INFRASTRUCTURE
|
||||
</text>
|
||||
|
||||
{/* ── Infra boxes ── */}
|
||||
{infra.map((inf) => (
|
||||
<InfraBox key={inf.label} x={inf.x} y={infraY} w={infraW} h={infraH} label={inf.label} sub={inf.sub} />
|
||||
{/* Infra boxes */}
|
||||
{infra.map(inf => (
|
||||
<InfraBox key={inf.label} x={inf.x} y={Y_INFRA} w={INF_W} h={H_MD} label={inf.label} sub={inf.sub} />
|
||||
))}
|
||||
|
||||
{/* ── Legend ── */}
|
||||
<g transform={`translate(${W - 175}, ${H - 52})`}>
|
||||
<line x1={0} y1={8} x2={28} y2={8} stroke={ACCENT} strokeWidth={1.5} markerEnd="url(#arrow)" />
|
||||
<text x={34} y={12} fill={TEXT_MUTED} fontSize={10} fontFamily="JetBrains Mono, monospace">request routing</text>
|
||||
<line x1={0} y1={26} x2={28} y2={26} stroke={ACCENT} strokeWidth={1} strokeDasharray="5 4" markerEnd="url(#arrow)" />
|
||||
<text x={34} y={30} fill={TEXT_MUTED} fontSize={10} fontFamily="JetBrains Mono, monospace">infra dependency</text>
|
||||
{/* ═══════════════════════════════════════════════════════
|
||||
LEGEND
|
||||
═══════════════════════════════════════════════════════ */}
|
||||
<g transform={`translate(14, ${H - 52})`}>
|
||||
<line x1={0} y1={8} x2={28} y2={8} stroke={ACCENT} strokeWidth={1.5} markerEnd="url(#arr)" />
|
||||
<text x={34} y={12} fill={TEXT_MUTED} fontSize={10} fontFamily="JetBrains Mono, monospace">
|
||||
request routing
|
||||
</text>
|
||||
<line x1={0} y1={28} x2={28} y2={28} stroke={ACCENT} strokeWidth={1} strokeDasharray="5 4" markerEnd="url(#arr)" />
|
||||
<text x={34} y={32} fill={TEXT_MUTED} fontSize={10} fontFamily="JetBrains Mono, monospace">
|
||||
infra dependency
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
94
portfolio-view/src/components/deliver/DeliverSection.tsx
Normal file
94
portfolio-view/src/components/deliver/DeliverSection.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
const CARDS = [
|
||||
{
|
||||
title: 'Clean REST APIs',
|
||||
detail: 'Spring Boot, OpenAPI, tested endpoints',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-6 h-6">
|
||||
<path d="M8 9l3 3-3 3M13 15h3" stroke="#22d3ee" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<rect x="3" y="3" width="18" height="18" rx="3" stroke="#22d3ee" strokeWidth="2"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Docker-Ready Deployments',
|
||||
detail: 'Containerized services, Docker Compose',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-6 h-6">
|
||||
<rect x="3" y="9" width="3" height="2.5" rx=".5" fill="#22d3ee"/>
|
||||
<rect x="6.5" y="9" width="3" height="2.5" rx=".5" fill="#22d3ee"/>
|
||||
<rect x="10" y="9" width="3" height="2.5" rx=".5" fill="#22d3ee"/>
|
||||
<rect x="10" y="6" width="3" height="2.5" rx=".5" fill="#22d3ee"/>
|
||||
<rect x="13.5" y="9" width="3" height="2.5" rx=".5" fill="#22d3ee"/>
|
||||
<rect x="6.5" y="6" width="3" height="2.5" rx=".5" fill="#22d3ee"/>
|
||||
<path d="M21 11c-.5-1-2-1.25-2.75-1.25-.15-1.25-1-2.25-2-2.25h-.5c.25.5.25 1 .25 1.5H16V11h-3V9.5h-.5V8H9v1H8.5V7H6v2h-.5C4.5 9 4 9.5 4 10.5c0 2.5 1.75 4.5 4.25 4.5h9c2 0 3.75-1.25 4.25-3 .75 0 1.25 0 1.5-1z" fill="#22d3ee"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'CI/CD From Day One',
|
||||
detail: 'GitLab pipelines, automated builds and deploys',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-6 h-6">
|
||||
<circle cx="12" cy="12" r="2" fill="#22d3ee"/>
|
||||
<circle cx="5" cy="7" r="2" fill="#22d3ee"/>
|
||||
<circle cx="19" cy="7" r="2" fill="#22d3ee"/>
|
||||
<circle cx="5" cy="17" r="2" fill="#22d3ee"/>
|
||||
<circle cx="19" cy="17" r="2" fill="#22d3ee"/>
|
||||
<path d="M7 7h10M7 17h10M12 10v4" stroke="#22d3ee" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
<path d="M12 10L5 7M12 10L19 7M12 14L5 17M12 14L19 17" stroke="#22d3ee" strokeWidth="1.5" strokeLinecap="round"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Production Infrastructure',
|
||||
detail: 'VPS, Nginx, SSL, monitoring',
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="w-6 h-6">
|
||||
<rect x="3" y="4" width="18" height="5" rx="1.5" stroke="#22d3ee" strokeWidth="2"/>
|
||||
<rect x="3" y="11" width="18" height="5" rx="1.5" stroke="#22d3ee" strokeWidth="2"/>
|
||||
<circle cx="7" cy="6.5" r="1" fill="#22d3ee"/>
|
||||
<circle cx="7" cy="13.5" r="1" fill="#22d3ee"/>
|
||||
<path d="M9 19c0-1.7 1.3-3 3-3s3 1.3 3 3" stroke="#22d3ee" strokeWidth="2" strokeLinecap="round"/>
|
||||
<circle cx="12" cy="21" r="1" fill="#22d3ee"/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function DeliverSection() {
|
||||
return (
|
||||
<section id="deliver" className="py-24 px-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="mb-14">
|
||||
<p className="font-mono text-xs text-accent tracking-widest mb-3 uppercase">
|
||||
Deliverables
|
||||
</p>
|
||||
<h2 className="font-heading font-bold text-4xl md:text-5xl text-white tracking-tight">
|
||||
What I Deliver
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{CARDS.map(({ title, detail, icon }) => (
|
||||
<div
|
||||
key={title}
|
||||
className="flex flex-col gap-4 p-6 bg-zinc-900 border border-zinc-800 rounded-lg hover:border-zinc-600 hover:bg-zinc-800/70 transition-all duration-200 group"
|
||||
>
|
||||
<div className="w-10 h-10 flex items-center justify-center rounded-md bg-zinc-800 group-hover:bg-zinc-700 transition-colors duration-200">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="font-heading font-semibold text-white text-sm leading-snug">
|
||||
{title}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-zinc-500 leading-relaxed">
|
||||
{detail}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Cycle timeline (all times relative to stage-1 firing):
|
||||
@@ -22,6 +22,7 @@ export function HeroSection() {
|
||||
const [alexOn, setAlexOn] = useState(false)
|
||||
const [shiftLeft, setShiftLeft] = useState(false)
|
||||
const [contentVisible, setContentVisible] = useState(false)
|
||||
const scrollIndicatorRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const timers: ReturnType<typeof setTimeout>[] = []
|
||||
@@ -51,6 +52,22 @@ export function HeroSection() {
|
||||
return () => { alive = false; timers.forEach(clearTimeout) }
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollIndicatorRef.current
|
||||
if (!el) return
|
||||
const onScroll = () => {
|
||||
if (window.scrollY > 80) {
|
||||
el.style.opacity = '0'
|
||||
el.style.pointerEvents = 'none'
|
||||
} else {
|
||||
el.style.opacity = '1'
|
||||
el.style.pointerEvents = 'auto'
|
||||
}
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
return () => window.removeEventListener('scroll', onScroll)
|
||||
}, [])
|
||||
|
||||
/* ── style helpers ── */
|
||||
|
||||
const noTrans = nameStage === 0
|
||||
@@ -244,18 +261,93 @@ export function HeroSection() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll hint */}
|
||||
<div style={fadeUp(contentVisible, '0.9s')}>
|
||||
<div className="mt-16 flex flex-col items-center gap-2 text-zinc-600 text-xs font-mono tracking-widest">
|
||||
<span>SCROLL</span>
|
||||
<svg width="14" height="20" viewBox="0 0 14 20" fill="none" className="animate-bounce">
|
||||
<rect x="1" y="1" width="12" height="18" rx="6" stroke="currentColor" strokeWidth="1.5" />
|
||||
<circle cx="7" cy="6" r="1.5" fill="currentColor" className="animate-pulse" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Animated scroll indicator */}
|
||||
<style>{`
|
||||
@keyframes scrollDot {
|
||||
0% { opacity: 1; transform: translateY(0); }
|
||||
60% { opacity: 0; transform: translateY(12px); }
|
||||
61% { opacity: 0; transform: translateY(0); }
|
||||
100% { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@keyframes arrowPulse {
|
||||
0%, 100% { opacity: 0.2; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
ref={scrollIndicatorRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '40px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
opacity: contentVisible ? 1 : 0,
|
||||
transition: 'opacity 0.3s',
|
||||
}}
|
||||
>
|
||||
{/* Label */}
|
||||
<span
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '10px',
|
||||
letterSpacing: '3px',
|
||||
textTransform: 'uppercase',
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
}}
|
||||
>
|
||||
SCROLL
|
||||
</span>
|
||||
|
||||
{/* Mouse body */}
|
||||
<div
|
||||
style={{
|
||||
width: '28px',
|
||||
height: '44px',
|
||||
border: '2px solid rgba(255,255,255,0.5)',
|
||||
borderRadius: '14px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
paddingTop: '6px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '4px',
|
||||
height: '8px',
|
||||
backgroundColor: '#00d4ff',
|
||||
borderRadius: '2px',
|
||||
animation: 'scrollDot 1.6s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Chevron arrows */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '3px' }}>
|
||||
{[0, 0.2, 0.4].map((delay, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRight: '2px solid #00d4ff',
|
||||
borderBottom: '2px solid #00d4ff',
|
||||
transform: 'rotate(45deg)',
|
||||
animation: `arrowPulse 1.6s ease-in-out ${delay}s infinite`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user