Compare commits

..

6 Commits

Author SHA1 Message Date
88adced09f 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
2026-09-08 11:31:36 +03:30
0ed36b3c32 feat(footer): add isometric Three.js contact scene 2026-09-07 22:27:44 +03:30
71f55b465a fix(hero): prevent scenic artwork clipping 2026-09-07 22:08:27 +03:30
90459f7d7c feat(projects): add accessible draggable carousel scrollbar 2026-09-07 22:08:03 +03:30
2d26c6c3a2 feat(hero): add upcoming actions and LinkedIn contact CTA 2026-09-07 22:07:41 +03:30
e635e201a0 fix(theme): default to light when no preference is saved 2026-09-07 22:07:12 +03:30
24 changed files with 1693 additions and 358 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

@@ -19,8 +19,7 @@ const themeScript = `
document.documentElement.dataset.siteLoading = 'true';
try {
const savedTheme = localStorage.getItem('portfolio-theme');
const systemTheme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.dataset.theme = savedTheme || systemTheme;
document.documentElement.dataset.theme = savedTheme === 'dark' ? 'dark' : 'light';
} catch {}
`;

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

@@ -19,7 +19,7 @@
z-index: 10;
display: flex;
align-items: center;
justify-content: flex-end;
justify-content: flex-start;
padding: clamp(1.125rem, 2.5vw, 2rem) var(--layout-page-gutter) 0;
}
@@ -202,17 +202,23 @@
}
.heroButton {
min-width: clamp(5rem, 9vw, 7.5rem);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: clamp(6.5rem, 10vw, 8.25rem);
height: clamp(2.5rem, 4.5vw, 3.25rem);
padding: 0 clamp(1rem, 2vw, 1.75rem);
gap: 0.5rem;
border-width: 2px;
font-size: clamp(0.8125rem, 1.1vw, 1rem);
}
.heroButtonOutline {
border-color: var(--color-blue-600);
background: transparent;
color: var(--color-blue-700);
.heroButtonUpcoming {
border-color: var(--border-default);
background: color-mix(in srgb, var(--bg-raised) 68%, transparent);
color: var(--text-secondary);
cursor: default;
opacity: 0.78;
}
.heroButtonPrimary {
@@ -226,10 +232,10 @@
outline-offset: 3px;
}
[data-theme="dark"] .heroButtonOutline {
border-color: var(--color-blue-white);
background: transparent;
color: var(--text-primary);
[data-theme="dark"] .heroButtonUpcoming {
border-color: var(--border-default);
background: color-mix(in srgb, var(--bg-raised) 72%, transparent);
color: var(--text-secondary);
}
[data-theme="dark"] .heroButtonPrimary {
@@ -367,9 +373,8 @@
.actionButtons {
display: grid;
grid-template-columns: repeat(2, clamp(5rem, 9vw, 7.5rem));
width: fit-content;
max-width: none;
grid-template-columns: repeat(2, minmax(0, 1fr));
width: min(100%, 21rem);
gap: 0.5rem;
}
@@ -379,9 +384,9 @@
.heroButton {
min-width: 0;
width: auto;
width: 100%;
height: 2.75rem;
padding: 0 0.375rem;
padding: 0 0.75rem;
font-size: 0.875rem;
}
}
@@ -410,21 +415,11 @@
color: var(--accent-default);
}
.heroButtonOutline:hover {
border-color: var(--color-blue-700);
background: rgba(255, 255, 255, 0.2);
}
.heroButtonPrimary:hover {
border-color: var(--color-blue-700);
background: var(--color-blue-700);
}
[data-theme="dark"] .heroButtonOutline:hover {
border-color: var(--color-blue-100);
background: rgba(130, 181, 255, 0.12);
}
[data-theme="dark"] .heroButtonPrimary:hover {
border-color: var(--color-blue-white);
background: var(--color-blue-white);

View File

@@ -2,7 +2,7 @@
import React from 'react';
import Image from 'next/image';
import { Moon, Sun } from 'lucide-react';
import { BookOpenText, GraduationCap, MessageCircle, Moon, Sun } from 'lucide-react';
import { useTheme } from '@/context/ThemeContext';
import styles from './HeroSection.module.css';
@@ -58,14 +58,35 @@ export const HeroSection: React.FC = () => {
{/* Action Buttons */}
<div className={styles.actionButtons}>
<a href="#about" className={`pill-btn ${styles.heroButton} ${styles.heroButtonOutline}`}>
About
</a>
<a href="#projects" className={`pill-btn ${styles.heroButton} ${styles.heroButtonOutline}`}>
Work
</a>
<a href="#footer" className={`pill-btn ${styles.heroButton} ${styles.heroButtonPrimary}`}>
Contact
<button
type="button"
className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`}
aria-label="Blog, coming soon"
title="Coming soon"
disabled
>
<BookOpenText size={17} strokeWidth={2} aria-hidden="true" />
Blog
</button>
<button
type="button"
className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`}
aria-label="Courses, coming soon"
title="Coming soon"
disabled
>
<GraduationCap size={18} strokeWidth={2} aria-hidden="true" />
Courses
</button>
<a
href="https://www.linkedin.com/messaging/compose/?recipient=ACoAAEJsAwMBCv3L9dYcEXWam-l3pLOJ3UspX7w"
target="_blank"
rel="noreferrer"
className={`pill-btn ${styles.heroButton} ${styles.heroButtonPrimary}`}
aria-label="Get in touch on LinkedIn"
>
<MessageCircle size={17} strokeWidth={2} aria-hidden="true" />
Get in touch
</a>
</div>

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

@@ -258,29 +258,49 @@
margin-top: 2.5rem;
}
.progressTrack {
.scrollbarTrack {
position: relative;
overflow: hidden;
width: 30rem;
max-width: 100%;
height: 0.375rem;
border-radius: var(--radius-pill);
background: var(--color-blue-200);
cursor: pointer;
touch-action: none;
}
.progressValue {
display: block;
.scrollbarTrack::before {
content: '';
position: absolute;
inset: -0.75rem 0;
}
.scrollbarTrack:focus-visible {
outline: 3px solid var(--focus-ring);
outline-offset: 0.5rem;
}
.scrollbarThumb {
position: absolute;
top: 0;
left: 0;
z-index: 1;
height: 100%;
border-radius: var(--radius-pill);
background: var(--color-blue-400);
transition: width var(--motion-duration-default) var(--motion-ease-out);
cursor: grab;
will-change: transform;
}
[data-theme="dark"] .progressTrack {
.scrollbarDragging .scrollbarThumb {
cursor: grabbing;
}
[data-theme="dark"] .scrollbarTrack {
background: var(--color-blue-800);
}
[data-theme="dark"] .progressValue {
[data-theme="dark"] .scrollbarThumb {
background: var(--color-blue-300);
}
@@ -298,7 +318,7 @@
width: clamp(18.125rem, 23vw, 20.625rem);
}
.progressTrack {
.scrollbarTrack {
width: min(30rem, 50vw);
}
}
@@ -369,12 +389,10 @@
}
.scrollbarWrapper {
display: flex;
justify-content: center;
margin-top: 2rem;
}
.progressTrack {
.scrollbarTrack {
width: min(22.5rem, 60vw);
}
}
@@ -458,12 +476,10 @@
}
.scrollbarWrapper {
display: flex;
justify-content: center;
margin-top: 1.5rem;
}
.progressTrack {
.scrollbarTrack {
width: min(12.5rem, 60vw);
}
}

View File

@@ -6,6 +6,8 @@ import { projects } from '@/data/projects';
import { ProjectThumbnail } from './ProjectThumbnail';
import styles from './ProjectsSection.module.css';
const MIN_SCROLLBAR_THUMB_PERCENT = 12.5;
export const ProjectsSection: React.FC = () => {
const viewportRef = useRef<HTMLDivElement>(null);
const dragState = useRef({
@@ -20,8 +22,17 @@ export const ProjectsSection: React.FC = () => {
pointerId: -1,
directionLocked: null as 'horizontal' | 'vertical' | null,
});
const scrollbarDragState = useRef({
active: false,
pointerId: -1,
grabOffset: 0,
});
const [isDragging, setIsDragging] = useState(false);
const [isScrollbarDragging, setIsScrollbarDragging] = useState(false);
const [scrollProgress, setScrollProgress] = useState(0);
const [scrollbarThumbPercent, setScrollbarThumbPercent] = useState(
MIN_SCROLLBAR_THUMB_PERCENT,
);
const [canScrollPrev, setCanScrollPrev] = useState(false);
const [canScrollNext, setCanScrollNext] = useState(true);
@@ -30,9 +41,16 @@ export const ProjectsSection: React.FC = () => {
if (!viewport) return;
const maxScroll = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
const progress = maxScroll > 0 ? (viewport.scrollLeft / maxScroll) * 100 : 0;
setScrollProgress(progress);
setScrollProgress(
maxScroll > 0 ? Math.max(0, Math.min(viewport.scrollLeft / maxScroll, 1)) : 0,
);
setScrollbarThumbPercent(
Math.max(
MIN_SCROLLBAR_THUMB_PERCENT,
Math.min((viewport.clientWidth / viewport.scrollWidth) * 100, 100),
),
);
setCanScrollPrev(viewport.scrollLeft > 1);
setCanScrollNext(viewport.scrollLeft < maxScroll - 1);
};
@@ -196,6 +214,94 @@ export const ProjectsSection: React.FC = () => {
}
};
const scrollFromScrollbarPointer = (
track: HTMLDivElement,
clientX: number,
grabOffset: number,
) => {
const viewport = viewportRef.current;
if (!viewport) return;
const trackRect = track.getBoundingClientRect();
const thumb = track.firstElementChild as HTMLElement | null;
const thumbWidth = thumb?.getBoundingClientRect().width ?? 0;
const availableTrack = trackRect.width - thumbWidth;
const maxScroll = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
if (availableTrack <= 0 || maxScroll <= 0) return;
const thumbLeft = Math.max(
0,
Math.min(clientX - trackRect.left - grabOffset, availableTrack),
);
const nextProgress = thumbLeft / availableTrack;
setScrollProgress(nextProgress);
viewport.scrollLeft = nextProgress * maxScroll;
};
const handleScrollbarPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0 && event.pointerType === 'mouse') return;
const track = event.currentTarget;
const thumb = track.firstElementChild as HTMLElement | null;
if (!thumb) return;
const thumbRect = thumb.getBoundingClientRect();
const clickedThumb = thumb.contains(event.target as Node);
const grabOffset = clickedThumb
? event.clientX - thumbRect.left
: thumbRect.width / 2;
scrollbarDragState.current = {
active: true,
pointerId: event.pointerId,
grabOffset,
};
track.setPointerCapture(event.pointerId);
setIsScrollbarDragging(true);
if (!clickedThumb) {
scrollFromScrollbarPointer(track, event.clientX, grabOffset);
}
event.preventDefault();
};
const handleScrollbarPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = scrollbarDragState.current;
if (!drag.active || drag.pointerId !== event.pointerId) return;
scrollFromScrollbarPointer(event.currentTarget, event.clientX, drag.grabOffset);
};
const stopScrollbarDragging = (event: React.PointerEvent<HTMLDivElement>) => {
const drag = scrollbarDragState.current;
if (!drag.active || drag.pointerId !== event.pointerId) return;
drag.active = false;
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId);
}
setIsScrollbarDragging(false);
};
const handleScrollbarKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
const viewport = viewportRef.current;
if (!viewport) return;
if (event.key === 'ArrowLeft') {
event.preventDefault();
scrollByCard(-1);
} else if (event.key === 'ArrowRight') {
event.preventDefault();
scrollByCard(1);
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
viewport.scrollTo({
left: event.key === 'Home' ? 0 : viewport.scrollWidth - viewport.clientWidth,
behavior: 'smooth',
});
}
};
return (
<section
id="projects"
@@ -238,6 +344,7 @@ export const ProjectsSection: React.FC = () => {
</div>
<div
id="projects-carousel"
ref={viewportRef}
className={`${styles.carouselViewport} ${isDragging ? styles.dragging : ''}`}
onScroll={syncScrollState}
@@ -301,16 +408,29 @@ export const ProjectsSection: React.FC = () => {
<div className={styles.scrollbarWrapper} data-reveal="soft">
<div
className={styles.progressTrack}
role="progressbar"
className={`${styles.scrollbarTrack} ${isScrollbarDragging ? styles.scrollbarDragging : ''}`}
role="scrollbar"
tabIndex={0}
aria-label="Project carousel position"
aria-controls="projects-carousel"
aria-orientation="horizontal"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(scrollProgress)}
aria-valuenow={Math.round(scrollProgress * 100)}
onPointerDown={handleScrollbarPointerDown}
onPointerMove={handleScrollbarPointerMove}
onPointerUp={stopScrollbarDragging}
onPointerCancel={stopScrollbarDragging}
onKeyDown={handleScrollbarKeyDown}
>
<span
className={styles.progressValue}
style={{ width: `${12.5 + scrollProgress * 0.875}%` }}
className={styles.scrollbarThumb}
style={{
width: `${scrollbarThumbPercent}%`,
transform: `translate3d(${scrollbarThumbPercent < 100
? scrollProgress * ((100 - scrollbarThumbPercent) / scrollbarThumbPercent) * 100
: 0}%, 0, 0)`,
}}
/>
</div>
</div>

View File

@@ -21,7 +21,7 @@
position: relative;
width: 100%;
height: auto;
min-height: clamp(36.25rem, 48vw, 43.75rem);
min-height: clamp(41.25rem, 52vw, 47.5rem);
overflow: hidden;
background: var(--bg-page);
}
@@ -138,7 +138,7 @@
@media (max-width: 1100px) and (min-width: 651px) {
.heroStage {
height: auto;
min-height: clamp(33.75rem, 64vw, 40rem);
min-height: clamp(38rem, 66vw, 43rem);
}
/* Left — visible slice ≈ width - 150px, always < content padding */
@@ -202,14 +202,13 @@
/* ================================================================
ULTRA-WIDE > 1440px
Canvas capped at 1440px but vw keeps growing → bleed explodes.
Freeze at the values the clamp() formulas resolve to @ 1440px.
heroStage overflow:visible lets illustrations escape into the
extra viewport space (body overflow-x:hidden prevents scrollbar).
Freeze at the values the clamp() formulas resolve to @ 1440px and
keep enough stage height to contain the full illustration.
================================================================ */
@media (min-width: 1441px) {
.heroStage {
height: auto;
min-height: 42.5rem;
min-height: 47.5rem;
overflow: visible;
}

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

@@ -4,6 +4,10 @@
inset: 0;
overflow: hidden;
background: var(--bg-page);
}
.heroScene,
.aboutScene {
pointer-events: none;
}
@@ -74,4 +78,3 @@
width: 100vw;
}
}

View File

@@ -21,10 +21,7 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
useEffect(() => {
const savedTheme = localStorage.getItem('portfolio-theme') as Theme | null;
const systemTheme: Theme = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
const initialTheme = savedTheme || systemTheme;
const initialTheme: Theme = savedTheme === 'dark' ? 'dark' : 'light';
setThemeState(initialTheme);
document.documentElement.setAttribute('data-theme', initialTheme);
}, []);

View File

@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
test('the isometric footer preserves the real contact destinations and email', async () => {
const footer = await readFile('src/components/IsometricFooter/IsometricFooter.tsx', 'utf8');
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/);
});