Compare commits
6 Commits
c4e8426db5
...
88adced09f
| Author | SHA1 | Date | |
|---|---|---|---|
| 88adced09f | |||
| 0ed36b3c32 | |||
| 71f55b465a | |||
| 90459f7d7c | |||
| 2d26c6c3a2 | |||
| e635e201a0 |
|
Before Width: | Height: | Size: 457 KiB |
|
Before Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 217 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 516 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 234 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 89 KiB |
@@ -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 {}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
497
src/components/IsometricFooter/IsoElements.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
77
src/components/IsometricFooter/IsoTypes.ts
Normal 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;
|
||||
}
|
||||
325
src/components/IsometricFooter/IsometricFooter.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
128
src/components/IsometricFooter/IsometricFooter.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
417
src/components/IsometricFooter/IsometricScene.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.heroScene,
|
||||
.aboutScene {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -74,4 +78,3 @@
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
23
tests/footer-scene.test.mjs
Normal 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/);
|
||||
});
|
||||