diff --git a/public/assets/footer-background 1.png b/public/assets/footer-background 1.png deleted file mode 100644 index 4dd1110..0000000 Binary files a/public/assets/footer-background 1.png and /dev/null differ diff --git a/public/assets/footer-background-dark.png b/public/assets/footer-background-dark.png deleted file mode 100644 index 5a396e3..0000000 Binary files a/public/assets/footer-background-dark.png and /dev/null differ diff --git a/public/images/cert-bootcamp.png b/public/images/cert-bootcamp.png index 9ecd463..233b989 100644 Binary files a/public/images/cert-bootcamp.png and b/public/images/cert-bootcamp.png differ diff --git a/public/images/cert-django.png b/public/images/cert-django.png index eb900eb..791d58d 100644 Binary files a/public/images/cert-django.png and b/public/images/cert-django.png differ diff --git a/public/images/cert-hackathon.png b/public/images/cert-hackathon.png index 56e6ba0..eeb9508 100644 Binary files a/public/images/cert-hackathon.png and b/public/images/cert-hackathon.png differ diff --git a/public/images/cert-icpc-2024.png b/public/images/cert-icpc-2024.png index ebbeda3..62391ce 100644 Binary files a/public/images/cert-icpc-2024.png and b/public/images/cert-icpc-2024.png differ diff --git a/public/images/cert-icpc-2025.png b/public/images/cert-icpc-2025.png index 5cc105b..4b097f0 100644 Binary files a/public/images/cert-icpc-2025.png and b/public/images/cert-icpc-2025.png differ diff --git a/src/components/FooterSection.module.css b/src/components/FooterSection.module.css index fba30c8..405be49 100644 --- a/src/components/FooterSection.module.css +++ b/src/components/FooterSection.module.css @@ -1,195 +1,14 @@ .footerSection { position: relative; + isolation: isolate; width: 100%; - max-width: var(--layout-frame); - height: 34rem; - overflow: hidden; + max-width: var(--layout-frame, 1440px); + min-height: auto; + height: auto; + overflow: visible; display: flex; flex-direction: column; align-items: center; justify-content: flex-start; - background-color: var(--bg-page); -} - -.bgContainer { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 34rem; - z-index: 1; -} - -.bgImage { - object-fit: cover; - object-position: bottom center; - animation: footerArtworkDrift 12s var(--motion-ease-in-out) infinite alternate; -} - -@keyframes footerArtworkDrift { - to { transform: scale(1.025) translateY(0.25rem); } -} - -.content { - position: relative; - z-index: 10; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - margin-top: 5.25rem; -} - -.ctaTitle { - font-size: var(--font-size-display-footer); - line-height: var(--line-height-display-footer); - color: var(--text-primary); - margin-bottom: 0.75rem; - text-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); -} - -.ctaSubtitle { - font-size: var(--font-size-body-large); - line-height: var(--line-height-body-large); - color: var(--text-secondary); - font-weight: 500; - letter-spacing: var(--letter-spacing-eyebrow); - margin-bottom: 1.25rem; -} - -.emailButton { - min-height: 3rem; - margin-bottom: 2rem; -} - -.socialRow { - display: flex; - align-items: center; - gap: 3rem; -} - -.socialItem { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.75rem; - color: var(--text-primary); - text-decoration: none; - transition: - color var(--motion-duration-fast) ease, - transform var(--motion-duration-default) var(--motion-ease-out); - min-width: 5rem; - min-height: 4.75rem; -} - -.socialItem:focus-visible { - outline: 2px solid var(--focus-ring); - outline-offset: 6px; - border-radius: var(--radius-sm); -} - -.socialIcon { - display: flex; - align-items: center; - justify-content: center; - width: 3rem; - height: 3rem; - transition: transform var(--motion-duration-default) var(--motion-ease-out); -} - -.socialIcon svg { - display: block; - width: 100%; - height: 100%; - fill: currentColor; - stroke: none; -} - -.socialIcon .giteaCutout { - fill: var(--bg-page); -} - -.socialLabel { - font-size: var(--font-size-body-small); - line-height: var(--line-height-body-small); - font-weight: 600; - color: var(--text-primary); -} - -@media (max-width: 900px) { - .footerSection { - height: auto; - min-height: 34rem; - padding: var(--space-10) var(--layout-page-gutter) var(--space-9); - } - - .bgContainer { - height: 100%; - } - - .content { - width: 100%; - margin-top: 0; - } - - .socialRow { - flex-wrap: wrap; - justify-content: center; - gap: var(--space-6) var(--space-8); - } -} - -@media (max-width: 560px) { - .footerSection { - min-height: 38rem; - padding-top: var(--space-9); - } - - .ctaTitle { - font-size: 2.25rem; - line-height: 2.875rem; - } - - .ctaSubtitle { - max-width: 18.75rem; - margin-bottom: var(--space-7); - font-size: var(--font-size-body-default); - line-height: var(--line-height-body-default); - letter-spacing: 0.08em; - } - - .emailButton { - width: 100%; - max-width: 20rem; - } - - .socialRow { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - width: 100%; - gap: var(--space-6) var(--space-4); - } - - .socialLabel { - overflow-wrap: anywhere; - } -} - -@media (hover: hover) and (pointer: fine) { - .socialItem:hover { - color: var(--accent-default); - transform: translateY(-2px); - } - - - .socialItem:hover .socialIcon { - transform: translateY(-2px) scale(1.05); - } -} - -@media (prefers-reduced-motion: reduce) { - .bgImage { - animation: none; - transform: none; - } + background-color: var(--bg-page, #ffffff); } diff --git a/src/components/FooterSection.tsx b/src/components/FooterSection.tsx index 999cead..159324b 100644 --- a/src/components/FooterSection.tsx +++ b/src/components/FooterSection.tsx @@ -1,112 +1,13 @@ 'use client'; import React from 'react'; -import Image from 'next/image'; -import { Mail } from 'lucide-react'; -import { useTheme } from '@/context/ThemeContext'; +import IsometricFooter from './IsometricFooter/IsometricFooter'; import styles from './FooterSection.module.css'; -const PhoneIcon = () => ( - -); - -const LinkedInIcon = () => ( - -); - -const GiteaIcon = () => ( - -); - -const TelegramIcon = () => ( - -); - export const FooterSection: React.FC = () => { - const { theme } = useTheme(); - return ( ); }; diff --git a/src/components/IsometricFooter/IsoElements.tsx b/src/components/IsometricFooter/IsoElements.tsx new file mode 100644 index 0000000..79ead21 --- /dev/null +++ b/src/components/IsometricFooter/IsoElements.tsx @@ -0,0 +1,497 @@ +import React from 'react'; +import type { + IsoBoxProps, + MountainClusterProps, + PineTreeProps, + IsoPencilProps, + IsoMonitorProps, + IsoTowerProps, + IsoSignpostProps, +} from './IsoTypes'; + +/** + * Single isometric box: top diamond + two shaded side faces. + * (x, y) is the TOP vertex of the diamond. s = half-width, h = height. + */ +export function IsoBox({ x, y, s, h, top, left, right, stroke = 'none' }: IsoBoxProps) { + const topPts = `${x},${y} ${x + s},${y + s / 2} ${x},${y + s} ${x - s},${y + s / 2}`; + const leftPts = `${x - s},${y + s / 2} ${x},${y + s} ${x},${y + s + h} ${x - s},${y + s / 2 + h}`; + const rightPts = `${x + s},${y + s / 2} ${x},${y + s} ${x},${y + s + h} ${x + s},${y + s / 2 + h}`; + return ( + + + + + + ); +} + +/** + * Mountain Cluster + * Four stacked isometric boxes forming the mountain peak and foothills + */ +export function MountainCluster({ x, y, flip = false, scale = 1 }: MountainClusterProps) { + const f = flip ? -1 : 1; + const boxes = [ + { dx: 15 * f, dy: -45, s: 95, h: 130 }, + { dx: -60 * f, dy: 52, s: 58, h: 70 }, + { dx: 85 * f, dy: 65, s: 55, h: 60 }, + { dx: 0, dy: 98, s: 46, h: 46 }, + ]; + + return ( + + {boxes.map((b, i) => ( + + ))} + + ); +} + +/** + * Pine tree + * Features signature smooth teardrop/conical foliage and rounded capsule trunk + */ +export function PineTree({ x, y, scale = 1, dark = false }: PineTreeProps) { + const foliageColor = dark + ? 'var(--hero-illus-tree-dark, #104179)' + : 'var(--hero-illus-tree-light, #2166A9)'; + const trunkColor = 'var(--hero-illus-tree-trunk, #08234B)'; + + return ( + + {/* Capsule Trunk */} + + {/* Foliage */} + + + ); +} + +/** + * Modern SVG Icons for Top Contact Bar and Signposts + */ +export const PhoneIcon: React.FC<{ size?: number; color?: string }> = ({ + size = 20, + color = 'currentColor', +}) => ( + +); + +export const LinkedInIcon: React.FC<{ size?: number; color?: string }> = ({ + size = 20, + color = 'currentColor', +}) => ( + +); + +export const GitHubIcon: React.FC<{ size?: number; color?: string }> = ({ + size = 20, + color = 'currentColor', +}) => ( + +); + +export const TelegramIcon: React.FC<{ size?: number; color?: string }> = ({ + size = 20, + color = 'currentColor', +}) => ( + +); + +export const MailIcon: React.FC<{ size?: number; color?: string }> = ({ + size = 18, + color = 'currentColor', +}) => ( + +); + +/** + * Upright Majestic Faceted Pencil matching reference + * Blue faceted hexagonal shaft, cool white wood collar, and royal blue conical tip. + */ +export function IsoUprightPencil({ x, y, height = 84, width = 18 }: IsoPencilProps) { + const hw = width / 2; + const bodyH = height - 26; + const collarH = 14; + const tipH = 12; + + const baseY = y; + const collarBaseY = baseY - bodyH; + const tipBaseY = collarBaseY - collarH; + const tipY = tipBaseY - tipH; + + const p1 = `${x - hw},${baseY - 2} ${x - hw * 0.25},${baseY + 1} ${x - hw * 0.25},${collarBaseY} ${x - hw},${collarBaseY - 2}`; + const p2 = `${x - hw * 0.25},${baseY + 1} ${x + hw * 0.25},${baseY + 1} ${x + hw * 0.25},${collarBaseY} ${x - hw * 0.25},${collarBaseY}`; + const p3 = `${x + hw * 0.25},${baseY + 1} ${x + hw},${baseY - 2} ${x + hw},${collarBaseY - 2} ${x + hw * 0.25},${collarBaseY}`; + + const woodLeft = `${x - hw},${collarBaseY - 2} ${x - hw * 0.25},${collarBaseY} ${x},${tipBaseY}`; + const woodMid = `${x - hw * 0.25},${collarBaseY} ${x + hw * 0.25},${collarBaseY} ${x},${tipBaseY}`; + const woodRight = `${x + hw * 0.25},${collarBaseY} ${x + hw},${collarBaseY - 2} ${x},${tipBaseY}`; + + const leadLeft = `${x - hw * 0.32},${tipBaseY} ${x},${tipBaseY} ${x},${tipY}`; + const leadRight = `${x},${tipBaseY} ${x + hw * 0.32},${tipBaseY} ${x},${tipY}`; + + return ( + + {/* Ground Contact Shadow */} + + + {/* Shaft Facets (Light / Front / Dark matching 3D isometric lighting) */} + + + + + {/* Scalloped Wood Facets */} + + + + + {/* Lead Tip */} + + + + ); +} + +/** + * Isometric Workstation Monitor matching reference + * Sizable flat panel screen angled in 3D, standing proudly on Tile 1 + */ +export function IsoMonitorDisplay({ x, y, scale = 1 }: IsoMonitorProps) { + const baseW = 20; + const baseH = 10; + + // Stand Base diamond + const sbFront = `${x},${y + baseH / 2}`; + const sbLeft = `${x - baseW / 2},${y}`; + const sbRight = `${x + baseW / 2},${y}`; + const sbBack = `${x},${y - baseH / 2}`; + const sbTop = `${sbBack} ${sbRight} ${sbFront} ${sbLeft}`; + const sbLeftDrop = `${sbLeft} ${sbFront} ${x},${y + baseH / 2 + 2} ${x - baseW / 2},${y + 2}`; + const sbRightDrop = `${sbFront} ${sbRight} ${x + baseW / 2},${y + 2} ${x},${y + baseH / 2 + 2}`; + + // Monitor Display Panel (sleek, well-proportioned, clear of pencil) + const panCx = x; + const panCy = y - 24; + + const hw = 21; + const hh = 15; + const slopeDy = 10.5; // 21 * 0.5 = 10.5 + const depth = 3.5; + + // Stand Neck column (extends from base plate up solidly behind the opaque screen panel) + const neckTop = panCy + 4; + const neckFront = `${x - 2},${neckTop} ${x + 2},${neckTop + 2} ${x + 2},${y} ${x - 2},${y - 2}`; + const neckSide = `${x + 2},${neckTop + 2} ${x + 4},${neckTop + 1} ${x + 4},${y - 1} ${x + 2},${y}`; + + const fTopLeft = `${panCx - hw},${panCy - hh - slopeDy}`; + const fTopRight = `${panCx + hw},${panCy - hh + slopeDy}`; + const fBottomRight = `${panCx + hw},${panCy + hh + slopeDy}`; + const fBottomLeft = `${panCx - hw},${panCy + hh - slopeDy}`; + const pFront = `${fTopLeft} ${fTopRight} ${fBottomRight} ${fBottomLeft}`; + + const bTopLeft = `${panCx - hw + depth},${panCy - hh - slopeDy - depth * 0.5}`; + const bTopRight = `${panCx + hw + depth},${panCy - hh + slopeDy - depth * 0.5}`; + const pTop = `${fTopLeft} ${bTopLeft} ${bTopRight} ${fTopRight}`; + + const bBottomRight = `${panCx + hw + depth},${panCy + hh + slopeDy - depth * 0.5}`; + const pRight = `${fTopRight} ${bTopRight} ${bBottomRight} ${fBottomRight}`; + + return ( + + {/* Ground Contact Shadow */} + + + {/* Stand Base Drop Faces */} + + + + + {/* Stand Neck Column */} + + + + {/* Monitor Display Bezel & Screen Face */} + + + + + ); +} + +/** + * Isometric PC Tower Unit matching reference + * Vertical rectangular prism sitting on a clean white pedestal plate + */ +export function IsoPCTower({ x, y, size = 20, height = 40 }: IsoTowerProps) { + const hs = size / 2; + const plateW = size + 10; + const plateH = (size + 10) * 0.5; + + // Base Pedestal Plate + const plFront = `${x},${y + plateH / 2}`; + const plLeft = `${x - plateW / 2},${y}`; + const plRight = `${x + plateW / 2},${y}`; + const plBack = `${x},${y - plateH / 2}`; + const plTop = `${plBack} ${plRight} ${plFront} ${plLeft}`; + const plLeftDrop = `${plLeft} ${plFront} ${x},${y + plateH / 2 + 2.5} ${x - plateW / 2},${y + 2.5}`; + const plRightDrop = `${plFront} ${plRight} ${x + plateW / 2},${y + 2.5} ${x},${y + plateH / 2 + 2.5}`; + + // Cube Base corners + const baseY = y - 1; + const cFront = `${x},${baseY + hs * 0.5}`; + const cLeft = `${x - hs},${baseY}`; + const cRight = `${x + hs},${baseY}`; + const cBack = `${x},${baseY - hs * 0.5}`; + + // Cube Top corners + const topY = baseY - height; + const tFront = `${x},${topY + hs * 0.5}`; + const tLeft = `${x - hs},${topY}`; + const tRight = `${x + hs},${topY}`; + const tBack = `${x},${topY - hs * 0.5}`; + + const faceTop = `${tBack} ${tRight} ${tFront} ${tLeft}`; + const faceLeft = `${cLeft} ${cFront} ${tFront} ${tLeft}`; + const faceRight = `${cFront} ${cRight} ${tRight} ${tFront}`; + + return ( + + {/* Pedestal Plate */} + + + + + {/* Main Vertical Tower Body */} + + + + + ); +} + +/** + * Isometric Signpost + * True 3D item with elevated 3D cylindrical base plate and shaded 3D cylindrical metallic pole + */ +export function IsoSignpost({ + id: _id, + x, + y, + postHeight = 48, + facing = 'right', + backdropDirection, + icon, + label: _label, + isActive = false, + onMouseEnter, + onMouseLeave, + onClick, +}: IsoSignpostProps) { + const isRight = facing === 'right'; + const postTopY = y - postHeight; + const cardSize = 32; + const radius = 6; + + // 3D Isometric Shear Matrix: + // facing="right" (SE): slope +0.5 -> matrix(1, 0.5, 0, 1, 0, 0) + // facing="left" (SW): slope -0.5 -> matrix(1, -0.5, 0, 1, 0, 0) + const shearSlope = isRight ? 0.5 : -0.5; + const gradId = `standPoleGrad-${_id}`; + + // Directional Backdrop Offset: + // Rightside items (GitHub, Telegram): 'left-top' (backShiftX = -3.5, backShiftY = -3.2) + // Leftside items (Phone, LinkedIn): 'right-top' (backShiftX = +3.5, backShiftY = -3.2) + const dir = backdropDirection ?? (isRight ? 'right-top' : 'left-top'); + const isRightTop = dir === 'right-top'; + const backShiftX = isRightTop ? 3.5 : -3.5; + const backShiftY = -3.2; + + return ( + + + {/* 3D Cylindrical Metallic Gradient for Upright Pole */} + + + + + + + + + {/* Expanded invisible touch hit box */} + + + {/* 3D Ground Base Assembly */} + {/* 1. 3D Cylindrical Disc Drop Apron (Thickness = 4px) */} + + {/* Center ridge divider on the cylinder drop */} + + + {/* 3. 3D Disc Top Surface */} + + + {/* 4. Concentric Socket Collar / Ring where pole enters */} + + + + + {/* 3D Cylindrical Metallic Pole (extends seamlessly up behind the card body) */} + + {/* Left-edge specular highlight glint line */} + + + {/* 3D Rotated Isometric Signboard */} + + {/* 1. Backdrop Layer (Positioned to left-top for rightside items, right-top for leftside items) */} + + {/* Subtle Ambient Shadow behind the backdrop plate */} + + + {/* 3D Backdrop Plate with signature royal blue fill and darker border */} + + + + {/* 2. Front Card Layer (Centered flush on the pole axis at x, postTopY - 18) */} + + {/* Crisp Opaque Card Body with Blue Border */} + + + {/* 3D Rotated Icon inside Card */} + + + {icon} + + + + + + ); +} diff --git a/src/components/IsometricFooter/IsoTypes.ts b/src/components/IsometricFooter/IsoTypes.ts new file mode 100644 index 0000000..3e1b12b --- /dev/null +++ b/src/components/IsometricFooter/IsoTypes.ts @@ -0,0 +1,77 @@ +import React from 'react'; + +export type FooterContactId = 'phone' | 'linkedin' | 'github' | 'telegram'; + +export interface ContactItem { + id: FooterContactId; + name: string; + label: string; + href: string; + Icon: React.FC<{ size?: number; color?: string }>; +} + +export interface IsoBoxProps { + x: number; + y: number; + s: number; + h: number; + top: string; + left: string; + right: string; + stroke?: string; +} + +export interface MountainClusterProps { + x: number; + y: number; + flip?: boolean; + scale?: number; +} + +export interface PineTreeProps { + x: number; + y: number; + scale?: number; + dark?: boolean; +} + +export interface IsoPencilProps { + x: number; + y: number; + height?: number; + width?: number; +} + +export interface IsoMonitorProps { + x: number; + y: number; + scale?: number; +} + +export interface IsoTowerProps { + x: number; + y: number; + size?: number; + height?: number; +} + +export interface IsoSignpostProps { + id: FooterContactId; + x: number; + y: number; + postHeight?: number; + facing?: 'left' | 'right'; + backdropDirection?: 'left-top' | 'right-top'; + icon: React.ReactNode; + label: string; + isActive?: boolean; + onMouseEnter?: () => void; + onMouseLeave?: () => void; + onClick?: () => void; +} + +export interface IsometricSceneProps { + activePost: FooterContactId | null; + onPostHover?: (id: FooterContactId | null) => void; + onPostClick?: (id: FooterContactId) => void; +} diff --git a/src/components/IsometricFooter/IsometricFooter.module.css b/src/components/IsometricFooter/IsometricFooter.module.css new file mode 100644 index 0000000..f83cc3c --- /dev/null +++ b/src/components/IsometricFooter/IsometricFooter.module.css @@ -0,0 +1,325 @@ +/* ========================================================================== + Isometric Footer Component Styles + ========================================================================== */ + +.footerContainer { + position: relative; + width: 100%; + max-width: 1200px; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 16px 24px 28px 24px; + box-sizing: border-box; +} + +/* -------------------------------------------------------------------------- + Header & Contact Invitation + -------------------------------------------------------------------------- */ +.footerHeader { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + margin-bottom: 16px; + width: 100%; + max-width: 780px; +} + +.headerTitle { + font-family: 'Pacifico', cursive, system-ui; + font-weight: 400; + font-size: clamp(2.2rem, 5.5vw, 3.6rem); + line-height: 1.3; + color: var(--text-primary); + margin: 0 0 6px 0; + word-break: break-word; + animation: footerFadeIn 0.7s ease-out forwards; +} + +.headerSubtitle { + font-size: clamp(0.85rem, 2.5vw, 1.05rem); + color: var(--text-secondary); + margin: 0 0 10px 0; + font-weight: 500; + letter-spacing: 0.02em; + animation: footerFadeIn 0.7s 0.15s ease-out both; +} + +/* Email Pill Button */ +.emailWrapper { + display: flex; + justify-content: center; + width: 100%; + animation: emailScaleIn 0.6s 0.25s ease-out both; +} + +.emailPill { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 9px 20px; + border-radius: 9999px; + background: var(--bg-raised); + border: 1.5px solid var(--border-default); + color: var(--text-primary); + text-decoration: none; + font-size: clamp(0.76rem, 2.6vw, 0.92rem); + font-weight: 600; + backdrop-filter: blur(8px); + box-shadow: 0 2px 10px var(--shadow-color); + transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); + max-width: 100%; +} + +.emailPill:hover { + background: var(--bg-surface); + border-color: var(--accent-default); + transform: translateY(-2px); + box-shadow: 0 6px 18px var(--shadow-color); + color: var(--accent-default); +} + +.emailIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--action-link); +} + +.emailText { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* -------------------------------------------------------------------------- + 3D Isometric Scene Canvas Wrapper + -------------------------------------------------------------------------- */ +.sceneContainer { + width: 100%; + max-width: 1200px; + display: flex; + justify-content: center; + align-items: center; + overflow: visible; + position: relative; + margin-top: 0px; +} + +.sceneContainer svg { + width: 100%; + height: auto; + max-height: 480px; + display: block; + overflow: visible; +} + +/* -------------------------------------------------------------------------- + Synchronized Contact Cards Grid + -------------------------------------------------------------------------- */ +.contactCardsSection { + width: 100%; + max-width: 960px; + margin-top: 16px; + margin-bottom: 16px; + padding: 0 12px; + box-sizing: border-box; +} + +.contactCardsGrid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + width: 100%; +} + +.contactCard { + display: flex; + align-items: center; + gap: 14px; + padding: 12px 18px; + border-radius: 14px; + background: var(--bg-raised); + border: 1.5px solid var(--border-default); + text-decoration: none; + color: var(--text-primary); + transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: 0 2px 8px var(--shadow-color); + backdrop-filter: blur(8px); +} + +.contactCard:hover, +.contactCard.active { + border-color: var(--accent-default); + background: var(--bg-surface); + transform: translateY(-3px); + box-shadow: 0 8px 22px var(--shadow-color); + color: var(--accent-default); +} + +.contactCardIconWrap { + width: 38px; + height: 38px; + border-radius: 10px; + background: var(--bg-subtle); + display: flex; + align-items: center; + justify-content: center; + color: var(--accent-default); + flex-shrink: 0; + transition: all 0.2s ease; +} + +.contactCard:hover .contactCardIconWrap, +.contactCard.active .contactCardIconWrap { + transform: scale(1.08); + background: var(--accent-default); + color: var(--action-primary-fg); +} + +.contactCardInfo { + display: flex; + flex-direction: column; + min-width: 0; + text-align: left; +} + +.contactCardName { + font-size: 0.72rem; + text-transform: uppercase; + font-weight: 700; + letter-spacing: 0.06em; + color: var(--text-muted); + margin-bottom: 2px; +} + +.contactCardLabel { + font-size: 0.88rem; + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.contactCard:hover .contactCardLabel, +.contactCard.active .contactCardLabel { + color: var(--accent-default); +} + +/* -------------------------------------------------------------------------- + Animations + -------------------------------------------------------------------------- */ +@keyframes footerFadeIn { + from { + opacity: 0; + transform: translateY(-16px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes emailScaleIn { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* -------------------------------------------------------------------------- + Responsive Media Queries + -------------------------------------------------------------------------- */ +@media (max-width: 900px) { + .footerContainer { + padding: 36px 20px 24px 20px; + } + + .contactCardsGrid { + grid-template-columns: repeat(4, 1fr); + gap: 12px; + } + + .contactCard { + padding: 10px 14px; + gap: 10px; + } + + .contactCardIconWrap { + width: 34px; + height: 34px; + } +} + +@media (max-width: 680px) { + .footerContainer { + padding: 28px 16px 20px 16px; + } + + .contactCardsGrid { + grid-template-columns: repeat(2, 1fr); + gap: 10px; + } + + .contactCard { + padding: 10px 14px; + } + + .sceneContainer svg { + max-height: 360px; + } +} + +@media (max-width: 480px) { + .footerContainer { + padding: 22px 12px 16px 12px; + } + + .headerTitle { + font-size: 2rem; + } + + .headerSubtitle { + font-size: 0.88rem; + margin-bottom: 16px; + } + + .emailPill { + padding: 8px 16px; + } + + .sceneContainer svg { + max-height: 280px; + } +} + +@media (max-width: 360px) { + .emailPill { + padding: 7px 12px; + gap: 6px; + } + + .contactCardsGrid { + grid-template-columns: 1fr; + gap: 8px; + } +} + +@media (max-height: 560px) and (orientation: landscape) { + .sceneContainer svg { + max-height: 240px; + } + + .footerContainer { + padding: 16px 16px 12px 16px; + } +} diff --git a/src/components/IsometricFooter/IsometricFooter.tsx b/src/components/IsometricFooter/IsometricFooter.tsx new file mode 100644 index 0000000..5fa2fe3 --- /dev/null +++ b/src/components/IsometricFooter/IsometricFooter.tsx @@ -0,0 +1,128 @@ +'use client'; + +import React, { useState } from 'react'; +import type { ContactItem, FooterContactId } from './IsoTypes'; +import IsometricScene from './IsometricScene'; +import { + MailIcon, + PhoneIcon, + LinkedInIcon, + GitHubIcon, + TelegramIcon, +} from './IsoElements'; +import styles from './IsometricFooter.module.css'; + +export const contacts: ContactItem[] = [ + { + id: 'phone', + name: 'Phone', + label: '09388263077', + href: 'tel:09388263077', + Icon: PhoneIcon, + }, + { + id: 'linkedin', + name: 'LinkedIn', + label: '@amiirkhl', + href: 'https://linkedin.com/in/amiirkhl', + Icon: LinkedInIcon, + }, + { + id: 'github', + name: 'GitHub', + label: 'git.amiirkhl.ir', + href: 'https://git.amiirkhl.ir', + Icon: GitHubIcon, + }, + { + id: 'telegram', + name: 'Telegram', + label: '@amiirkhl', + href: 'https://t.me/amiirkhl', + Icon: TelegramIcon, + }, +]; + +export default function IsometricFooter() { + const [activePost, setActivePost] = useState(null); + + const handlePostClick = (id: FooterContactId) => { + const item = contacts.find((c) => c.id === id); + if (item && item.href) { + if (item.href.startsWith('tel:')) { + window.location.href = item.href; + } else { + window.open(item.href, '_blank', 'noopener,noreferrer'); + } + } + }; + + return ( +
+ {/* Top Header & Contact Section */} +
+

+ Let's build something meaningful. +

+ +

+ Open to opportunities and interesting projects. +

+ + {/* Email Pill Button */} +
+ + + + + amirhosseinkhalili901@gmail.com + +
+
+ + {/* 3D Isometric SVG Scene */} +
+ +
+ + {/* Responsive Synchronized Contact Cards Section */} +
+
+ {contacts.map((contact) => { + const IconComponent = contact.Icon; + const isHovered = activePost === contact.id; + return ( + setActivePost(contact.id)} + onMouseLeave={() => setActivePost(null)} + onClick={() => setActivePost(contact.id)} + aria-label={`${contact.name}: ${contact.label}`} + > +
+ +
+
+ {contact.name} + {contact.label} +
+
+ ); + })} +
+
+
+ ); +} diff --git a/src/components/IsometricFooter/IsometricScene.tsx b/src/components/IsometricFooter/IsometricScene.tsx new file mode 100644 index 0000000..a1dae35 --- /dev/null +++ b/src/components/IsometricFooter/IsometricScene.tsx @@ -0,0 +1,417 @@ +import React from 'react'; +import type { IsometricSceneProps } from './IsoTypes'; +import { + MountainCluster, + PineTree, + IsoUprightPencil, + IsoMonitorDisplay, + IsoPCTower, + IsoSignpost, + PhoneIcon, + LinkedInIcon, + GitHubIcon, + TelegramIcon, +} from './IsoElements'; + +/** + * Faint Isometric Background Grid + * Fine architectural diamond grid lines covering the ground (slope 0.5, 2:1 isometric) + */ +function IsometricBackgroundGrid() { + const lines = []; + const spacing = 40; + const yStart = 150; + const yEnd = 500; + + // Axis 1: / lines (slope 0.5, dx = 2 * dy) + for (let c = -600; c <= 1600; c += spacing * 2) { + const x1 = c; + const y1 = yStart; + const x2 = c + (yEnd - yStart) * 2; + const y2 = yEnd; + lines.push( + + ); + } + + // Axis 2: \ lines (slope -0.5, dx = -2 * dy) + for (let c = -400; c <= 1800; c += spacing * 2) { + const x1 = c; + const y1 = yStart; + const x2 = c - (yEnd - yStart) * 2; + const y2 = yEnd; + lines.push( + + ); + } + + return {lines}; +} + +/** + * Architectural Stepped Terraces: + * 5 ground steps on the floor along the 2:1 isometric grid, + * plus the full-sized large center workstation platform with bold 20px blue drop apron + * and the elevated stepped workstation tier. + */ +function SteppedTerraces() { + return ( + + + {/* Floor Step Fade Mask: solid at front (y=400..360), smooth fade backwards (y=210) */} + + + + + + + + + + + + + + {/* 1. 3D FRONT VERTICAL DROP APRON (5 STEPS: Steps 2 to 6, x=200 to 1000) */} + + {/* Step 2 (Phone) */} + + + + {/* Step 3 (LinkedIn) */} + + + + {/* Step 4 (Center Platform) */} + + + + {/* Step 5 (GitHub) */} + + + + {/* Step 6 (Telegram) */} + + + + {/* Vertical corner edges at peaks */} + + + + + + + {/* Vertical corner edges at valleys and outer ends */} + + + + + + + + + {/* 2. FADING GROUND FLOOR TOP SURFACE & OUTLINE BORDERS */} + + + + {/* Fading Diamond Tile Outlines */} + + + + + + + + + + + + + + + + + {/* 3. CENTER ELEVATED WORKSTATION PLATFORM (TIER 1 & TIER 2) */} + + {/* Tier 1 3D Base Diamond Platform with Sleek 15px Blue Drop Edge (248px wide) */} + {/* Left Front Drop Face (Slope +0.5 from 476,290 to 600,352, Drop Height = 15px) */} + + {/* Right Front Drop Face (Slope -0.5 from 600,352 to 724,290, Drop Height = 15px) */} + + {/* Center Vertical Ridge Line */} + + + {/* Tier 1 White Diamond Top Surface (Well-Proportioned 248px wide: x=476 to 724, y=228 to 352) */} + + + {/* Tier 2: Refined & Compact 3D Elevated Stepped Workstation Platform (Drop Height = 12px) */} + {/* Front-left drop 1 (under monitor) */} + + {/* Front-left drop 2 (middle step in front of pencil) */} + + {/* Front-right drop (middle step facing SE) */} + + {/* Inner corner step drop (facing right towards PC Tower) */} + + + + {/* Tier 2: White Top Surfaces of the 3 Workstation Step Tiles */} + {/* Left Tile (holding Monitor) */} + + {/* Center Middle Tile */} + + {/* Back Tile (holding Pencil) */} + + + {/* Dividing Grid Lines on the Step */} + + + + + + ); +} + +/** + * Glowing Blue Circuit Lines + * Completely uniform 2:1 isometric slope (26.565°, dy/dx = ±0.5) across ALL conduit segments + */ +function CircuitConduits({ activePost }: { activePost: string | null }) { + return ( + + + + + + + + {/* Left Branch: Phone -> Step 3 -> LinkedIn -> Center Platform with exact ±0.5 slope */} + + + {/* Right Branch: Center Platform -> GitHub -> Step 5 -> Telegram with exact ±0.5 slope */} + + + ); +} + +/** + * Main Isometric Footer Scene SVG Component + */ +export default function IsometricScene({ + activePost, + onPostHover, + onPostClick, +}: IsometricSceneProps) { + return ( + + {/* 1. Faint Isometric Ground Grid */} + + + {/* 2. Solid 3D Architectural Stepped Terraces */} + + + {/* 3. Glowing Royal Blue Circuit Conduits */} + + + {/* 4. Left Mountain Cluster */} + + + {/* 5. Right Mountain Cluster (mirrored) */} + + + {/* 6. Pine Trees on the floor along ground line */} + + + + + + + + {/* 7. Central Workstation Illustrations resting flush on the large platform */} + + {/* Prominent Monitor Display resting on left elevated step */} + + {/* Majestic Upright Faceted Pencil resting on back elevated step */} + + {/* Vertical PC Tower on Pedestal Plate resting on Tier 1 */} + + + + {/* 8. The 4 Interactive Isometric Signposts rotated in 3D */} + } + label="09388263077" + isActive={activePost === 'phone'} + onMouseEnter={() => onPostHover?.('phone')} + onMouseLeave={() => onPostHover?.(null)} + onClick={() => onPostClick?.('phone')} + /> + + } + label="@amiirkhl" + isActive={activePost === 'linkedin'} + onMouseEnter={() => onPostHover?.('linkedin')} + onMouseLeave={() => onPostHover?.(null)} + onClick={() => onPostClick?.('linkedin')} + /> + + } + label="git.amiirkhl.ir" + isActive={activePost === 'github'} + onMouseEnter={() => onPostHover?.('github')} + onMouseLeave={() => onPostHover?.(null)} + onClick={() => onPostClick?.('github')} + /> + + } + label="@amiirkhl" + isActive={activePost === 'telegram'} + onMouseEnter={() => onPostHover?.('telegram')} + onMouseLeave={() => onPostHover?.(null)} + onClick={() => onPostClick?.('telegram')} + /> + + ); +} diff --git a/src/components/SiteLoader.tsx b/src/components/SiteLoader.tsx index 7575a3b..ef0f960 100644 --- a/src/components/SiteLoader.tsx +++ b/src/components/SiteLoader.tsx @@ -22,8 +22,6 @@ const getDarkImagePath = (image: string) => const SITE_ASSETS = Array.from( new Set([ '/images/avatar.png', - '/assets/footer-background 1.png', - '/assets/footer-background-dark.png', ...projects.flatMap((project) => [ project.image, ...(DARK_PROJECT_IDS.has(project.id) diff --git a/src/components/footerContacts.ts b/src/components/footerContacts.ts deleted file mode 100644 index b78adef..0000000 --- a/src/components/footerContacts.ts +++ /dev/null @@ -1,40 +0,0 @@ -export type FooterContactId = 'phone' | 'linkedin' | 'gitea' | 'telegram'; - -export interface FooterContact { - id: FooterContactId; - value: string; - href: string; - ariaLabel: string; - external: boolean; -} - -export const footerContacts: readonly FooterContact[] = [ - { - id: 'phone', - value: '09388263077', - href: 'tel:+989388263077', - ariaLabel: 'Call 09388263077', - external: false, - }, - { - id: 'linkedin', - value: '@amiirkhl', - href: 'https://linkedin.com/in/amiirkhl', - ariaLabel: 'LinkedIn profile', - external: true, - }, - { - id: 'gitea', - value: 'git.amiirkhl.ir', - href: 'https://git.amiirkhl.ir', - ariaLabel: 'Personal Git server', - external: true, - }, - { - id: 'telegram', - value: '@amiirkhl', - href: 'https://t.me/amiirkhl', - ariaLabel: 'Telegram profile', - external: true, - }, -] as const; diff --git a/src/components/scenes/FooterScene.tsx b/src/components/scenes/FooterScene.tsx deleted file mode 100644 index 85f59e1..0000000 --- a/src/components/scenes/FooterScene.tsx +++ /dev/null @@ -1,600 +0,0 @@ -'use client'; - -import * as THREE from 'three'; -import { useTheme } from '@/context/ThemeContext'; -import { - footerContacts, - type FooterContact, - type FooterContactId, -} from '../footerContacts'; -import { ThreeCanvas, type SceneFactory } from './ThreeCanvas'; -import { - addSoftLights, - createMesa, - createTree, - fitOrthographicCamera, - palettes, - type ScenePalette, -} from './scenePrimitives'; -import styles from './Scenes.module.css'; - -const DESKTOP_MARKERS: Record = { - phone: [-7.2, 0.4], - linkedin: [-3.8, -0.45], - gitea: [3.8, -0.45], - telegram: [7.2, 0.4], -}; - -const MOBILE_MARKERS: Record = { - phone: [-4.25, 0.15], - linkedin: [-1.55, -0.85], - gitea: [1.55, -0.85], - telegram: [4.25, 0.15], -}; - -const colorToCss = (color: number) => `#${color.toString(16).padStart(6, '0')}`; - -function roundedRect( - context: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number, -) { - context.beginPath(); - context.moveTo(x + radius, y); - context.lineTo(x + width - radius, y); - context.quadraticCurveTo(x + width, y, x + width, y + radius); - context.lineTo(x + width, y + height - radius); - context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); - context.lineTo(x + radius, y + height); - context.quadraticCurveTo(x, y + height, x, y + height - radius); - context.lineTo(x, y + radius); - context.quadraticCurveTo(x, y, x + radius, y); - context.closePath(); -} - -function drawMarkerIcon( - context: CanvasRenderingContext2D, - id: FooterContactId, - color: string, -) { - context.save(); - context.strokeStyle = color; - context.fillStyle = color; - context.lineCap = 'round'; - context.lineJoin = 'round'; - - if (id === 'linkedin') { - context.font = '700 74px Arial, sans-serif'; - context.textAlign = 'center'; - context.textBaseline = 'middle'; - context.fillText('in', 96, 103); - } else if (id === 'telegram') { - context.beginPath(); - context.moveTo(48, 93); - context.lineTo(145, 50); - context.lineTo(124, 143); - context.lineTo(91, 112); - context.lineTo(72, 132); - context.lineTo(76, 103); - context.closePath(); - context.fill(); - context.strokeStyle = '#f8faff'; - context.lineWidth = 5; - context.beginPath(); - context.moveTo(78, 103); - context.lineTo(124, 72); - context.stroke(); - } else if (id === 'gitea') { - context.lineWidth = 10; - context.beginPath(); - context.arc(96, 94, 39, Math.PI * 0.1, Math.PI * 1.9); - context.stroke(); - context.beginPath(); - context.moveTo(75, 94); - context.lineTo(96, 73); - context.lineTo(119, 98); - context.stroke(); - [[75, 94], [96, 73], [119, 98]].forEach(([nodeX, nodeY]) => { - context.beginPath(); - context.arc(nodeX, nodeY, 8, 0, Math.PI * 2); - context.fill(); - }); - } else { - context.lineWidth = 16; - context.beginPath(); - context.moveTo(64, 57); - context.bezierCurveTo(55, 76, 67, 111, 89, 128); - context.bezierCurveTo(105, 140, 128, 143, 140, 128); - context.stroke(); - context.lineWidth = 20; - context.beginPath(); - context.moveTo(63, 58); - context.lineTo(77, 78); - context.moveTo(123, 115); - context.lineTo(141, 128); - context.stroke(); - } - context.restore(); -} - -function createMarkerTexture(contact: FooterContact, palette: ScenePalette) { - const canvas = document.createElement('canvas'); - canvas.width = 192; - canvas.height = 192; - const context = canvas.getContext('2d'); - if (!context) return new THREE.CanvasTexture(canvas); - - roundedRect(context, 14, 14, 164, 164, 28); - context.fillStyle = colorToCss(palette.ground); - context.fill(); - context.lineWidth = 6; - context.strokeStyle = colorToCss(palette.accent); - context.stroke(); - drawMarkerIcon(context, contact.id, colorToCss(palette.edge)); - - const texture = new THREE.CanvasTexture(canvas); - texture.colorSpace = THREE.SRGBColorSpace; - texture.minFilter = THREE.LinearFilter; - texture.magFilter = THREE.LinearFilter; - return texture; -} - -function createCelestialTexture(theme: 'light' | 'dark') { - const canvas = document.createElement('canvas'); - canvas.width = 192; - canvas.height = 192; - const context = canvas.getContext('2d'); - if (!context) return new THREE.CanvasTexture(canvas); - - context.translate(96, 96); - context.fillStyle = theme === 'light' ? '#ffe66d' : '#fffdf0'; - if (theme === 'light') { - for (let index = 0; index < 12; index += 1) { - context.save(); - context.rotate((index / 12) * Math.PI * 2); - context.fillRect(-4, -78, 8, 24); - context.restore(); - } - context.beginPath(); - context.arc(0, 0, 41, 0, Math.PI * 2); - context.fill(); - } else { - context.beginPath(); - context.arc(0, 0, 51, 0, Math.PI * 2); - context.fill(); - context.globalCompositeOperation = 'destination-out'; - context.beginPath(); - context.arc(25, -17, 51, 0, Math.PI * 2); - context.fill(); - } - - const texture = new THREE.CanvasTexture(canvas); - texture.colorSpace = THREE.SRGBColorSpace; - return texture; -} - -const createFooterScene: SceneFactory = ({ renderer, pointer, theme, reduceMotion }) => { - const palette = palettes[theme]; - const scene = new THREE.Scene(); - const camera = new THREE.OrthographicCamera(-16, 16, 8, -8, 0.1, 80); - const cameraBase = new THREE.Vector3(15, 15, 18); - camera.position.copy(cameraBase); - camera.lookAt(0, 0.5, 0); - addSoftLights(scene, theme, 22); - - const world = new THREE.Group(); - world.position.y = -1.55; - scene.add(world); - - const edgeMaterial = new THREE.LineBasicMaterial({ - color: palette.edge, - transparent: true, - opacity: theme === 'dark' ? 0.58 : 0.5, - }); - const groundMaterial = new THREE.MeshStandardMaterial({ - color: palette.ground, - roughness: 0.95, - }); - const softGroundMaterial = new THREE.MeshStandardMaterial({ - color: theme === 'dark' ? 0x061943 : 0xffffff, - roughness: 0.95, - }); - const accentMaterial = new THREE.MeshStandardMaterial({ - color: palette.accent, - emissive: palette.accent, - emissiveIntensity: theme === 'dark' ? 0.24 : 0.08, - roughness: 0.72, - }); - - const gridVertices: number[] = []; - for (let value = -24; value <= 24; value += 1) { - gridVertices.push(-24, -0.34, value, 24, -0.34, value); - gridVertices.push(value, -0.34, -24, value, -0.34, 24); - } - const gridGeometry = new THREE.BufferGeometry(); - gridGeometry.setAttribute('position', new THREE.Float32BufferAttribute(gridVertices, 3)); - const grid = new THREE.LineSegments( - gridGeometry, - new THREE.ShaderMaterial({ - transparent: true, - depthWrite: false, - uniforms: { - gridColor: { value: new THREE.Color(palette.edge) }, - gridOpacity: { value: theme === 'dark' ? 0.2 : 0.17 }, - }, - vertexShader: ` - varying vec3 gridPosition; - void main() { - gridPosition = position; - gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); - } - `, - fragmentShader: ` - uniform vec3 gridColor; - uniform float gridOpacity; - varying vec3 gridPosition; - void main() { - float distanceFromCenter = length(gridPosition.xz); - float fade = 1.0 - smoothstep(7.0, 24.0, distanceFromCenter); - gl_FragColor = vec4(gridColor, gridOpacity * fade); - } - `, - }), - ); - world.add(grid); - - const shadowGround = new THREE.Mesh( - new THREE.PlaneGeometry(48, 30), - new THREE.ShadowMaterial({ - color: 0x000000, - opacity: theme === 'dark' ? 0.22 : 0.07, - transparent: true, - }), - ); - shadowGround.rotation.x = -Math.PI / 2; - shadowGround.position.y = -0.36; - shadowGround.receiveShadow = true; - world.add(shadowGround); - - const addPlate = ( - parent: THREE.Group, - x: number, - z: number, - width: number, - depth: number, - height = 0.42, - material: THREE.Material = groundMaterial, - ) => { - const geometry = new THREE.BoxGeometry(width, height, depth); - const plate = new THREE.Mesh(geometry, material); - plate.position.set(x, height / 2 - 0.28, z); - plate.castShadow = true; - plate.receiveShadow = true; - plate.add(new THREE.LineSegments(new THREE.EdgesGeometry(geometry, 24), edgeMaterial)); - parent.add(plate); - return plate; - }; - - const plates = new THREE.Group(); - world.add(plates); - [ - [-10.2, 1.2, 7.6, 6.2], - [-5.2, 0.5, 6.5, 5.6], - [0, 0.85, 7.2, 5.8], - [5.2, 0.5, 6.5, 5.6], - [10.2, 1.2, 7.6, 6.2], - ].forEach(([x, z, width, depth]) => addPlate(plates, x, z, width, depth)); - addPlate(plates, 0, 0.75, 5.2, 4.1, 0.68, softGroundMaterial); - addPlate(plates, 0, 0.75, 4.25, 3.2, 0.96, groundMaterial); - - const outerLandscape = new THREE.Group(); - world.add(outerLandscape); - [ - [-13.2, 2.2, 3.7, 4.8, 3.6], - [-10.7, 3, 2.7, 3.4, 2.4], - [-14.6, -0.6, 2.8, 3.2, 2.2], - [13.2, 2.2, 3.7, 4.8, 3.6], - [10.7, 3, 2.7, 3.4, 2.4], - [14.6, -0.6, 2.8, 3.2, 2.2], - ].forEach(([x, z, width, height, depth]) => { - const mesa = createMesa(width, height, depth, palette); - mesa.position.set(x, 0, z); - outerLandscape.add(mesa); - }); - - const trees = new THREE.Group(); - const secondaryTrees = new THREE.Group(); - world.add(trees, secondaryTrees); - [ - [-11.3, -1.2, 0.72], [-9.1, 2.8, 0.62], [-7.9, -1.5, 0.6], - [11.3, -1.2, 0.72], [9.1, 2.8, 0.62], [7.9, -1.5, 0.6], - ].forEach(([x, z, scale]) => { - const tree = createTree(palette, scale); - tree.position.set(x, 0.02, z); - trees.add(tree); - }); - [ - [-13.8, 4.4, 0.55], [-6.5, 2.9, 0.5], - [13.8, 4.4, 0.55], [6.5, 2.9, 0.5], - ].forEach(([x, z, scale]) => { - const tree = createTree(palette, scale); - tree.position.set(x, 0.02, z); - secondaryTrees.add(tree); - }); - - const workstation = new THREE.Group(); - workstation.position.set(0, 0.42, 0.75); - world.add(workstation); - - const monitor = new THREE.Group(); - monitor.rotation.y = Math.PI / 4; - monitor.position.set(-0.65, 0.75, 0.1); - const screenGeometry = new THREE.BoxGeometry(2.05, 1.25, 0.12); - const screen = new THREE.Mesh(screenGeometry, accentMaterial); - screen.castShadow = true; - screen.add(new THREE.LineSegments(new THREE.EdgesGeometry(screenGeometry), edgeMaterial)); - screen.position.y = 1.04; - const monitorStand = new THREE.Mesh( - new THREE.CylinderGeometry(0.1, 0.14, 0.8, 8), - new THREE.MeshStandardMaterial({ color: palette.edge, roughness: 0.84 }), - ); - monitorStand.position.y = 0.4; - const monitorFoot = new THREE.Mesh( - new THREE.BoxGeometry(0.95, 0.1, 0.55), - new THREE.MeshStandardMaterial({ color: palette.mountainLight, roughness: 0.86 }), - ); - monitor.add(screen, monitorStand, monitorFoot); - workstation.add(monitor); - - const pencil = new THREE.Group(); - const pencilBody = new THREE.Mesh( - new THREE.CylinderGeometry(0.16, 0.16, 2.6, 6), - new THREE.MeshStandardMaterial({ - color: palette.mountain, - flatShading: true, - roughness: 0.86, - }), - ); - pencilBody.castShadow = true; - const pencilTip = new THREE.Mesh( - new THREE.ConeGeometry(0.17, 0.48, 6), - new THREE.MeshStandardMaterial({ color: palette.snow, flatShading: true, roughness: 0.92 }), - ); - pencilTip.position.y = 1.54; - pencil.add(pencilBody, pencilTip); - pencil.position.set(0.65, 1.3, 0.15); - workstation.add(pencil); - - const productGeometry = new THREE.BoxGeometry(1.15, 1.15, 1.15); - const product = new THREE.Mesh( - productGeometry, - new THREE.MeshStandardMaterial({ color: palette.mountain, roughness: 0.86 }), - ); - product.position.set(1.65, 0.58, 0.25); - product.castShadow = true; - product.add(new THREE.LineSegments(new THREE.EdgesGeometry(productGeometry), edgeMaterial)); - workstation.add(product); - - const connectorMaterial = new THREE.MeshBasicMaterial({ color: palette.accent }); - const desktopConnectors = new THREE.Group(); - const mobileConnectors = new THREE.Group(); - world.add(desktopConnectors, mobileConnectors); - - const addConnectorSegment = ( - parent: THREE.Group, - from: THREE.Vector3, - to: THREE.Vector3, - ) => { - const direction = to.clone().sub(from); - const geometry = new THREE.CylinderGeometry(0.045, 0.045, direction.length(), 6); - const segment = new THREE.Mesh(geometry, connectorMaterial); - segment.position.copy(from).add(to).multiplyScalar(0.5); - segment.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), direction.normalize()); - parent.add(segment); - }; - - const addConnectors = ( - parent: THREE.Group, - positions: Record, - ) => { - footerContacts.forEach(({ id }) => { - const [x, z] = positions[id]; - const elbowX = x * 0.52; - addConnectorSegment( - parent, - new THREE.Vector3(Math.sign(x) * 2.1, 0.28, 0.72), - new THREE.Vector3(elbowX, 0.28, z - 0.85), - ); - addConnectorSegment( - parent, - new THREE.Vector3(elbowX, 0.28, z - 0.85), - new THREE.Vector3(x, 0.28, z), - ); - }); - }; - addConnectors(desktopConnectors, DESKTOP_MARKERS); - addConnectors(mobileConnectors, MOBILE_MARKERS); - mobileConnectors.visible = false; - - const markerGroups = new Map(); - const markerTargets: THREE.Sprite[] = []; - footerContacts.forEach((contact) => { - const group = new THREE.Group(); - const [x, z] = DESKTOP_MARKERS[contact.id]; - group.position.set(x, 0.18, z); - group.userData.baseY = group.position.y; - group.userData.hovered = false; - - const pole = new THREE.Mesh( - new THREE.CylinderGeometry(0.065, 0.085, 1.55, 8), - new THREE.MeshStandardMaterial({ color: palette.edge, roughness: 0.86 }), - ); - pole.position.y = 0.77; - pole.castShadow = true; - const foot = new THREE.Mesh( - new THREE.CylinderGeometry(0.32, 0.4, 0.12, 16), - new THREE.MeshStandardMaterial({ color: palette.accentSoft, roughness: 0.8 }), - ); - foot.position.y = 0.06; - const marker = new THREE.Sprite( - new THREE.SpriteMaterial({ - map: createMarkerTexture(contact, palette), - transparent: true, - depthWrite: false, - }), - ); - marker.position.y = 1.82; - marker.scale.setScalar(1.55); - marker.userData.contactId = contact.id; - group.add(pole, foot, marker); - world.add(group); - markerGroups.set(contact.id, group); - markerTargets.push(marker); - }); - - const celestial = new THREE.Sprite( - new THREE.SpriteMaterial({ - map: createCelestialTexture(theme), - transparent: true, - depthWrite: false, - }), - ); - celestial.position.set(9.8, 7.2, 0); - celestial.scale.setScalar(2.3); - scene.add(celestial); - - const raycaster = new THREE.Raycaster(); - const hitPointer = new THREE.Vector2(); - let hoveredMarker: THREE.Sprite | null = null; - let pointerDown: { id: FooterContactId; x: number; y: number } | null = null; - - const getMarkerAt = (event: PointerEvent) => { - const bounds = renderer.domElement.getBoundingClientRect(); - if (!bounds.width || !bounds.height) return null; - hitPointer.set( - ((event.clientX - bounds.left) / bounds.width) * 2 - 1, - -(((event.clientY - bounds.top) / bounds.height) * 2 - 1), - ); - raycaster.setFromCamera(hitPointer, camera); - return (raycaster.intersectObjects(markerTargets, false)[0]?.object as THREE.Sprite | undefined) ?? null; - }; - - const renderNow = () => renderer.render(scene, camera); - const setHoveredMarker = (next: THREE.Sprite | null) => { - if (hoveredMarker === next) return; - if (hoveredMarker) { - const previousId = hoveredMarker.userData.contactId as FooterContactId; - const previousGroup = markerGroups.get(previousId); - if (previousGroup) previousGroup.userData.hovered = false; - } - hoveredMarker = next; - if (hoveredMarker) { - const nextId = hoveredMarker.userData.contactId as FooterContactId; - const nextGroup = markerGroups.get(nextId); - if (nextGroup) nextGroup.userData.hovered = true; - } - renderer.domElement.style.cursor = hoveredMarker ? 'pointer' : ''; - if (reduceMotion) renderNow(); - }; - - const onPointerMove = (event: PointerEvent) => setHoveredMarker(getMarkerAt(event)); - const onPointerLeave = () => { - pointerDown = null; - setHoveredMarker(null); - }; - const onPointerDown = (event: PointerEvent) => { - if (event.pointerType === 'mouse' && event.button !== 0) return; - const marker = getMarkerAt(event); - if (!marker) return; - pointerDown = { - id: marker.userData.contactId as FooterContactId, - x: event.clientX, - y: event.clientY, - }; - }; - const onPointerUp = (event: PointerEvent) => { - const marker = getMarkerAt(event); - const activePointer = pointerDown; - pointerDown = null; - if (!marker || !activePointer) return; - const markerId = marker.userData.contactId as FooterContactId; - const movement = Math.hypot(event.clientX - activePointer.x, event.clientY - activePointer.y); - if (markerId !== activePointer.id || movement > 8) return; - const contact = footerContacts.find((item) => item.id === markerId); - if (!contact) return; - if (contact.external) { - window.open(contact.href, '_blank', 'noopener,noreferrer'); - } else { - window.location.href = contact.href; - } - }; - const onPointerCancel = () => { - pointerDown = null; - }; - - const canvas = renderer.domElement; - canvas.addEventListener('pointermove', onPointerMove, { passive: true }); - canvas.addEventListener('pointerleave', onPointerLeave); - canvas.addEventListener('pointerdown', onPointerDown, { passive: true }); - canvas.addEventListener('pointerup', onPointerUp, { passive: true }); - canvas.addEventListener('pointercancel', onPointerCancel); - - return { - scene, - camera, - resize: (width, height) => { - const mobile = width < 640; - const tablet = width < 1024; - outerLandscape.visible = !mobile; - secondaryTrees.visible = !tablet; - trees.visible = !mobile; - celestial.visible = !mobile; - desktopConnectors.visible = !mobile; - mobileConnectors.visible = mobile; - grid.scale.setScalar(mobile ? 0.62 : tablet ? 0.84 : 1); - world.scale.setScalar(mobile ? 0.82 : tablet ? 0.92 : 1); - world.position.y = mobile ? -1.05 : tablet ? -1.35 : -1.55; - const positions = mobile ? MOBILE_MARKERS : DESKTOP_MARKERS; - markerGroups.forEach((group, id) => { - const [x, z] = positions[id]; - group.position.x = x; - group.position.z = z; - }); - fitOrthographicCamera(camera, width, height, mobile ? 14.5 : tablet ? 15.5 : 16.5); - }, - update: (elapsed) => { - markerGroups.forEach((group, id) => { - const hovered = Boolean(group.userData.hovered); - const desiredScale = hovered ? 1.1 : 1; - group.scale.setScalar(group.scale.x + (desiredScale - group.scale.x) * 0.13); - const baseY = Number(group.userData.baseY); - const idleOffset = reduceMotion ? 0 : Math.sin(elapsed * 0.75 + id.length) * 0.015; - const desiredY = baseY + (hovered ? 0.22 : 0) + idleOffset; - group.position.y += (desiredY - group.position.y) * 0.14; - }); - if (!reduceMotion) { - pencil.rotation.y = Math.sin(elapsed * 0.45) * 0.035; - celestial.material.rotation = elapsed * 0.025; - camera.position.x += (cameraBase.x + pointer.x * 0.18 - camera.position.x) * 0.025; - camera.position.z += (cameraBase.z - pointer.x * 0.14 - camera.position.z) * 0.025; - camera.lookAt(0, 0.5, 0); - } - }, - dispose: () => { - canvas.removeEventListener('pointermove', onPointerMove); - canvas.removeEventListener('pointerleave', onPointerLeave); - canvas.removeEventListener('pointerdown', onPointerDown); - canvas.removeEventListener('pointerup', onPointerUp); - canvas.removeEventListener('pointercancel', onPointerCancel); - canvas.style.cursor = ''; - }, - }; -}; - -export function FooterScene() { - const { theme } = useTheme(); - return ; -} diff --git a/src/components/scenes/Scenes.module.css b/src/components/scenes/Scenes.module.css index 3e9a072..73fc805 100644 --- a/src/components/scenes/Scenes.module.css +++ b/src/components/scenes/Scenes.module.css @@ -1,6 +1,5 @@ .heroScene, -.aboutScene, -.footerScene { +.aboutScene { position: absolute; inset: 0; overflow: hidden; @@ -31,27 +30,16 @@ } .heroScene canvas, -.aboutScene canvas, -.footerScene canvas { +.aboutScene canvas { display: block; width: 100%; height: 100%; } -.footerScene canvas { - touch-action: manipulation; -} - .aboutScene { z-index: 0; } -.footerScene { - z-index: 1; - background: transparent; - pointer-events: auto; -} - .aboutScene::after { content: ''; position: absolute; @@ -90,4 +78,3 @@ width: 100vw; } } - diff --git a/tests/footer-scene.test.mjs b/tests/footer-scene.test.mjs index 464a7bb..a377753 100644 --- a/tests/footer-scene.test.mjs +++ b/tests/footer-scene.test.mjs @@ -2,14 +2,22 @@ import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import test from 'node:test'; -test('the Three.js footer scene preserves the real contact destinations', async () => { - const contacts = await readFile('src/components/footerContacts.ts', 'utf8'); - const scene = await readFile('src/components/scenes/FooterScene.tsx', 'utf8'); +test('the isometric footer preserves the real contact destinations and email', async () => { + const footer = await readFile('src/components/IsometricFooter/IsometricFooter.tsx', 'utf8'); - assert.match(contacts, /tel:\+989388263077/); - assert.match(contacts, /linkedin\.com\/in\/amiirkhl/); - assert.match(contacts, /https:\/\/git\.amiirkhl\.ir/); - assert.match(contacts, /https:\/\/t\.me\/amiirkhl/); - assert.match(scene, /Raycaster/); - assert.match(scene, /FooterContactId/); + assert.match(footer, /tel:09388263077/); + assert.match(footer, /linkedin\.com\/in\/amiirkhl/); + assert.match(footer, /https:\/\/git\.amiirkhl\.ir/); + assert.match(footer, /https:\/\/t\.me\/amiirkhl/); + assert.match(footer, /mailto:amirhosseinkhalili901@gmail\.com/); +}); + +test('the footer renders the IsometricFooter scene instead of preloading PNG artwork', async () => { + const footerSection = await readFile('src/components/FooterSection.tsx', 'utf8'); + const loader = await readFile('src/components/SiteLoader.tsx', 'utf8'); + + assert.match(footerSection, //); + assert.doesNotMatch(footerSection, /next\/image/); + assert.doesNotMatch(footerSection, /footer-background.*\.png/); + assert.doesNotMatch(loader, /footer-background.*\.png/); });