feat(footer): implement responsive 3D isometric SVG footer scene with theme tokens
Some checks failed
Test and deploy / Verify production build (push) Has been cancelled
Test and deploy / Deploy production (push) Has been cancelled

This commit is contained in:
2026-09-08 11:31:36 +03:30
parent 0ed36b3c32
commit 88adced09f
19 changed files with 1471 additions and 954 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 457 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 516 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -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);
}

View File

@@ -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 = () => (
<svg viewBox="2 2 20 20" aria-hidden="true">
<path d="M6.62 10.79a15.46 15.46 0 0 0 6.59 6.59l2.2-2.2a1.02 1.02 0 0 1 1.02-.24c1.12.37 2.33.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1C10.61 21 3 13.39 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2Z" />
</svg>
);
const LinkedInIcon = () => (
<svg viewBox="0 0 448 512" aria-hidden="true">
<path d="M100.28 448H7.4V148.9h92.88ZM53.79 108.1C24.09 108.1 0 83.5 0 53.8A53.79 53.79 0 0 1 107.58 53.8c0 29.7-24.09 54.3-53.79 54.3ZM447.9 448h-92.68V302.4c0-34.7-.7-79.2-48.29-79.2-48.29 0-55.69 37.7-55.69 76.7V448h-92.78V148.9h89.08v40.8h1.3c12.4-23.5 42.69-48.3 87.88-48.3 94 0 111.28 61.9 111.28 142.3V448Z" />
</svg>
);
const GiteaIcon = () => (
<svg viewBox="0 0 640 640" aria-hidden="true">
<path d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2l-16.6-7.9-.1-109.2c-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9-45 40.1-33.5 103.9-32.1 113.5 1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4s12.9-13.8 30.9-45.3c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6ZM125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1Zm300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c.6-.3 2.3-1 4.5-1.5 8.1-2.1 18 2.8 18 2.8L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1Z" />
<path className={styles.giteaCutout} d="m395.9 484.2-126.9-61c-12.5-6-17.9-21.2-11.8-33.8l61-126.9c6-12.5 21.2-17.9 33.8-11.8l27.1 13-.1-109.2 16.7-.1.1 117.1s57.4 24.2 83.1 40.1c3.7 2.3 10.2 6.8 12.9 14.4 2.1 6.1 2 13.1-1 19.3l-61 126.9c-6.2 12.7-21.4 18.1-33.9 12Z" />
<path d="M326.8 380.1a18 18 0 1 0 14.5 28.4l24-49.1c1.5.1 3.7.2 6.2-.5 4.1-.9 7.1-3.6 7.1-3.6 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3.9.5 1.8 1.1 2.8 1.9 1.6 1.3 3.4 3.1 4.7 5.5 1.9 5.5-1.9 14.9-1.9 14.9-2.3 7.6-18.4 40.6-18.4 40.6a18 18 0 1 0 16.6 5.9c5-6.8 4.6-16.3-1.1-22.6 1.9-3.7 3.7-7.4 5.6-11.3 5-10.4 13.5-30.4 13.5-30.4.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3l14.1-29-12.2-6.1-14.5 29.5c-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8l-24.6 50.4Z" />
</svg>
);
const TelegramIcon = () => (
<svg viewBox="0 32 448 448" aria-hidden="true">
<path d="M446.7 98.6 379.1 417.8c-5.1 22.5-18.4 28.1-37.3 17.5l-103-76-49.7 47.8c-5.5 5.5-10.1 10.1-20.7 10.1l7.4-104.9 190.9-172.5c8.3-7.4-1.8-11.5-12.9-4.1L117.8 284.3 16.2 252.5c-22.1-6.9-22.5-22.1 4.6-32.7L418 66.8c18.4-6.9 34.5 4.1 28.7 31.8Z" />
</svg>
);
export const FooterSection: React.FC = () => {
const { theme } = useTheme();
return (
<footer id="footer" className={styles.footerSection}>
{/* Background artwork */}
<div className={styles.bgContainer}>
<Image
src={
theme === 'dark'
? '/assets/footer-background-dark.png'
: '/assets/footer-background 1.png'
}
alt=""
aria-hidden="true"
fill
className={styles.bgImage}
/>
</div>
{/* Content */}
<div className={styles.content} data-reveal="scale">
<h2 className={`font-display ${styles.ctaTitle}`}>
Let&apos;s build something meaningful.
</h2>
<p className={styles.ctaSubtitle}>
Open to opportunities and interesting projects.
</p>
<a href="mailto:amirhosseinkhalili901@gmail.com" className={`pill-btn pill-btn-primary ${styles.emailButton}`}>
<Mail size={19} strokeWidth={2} aria-hidden="true" />
Send me an email
</a>
<div className={styles.socialRow}>
<a href="tel:+989388263077" className={styles.socialItem} aria-label="Call 09388263077">
<span className={styles.socialIcon}><PhoneIcon /></span>
<span className={styles.socialLabel}>09388263077</span>
</a>
<a
href="https://linkedin.com/in/amiirkhl"
target="_blank"
rel="noopener noreferrer"
className={styles.socialItem}
aria-label="LinkedIn profile"
>
<span className={styles.socialIcon}><LinkedInIcon /></span>
<span className={styles.socialLabel}>@amiirkhl</span>
</a>
<a
href="https://git.amiirkhl.ir"
target="_blank"
rel="noopener noreferrer"
className={styles.socialItem}
aria-label="Personal Git server"
>
<span className={styles.socialIcon}><GiteaIcon /></span>
<span className={styles.socialLabel}>git.amiirkhl.ir</span>
</a>
<a
href="https://t.me/amiirkhl"
target="_blank"
rel="noopener noreferrer"
className={styles.socialItem}
aria-label="Telegram profile"
>
<span className={styles.socialIcon}><TelegramIcon /></span>
<span className={styles.socialLabel}>@amiirkhl</span>
</a>
</div>
</div>
<IsometricFooter />
</footer>
);
};

View File

@@ -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 (
<g>
<polygon points={leftPts} fill={left} stroke={stroke} strokeWidth={stroke === 'none' ? '0' : '1'} />
<polygon points={rightPts} fill={right} stroke={stroke} strokeWidth={stroke === 'none' ? '0' : '1'} />
<polygon points={topPts} fill={top} stroke={stroke} strokeWidth={stroke === 'none' ? '0' : '1'} />
</g>
);
}
/**
* 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 (
<g transform={`translate(${x} ${y}) scale(${scale})`}>
{boxes.map((b, i) => (
<IsoBox
key={i}
x={b.dx}
y={b.dy}
s={b.s}
h={b.h}
top="var(--hero-illus-cube-top, #9DCDFD)"
left="var(--hero-illus-cube-front, #2066AB)"
right="var(--hero-illus-cube-side, #0E4073)"
stroke="none"
/>
))}
</g>
);
}
/**
* 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 (
<g transform={`translate(${x} ${y}) scale(${scale})`}>
{/* Capsule Trunk */}
<path
d="M-4.044 -8.583C-4.044 -10.544 -2.455 -12.133 -0.494 -12.133H0.494C2.455 -12.133 4.044 -10.544 4.044 -8.583V-3.551C4.044 -1.59 2.455 0 0.494 0H-0.494C-2.455 0 -4.044 -1.59 -4.044 -3.551Z"
fill={trunkColor}
/>
{/* Foliage */}
<path
d="M12.551 -18.365L0 -56.958L-12.551 -18.365C-13.154 -16.509 -13.163 -14.402 -11.681 -13.133C-9.714 -11.447 -6.067 -9.774 0 -9.774C6.067 -9.774 9.714 -11.447 11.681 -13.133C13.163 -14.402 13.154 -16.509 12.551 -18.365Z"
fill={foliageColor}
/>
</g>
);
}
/**
* Modern SVG Icons for Top Contact Bar and Signposts
*/
export const PhoneIcon: React.FC<{ size?: number; color?: string }> = ({
size = 20,
color = 'currentColor',
}) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill={color} aria-hidden="true">
<path d="M6.62 10.79a15.46 15.46 0 0 0 6.59 6.59l2.2-2.2a1.02 1.02 0 0 1 1.02-.24c1.12.37 2.33.57 3.57.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1C10.61 21 3 13.39 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2Z" />
</svg>
);
export const LinkedInIcon: React.FC<{ size?: number; color?: string }> = ({
size = 20,
color = 'currentColor',
}) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill={color} aria-hidden="true">
<path d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h14m-.5 15.5v-5.3a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.28 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1 1.4 1.4v4.93h2.75M6.88 8.56a1.68 1.68 0 0 0 1.68-1.68c0-.93-.75-1.69-1.68-1.69a1.69 1.69 0 0 0-1.69 1.69c0 .93.76 1.68 1.69 1.68m1.39 9.94v-8.37H5.5v8.37h2.77z" />
</svg>
);
export const GitHubIcon: React.FC<{ size?: number; color?: string }> = ({
size = 20,
color = 'currentColor',
}) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill={color} aria-hidden="true">
<path d="M12 2A10 10 0 0 0 2 12c0 4.42 2.87 8.17 6.84 9.5.5.08.66-.23.66-.5v-1.69c-2.77.6-3.36-1.34-3.36-1.34-.46-1.16-1.11-1.47-1.11-1.47-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.87 1.52 2.34 1.07 2.91.83.09-.65.35-1.09.63-1.34-2.22-.25-4.55-1.11-4.55-4.92 0-1.11.38-2 1.03-2.71-.1-.25-.45-1.29.1-2.64 0 0 .84-.27 2.75 1.02.79-.22 1.65-.33 2.5-.33.85 0 1.71.11 2.5.33 1.91-1.29 2.75-1.02 2.75-1.02.55 1.35.2 2.39.1 2.64.65.71 1.03 1.6 1.03 2.71 0 3.82-2.34 4.66-4.57 4.91.36.31.69.92.69 1.85V21c0 .27.16.59.67.5C19.14 20.16 22 16.42 22 12A10 10 0 0 0 12 2z" />
</svg>
);
export const TelegramIcon: React.FC<{ size?: number; color?: string }> = ({
size = 20,
color = 'currentColor',
}) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill={color} aria-hidden="true">
<path d="M9.8 15.6l-.4 5.3c.6 0 .8-.2 1.1-.5l2.7-2.6 5.6 4.1c1 .6 1.7.3 2-.9l3.7-17.3c.4-1.5-.5-2.2-1.5-1.8L1.6 10.3c-1.5.6-1.5 1.5-.2 1.9l5.5 1.7 12.8-8.1c.6-.4 1.1-.2.7.2z" />
</svg>
);
export const MailIcon: React.FC<{ size?: number; color?: string }> = ({
size = 18,
color = 'currentColor',
}) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill={color} aria-hidden="true">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z" />
</svg>
);
/**
* 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 (
<g className="iso-pencil">
{/* Ground Contact Shadow */}
<polygon
points={`${x - hw * 1.2},${baseY - 1} ${x},${baseY + 3} ${x + hw * 1.2},${baseY - 1} ${x},${baseY - 4}`}
fill="var(--hero-illus-cube-shadow, #0C234F)"
opacity={0.18}
/>
{/* Shaft Facets (Light / Front / Dark matching 3D isometric lighting) */}
<polygon points={p1} fill="var(--hero-illus-cube-top, #9DCDFD)" stroke="var(--hero-illus-cube-front, #2066AB)" strokeWidth={0.4} />
<polygon points={p2} fill="var(--hero-illus-cube-front, #2066AB)" stroke="var(--hero-illus-cube-side, #0E4073)" strokeWidth={0.4} />
<polygon points={p3} fill="var(--hero-illus-cube-side, #0E4073)" stroke="var(--hero-illus-cube-shadow, #0C234F)" strokeWidth={0.4} />
{/* Scalloped Wood Facets */}
<polygon points={woodLeft} fill="var(--bg-subtle, #E6F0FE)" stroke="var(--border-default, #D9E2EF)" strokeWidth={0.3} />
<polygon points={woodMid} fill="var(--bg-raised, #FCFDFF)" stroke="var(--border-default, #D9E2EF)" strokeWidth={0.3} />
<polygon points={woodRight} fill="var(--color-border-light, #D9E2EF)" stroke="var(--border-strong, #A9BEDA)" strokeWidth={0.3} />
{/* Lead Tip */}
<polygon points={leadLeft} fill="var(--hero-illus-cube-front, #2066AB)" stroke="var(--hero-illus-cube-side, #0E4073)" strokeWidth={0.3} />
<polygon points={leadRight} fill="var(--hero-illus-cube-side, #0E4073)" stroke="var(--hero-illus-cube-shadow, #0C234F)" strokeWidth={0.3} />
</g>
);
}
/**
* 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 (
<g
className="iso-monitor"
transform={scale !== 1 ? `translate(${x}, ${y}) scale(${scale}) translate(${-x}, ${-y})` : undefined}
>
{/* Ground Contact Shadow */}
<polygon
points={`${x - baseW * 0.7},${y} ${x},${y + baseH * 0.7} ${x + baseW * 0.7},${y} ${x},${y - baseH * 0.7}`}
fill="var(--hero-illus-cube-shadow, #0C234F)"
opacity={0.16}
/>
{/* Stand Base Drop Faces */}
<polygon points={sbLeftDrop} fill="var(--hero-illus-cube-front, #2066AB)" stroke="var(--hero-illus-cube-side, #0E4073)" strokeWidth={0.4} />
<polygon points={sbRightDrop} fill="var(--hero-illus-cube-side, #0E4073)" stroke="var(--hero-illus-cube-shadow, #0C234F)" strokeWidth={0.4} />
<polygon points={sbTop} fill="var(--hero-illus-cube-top, #9DCDFD)" stroke="var(--color-blue-300, #82B5FF)" strokeWidth={0.6} />
{/* Stand Neck Column */}
<polygon points={neckFront} fill="var(--hero-illus-cube-top, #9DCDFD)" stroke="var(--hero-illus-cube-front, #2066AB)" strokeWidth={0.4} />
<polygon points={neckSide} fill="var(--hero-illus-cube-side, #0E4073)" stroke="var(--hero-illus-cube-shadow, #0C234F)" strokeWidth={0.4} />
{/* Monitor Display Bezel & Screen Face */}
<polygon points={pTop} fill="var(--hero-illus-cube-top, #9DCDFD)" stroke="var(--color-blue-300, #82B5FF)" strokeWidth={0.6} />
<polygon points={pRight} fill="var(--hero-illus-cube-side, #0E4073)" stroke="var(--hero-illus-cube-shadow, #0C234F)" strokeWidth={0.6} />
<polygon points={pFront} fill="var(--accent-default, #3D8AFA)" stroke="var(--color-blue-500, #1557A6)" strokeWidth={0.8} />
</g>
);
}
/**
* 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 (
<g className="iso-pc-tower">
{/* Pedestal Plate */}
<polygon points={plLeftDrop} fill="var(--hero-illus-cube-front, #2066AB)" stroke="var(--hero-illus-cube-side, #0E4073)" strokeWidth={0.4} />
<polygon points={plRightDrop} fill="var(--hero-illus-cube-side, #0E4073)" stroke="var(--hero-illus-cube-shadow, #0C234F)" strokeWidth={0.4} />
<polygon points={plTop} fill="var(--bg-raised, #FCFDFF)" stroke="var(--border-strong, #A9BEDA)" strokeWidth={0.7} />
{/* Main Vertical Tower Body */}
<polygon points={faceLeft} fill="var(--hero-illus-cube-front, #2066AB)" stroke="var(--hero-illus-cube-side, #0E4073)" strokeWidth={0.6} />
<polygon points={faceRight} fill="var(--hero-illus-cube-side, #0E4073)" stroke="var(--hero-illus-cube-shadow, #0C234F)" strokeWidth={0.6} />
<polygon points={faceTop} fill="var(--hero-illus-cube-top, #9DCDFD)" stroke="var(--hero-illus-cube-front, #2066AB)" strokeWidth={0.7} />
</g>
);
}
/**
* 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 (
<g
className={`iso-signpost ${isActive ? 'active' : ''}`}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
style={{ cursor: 'pointer', touchAction: 'manipulation' }}
>
<defs>
{/* 3D Cylindrical Metallic Gradient for Upright Pole */}
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="var(--hero-illus-cube-top, #9DCDFD)" />
<stop offset="35%" stopColor="var(--accent-default, #3D8AFA)" />
<stop offset="70%" stopColor="var(--hero-illus-cube-front, #2066AB)" />
<stop offset="100%" stopColor="var(--hero-illus-cube-side, #0E4073)" />
</linearGradient>
</defs>
{/* Expanded invisible touch hit box */}
<rect
x={x - 28}
y={postTopY - cardSize - 10}
width={56}
height={cardSize + postHeight + 16}
fill="transparent"
/>
{/* 3D Ground Base Assembly */}
{/* 1. 3D Cylindrical Disc Drop Apron (Thickness = 4px) */}
<path
d={`M ${x - 11.5},${y} A 11.5 5.75 0 0 0 ${x + 11.5},${y} L ${x + 11.5},${y + 4} A 11.5 5.75 0 0 1 ${x - 11.5},${y + 4} Z`}
fill={isActive ? 'var(--hero-illus-cube-side, #0E4073)' : 'var(--hero-illus-cube-front, #2066AB)'}
stroke="var(--hero-illus-cube-side, #0E4073)"
strokeWidth={0.6}
/>
{/* Center ridge divider on the cylinder drop */}
<line x1={x} y1={y + 5.75} x2={x} y2={y + 9.75} stroke="var(--hero-illus-cube-shadow, #0C234F)" strokeWidth={0.8} />
{/* 3. 3D Disc Top Surface */}
<ellipse
cx={x}
cy={y}
rx={11.5}
ry={5.75}
fill={isActive ? 'var(--color-blue-300, #82B5FF)' : 'var(--bg-subtle, #E6F0FE)'}
stroke={isActive ? 'var(--hero-illus-cube-front, #2066AB)' : 'var(--border-strong, #A9BEDA)'}
strokeWidth={1}
/>
{/* 4. Concentric Socket Collar / Ring where pole enters */}
<path
d={`M ${x - 4.5},${y - 0.5} A 4.5 2.25 0 0 0 ${x + 4.5},${y - 0.5} L ${x + 4.5},${y + 1.5} A 4.5 2.25 0 0 1 ${x - 4.5},${y + 1.5} Z`}
fill="var(--hero-illus-cube-shadow, #0C234F)"
/>
<ellipse
cx={x}
cy={y - 0.5}
rx={4.5}
ry={2.25}
fill="var(--hero-illus-cube-front, #2066AB)"
stroke="var(--hero-illus-cube-side, #0E4073)"
strokeWidth={0.6}
/>
<ellipse
cx={x}
cy={y - 0.5}
rx={2}
ry={1}
fill="var(--hero-illus-cube-shadow, #0C234F)"
/>
{/* 3D Cylindrical Metallic Pole (extends seamlessly up behind the card body) */}
<rect
x={x - 1.4}
y={postTopY - 16}
width={2.8}
height={y - (postTopY - 16)}
rx={1.4}
fill={`url(#${gradId})`}
/>
{/* Left-edge specular highlight glint line */}
<line
x1={x - 0.7}
y1={y - 2}
x2={x - 0.7}
y2={postTopY - 14}
stroke="var(--color-blue-white, #F8FAFF)"
strokeWidth={0.6}
opacity={0.65}
/>
{/* 3D Rotated Isometric Signboard */}
<g
transform={isActive ? 'translate(0, -3)' : 'translate(0, 0)'}
style={{ transition: 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }}
>
{/* 1. Backdrop Layer (Positioned to left-top for rightside items, right-top for leftside items) */}
<g transform={`translate(${x + backShiftX}, ${postTopY - 18 + backShiftY}) matrix(1, ${shearSlope}, 0, 1, 0, 0)`}>
{/* Subtle Ambient Shadow behind the backdrop plate */}
<rect
x={-cardSize / 2 + (isRightTop ? 1.2 : -1.2)}
y={-cardSize / 2 + 1}
width={cardSize}
height={cardSize}
rx={radius}
fill="var(--hero-illus-cube-shadow, #0C234F)"
opacity={isActive ? 0.35 : 0.18}
/>
{/* 3D Backdrop Plate with signature royal blue fill and darker border */}
<rect
x={-cardSize / 2}
y={-cardSize / 2}
width={cardSize}
height={cardSize}
rx={radius}
fill={isActive ? 'var(--hero-illus-cube-side, #0E4073)' : 'var(--hero-illus-cube-front, #2066AB)'}
stroke={isActive ? 'var(--hero-illus-cube-shadow, #0C234F)' : 'var(--hero-illus-cube-side, #0E4073)'}
strokeWidth={1}
/>
</g>
{/* 2. Front Card Layer (Centered flush on the pole axis at x, postTopY - 18) */}
<g transform={`translate(${x}, ${postTopY - 18}) matrix(1, ${shearSlope}, 0, 1, 0, 0)`}>
{/* Crisp Opaque Card Body with Blue Border */}
<rect
x={-cardSize / 2}
y={-cardSize / 2}
width={cardSize}
height={cardSize}
rx={radius}
fill="var(--bg-raised, #FCFDFF)"
stroke={isActive ? 'var(--accent-default, #3D8AFA)' : 'var(--border-strong, #A9BEDA)'}
strokeWidth={isActive ? 2 : 1.6}
/>
{/* 3D Rotated Icon inside Card */}
<g
transform="translate(0, 0)"
color={isActive ? 'var(--accent-default, #3D8AFA)' : 'var(--hero-illus-cube-front, #2066AB)'}
>
<g transform="translate(-8.5, -8.5) scale(0.85)">
{icon}
</g>
</g>
</g>
</g>
</g>
);
}

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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<FooterContactId | null>(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 (
<div className={styles.footerContainer}>
{/* Top Header & Contact Section */}
<header className={styles.footerHeader}>
<h2 className={styles.headerTitle}>
Let&apos;s build something meaningful.
</h2>
<p className={styles.headerSubtitle}>
Open to opportunities and interesting projects.
</p>
{/* Email Pill Button */}
<div className={styles.emailWrapper}>
<a
href="mailto:amirhosseinkhalili901@gmail.com"
className={styles.emailPill}
aria-label="Send email to amirhosseinkhalili901@gmail.com"
>
<span className={styles.emailIcon}>
<MailIcon size={16} color="currentColor" />
</span>
<span className={styles.emailText}>amirhosseinkhalili901@gmail.com</span>
</a>
</div>
</header>
{/* 3D Isometric SVG Scene */}
<div className={styles.sceneContainer}>
<IsometricScene
activePost={activePost}
onPostHover={setActivePost}
onPostClick={handlePostClick}
/>
</div>
{/* Responsive Synchronized Contact Cards Section */}
<section className={styles.contactCardsSection} aria-label="Social and contact links">
<div className={styles.contactCardsGrid}>
{contacts.map((contact) => {
const IconComponent = contact.Icon;
const isHovered = activePost === contact.id;
return (
<a
key={contact.id}
href={contact.href}
target={contact.href.startsWith('tel:') ? undefined : '_blank'}
rel={contact.href.startsWith('tel:') ? undefined : 'noopener noreferrer'}
className={`${styles.contactCard} ${isHovered ? styles.active : ''}`}
onMouseEnter={() => setActivePost(contact.id)}
onMouseLeave={() => setActivePost(null)}
onClick={() => setActivePost(contact.id)}
aria-label={`${contact.name}: ${contact.label}`}
>
<div className={styles.contactCardIconWrap}>
<IconComponent size={18} color="currentColor" />
</div>
<div className={styles.contactCardInfo}>
<span className={styles.contactCardName}>{contact.name}</span>
<span className={styles.contactCardLabel}>{contact.label}</span>
</div>
</a>
);
})}
</div>
</section>
</div>
);
}

View File

@@ -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(
<line
key={`a1-${c}`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="var(--border-default, #D9E2EF)"
strokeWidth={1}
opacity={0.65}
/>
);
}
// 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(
<line
key={`a2-${c}`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="var(--border-default, #D9E2EF)"
strokeWidth={1}
opacity={0.65}
/>
);
}
return <g className="iso-ground-grid">{lines}</g>;
}
/**
* 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 (
<g className="stepped-terraces">
<defs>
{/* Floor Step Fade Mask: solid at front (y=400..360), smooth fade backwards (y=210) */}
<linearGradient
id="floorFadeMaskGrad"
x1="0"
y1="410"
x2="0"
y2="210"
gradientUnits="userSpaceOnUse"
>
<stop offset="0%" stopColor="var(--bg-page, #F8FAFF)" stopOpacity="1" />
<stop offset="20%" stopColor="var(--bg-page, #F8FAFF)" stopOpacity="0.95" />
<stop offset="45%" stopColor="var(--bg-page, #F8FAFF)" stopOpacity="0.65" />
<stop offset="70%" stopColor="var(--bg-page, #F8FAFF)" stopOpacity="0.25" />
<stop offset="90%" stopColor="var(--bg-page, #F8FAFF)" stopOpacity="0.05" />
<stop offset="100%" stopColor="var(--bg-page, #F8FAFF)" stopOpacity="0" />
</linearGradient>
<mask
id="floorFadeMask"
maskUnits="userSpaceOnUse"
x="-100"
y="180"
width="1400"
height="260"
>
<rect x="-100" y="180" width="1400" height="260" fill="url(#floorFadeMaskGrad)" />
</mask>
</defs>
{/* 1. 3D FRONT VERTICAL DROP APRON (5 STEPS: Steps 2 to 6, x=200 to 1000) */}
<g id="terrace-front-drop-apron">
{/* Step 2 (Phone) */}
<polygon points="200,360 280,400 280,416 200,376" fill="var(--bg-subtle, #E6F0FE)" stroke="var(--border-default, #D9E2EF)" strokeWidth={1.2} />
<polygon points="360,360 280,400 280,416 360,376" fill="var(--color-border-light, #D9E2EF)" stroke="var(--border-default, #D9E2EF)" strokeWidth={1.2} />
{/* Step 3 (LinkedIn) */}
<polygon points="360,360 440,400 440,416 360,376" fill="var(--bg-subtle, #E6F0FE)" stroke="var(--border-default, #D9E2EF)" strokeWidth={1.2} />
<polygon points="520,360 440,400 440,416 520,376" fill="var(--color-border-light, #D9E2EF)" stroke="var(--border-default, #D9E2EF)" strokeWidth={1.2} />
{/* Step 4 (Center Platform) */}
<polygon points="520,360 600,400 600,416 520,376" fill="var(--bg-subtle, #E6F0FE)" stroke="var(--border-default, #D9E2EF)" strokeWidth={1.2} />
<polygon points="680,360 600,400 600,416 680,376" fill="var(--color-border-light, #D9E2EF)" stroke="var(--border-default, #D9E2EF)" strokeWidth={1.2} />
{/* Step 5 (GitHub) */}
<polygon points="680,360 760,400 760,416 680,376" fill="var(--bg-subtle, #E6F0FE)" stroke="var(--border-default, #D9E2EF)" strokeWidth={1.2} />
<polygon points="840,360 760,400 760,416 840,376" fill="var(--color-border-light, #D9E2EF)" stroke="var(--border-default, #D9E2EF)" strokeWidth={1.2} />
{/* Step 6 (Telegram) */}
<polygon points="840,360 920,400 920,416 840,376" fill="var(--bg-subtle, #E6F0FE)" stroke="var(--border-default, #D9E2EF)" strokeWidth={1.2} />
<polygon points="1000,360 920,400 920,416 1000,376" fill="var(--color-border-light, #D9E2EF)" stroke="var(--border-default, #D9E2EF)" strokeWidth={1.2} />
{/* Vertical corner edges at peaks */}
<line x1={280} y1={400} x2={280} y2={416} stroke="var(--border-strong, #A9BEDA)" strokeWidth={1.2} />
<line x1={440} y1={400} x2={440} y2={416} stroke="var(--border-strong, #A9BEDA)" strokeWidth={1.2} />
<line x1={600} y1={400} x2={600} y2={416} stroke="var(--border-strong, #A9BEDA)" strokeWidth={1.2} />
<line x1={760} y1={400} x2={760} y2={416} stroke="var(--border-strong, #A9BEDA)" strokeWidth={1.2} />
<line x1={920} y1={400} x2={920} y2={416} stroke="var(--border-strong, #A9BEDA)" strokeWidth={1.2} />
{/* Vertical corner edges at valleys and outer ends */}
<line x1={200} y1={360} x2={200} y2={376} stroke="var(--border-strong, #A9BEDA)" strokeWidth={1.2} />
<line x1={360} y1={360} x2={360} y2={376} stroke="var(--border-strong, #A9BEDA)" strokeWidth={1.2} />
<line x1={520} y1={360} x2={520} y2={376} stroke="var(--border-strong, #A9BEDA)" strokeWidth={1.2} />
<line x1={680} y1={360} x2={680} y2={376} stroke="var(--border-strong, #A9BEDA)" strokeWidth={1.2} />
<line x1={840} y1={360} x2={840} y2={376} stroke="var(--border-strong, #A9BEDA)" strokeWidth={1.2} />
<line x1={1000} y1={360} x2={1000} y2={376} stroke="var(--border-strong, #A9BEDA)" strokeWidth={1.2} />
</g>
{/* 2. FADING GROUND FLOOR TOP SURFACE & OUTLINE BORDERS */}
<g className="terrace-fading-top-surface" mask="url(#floorFadeMask)">
<polygon
points="200,360 280,400 360,360 440,400 520,360 600,400 680,360 760,400 840,360 920,400 1000,360 1000,200 200,200"
fill="var(--bg-page, #F8FAFF)"
/>
{/* Fading Diamond Tile Outlines */}
<g stroke="var(--border-default, #D9E2EF)" strokeWidth={1.05} opacity={0.65}>
<line x1={200} y1={360} x2={40} y2={280} />
<line x1={360} y1={360} x2={200} y2={280} />
<line x1={520} y1={360} x2={360} y2={280} />
<line x1={680} y1={360} x2={520} y2={280} />
<line x1={840} y1={360} x2={680} y2={280} />
<line x1={1000} y1={360} x2={840} y2={280} />
<line x1={280} y1={400} x2={440} y2={320} />
<line x1={440} y1={400} x2={600} y2={320} />
<line x1={600} y1={400} x2={760} y2={320} />
<line x1={760} y1={400} x2={920} y2={320} />
<line x1={920} y1={400} x2={1080} y2={320} />
</g>
</g>
{/* 3. CENTER ELEVATED WORKSTATION PLATFORM (TIER 1 & TIER 2) */}
<g id="workstation-elevated-platform">
{/* 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) */}
<polygon
points="476,290 600,352 600,367 476,305"
fill="var(--hero-illus-cube-front, #2066AB)"
stroke="var(--hero-illus-cube-side, #0E4073)"
strokeWidth={0.9}
/>
{/* Right Front Drop Face (Slope -0.5 from 600,352 to 724,290, Drop Height = 15px) */}
<polygon
points="600,352 724,290 724,305 600,367"
fill="var(--hero-illus-cube-side, #0E4073)"
stroke="var(--hero-illus-cube-shadow, #0C234F)"
strokeWidth={0.9}
/>
{/* Center Vertical Ridge Line */}
<line x1={600} y1={352} x2={600} y2={367} stroke="var(--hero-illus-cube-shadow, #0C234F)" strokeWidth={1.2} />
{/* Tier 1 White Diamond Top Surface (Well-Proportioned 248px wide: x=476 to 724, y=228 to 352) */}
<polygon
points="476,290 600,352 724,290 600,228"
fill="var(--bg-raised, #FCFDFF)"
stroke="var(--border-strong, #A9BEDA)"
strokeWidth={1.1}
/>
{/* Tier 2: Refined & Compact 3D Elevated Stepped Workstation Platform (Drop Height = 12px) */}
{/* Front-left drop 1 (under monitor) */}
<polygon
points="506,285 538,301 538,313 506,297"
fill="var(--hero-illus-cube-front, #2066AB)"
stroke="var(--hero-illus-cube-side, #0E4073)"
strokeWidth={0.75}
/>
{/* Front-left drop 2 (middle step in front of pencil) */}
<polygon
points="538,301 570,317 570,329 538,313"
fill="var(--hero-illus-cube-front, #2066AB)"
stroke="var(--hero-illus-cube-side, #0E4073)"
strokeWidth={0.75}
/>
{/* Front-right drop (middle step facing SE) */}
<polygon
points="570,317 602,301 602,313 570,329"
fill="var(--hero-illus-cube-side, #0E4073)"
stroke="var(--hero-illus-cube-shadow, #0C234F)"
strokeWidth={0.75}
/>
{/* Inner corner step drop (facing right towards PC Tower) */}
<polygon
points="602,301 570,285 570,297 602,313"
fill="var(--hero-illus-cube-side, #0E4073)"
stroke="var(--hero-illus-cube-shadow, #0C234F)"
strokeWidth={0.75}
/>
<polygon
points="570,285 602,269 602,281 570,297"
fill="var(--hero-illus-cube-shadow, #0C234F)"
stroke="var(--color-black, #07132F)"
strokeWidth={0.75}
/>
{/* Tier 2: White Top Surfaces of the 3 Workstation Step Tiles */}
{/* Left Tile (holding Monitor) */}
<polygon
points="506,285 538,269 570,285 538,301"
fill="var(--bg-raised, #FCFDFF)"
stroke="var(--border-strong, #A9BEDA)"
strokeWidth={0.9}
/>
{/* Center Middle Tile */}
<polygon
points="538,301 570,285 602,301 570,317"
fill="var(--bg-raised, #FCFDFF)"
stroke="var(--border-strong, #A9BEDA)"
strokeWidth={0.9}
/>
{/* Back Tile (holding Pencil) */}
<polygon
points="538,269 570,253 602,269 570,285"
fill="var(--bg-raised, #FCFDFF)"
stroke="var(--border-strong, #A9BEDA)"
strokeWidth={0.9}
/>
{/* Dividing Grid Lines on the Step */}
<line x1={538} y1={301} x2={570} y2={285} stroke="var(--border-default, #D9E2EF)" strokeWidth={0.8} />
<line x1={570} y1={285} x2={602} y2={301} stroke="var(--border-default, #D9E2EF)" strokeWidth={0.8} />
<line x1={538} y1={269} x2={570} y2={285} stroke="var(--border-default, #D9E2EF)" strokeWidth={0.8} />
</g>
</g>
);
}
/**
* 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 (
<g className="circuit-conduits">
<defs>
<filter id="circuitGlow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="0" stdDeviation="2" floodColor="var(--accent-default, #3D8AFA)" floodOpacity="0.6" />
</filter>
</defs>
{/* Left Branch: Phone -> Step 3 -> LinkedIn -> Center Platform with exact ±0.5 slope */}
<path
d="M 280,360 L 360,320 L 440,360 L 514,323"
fill="none"
stroke="var(--accent-default, #3D8AFA)"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="miter"
filter="url(#circuitGlow)"
opacity={activePost === 'phone' || activePost === 'linkedin' ? 1 : 0.85}
/>
{/* Right Branch: Center Platform -> GitHub -> Step 5 -> Telegram with exact ±0.5 slope */}
<path
d="M 686,323 L 760,360 L 840,320 L 920,360"
fill="none"
stroke="var(--accent-default, #3D8AFA)"
strokeWidth={2.5}
strokeLinecap="round"
strokeLinejoin="miter"
filter="url(#circuitGlow)"
opacity={activePost === 'github' || activePost === 'telegram' ? 1 : 0.85}
/>
</g>
);
}
/**
* Main Isometric Footer Scene SVG Component
*/
export default function IsometricScene({
activePost,
onPostHover,
onPostClick,
}: IsometricSceneProps) {
return (
<svg
viewBox="0 175 1200 290"
xmlns="http://www.w3.org/2000/svg"
className="isometric-svg"
preserveAspectRatio="xMidYMax meet"
role="img"
aria-label="Interactive 3D isometric footer scene"
>
{/* 1. Faint Isometric Ground Grid */}
<IsometricBackgroundGrid />
{/* 2. Solid 3D Architectural Stepped Terraces */}
<SteppedTerraces />
{/* 3. Glowing Royal Blue Circuit Conduits */}
<CircuitConduits activePost={activePost} />
{/* 4. Left Mountain Cluster */}
<MountainCluster x={80} y={250} scale={0.92} />
{/* 5. Right Mountain Cluster (mirrored) */}
<MountainCluster x={1120} y={250} flip={true} scale={0.92} />
{/* 6. Pine Trees on the floor along ground line */}
<PineTree x={335} y={425} scale={0.84} dark={true} />
<PineTree x={395} y={422} scale={0.96} dark={false} />
<PineTree x={490} y={422} scale={0.92} />
<PineTree x={810} y={422} scale={0.96} dark={false} />
<PineTree x={865} y={425} scale={0.84} dark={true} />
<PineTree x={965} y={422} scale={0.92} />
{/* 7. Central Workstation Illustrations resting flush on the large platform */}
<g className="central-workstation">
{/* Prominent Monitor Display resting on left elevated step */}
<IsoMonitorDisplay x={534} y={285} />
{/* Majestic Upright Faceted Pencil resting on back elevated step */}
<IsoUprightPencil x={570} y={269} height={80} width={17} />
{/* Vertical PC Tower on Pedestal Plate resting on Tier 1 */}
<IsoPCTower x={642} y={290} size={20} height={40} />
</g>
{/* 8. The 4 Interactive Isometric Signposts rotated in 3D */}
<IsoSignpost
id="phone"
x={280}
y={360}
postHeight={48}
facing="right"
backdropDirection="right-top"
icon={<PhoneIcon />}
label="09388263077"
isActive={activePost === 'phone'}
onMouseEnter={() => onPostHover?.('phone')}
onMouseLeave={() => onPostHover?.(null)}
onClick={() => onPostClick?.('phone')}
/>
<IsoSignpost
id="linkedin"
x={440}
y={360}
postHeight={48}
facing="right"
backdropDirection="right-top"
icon={<LinkedInIcon />}
label="@amiirkhl"
isActive={activePost === 'linkedin'}
onMouseEnter={() => onPostHover?.('linkedin')}
onMouseLeave={() => onPostHover?.(null)}
onClick={() => onPostClick?.('linkedin')}
/>
<IsoSignpost
id="github"
x={760}
y={360}
postHeight={48}
facing="left"
backdropDirection="left-top"
icon={<GitHubIcon />}
label="git.amiirkhl.ir"
isActive={activePost === 'github'}
onMouseEnter={() => onPostHover?.('github')}
onMouseLeave={() => onPostHover?.(null)}
onClick={() => onPostClick?.('github')}
/>
<IsoSignpost
id="telegram"
x={920}
y={360}
postHeight={48}
facing="left"
backdropDirection="left-top"
icon={<TelegramIcon />}
label="@amiirkhl"
isActive={activePost === 'telegram'}
onMouseEnter={() => onPostHover?.('telegram')}
onMouseLeave={() => onPostHover?.(null)}
onClick={() => onPostClick?.('telegram')}
/>
</svg>
);
}

View File

@@ -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)

View File

@@ -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;

View File

@@ -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<FooterContactId, [number, number]> = {
phone: [-7.2, 0.4],
linkedin: [-3.8, -0.45],
gitea: [3.8, -0.45],
telegram: [7.2, 0.4],
};
const MOBILE_MARKERS: Record<FooterContactId, [number, number]> = {
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<FooterContactId, [number, number]>,
) => {
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<FooterContactId, THREE.Group>();
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 <ThreeCanvas className={styles.footerScene} createScene={createFooterScene} theme={theme} />;
}

View File

@@ -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;
}
}

View File

@@ -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, /<IsometricFooter\s*\/>/);
assert.doesNotMatch(footerSection, /next\/image/);
assert.doesNotMatch(footerSection, /footer-background.*\.png/);
assert.doesNotMatch(loader, /footer-background.*\.png/);
});