perf: optimize portfolio runtime delivery
Some checks failed
Test and deploy / Verify production build (push) Has been cancelled
Test and deploy / Deploy production (push) Has been cancelled

Replace the blocking asset loader with progressive rendering, defer expensive media and Three.js work, and reduce hydration and animation costs. Move deployment from a static Nginx export to the standalone Next.js runtime with optimized dynamic images.
This commit is contained in:
2026-09-09 12:43:37 +03:30
parent 9143ba8504
commit f34bea05cd
63 changed files with 1296 additions and 1048 deletions

View File

@@ -31,10 +31,10 @@ jobs:
- name: Type-check - name: Type-check
run: npm run typecheck run: npm run typecheck
- name: Build static site - name: Build standalone Next.js application
run: npm run build run: npm run build
- name: Test static export - name: Test standalone runtime
run: npm test run: npm test
- name: Audit production dependencies - name: Audit production dependencies

View File

@@ -49,9 +49,27 @@ Replace `deploy:deploy` and `/srv/portfolio` with the values used for
`DEPLOY_USER` and `DEPLOY_PATH`. Add the public half of `DEPLOY_SSH_KEY` to the `DEPLOY_USER` and `DEPLOY_PATH`. Add the public half of `DEPLOY_SSH_KEY` to the
deployment user's `~/.ssh/authorized_keys`. deployment user's `~/.ssh/authorized_keys`.
The container joins `caddy_proxy` under the `static-site` service name. Caddy The standalone Next.js container joins `caddy_proxy` under the `portfolio-app`
must also join that network and proxy to `static-site:80`. No host port is service name. Caddy must also join that network and proxy to
published by this project. `portfolio-app:3000`. No host port is published by this project.
A minimal Caddy route is:
```caddyfile
portfolio.example.com {
reverse_proxy portfolio-app:3000
}
```
Caddy forwards the browser's `Accept` header by default; keep that behavior so
Next.js can negotiate WebP responses. Do not replace the cache headers emitted
for `/_next/image`. The named `next-image-cache` volume persists `.next/cache`
between container replacements so optimized variants can be reused.
The application expects immutable media URLs. When an admin or future upload
workflow replaces an image, write it to a UUID or content-versioned URL instead
of overwriting the previous object at the same URL. Next.js has no per-image
optimizer-cache invalidation API.
## Manual deployment ## Manual deployment
@@ -64,3 +82,10 @@ docker compose --project-name portfolio up --detach --build --remove-orphans
Old versioned release directories are deliberately retained for rollback. They Old versioned release directories are deliberately retained for rollback. They
can be removed manually after a newer release has been proven stable; keep the can be removed manually after a newer release has been proven stable; keep the
directory referenced by the `current` symlink. directory referenced by the `current` symlink.
## Runtime verification
The production image contains `.next/standalone`, `.next/static`, and `public`,
and runs the generated `server.js` with Node.js 22 on port 3000. CI smoke-tests
the English and Persian routes, a built static asset, and `/_next/image` with
WebP negotiation before deployment.

View File

@@ -8,14 +8,25 @@ RUN npm ci
COPY . . COPY . .
RUN npm run build RUN npm run build
FROM nginx:alpine AS runtime FROM node:22-alpine AS runtime
COPY nginx.conf /etc/nginx/conf.d/default.conf WORKDIR /app
COPY --from=builder /app/out/ /usr/share/nginx/html/ ENV NODE_ENV=production \
HOSTNAME=0.0.0.0 \
PORT=3000
EXPOSE 80 RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1/ || exit 1 COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
RUN mkdir -p .next/cache && chown -R nextjs:nodejs .next/cache
CMD ["nginx", "-g", "daemon off;"] USER nextjs
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/ || exit 1
CMD ["node", "server.js"]

36
PERFORMANCE.md Normal file
View File

@@ -0,0 +1,36 @@
# Performance baseline and targets
The pre-change development build blocked the page behind a preload manifest of
76 images (about 6.49 MiB encoded). Under the recorded throttled mobile run the
loader remained visible for about 73 seconds and LCP was about 36 seconds. The
old initial client chunks were about 346 KiB gzip, including a Three.js chunk of
about 172 KiB gzip.
Measured pre-change section heights used for intrinsic containment:
| Viewport | Projects | Experience | Skills | Education | Milestones | Footer |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| 390 px | 746 | 3776 | 3384 | 1010 | 3688 | 472 |
| 768 px | 727 | 2786 | 1877 | 1005 | 2338 | 495 |
| 1024 px | 745 | 2304 | 1391 | 568 | 1830 | 569 |
| 1440 px | 797 | 2081 | 1233 | 475 | 1665 | 613 |
Cold-cache acceptance targets are initial JavaScript at or below 200 KiB gzip,
initial transfer at or below 500 KiB before scrolling, LCP at or below 2.5 s,
INP at or below 200 ms, and CLS at or below 0.1. Compare medians from three
mobile-profile runs; treat differences inside run-to-run variance as neutral.
## Post-change measurements
The production standalone build references 198,652 bytes (194.0 KiB) of
initial JavaScript after gzip. Its estimated English first-view transfer is
391,274 bytes (382.1 KiB), including compressed HTML, JavaScript, CSS, the hero
avatar, flags, and the two locale-relevant fonts. No font is force-preloaded;
the browser requests only the faces used by the active locale. The deferred
Three.js chunk is 131,036 bytes gzip and is not part of the initial script set.
Three fresh 390 x 844 production-browser runs measured LCP at 1,048 ms,
1,296 ms, and 1,432 ms (1,296 ms median), with CLS 0 in every run. These local
runs validate the build and relative improvement but are not a substitute for
field data or a repeatable hosted-network/mobile-CPU lab profile. INP should be
tracked in production once real interaction samples are available.

View File

@@ -1,11 +1,19 @@
services: services:
static-site: portfolio-app:
image: amiirkhl-portfolio:latest image: amiirkhl-portfolio:latest
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: static-site container_name: portfolio-app
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/"]
interval: 30s
timeout: 3s
start_period: 10s
retries: 3
volumes:
- next-image-cache:/app/.next/cache
networks: networks:
- caddy_proxy - caddy_proxy
@@ -13,3 +21,6 @@ networks:
caddy_proxy: caddy_proxy:
external: true external: true
name: caddy_proxy name: caddy_proxy
volumes:
next-image-cache:

View File

@@ -5,5 +5,5 @@ import nextTypeScript from 'eslint-config-next/typescript';
export default defineConfig([ export default defineConfig([
...nextVitals, ...nextVitals,
...nextTypeScript, ...nextTypeScript,
globalIgnores(['.next/**', 'out/**', 'node_modules/**', 'next-env.d.ts']), globalIgnores(['.next/**', 'node_modules/**', 'next-env.d.ts']),
]); ]);

View File

@@ -1,8 +1,15 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
output: 'export', output: 'standalone',
turbopack: {
root: process.cwd(),
},
images: { images: {
unoptimized: true, formats: ['image/webp'],
qualities: [60, 75, 85],
deviceSizes: [360, 390, 640, 768, 1024, 1280, 1440],
imageSizes: [32, 48, 64, 72, 96, 128, 192, 256, 320],
minimumCacheTTL: 86_400,
}, },
}; };

View File

@@ -1,41 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
charset utf-8;
server_tokens off;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types
application/javascript
application/json
application/xml
image/svg+xml
text/css
text/plain;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
location ^~ /_next/static/ {
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
access_log off;
}
location / {
try_files $uri $uri.html $uri/ =404;
}
error_page 404 /404.html;
location = /404.html {
internal;
}
}

5
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"next": "^16.3.4", "next": "^16.3.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"sharp": "0.35.4",
"three": "^0.180.0" "three": "^0.180.0"
}, },
"devDependencies": { "devDependencies": {
@@ -614,7 +615,6 @@
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -2737,7 +2737,6 @@
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -5406,7 +5405,6 @@
"version": "7.8.5", "version": "7.8.5",
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/semver/-/semver-7.8.5.tgz", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"devOptional": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -5469,7 +5467,6 @@
"resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/sharp/-/sharp-0.35.4.tgz", "resolved": "https://package-mirror.liara.ir/artifactory/api/npm/npm/sharp/-/sharp-0.35.4.tgz",
"integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==", "integrity": "sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.1.0", "@img/colour": "^1.1.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",

View File

@@ -15,6 +15,7 @@
"next": "^16.3.4", "next": "^16.3.4",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"sharp": "0.35.4",
"three": "^0.180.0" "three": "^0.180.0"
}, },
"devDependencies": { "devDependencies": {

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,93 @@
Copyright 2018 The Pacifico Project Authors (https://github.com/googlefonts/Pacifico)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -64,9 +64,9 @@ wait_until_healthy() {
local directory="$1" local directory="$1"
local container_id status local container_id status
container_id="$(compose "$directory" ps -q static-site)" container_id="$(compose "$directory" ps -q portfolio-app)"
if [[ -z "$container_id" ]]; then if [[ -z "$container_id" ]]; then
echo "The static-site container was not created." >&2 echo "The portfolio-app container was not created." >&2
return 1 return 1
fi fi

5
src/app/(en)/loading.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { PortfolioSkeleton } from '@/components/skeletons/PortfolioSkeleton';
export default function Loading() {
return <PortfolioSkeleton />;
}

View File

@@ -0,0 +1,5 @@
import { PortfolioSkeleton } from '@/components/skeletons/PortfolioSkeleton';
export default function Loading() {
return <PortfolioSkeleton />;
}

View File

@@ -1,4 +1,4 @@
import React from 'react'; import { Suspense } from 'react';
import { ScenicHeroJourney } from '@/components/ScenicHeroJourney'; import { ScenicHeroJourney } from '@/components/ScenicHeroJourney';
import { ProjectsSection } from '@/components/ProjectsSection'; import { ProjectsSection } from '@/components/ProjectsSection';
import { ExperienceSection } from '@/components/ExperienceSection'; import { ExperienceSection } from '@/components/ExperienceSection';
@@ -7,21 +7,20 @@ import { EducationSection } from '@/components/EducationSection';
import { MilestonesSection } from '@/components/MilestonesSection'; import { MilestonesSection } from '@/components/MilestonesSection';
import { FooterSection } from '@/components/FooterSection'; import { FooterSection } from '@/components/FooterSection';
import { MotionController } from '@/components/MotionController'; import { MotionController } from '@/components/MotionController';
import { SiteLoader } from '@/components/SiteLoader'; import { SectionSkeleton } from '@/components/skeletons/PortfolioSkeleton';
import type { Locale } from '@/i18n/content'; import type { Locale } from '@/i18n/content';
export function PortfolioPage({ locale }: { locale: Locale }) { export function PortfolioPage({ locale }: { locale: Locale }) {
return ( return (
<main className="desktop-canvas" data-locale={locale}> <main className="desktop-canvas" data-locale={locale}>
<SiteLoader locale={locale} />
<MotionController /> <MotionController />
<ScenicHeroJourney locale={locale} /> <ScenicHeroJourney locale={locale} />
<ProjectsSection locale={locale} /> <Suspense fallback={<SectionSkeleton section="projects" />}><ProjectsSection locale={locale} /></Suspense>
<ExperienceSection locale={locale} /> <Suspense fallback={<SectionSkeleton section="experience" />}><ExperienceSection locale={locale} /></Suspense>
<SkillsSection locale={locale} /> <Suspense fallback={<SectionSkeleton section="skills" />}><SkillsSection locale={locale} /></Suspense>
<EducationSection locale={locale} /> <Suspense fallback={<SectionSkeleton section="education" />}><EducationSection locale={locale} /></Suspense>
<MilestonesSection locale={locale} /> <Suspense fallback={<SectionSkeleton section="milestones" />}><MilestonesSection locale={locale} /></Suspense>
<FooterSection locale={locale} /> <Suspense fallback={<SectionSkeleton section="footer" />}><FooterSection locale={locale} /></Suspense>
</main> </main>
); );
} }

View File

@@ -1,15 +1,45 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import localFont from 'next/font/local';
import { ThemeProvider } from '@/context/ThemeContext'; import { ThemeProvider } from '@/context/ThemeContext';
import type { Locale } from '@/i18n/content'; import type { Locale } from '@/i18n/content';
const themeScript = ` const themeScript = `
document.documentElement.dataset.siteLoading = 'true';
try { try {
const savedTheme = localStorage.getItem('portfolio-theme'); const savedTheme = localStorage.getItem('portfolio-theme');
document.documentElement.dataset.theme = savedTheme === 'dark' ? 'dark' : 'light'; document.documentElement.dataset.theme = savedTheme === 'dark' ? 'dark' : 'light';
} catch {} } catch {}
`; `;
const iranYekan = localFont({
src: '../../public/fonts/IRANYekanXVF.woff2',
variable: '--font-body',
weight: '100 1000',
style: 'normal',
display: 'swap',
preload: false,
fallback: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'sans-serif'],
});
const pacifico = localFont({
src: '../../public/fonts/Pacifico-LatinExt.woff2',
variable: '--font-display',
weight: '400',
style: 'normal',
display: 'swap',
preload: false,
fallback: ['cursive'],
});
const peyda = localFont({
src: '../../public/fonts/PeydaWeb-Black.woff2',
variable: '--font-display',
weight: '900',
style: 'normal',
display: 'swap',
preload: false,
fallback: ['sans-serif'],
});
export function SiteRoot({ export function SiteRoot({
children, children,
locale, locale,
@@ -26,11 +56,8 @@ export function SiteRoot({
<head> <head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} /> <script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head> </head>
<body> <body className={`${iranYekan.variable} ${locale === 'fa' ? peyda.variable : pacifico.variable}`}>
<ThemeProvider>{children}</ThemeProvider> <ThemeProvider>{children}</ThemeProvider>
<noscript>
<style>{`#site-loader { display: none !important; }`}</style>
</noscript>
</body> </body>
</html> </html>
); );

View File

@@ -29,6 +29,7 @@
} }
.crestContainer { .crestContainer {
position: relative;
width: 16.5rem; width: 16.5rem;
height: 12rem; height: 12rem;
overflow: hidden; overflow: hidden;
@@ -112,6 +113,7 @@
.crestContainer { .crestContainer {
width: 100%; width: 100%;
height: auto; height: auto;
aspect-ratio: 11 / 8;
} }
.crestImage { .crestImage {

View File

@@ -1,4 +1,4 @@
import Image from 'next/image'; import { ImageWithSkeleton } from './ImageWithSkeleton';
import { getEducation } from '@/data/education'; import { getEducation } from '@/data/education';
import { messagesByLocale, type Locale } from '@/i18n/content'; import { messagesByLocale, type Locale } from '@/i18n/content';
import styles from './EducationSection.module.css'; import styles from './EducationSection.module.css';
@@ -8,7 +8,7 @@ export const EducationSection = ({ locale }: { locale: Locale }) => {
const copy = messagesByLocale[locale].education; const copy = messagesByLocale[locale].education;
return ( return (
<section id="education" className={styles.educationSection}> <section id="education" className={styles.educationSection} data-deferred-render data-looping-motion>
<div className="content-container"> <div className="content-container">
<div className="section-header" data-reveal="soft"> <div className="section-header" data-reveal="soft">
<div className="section-title-wrap"> <div className="section-title-wrap">
@@ -21,12 +21,13 @@ export const EducationSection = ({ locale }: { locale: Locale }) => {
<div className={styles.card} data-reveal-group> <div className={styles.card} data-reveal-group>
<div className={styles.crestCol} data-reveal="image"> <div className={styles.crestCol} data-reveal="image">
<div className={styles.crestContainer}> <div className={styles.crestContainer}>
<Image <ImageWithSkeleton
src={education.logo} src={education.logo.src}
alt={education.institution} alt={education.institution}
width={264} fill
height={192} sizes="(max-width: 650px) 12rem, 16.5rem"
className={styles.crestImage} className={styles.crestImage}
skeletonShape="rounded"
/> />
</div> </div>
</div> </div>

View File

@@ -155,6 +155,7 @@
} }
.logoContainer { .logoContainer {
position: relative;
width: 4.5rem; width: 4.5rem;
height: 4.5rem; height: 4.5rem;
border-radius: var(--radius-md); border-radius: var(--radius-md);

View File

@@ -1,4 +1,4 @@
import Image from 'next/image'; import { ImageWithSkeleton } from './ImageWithSkeleton';
import { getExperiences } from '@/data/experience'; import { getExperiences } from '@/data/experience';
import { messagesByLocale, type Locale } from '@/i18n/content'; import { messagesByLocale, type Locale } from '@/i18n/content';
import styles from './ExperienceSection.module.css'; import styles from './ExperienceSection.module.css';
@@ -8,7 +8,7 @@ export const ExperienceSection = ({ locale }: { locale: Locale }) => {
const copy = messagesByLocale[locale].experience; const copy = messagesByLocale[locale].experience;
return ( return (
<section id="experience" className={styles.experienceSection}> <section id="experience" className={styles.experienceSection} data-deferred-render data-looping-motion>
<div className="content-container"> <div className="content-container">
<div className="section-header" data-reveal="soft"> <div className="section-header" data-reveal="soft">
<div className="section-title-wrap"> <div className="section-title-wrap">
@@ -65,12 +65,13 @@ export const ExperienceSection = ({ locale }: { locale: Locale }) => {
<div className={styles.roleIdentityRow}> <div className={styles.roleIdentityRow}>
<div className={styles.logoContainer}> <div className={styles.logoContainer}>
<Image <ImageWithSkeleton
src={item.logo} src={item.logo.src}
alt={copy.logoAlt(item.company)} alt={copy.logoAlt(item.company)}
width={72} fill
height={72} sizes="72px"
className={styles.logoImage} className={styles.logoImage}
skeletonShape="rounded"
/> />
</div> </div>

View File

@@ -1,12 +1,10 @@
'use client';
import IsometricFooter from './IsometricFooter/IsometricFooter'; import IsometricFooter from './IsometricFooter/IsometricFooter';
import type { Locale } from '@/i18n/content'; import type { Locale } from '@/i18n/content';
import styles from './FooterSection.module.css'; import styles from './FooterSection.module.css';
export const FooterSection = ({ locale }: { locale: Locale }) => { export const FooterSection = ({ locale }: { locale: Locale }) => {
return ( return (
<footer id="footer" className={styles.footerSection}> <footer id="footer" className={styles.footerSection} data-deferred-render data-looping-motion>
<IsometricFooter locale={locale} /> <IsometricFooter locale={locale} />
</footer> </footer>
); );

View File

@@ -0,0 +1,59 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Check, Languages, Moon, Sun } from 'lucide-react';
import { useTheme } from '@/context/ThemeContext';
import { messagesByLocale, type Locale } from '@/i18n/content';
import styles from './HeroSection.module.css';
export function HeroControls({ locale }: { locale: Locale }) {
const { theme, toggleTheme } = useTheme();
const copy = messagesByLocale[locale].hero;
const [isOpen, setIsOpen] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isOpen) return;
const onPointerDown = (event: PointerEvent) => {
if (!menuRef.current?.contains(event.target as Node)) setIsOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
setIsOpen(false);
buttonRef.current?.focus();
};
document.addEventListener('pointerdown', onPointerDown);
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('pointerdown', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
};
}, [isOpen]);
const themeLabel = theme === 'light' ? copy.themeDarkLabel : copy.themeLightLabel;
return (
<div className={styles.topBar}>
<button type="button" onClick={toggleTheme} className={styles.iconButton} aria-label={themeLabel} title={themeLabel}>
{theme === 'light' ? <Moon size={28} strokeWidth={1.8} className={styles.themeIcon} /> : <Sun size={28} strokeWidth={1.8} className={styles.controlIcon} />}
</button>
<div className={styles.languageMenuWrapper} ref={menuRef}>
<button ref={buttonRef} type="button" className={`${styles.iconButton} ${styles.languageButton}`} aria-label={copy.languageButtonLabel} title={copy.languageButtonLabel} aria-haspopup="menu" aria-expanded={isOpen} aria-controls="language-menu" onClick={() => setIsOpen((value) => !value)} data-language-switch>
<Languages size={23} strokeWidth={1.9} aria-hidden="true" />
</button>
<div id="language-menu" className={styles.languageMenu} role="menu" aria-label={copy.languageMenuLabel} data-open={isOpen}>
<Link href="/" hrefLang="en" role="menuitem" aria-current={locale === 'en' ? 'page' : undefined} className={styles.languageOption}>
<span className={styles.languageOptionLabel}><Image src="/images/flags/english.svg" alt="" width={20} height={20} unoptimized className={styles.languageFlag} aria-hidden="true" /><span lang="en" dir="ltr">{copy.englishLabel}</span></span>
{locale === 'en' && <Check size={16} className={styles.selectedIcon} aria-hidden="true" />}
</Link>
<Link href="/fa" hrefLang="fa" role="menuitem" aria-current={locale === 'fa' ? 'page' : undefined} className={styles.languageOption}>
{locale === 'fa' && <Check size={16} className={styles.selectedIcon} aria-hidden="true" />}
<span className={styles.languageOptionLabel}><Image src="/images/flags/persian.svg" alt="" width={20} height={20} unoptimized className={styles.languageFlag} aria-hidden="true" /><span lang="fa" dir="rtl">{copy.persianLabel}</span></span>
</Link>
</div>
</div>
</div>
);
}

View File

@@ -115,7 +115,7 @@
padding-inline-end: 2.25rem; padding-inline-end: 2.25rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
color: var(--text-primary); color: var(--text-primary);
font-family: 'IRANYekanXVF', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: var(--font-body), -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;

View File

@@ -1,208 +1,52 @@
'use client'; import { BookOpenText, GraduationCap, MessageCircle } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import {
BookOpenText,
Check,
GraduationCap,
Languages,
MessageCircle,
Moon,
Sun,
} from 'lucide-react';
import { useTheme } from '@/context/ThemeContext';
import { messagesByLocale, type Locale } from '@/i18n/content'; import { messagesByLocale, type Locale } from '@/i18n/content';
import { HeroControls } from './HeroControls';
import { ImageWithSkeleton } from './ImageWithSkeleton';
import styles from './HeroSection.module.css'; import styles from './HeroSection.module.css';
export const HeroSection = ({ locale }: { locale: Locale }) => { export const HeroSection = ({ locale }: { locale: Locale }) => {
const { theme, toggleTheme } = useTheme();
const copy = messagesByLocale[locale].hero; const copy = messagesByLocale[locale].hero;
const themeLabel = theme === 'light' ? copy.themeDarkLabel : copy.themeLightLabel;
const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false);
const languageMenuRef = useRef<HTMLDivElement>(null);
const languageButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
if (!isLanguageMenuOpen) return;
const closeOnOutsideClick = (event: PointerEvent) => {
if (!languageMenuRef.current?.contains(event.target as Node)) {
setIsLanguageMenuOpen(false);
}
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
setIsLanguageMenuOpen(false);
languageButtonRef.current?.focus();
};
document.addEventListener('pointerdown', closeOnOutsideClick);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('pointerdown', closeOnOutsideClick);
document.removeEventListener('keydown', closeOnEscape);
};
}, [isLanguageMenuOpen]);
return ( return (
<div className={styles.heroContentWrapper}> <div className={styles.heroContentWrapper}>
{/* Top utility control */} <HeroControls locale={locale} />
<div className={styles.topBar}>
<button
type="button"
onClick={toggleTheme}
className={styles.iconButton}
aria-label={themeLabel}
title={themeLabel}
>
{theme === 'light' ? (
<Moon size={28} strokeWidth={1.8} className={styles.themeIcon} />
) : (
<Sun size={28} strokeWidth={1.8} className={styles.controlIcon} />
)}
</button>
<div className={styles.languageMenuWrapper} ref={languageMenuRef}>
<button
ref={languageButtonRef}
type="button"
className={`${styles.iconButton} ${styles.languageButton}`}
aria-label={copy.languageButtonLabel}
title={copy.languageButtonLabel}
aria-haspopup="menu"
aria-expanded={isLanguageMenuOpen}
aria-controls="language-menu"
onClick={() => setIsLanguageMenuOpen((isOpen) => !isOpen)}
data-language-switch
>
<Languages size={23} strokeWidth={1.9} aria-hidden="true" />
</button>
<div
id="language-menu"
className={styles.languageMenu}
role="menu"
aria-label={copy.languageMenuLabel}
data-open={isLanguageMenuOpen}
>
<Link
href="/"
hrefLang="en"
role="menuitem"
aria-current={locale === 'en' ? 'page' : undefined}
className={styles.languageOption}
>
<span className={styles.languageOptionLabel}>
<Image
src="/images/flags/english.svg"
alt=""
width={20}
height={20}
className={styles.languageFlag}
aria-hidden="true"
/>
<span lang="en" dir="ltr">{copy.englishLabel}</span>
</span>
{locale === 'en' && (
<Check
size={16}
className={styles.selectedIcon}
aria-hidden="true"
/>
)}
</Link>
<Link
href="/fa"
hrefLang="fa"
role="menuitem"
aria-current={locale === 'fa' ? 'page' : undefined}
className={styles.languageOption}
>
{locale === 'fa' && (
<Check
size={16}
className={styles.selectedIcon}
aria-hidden="true"
/>
)}
<span className={styles.languageOptionLabel}>
<Image
src="/images/flags/persian.svg"
alt=""
width={20}
height={20}
className={styles.languageFlag}
aria-hidden="true"
/>
<span lang="fa" dir="rtl">{copy.persianLabel}</span>
</span>
</Link>
</div>
</div>
</div>
{/* Hero Central Content */}
<div className={styles.heroContent}> <div className={styles.heroContent}>
{/* Avatar with double ring */}
<div className={styles.avatarWrapper}> <div className={styles.avatarWrapper}>
<div className={styles.avatarRingOuter}> <div className={styles.avatarRingOuter}>
<div className={styles.avatarContainer}> <div className={styles.avatarContainer}>
<Image <ImageWithSkeleton
src="/images/avatar.png" src="/images/avatar.png"
alt={copy.avatarAlt} alt={copy.avatarAlt}
width={192} fill
height={192} sizes="192px"
preload
skeletonShape="circle"
className={styles.avatarImg} className={styles.avatarImg}
priority
/> />
</div> </div>
</div> </div>
</div> </div>
{/* Display Typography */}
<h1 className={`font-display ${styles.heroTitle}`} aria-label={copy.titleLabel}> <h1 className={`font-display ${styles.heroTitle}`} aria-label={copy.titleLabel}>
<span className={styles.heroGreeting}>{copy.greeting}</span> <span className={styles.heroGreeting}>{copy.greeting}</span>
<span className={styles.heroName}>{copy.name}</span> <span className={styles.heroName}>{copy.name}</span>
</h1> </h1>
<p className={styles.heroSubtitle}>{copy.subtitle}</p> <p className={styles.heroSubtitle}>{copy.subtitle}</p>
{/* Action Buttons */}
<div className={styles.actionButtons}> <div className={styles.actionButtons}>
<button <button type="button" className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`} aria-label={copy.blogLabel} title={copy.comingSoon} disabled>
type="button"
className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`}
aria-label={copy.blogLabel}
title={copy.comingSoon}
disabled
>
<BookOpenText size={17} strokeWidth={2} aria-hidden="true" /> <BookOpenText size={17} strokeWidth={2} aria-hidden="true" />
{copy.blog} {copy.blog}
</button> </button>
<button <button type="button" className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`} aria-label={copy.coursesLabel} title={copy.comingSoon} disabled>
type="button"
className={`pill-btn ${styles.heroButton} ${styles.heroButtonUpcoming}`}
aria-label={copy.coursesLabel}
title={copy.comingSoon}
disabled
>
<GraduationCap size={18} strokeWidth={2} aria-hidden="true" /> <GraduationCap size={18} strokeWidth={2} aria-hidden="true" />
{copy.courses} {copy.courses}
</button> </button>
<a <a href="https://www.linkedin.com/messaging/compose/?recipient=ACoAAEJsAwMBCv3L9dYcEXWam-l3pLOJ3UspX7w" target="_blank" rel="noreferrer" className={`pill-btn ${styles.heroButton} ${styles.heroButtonPrimary}`} aria-label={copy.contactLabel}>
href="https://www.linkedin.com/messaging/compose/?recipient=ACoAAEJsAwMBCv3L9dYcEXWam-l3pLOJ3UspX7w"
target="_blank"
rel="noreferrer"
className={`pill-btn ${styles.heroButton} ${styles.heroButtonPrimary}`}
aria-label={copy.contactLabel}
>
<MessageCircle size={17} strokeWidth={2} aria-hidden="true" /> <MessageCircle size={17} strokeWidth={2} aria-hidden="true" />
{copy.contact} {copy.contact}
</a> </a>
</div> </div>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,73 @@
.frame {
position: absolute;
inset: 0;
display: block;
overflow: hidden;
border-radius: inherit;
}
.intrinsicFrame {
position: relative;
inset: auto;
width: var(--image-width);
height: var(--image-height);
}
.skeleton,
.error {
position: absolute;
inset: 0;
background: var(--bg-subtle);
}
.skeleton::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(
100deg,
transparent 20%,
color-mix(in srgb, var(--bg-raised) 72%, transparent) 48%,
transparent 76%
);
transform: translateX(-100%);
animation: imageSkeletonShimmer 1.35s ease-in-out infinite;
}
.circle {
border-radius: 50%;
}
.rounded {
border-radius: inherit;
}
.image {
opacity: 0;
transition: opacity 220ms var(--motion-ease-out);
}
.loaded {
opacity: 1;
}
.error {
display: grid;
place-items: center;
color: var(--text-muted);
background:
linear-gradient(135deg, transparent 46%, var(--border-default) 47% 53%, transparent 54%),
var(--bg-subtle);
}
@keyframes imageSkeletonShimmer {
to {
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton::after {
display: none;
}
}

View File

@@ -0,0 +1,113 @@
'use client';
import Image, { type ImageProps } from 'next/image';
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import styles from './ImageWithSkeleton.module.css';
type SkeletonShape = 'circle' | 'rounded' | 'square';
const nearViewportCallbacks = new WeakMap<Element, () => void>();
let nearViewportObserver: IntersectionObserver | undefined;
function observeNearViewport(element: Element, callback: () => void) {
nearViewportCallbacks.set(element, callback);
nearViewportObserver ??= new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
nearViewportCallbacks.get(entry.target)?.();
nearViewportCallbacks.delete(entry.target);
nearViewportObserver?.unobserve(entry.target);
});
},
{ rootMargin: '300px 0px' },
);
nearViewportObserver.observe(element);
return () => {
nearViewportCallbacks.delete(element);
nearViewportObserver?.unobserve(element);
};
}
export interface ImageWithSkeletonProps extends Omit<ImageProps, 'onLoad' | 'onError'> {
skeletonShape?: SkeletonShape;
onLoad?: ImageProps['onLoad'];
onError?: ImageProps['onError'];
}
export function ImageWithSkeleton({
alt,
className,
fill,
height,
width,
loading,
preload = false,
skeletonShape = 'rounded',
onLoad,
onError,
...props
}: ImageWithSkeletonProps) {
const frameRef = useRef<HTMLSpanElement>(null);
const eager = preload || loading === 'eager';
const [shouldRender, setShouldRender] = useState(eager);
const [status, setStatus] = useState<'loading' | 'loaded' | 'error'>('loading');
const intrinsicStyle = !fill
? ({
'--image-width': `${Number(width)}px`,
'--image-height': `${Number(height)}px`,
} as CSSProperties)
: undefined;
useEffect(() => {
const frame = frameRef.current;
if (eager || shouldRender || !frame) return;
if (!('IntersectionObserver' in window)) {
const timer = setTimeout(() => setShouldRender(true), 0);
return () => clearTimeout(timer);
}
return observeNearViewport(frame, () => setShouldRender(true));
}, [eager, shouldRender]);
return (
<span
ref={frameRef}
className={`${styles.frame} ${!fill ? styles.intrinsicFrame : ''}`}
style={intrinsicStyle}
aria-busy={status === 'loading'}
data-image-state={status}
>
{status === 'loading' && (
<span
className={`${styles.skeleton} ${styles[skeletonShape]}`}
aria-hidden="true"
/>
)}
{status === 'error' && (
<span
className={`${styles.error} ${styles[skeletonShape]}`}
role={alt ? 'img' : undefined}
aria-label={alt || undefined}
aria-hidden={alt ? undefined : true}
/>
)}
{shouldRender && <Image
{...props}
alt={alt}
fill={fill}
height={height}
width={width}
loading={loading}
preload={preload}
className={`${styles.image} ${status === 'loaded' ? styles.loaded : ''} ${className ?? ''}`}
onLoad={(event) => {
setStatus('loaded');
onLoad?.(event);
}}
onError={(event) => {
setStatus('error');
onError?.(event);
}}
/>}
</span>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import { type FocusEvent, type PointerEvent, type ReactNode, useRef } from 'react';
import type { FooterContactId } from './IsoTypes';
const getContactId = (target: EventTarget | null) =>
target instanceof Element
? (target.closest<HTMLElement>('[data-contact-id]')?.dataset.contactId as FooterContactId | undefined)
: undefined;
export function FooterInteraction({ children, className }: { children: ReactNode; className: string }) {
const rootRef = useRef<HTMLDivElement>(null);
const setActive = (id?: FooterContactId) => {
const root = rootRef.current;
if (!root) return;
if (id) root.dataset.contactActive = id;
else delete root.dataset.contactActive;
};
const onPointerOver = (event: PointerEvent<HTMLDivElement>) => setActive(getContactId(event.target));
const onPointerOut = (event: PointerEvent<HTMLDivElement>) => {
if (getContactId(event.target) === getContactId(event.relatedTarget)) return;
setActive(getContactId(event.relatedTarget));
};
const onFocus = (event: FocusEvent<HTMLDivElement>) => setActive(getContactId(event.target));
const onBlur = (event: FocusEvent<HTMLDivElement>) => setActive(getContactId(event.relatedTarget));
return <div ref={rootRef} className={className} onPointerOver={onPointerOver} onPointerOut={onPointerOut} onFocus={onFocus} onBlur={onBlur}>{children}</div>;
}

View File

@@ -280,7 +280,6 @@ export function IsoPCTower({ x, y, size = 20, height = 40 }: IsoTowerProps) {
const cFront = `${x},${baseY + hs * 0.5}`; const cFront = `${x},${baseY + hs * 0.5}`;
const cLeft = `${x - hs},${baseY}`; const cLeft = `${x - hs},${baseY}`;
const cRight = `${x + hs},${baseY}`; const cRight = `${x + hs},${baseY}`;
const cBack = `${x},${baseY - hs * 0.5}`;
// Cube Top corners // Cube Top corners
const topY = baseY - height; const topY = baseY - height;
@@ -320,11 +319,9 @@ export function IsoSignpost({
facing = 'right', facing = 'right',
backdropDirection, backdropDirection,
icon, icon,
label: _label, label,
isActive = false, href,
onMouseEnter, external = false,
onMouseLeave,
onClick,
}: IsoSignpostProps) { }: IsoSignpostProps) {
const isRight = facing === 'right'; const isRight = facing === 'right';
const postTopY = y - postHeight; const postTopY = y - postHeight;
@@ -346,14 +343,8 @@ export function IsoSignpost({
const backShiftY = -3.2; const backShiftY = -3.2;
return ( return (
<g <a href={href} target={external ? '_blank' : undefined} rel={external ? 'noopener noreferrer' : undefined} data-contact-id={_id} aria-label={label}>
className={`iso-signpost ${isActive ? 'active' : ''}`} <g className="iso-signpost" data-footer-contact={_id} style={{ cursor: 'pointer', touchAction: 'manipulation' }}>
data-footer-contact={_id}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onClick={onClick}
style={{ cursor: 'pointer', touchAction: 'manipulation' }}
>
<defs> <defs>
{/* 3D Cylindrical Metallic Gradient for Upright Pole */} {/* 3D Cylindrical Metallic Gradient for Upright Pole */}
<linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="0%"> <linearGradient id={gradId} x1="0%" y1="0%" x2="100%" y2="0%">
@@ -377,7 +368,7 @@ export function IsoSignpost({
{/* 1. 3D Cylindrical Disc Drop Apron (Thickness = 4px) */} {/* 1. 3D Cylindrical Disc Drop Apron (Thickness = 4px) */}
<path <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`} 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)'} fill="var(--hero-illus-cube-front, #2066AB)"
stroke="var(--hero-illus-cube-side, #0E4073)" stroke="var(--hero-illus-cube-side, #0E4073)"
strokeWidth={0.6} strokeWidth={0.6}
/> />
@@ -390,8 +381,8 @@ export function IsoSignpost({
cy={y} cy={y}
rx={11.5} rx={11.5}
ry={5.75} ry={5.75}
fill={isActive ? 'var(--color-blue-300, #82B5FF)' : 'var(--bg-subtle, #E6F0FE)'} fill="var(--bg-subtle, #E6F0FE)"
stroke={isActive ? 'var(--hero-illus-cube-front, #2066AB)' : 'var(--border-strong, #A9BEDA)'} stroke="var(--border-strong, #A9BEDA)"
strokeWidth={1} strokeWidth={1}
/> />
@@ -439,7 +430,8 @@ export function IsoSignpost({
{/* 3D Rotated Isometric Signboard */} {/* 3D Rotated Isometric Signboard */}
<g <g
transform={isActive ? 'translate(0, -3)' : 'translate(0, 0)'} className="signboard-motion"
transform="translate(0, 0)"
style={{ transition: 'transform 0.25s cubic-bezier(0.16, 1, 0.3, 1)' }} 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) */} {/* 1. Backdrop Layer (Positioned to left-top for rightside items, right-top for leftside items) */}
@@ -452,7 +444,7 @@ export function IsoSignpost({
height={cardSize} height={cardSize}
rx={radius} rx={radius}
fill="var(--hero-illus-cube-shadow, #0C234F)" fill="var(--hero-illus-cube-shadow, #0C234F)"
opacity={isActive ? 0.35 : 0.18} opacity={0.18}
/> />
{/* 3D Backdrop Plate with signature royal blue fill and darker border */} {/* 3D Backdrop Plate with signature royal blue fill and darker border */}
@@ -462,8 +454,8 @@ export function IsoSignpost({
width={cardSize} width={cardSize}
height={cardSize} height={cardSize}
rx={radius} rx={radius}
fill={isActive ? 'var(--hero-illus-cube-side, #0E4073)' : 'var(--hero-illus-cube-front, #2066AB)'} fill="var(--hero-illus-cube-front, #2066AB)"
stroke={isActive ? 'var(--hero-illus-cube-shadow, #0C234F)' : 'var(--hero-illus-cube-side, #0E4073)'} stroke="var(--hero-illus-cube-side, #0E4073)"
strokeWidth={1} strokeWidth={1}
/> />
</g> </g>
@@ -478,14 +470,14 @@ export function IsoSignpost({
height={cardSize} height={cardSize}
rx={radius} rx={radius}
fill="var(--bg-raised, #FCFDFF)" fill="var(--bg-raised, #FCFDFF)"
stroke={isActive ? 'var(--accent-default, #3D8AFA)' : 'var(--border-strong, #A9BEDA)'} stroke="var(--border-strong, #A9BEDA)"
strokeWidth={isActive ? 2 : 1.6} strokeWidth={1.6}
/> />
{/* 3D Rotated Icon inside Card */} {/* 3D Rotated Icon inside Card */}
<g <g
transform="translate(0, 0)" transform="translate(0, 0)"
color={isActive ? 'var(--accent-default, #3D8AFA)' : 'var(--hero-illus-cube-front, #2066AB)'} color="var(--hero-illus-cube-front, #2066AB)"
> >
<g transform="translate(-8.5, -8.5) scale(0.85)"> <g transform="translate(-8.5, -8.5) scale(0.85)">
{icon} {icon}
@@ -494,5 +486,6 @@ export function IsoSignpost({
</g> </g>
</g> </g>
</g> </g>
</a>
); );
} }

View File

@@ -64,15 +64,10 @@ export interface IsoSignpostProps {
backdropDirection?: 'left-top' | 'right-top'; backdropDirection?: 'left-top' | 'right-top';
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
isActive?: boolean; href: string;
onMouseEnter?: () => void; external?: boolean;
onMouseLeave?: () => void;
onClick?: () => void;
} }
export interface IsometricSceneProps { export interface IsometricSceneProps {
activePost: FooterContactId | null;
ariaLabel: string; ariaLabel: string;
onPostHover?: (id: FooterContactId | null) => void;
onPostClick?: (id: FooterContactId) => void;
} }

View File

@@ -29,7 +29,7 @@
} }
.headerTitle { .headerTitle {
font-family: 'Pacifico', cursive, system-ui; font-family: var(--font-display), cursive, system-ui;
font-weight: 400; font-weight: 400;
font-size: clamp(2.2rem, 5.5vw, 3.6rem); font-size: clamp(2.2rem, 5.5vw, 3.6rem);
line-height: 1.35; line-height: 1.35;
@@ -40,7 +40,7 @@
} }
:global(html[lang='fa']) .headerTitle { :global(html[lang='fa']) .headerTitle {
font-family: 'Peyda', 'IRANYekanXVF', sans-serif; font-family: var(--font-display), var(--font-body), sans-serif;
font-weight: 900; font-weight: 900;
} }
@@ -75,7 +75,12 @@
font-weight: 600; font-weight: 600;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
box-shadow: 0 2px 10px var(--shadow-color); box-shadow: 0 2px 10px var(--shadow-color);
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); transition:
color 0.25s cubic-bezier(0.16, 1, 0.3, 1),
background-color 0.25s cubic-bezier(0.16, 1, 0.3, 1),
border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
max-width: 100%; max-width: 100%;
} }
@@ -214,13 +219,21 @@
border: 1.5px solid var(--border-default); border: 1.5px solid var(--border-default);
text-decoration: none; text-decoration: none;
color: var(--text-primary); color: var(--text-primary);
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1); transition:
color 0.25s cubic-bezier(0.16, 1, 0.3, 1),
background-color 0.25s cubic-bezier(0.16, 1, 0.3, 1),
border-color 0.25s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.25s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.25s cubic-bezier(0.16, 1, 0.3, 1);
box-shadow: 0 2px 8px var(--shadow-color); box-shadow: 0 2px 8px var(--shadow-color);
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
.contactCard:hover, .contactCard:hover,
.contactCard.active { .footerContainer[data-contact-active='phone'] .contactCard[data-contact-id='phone'],
.footerContainer[data-contact-active='linkedin'] .contactCard[data-contact-id='linkedin'],
.footerContainer[data-contact-active='github'] .contactCard[data-contact-id='github'],
.footerContainer[data-contact-active='telegram'] .contactCard[data-contact-id='telegram'] {
border-color: var(--accent-default); border-color: var(--accent-default);
background: var(--bg-surface); background: var(--bg-surface);
transform: translateY(-3px); transform: translateY(-3px);
@@ -238,11 +251,14 @@
justify-content: center; justify-content: center;
color: var(--accent-default); color: var(--accent-default);
flex-shrink: 0; flex-shrink: 0;
transition: all 0.2s ease; transition: color 0.2s ease, background-color 0.2s ease, transform 0.2s ease;
} }
.contactCard:hover .contactCardIconWrap, .contactCard:hover .contactCardIconWrap,
.contactCard.active .contactCardIconWrap { .footerContainer[data-contact-active='phone'] .contactCard[data-contact-id='phone'] .contactCardIconWrap,
.footerContainer[data-contact-active='linkedin'] .contactCard[data-contact-id='linkedin'] .contactCardIconWrap,
.footerContainer[data-contact-active='github'] .contactCard[data-contact-id='github'] .contactCardIconWrap,
.footerContainer[data-contact-active='telegram'] .contactCard[data-contact-id='telegram'] .contactCardIconWrap {
transform: scale(1.08); transform: scale(1.08);
background: var(--accent-default); background: var(--accent-default);
color: var(--action-primary-fg); color: var(--action-primary-fg);
@@ -276,10 +292,27 @@
} }
.contactCard:hover .contactCardLabel, .contactCard:hover .contactCardLabel,
.contactCard.active .contactCardLabel { .footerContainer[data-contact-active='phone'] .contactCard[data-contact-id='phone'] .contactCardLabel,
.footerContainer[data-contact-active='linkedin'] .contactCard[data-contact-id='linkedin'] .contactCardLabel,
.footerContainer[data-contact-active='github'] .contactCard[data-contact-id='github'] .contactCardLabel,
.footerContainer[data-contact-active='telegram'] .contactCard[data-contact-id='telegram'] .contactCardLabel {
color: var(--accent-default); color: var(--accent-default);
} }
.footerContainer[data-contact-active='phone'] :global([data-contact-id='phone'] .signboard-motion),
.footerContainer[data-contact-active='linkedin'] :global([data-contact-id='linkedin'] .signboard-motion),
.footerContainer[data-contact-active='github'] :global([data-contact-id='github'] .signboard-motion),
.footerContainer[data-contact-active='telegram'] :global([data-contact-id='telegram'] .signboard-motion) {
transform: translateY(-3px);
}
.footerContainer[data-contact-active='phone'] :global(.footer-circuit-path[data-contact-ids~='phone']),
.footerContainer[data-contact-active='linkedin'] :global(.footer-circuit-path[data-contact-ids~='linkedin']),
.footerContainer[data-contact-active='github'] :global(.footer-circuit-path[data-contact-ids~='github']),
.footerContainer[data-contact-active='telegram'] :global(.footer-circuit-path[data-contact-ids~='telegram']) {
opacity: 1;
}
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
Animations Animations
-------------------------------------------------------------------------- */ -------------------------------------------------------------------------- */

View File

@@ -1,7 +1,4 @@
'use client'; import type { ContactItem } from './IsoTypes';
import { useState } from 'react';
import type { ContactItem, FooterContactId } from './IsoTypes';
import IsometricScene from './IsometricScene'; import IsometricScene from './IsometricScene';
import { import {
MailIcon, MailIcon,
@@ -12,6 +9,7 @@ import {
} from './IsoElements'; } from './IsoElements';
import styles from './IsometricFooter.module.css'; import styles from './IsometricFooter.module.css';
import { messagesByLocale, type Locale } from '@/i18n/content'; import { messagesByLocale, type Locale } from '@/i18n/content';
import { FooterInteraction } from './FooterInteraction';
export const contacts: ContactItem[] = [ export const contacts: ContactItem[] = [
{ {
@@ -50,21 +48,8 @@ export default function IsometricFooter({ locale }: { locale: Locale }) {
...contact, ...contact,
name: copy[contact.id], name: copy[contact.id],
})); }));
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 ( return (
<div className={styles.footerContainer}> <FooterInteraction className={styles.footerContainer}>
{/* Top Header & Contact Section */} {/* Top Header & Contact Section */}
<header className={styles.footerHeader}> <header className={styles.footerHeader}>
<h2 className={styles.headerTitle}> <h2 className={styles.headerTitle}>
@@ -94,12 +79,7 @@ export default function IsometricFooter({ locale }: { locale: Locale }) {
{/* 3D Isometric SVG Scene */} {/* 3D Isometric SVG Scene */}
<div className={styles.sceneContainer} data-footer-reveal> <div className={styles.sceneContainer} data-footer-reveal>
<IsometricScene <IsometricScene ariaLabel={copy.sceneLabel} />
activePost={activePost}
ariaLabel={copy.sceneLabel}
onPostHover={setActivePost}
onPostClick={handlePostClick}
/>
</div> </div>
{/* Responsive Synchronized Contact Cards Section */} {/* Responsive Synchronized Contact Cards Section */}
@@ -107,17 +87,14 @@ export default function IsometricFooter({ locale }: { locale: Locale }) {
<div className={styles.contactCardsGrid}> <div className={styles.contactCardsGrid}>
{localizedContacts.map((contact) => { {localizedContacts.map((contact) => {
const IconComponent = contact.Icon; const IconComponent = contact.Icon;
const isHovered = activePost === contact.id;
return ( return (
<a <a
key={contact.id} key={contact.id}
href={contact.href} href={contact.href}
target={contact.href.startsWith('tel:') ? undefined : '_blank'} target={contact.href.startsWith('tel:') ? undefined : '_blank'}
rel={contact.href.startsWith('tel:') ? undefined : 'noopener noreferrer'} rel={contact.href.startsWith('tel:') ? undefined : 'noopener noreferrer'}
className={`${styles.contactCard} ${isHovered ? styles.active : ''}`} className={styles.contactCard}
onMouseEnter={() => setActivePost(contact.id)} data-contact-id={contact.id}
onMouseLeave={() => setActivePost(null)}
onClick={() => setActivePost(contact.id)}
aria-label={`${contact.name}: ${contact.label}`} aria-label={`${contact.name}: ${contact.label}`}
> >
<div className={styles.contactCardIconWrap}> <div className={styles.contactCardIconWrap}>
@@ -134,6 +111,6 @@ export default function IsometricFooter({ locale }: { locale: Locale }) {
})} })}
</div> </div>
</section> </section>
</div> </FooterInteraction>
); );
} }

View File

@@ -212,7 +212,7 @@ function SteppedTerraces() {
* Glowing Blue Circuit Lines * Glowing Blue Circuit Lines
* Completely uniform 2:1 isometric slope (26.565°, dy/dx = ±0.5) across ALL conduit segments * Completely uniform 2:1 isometric slope (26.565°, dy/dx = ±0.5) across ALL conduit segments
*/ */
function CircuitConduits({ activePost }: { activePost: string | null }) { function CircuitConduits() {
return ( return (
<g className="circuit-conduits"> <g className="circuit-conduits">
<defs> <defs>
@@ -225,6 +225,7 @@ function CircuitConduits({ activePost }: { activePost: string | null }) {
<path <path
d="M 514,323 L 440,360 L 360,320 L 280,360" d="M 514,323 L 440,360 L 360,320 L 280,360"
className="footer-circuit-path" className="footer-circuit-path"
data-contact-ids="phone linkedin"
pathLength={1} pathLength={1}
fill="none" fill="none"
stroke="var(--accent-default, #3D8AFA)" stroke="var(--accent-default, #3D8AFA)"
@@ -232,13 +233,14 @@ function CircuitConduits({ activePost }: { activePost: string | null }) {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="miter" strokeLinejoin="miter"
filter="url(#circuitGlow)" filter="url(#circuitGlow)"
opacity={activePost === 'phone' || activePost === 'linkedin' ? 1 : 0.85} opacity={0.85}
/> />
{/* Right Branch: Center Platform -> GitHub -> Step 5 -> Telegram with exact ±0.5 slope */} {/* Right Branch: Center Platform -> GitHub -> Step 5 -> Telegram with exact ±0.5 slope */}
<path <path
d="M 686,323 L 760,360 L 840,320 L 920,360" d="M 686,323 L 760,360 L 840,320 L 920,360"
className="footer-circuit-path" className="footer-circuit-path"
data-contact-ids="github telegram"
pathLength={1} pathLength={1}
fill="none" fill="none"
stroke="var(--accent-default, #3D8AFA)" stroke="var(--accent-default, #3D8AFA)"
@@ -246,7 +248,7 @@ function CircuitConduits({ activePost }: { activePost: string | null }) {
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="miter" strokeLinejoin="miter"
filter="url(#circuitGlow)" filter="url(#circuitGlow)"
opacity={activePost === 'github' || activePost === 'telegram' ? 1 : 0.85} opacity={0.85}
/> />
</g> </g>
); );
@@ -256,10 +258,7 @@ function CircuitConduits({ activePost }: { activePost: string | null }) {
* Main Isometric Footer Scene SVG Component * Main Isometric Footer Scene SVG Component
*/ */
export default function IsometricScene({ export default function IsometricScene({
activePost,
ariaLabel, ariaLabel,
onPostHover,
onPostClick,
}: IsometricSceneProps) { }: IsometricSceneProps) {
return ( return (
<svg <svg
@@ -274,7 +273,7 @@ export default function IsometricScene({
<SteppedTerraces /> <SteppedTerraces />
{/* 2. Glowing Royal Blue Circuit Conduits */} {/* 2. Glowing Royal Blue Circuit Conduits */}
<CircuitConduits activePost={activePost} /> <CircuitConduits />
{/* 3. Left Mountain Cluster */} {/* 3. Left Mountain Cluster */}
<MountainCluster x={80} y={250} scale={0.92} /> <MountainCluster x={80} y={250} scale={0.92} />
@@ -310,10 +309,7 @@ export default function IsometricScene({
backdropDirection="right-top" backdropDirection="right-top"
icon={<PhoneIcon />} icon={<PhoneIcon />}
label="09388263077" label="09388263077"
isActive={activePost === 'phone'} href="tel:09388263077"
onMouseEnter={() => onPostHover?.('phone')}
onMouseLeave={() => onPostHover?.(null)}
onClick={() => onPostClick?.('phone')}
/> />
<IsoSignpost <IsoSignpost
@@ -325,10 +321,8 @@ export default function IsometricScene({
backdropDirection="right-top" backdropDirection="right-top"
icon={<LinkedInIcon />} icon={<LinkedInIcon />}
label="@amiirkhl" label="@amiirkhl"
isActive={activePost === 'linkedin'} href="https://linkedin.com/in/amiirkhl"
onMouseEnter={() => onPostHover?.('linkedin')} external
onMouseLeave={() => onPostHover?.(null)}
onClick={() => onPostClick?.('linkedin')}
/> />
<IsoSignpost <IsoSignpost
@@ -340,10 +334,8 @@ export default function IsometricScene({
backdropDirection="left-top" backdropDirection="left-top"
icon={<GitHubIcon />} icon={<GitHubIcon />}
label="git.amiirkhl.ir" label="git.amiirkhl.ir"
isActive={activePost === 'github'} href="https://git.amiirkhl.ir"
onMouseEnter={() => onPostHover?.('github')} external
onMouseLeave={() => onPostHover?.(null)}
onClick={() => onPostClick?.('github')}
/> />
<IsoSignpost <IsoSignpost
@@ -355,10 +347,8 @@ export default function IsometricScene({
backdropDirection="left-top" backdropDirection="left-top"
icon={<TelegramIcon />} icon={<TelegramIcon />}
label="@amiirkhl" label="@amiirkhl"
isActive={activePost === 'telegram'} href="https://t.me/amiirkhl"
onMouseEnter={() => onPostHover?.('telegram')} external
onMouseLeave={() => onPostHover?.(null)}
onClick={() => onPostClick?.('telegram')}
/> />
</svg> </svg>
); );

View File

@@ -1,6 +1,4 @@
'use client'; import { ImageWithSkeleton } from './ImageWithSkeleton';
import Image from 'next/image';
import { ExternalLink } from 'lucide-react'; import { ExternalLink } from 'lucide-react';
import { getMilestones } from '@/data/milestones'; import { getMilestones } from '@/data/milestones';
import { messagesByLocale, type Locale } from '@/i18n/content'; import { messagesByLocale, type Locale } from '@/i18n/content';
@@ -12,7 +10,7 @@ export const MilestonesSection = ({ locale }: { locale: Locale }) => {
const copy = messagesByLocale[locale].milestones; const copy = messagesByLocale[locale].milestones;
return ( return (
<section id="milestones" className={styles.milestonesSection}> <section id="milestones" className={styles.milestonesSection} data-deferred-render data-looping-motion>
<div className="content-container"> <div className="content-container">
{/* Section Header */} {/* Section Header */}
<div className="section-header" data-reveal="soft"> <div className="section-header" data-reveal="soft">
@@ -29,11 +27,13 @@ export const MilestonesSection = ({ locale }: { locale: Locale }) => {
<div key={item.id} className={styles.certCard}> <div key={item.id} className={styles.certCard}>
{/* Thumbnail */} {/* Thumbnail */}
<div className={styles.thumbnailContainer} data-reveal="image"> <div className={styles.thumbnailContainer} data-reveal="image">
<Image <ImageWithSkeleton
src={item.image} src={item.image.src}
alt={item.title} alt={item.title}
fill fill
sizes="(max-width: 650px) calc(100vw - 3rem), (max-width: 1024px) 44vw, 520px"
className={styles.certImage} className={styles.certImage}
skeletonShape="rounded"
/> />
</div> </div>

View File

@@ -4,70 +4,136 @@ import { useEffect } from 'react';
const REVEAL_SELECTOR = '[data-reveal], [data-footer-reveal]'; const REVEAL_SELECTOR = '[data-reveal], [data-footer-reveal]';
function revealTransform(element: HTMLElement) {
switch (element.dataset.reveal) {
case 'scale':
return 'translateY(0.5rem) scale(0.97)';
case 'image':
return 'translateY(0.5rem) scale(0.985)';
case 'soft':
return 'translateY(0.625rem)';
default:
return 'translateY(1rem)';
}
}
export const MotionController = () => { export const MotionController = () => {
useEffect(() => { useEffect(() => {
const elements = Array.from( let anchorFrame = 0;
document.querySelectorAll<HTMLElement>(REVEAL_SELECTOR), let anchorObserver: MutationObserver | undefined;
);
if (!elements.length) return; const restoreAnchor = () => {
const hash = window.location.hash.slice(1);
if (!hash) return true;
let id = hash;
try {
id = decodeURIComponent(hash);
} catch {
// Keep the literal fragment when it is not valid URI-encoded text.
}
const target = document.getElementById(id);
if (!target) return false;
target.scrollIntoView({ block: 'start' });
anchorObserver?.disconnect();
return true;
};
const queueAnchorRestore = () => {
cancelAnimationFrame(anchorFrame);
let attempts = 0;
const settleAnchor = () => {
attempts += 1;
if (!restoreAnchor()) {
anchorObserver ??= new MutationObserver(() => restoreAnchor());
anchorObserver.observe(document.body, { childList: true, subtree: true });
}
if (attempts < 60) anchorFrame = requestAnimationFrame(settleAnchor);
};
anchorFrame = requestAnimationFrame(settleAnchor);
};
queueAnchorRestore();
window.addEventListener('hashchange', queueAnchorRestore);
const reduceMotion = window.matchMedia( const reduceMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)', '(prefers-reduced-motion: reduce)',
).matches; ).matches;
document.querySelectorAll<HTMLElement>('[data-reveal-group]').forEach((group) => {
group.querySelectorAll<HTMLElement>(REVEAL_SELECTOR).forEach((element, index) => {
element.style.setProperty(
'--reveal-delay',
`${Math.min(index, 5) * 50}ms`,
);
});
});
if (reduceMotion) { if (reduceMotion) {
elements.forEach((element) => {
element.dataset.visible = 'true';
});
document.documentElement.dataset.motionReady = 'true';
return () => { return () => {
delete document.documentElement.dataset.motionReady; cancelAnimationFrame(anchorFrame);
anchorObserver?.disconnect();
window.removeEventListener('hashchange', queueAnchorRestore);
}; };
} }
const initialRevealBoundary = window.innerHeight * 0.92; const elements = Array.from(
elements.forEach((element) => { document.querySelectorAll<HTMLElement>(REVEAL_SELECTOR),
const bounds = element.getBoundingClientRect(); );
if (bounds.top <= initialRevealBoundary && bounds.bottom >= 0) { const revealObserver = new IntersectionObserver(
element.dataset.visible = 'true';
}
});
document.documentElement.dataset.motionReady = 'true';
const observer = new IntersectionObserver(
(entries) => { (entries) => {
entries.forEach((entry) => { entries.forEach((entry) => {
if (!entry.isIntersecting) return; if (!entry.isIntersecting) return;
const element = entry.target as HTMLElement; const element = entry.target as HTMLElement;
element.dataset.visible = 'true'; const siblingIndex = element.parentElement
observer.unobserve(element); ? Array.from(element.parentElement.children).indexOf(element)
: 0;
element.animate(
[
{ opacity: 0, transform: revealTransform(element) },
{ opacity: 1, transform: 'translateY(0) scale(1)' },
],
{
duration: 600,
delay: Math.min(siblingIndex * 50, 250),
easing: 'cubic-bezier(0.16, 1, 0.3, 1)',
},
);
revealObserver.unobserve(element);
}); });
}, },
{ { rootMargin: '0px 0px -8% 0px', threshold: 0.12 },
rootMargin: '0px 0px -8% 0px',
threshold: 0.12,
},
); );
elements.forEach((element) => revealObserver.observe(element));
elements.forEach((element) => { const loopingScopes = Array.from(
if (element.dataset.visible !== 'true') observer.observe(element); document.querySelectorAll<HTMLElement>('[data-looping-motion]'),
}); );
const intersectingScopes = new Set<HTMLElement>();
const setScopeState = (scope: HTMLElement, active: boolean) => {
scope.getAnimations({ subtree: true }).forEach((animation) => {
if (active && !document.hidden) animation.play();
else animation.pause();
});
};
const loopingObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
const scope = entry.target as HTMLElement;
if (entry.isIntersecting) intersectingScopes.add(scope);
else intersectingScopes.delete(scope);
setScopeState(scope, entry.isIntersecting);
});
},
{ rootMargin: '160px 0px', threshold: 0 },
);
loopingScopes.forEach((scope) => loopingObserver.observe(scope));
const onVisibilityChange = () => {
loopingScopes.forEach((scope) => {
setScopeState(scope, intersectingScopes.has(scope));
});
};
document.addEventListener('visibilitychange', onVisibilityChange);
return () => { return () => {
observer.disconnect(); cancelAnimationFrame(anchorFrame);
delete document.documentElement.dataset.motionReady; anchorObserver?.disconnect();
window.removeEventListener('hashchange', queueAnchorRestore);
revealObserver.disconnect();
loopingObserver.disconnect();
document.removeEventListener('visibilitychange', onVisibilityChange);
}; };
}, []); }, []);

View File

@@ -1,42 +1,36 @@
'use client'; 'use client';
import Image from 'next/image';
import { useState, type FC } from 'react'; import { useState, type FC } from 'react';
import { useTheme } from '@/context/ThemeContext'; import { useTheme } from '@/context/ThemeContext';
import type { PortfolioImage } from '@/types/media';
import { ImageWithSkeleton } from './ImageWithSkeleton';
interface ProjectThumbnailProps { interface ProjectThumbnailProps {
image: string; image: PortfolioImage;
title: string; title: string;
priority?: boolean;
className?: string; className?: string;
} }
const getDarkImagePath = (image: string) => const getDarkImagePath = (src: string) => src.replace(/(\.[^./]+)$/, '-dark$1');
image.replace(/(\.[^./]+)$/, '-dark$1');
export const ProjectThumbnail: FC<ProjectThumbnailProps> = ({ export const ProjectThumbnail: FC<ProjectThumbnailProps> = ({ image, title, className }) => {
image,
title,
priority = false,
className,
}) => {
const { theme } = useTheme(); const { theme } = useTheme();
const darkImage = getDarkImagePath(image); const darkImage = getDarkImagePath(image.src);
const [unavailableDarkImage, setUnavailableDarkImage] = useState<string | null>( const [unavailableDarkImage, setUnavailableDarkImage] = useState<string | null>(null);
null,
);
const useDarkImage = theme === 'dark' && unavailableDarkImage !== darkImage; const useDarkImage = theme === 'dark' && unavailableDarkImage !== darkImage;
return ( return (
<Image <ImageWithSkeleton
key={useDarkImage ? darkImage : image} key={useDarkImage ? darkImage : image.src}
src={useDarkImage ? darkImage : image} src={useDarkImage ? darkImage : image.src}
alt={title} alt={title}
fill fill
sizes="(max-width: 650px) 100vw, (max-width: 1100px) 100vw, 360px" sizes="(max-width: 650px) calc(100vw - 3rem), (max-width: 1100px) 46vw, 360px"
priority={priority} quality={75}
draggable={false} draggable={false}
className={className} className={className}
skeletonShape="rounded"
blurDataURL={!useDarkImage ? image.blurDataURL : undefined}
onError={() => { onError={() => {
if (useDarkImage) setUnavailableDarkImage(darkImage); if (useDarkImage) setUnavailableDarkImage(darkImage);
}} }}

View File

@@ -43,6 +43,13 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
pointerId: -1, pointerId: -1,
grabOffset: 0, grabOffset: 0,
}); });
const scrollFrameRef = useRef(0);
const measuredScrollStateRef = useRef({
progress: -1,
thumbPercent: -1,
canPrev: true,
canNext: false,
});
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [isScrollbarDragging, setIsScrollbarDragging] = useState(false); const [isScrollbarDragging, setIsScrollbarDragging] = useState(false);
const [scrollProgress, setScrollProgress] = useState(0); const [scrollProgress, setScrollProgress] = useState(0);
@@ -52,26 +59,38 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
const [canScrollPrev, setCanScrollPrev] = useState(false); const [canScrollPrev, setCanScrollPrev] = useState(false);
const [canScrollNext, setCanScrollNext] = useState(true); const [canScrollNext, setCanScrollNext] = useState(true);
const syncScrollState = useCallback(() => { const measureScrollState = useCallback(() => {
const viewport = viewportRef.current; const viewport = viewportRef.current;
if (!viewport) return; if (!viewport) return;
const maxScroll = Math.max(0, viewport.scrollWidth - viewport.clientWidth); const maxScroll = Math.max(0, viewport.scrollWidth - viewport.clientWidth);
const logicalScroll = getLogicalScrollLeft(viewport, isRtl); const logicalScroll = getLogicalScrollLeft(viewport, isRtl);
setScrollProgress( const next = {
maxScroll > 0 ? Math.max(0, Math.min(logicalScroll / maxScroll, 1)) : 0, progress: maxScroll > 0 ? Math.max(0, Math.min(logicalScroll / maxScroll, 1)) : 0,
); thumbPercent: Math.max(
setScrollbarThumbPercent(
Math.max(
MIN_SCROLLBAR_THUMB_PERCENT, MIN_SCROLLBAR_THUMB_PERCENT,
Math.min((viewport.clientWidth / viewport.scrollWidth) * 100, 100), Math.min((viewport.clientWidth / viewport.scrollWidth) * 100, 100),
), ),
); canPrev: logicalScroll > 1,
setCanScrollPrev(logicalScroll > 1); canNext: logicalScroll < maxScroll - 1,
setCanScrollNext(logicalScroll < maxScroll - 1); };
const previous = measuredScrollStateRef.current;
measuredScrollStateRef.current = next;
if (Math.abs(next.progress - previous.progress) > 0.0001) setScrollProgress(next.progress);
if (Math.abs(next.thumbPercent - previous.thumbPercent) > 0.01) setScrollbarThumbPercent(next.thumbPercent);
if (next.canPrev !== previous.canPrev) setCanScrollPrev(next.canPrev);
if (next.canNext !== previous.canNext) setCanScrollNext(next.canNext);
}, [isRtl]); }, [isRtl]);
const syncScrollState = useCallback(() => {
if (scrollFrameRef.current) return;
scrollFrameRef.current = window.requestAnimationFrame(() => {
scrollFrameRef.current = 0;
measureScrollState();
});
}, [measureScrollState]);
useEffect(() => { useEffect(() => {
syncScrollState(); syncScrollState();
const viewport = viewportRef.current; const viewport = viewportRef.current;
@@ -79,7 +98,10 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
const resizeObserver = new ResizeObserver(syncScrollState); const resizeObserver = new ResizeObserver(syncScrollState);
resizeObserver.observe(viewport); resizeObserver.observe(viewport);
return () => resizeObserver.disconnect(); return () => {
resizeObserver.disconnect();
window.cancelAnimationFrame(scrollFrameRef.current);
};
}, [syncScrollState]); }, [syncScrollState]);
const scrollByCard = (direction: -1 | 1) => { const scrollByCard = (direction: -1 | 1) => {
@@ -343,6 +365,8 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
id="projects" id="projects"
className={styles.projectsSection} className={styles.projectsSection}
aria-labelledby="projects-title" aria-labelledby="projects-title"
data-deferred-render
data-looping-motion
> >
<div className="content-container"> <div className="content-container">
<div className={styles.headerRow} data-reveal="soft"> <div className={styles.headerRow} data-reveal="soft">
@@ -409,7 +433,6 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
<ProjectThumbnail <ProjectThumbnail
image={project.image} image={project.image}
title={project.title} title={project.title}
priority={project.id === 'qlockify'}
className={styles.projectImage} className={styles.projectImage}
/> />
</div> </div>

View File

@@ -1,5 +1,3 @@
'use client';
import { import {
Code2, Code2,
GraduationCap, GraduationCap,
@@ -7,7 +5,7 @@ import {
PanelsTopLeft, PanelsTopLeft,
Rocket, Rocket,
} from 'lucide-react'; } from 'lucide-react';
import { AboutRiverScene } from './scenes/AboutRiverScene'; import { DeferredAboutRiverScene } from './scenes/DeferredAboutRiverScene';
import { messagesByLocale, type Locale } from '@/i18n/content'; import { messagesByLocale, type Locale } from '@/i18n/content';
import styles from './RiverJourney.module.css'; import styles from './RiverJourney.module.css';
@@ -18,7 +16,7 @@ export const RiverJourney = ({ locale }: { locale: Locale }) => {
return ( return (
<section id="about" className={styles.journeySection} aria-labelledby="about-title"> <section id="about" className={styles.journeySection} aria-labelledby="about-title">
<AboutRiverScene /> <DeferredAboutRiverScene />
{/* Intro Header & Description on the Left */} {/* Intro Header & Description on the Left */}
<div className={styles.introBlock} data-reveal="scale"> <div className={styles.introBlock} data-reveal="scale">
<span className={styles.eyebrow}>{copy.eyebrow}</span> <span className={styles.eyebrow}>{copy.eyebrow}</span>

View File

@@ -1,5 +1,3 @@
'use client';
import { HeroSection } from './HeroSection'; import { HeroSection } from './HeroSection';
import { RiverJourney } from './RiverJourney'; import { RiverJourney } from './RiverJourney';
import { import {
@@ -26,7 +24,7 @@ export const ScenicHeroJourney = ({ locale }: { locale: Locale }) => {
const copy = messagesByLocale[locale]; const copy = messagesByLocale[locale];
return ( return (
<div className={styles.scenicContainer}> <div className={styles.scenicContainer} data-looping-motion>
<section className={styles.heroStage} aria-label={copy.hero.sectionLabel}> <section className={styles.heroStage} aria-label={copy.hero.sectionLabel}>
<HeroBackdrop /> <HeroBackdrop />
<HeroSection locale={locale} /> <HeroSection locale={locale} />

View File

@@ -1,238 +0,0 @@
.loader {
position: fixed;
inset: 0;
z-index: 10000;
display: grid;
place-items: center;
overflow: hidden;
background: var(--bg-page);
color: var(--text-primary);
opacity: 1;
visibility: visible;
transition:
opacity 500ms var(--motion-ease-in-out),
visibility 0s linear 500ms;
}
.loader[data-phase='leaving'] {
opacity: 0;
visibility: hidden;
pointer-events: none;
}
.grid {
position: absolute;
inset: -25%;
background-image:
linear-gradient(color-mix(in srgb, var(--border-default) 46%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--border-default) 46%, transparent) 1px, transparent 1px);
background-size: 3.5rem 3.5rem;
opacity: 0.55;
transform: perspective(34rem) rotateX(58deg) rotateZ(-45deg) scale(1.35);
transform-origin: center;
mask-image: radial-gradient(circle at center, black 0%, transparent 64%);
}
.content {
position: relative;
z-index: 1;
display: flex;
width: min(25rem, calc(100vw - 2.5rem));
flex-direction: column;
align-items: center;
text-align: center;
transition:
opacity 400ms var(--motion-ease-in-out),
transform 400ms var(--motion-ease-in-out);
}
.loader[data-phase='leaving'] .content {
opacity: 0;
transform: translateY(-0.5rem) scale(0.98);
}
.scene {
position: relative;
width: 12.5rem;
height: 6rem;
margin-bottom: 1.75rem;
}
.river {
position: absolute;
top: 2.8rem;
left: 1.65rem;
width: 9.25rem;
height: 0.42rem;
border-radius: var(--radius-pill);
background: linear-gradient(
90deg,
transparent,
var(--accent-default) 16%,
var(--accent-default) 84%,
transparent
);
transform: rotate(-8deg) scaleX(0.92);
transform-origin: center;
animation: riverArrive 900ms var(--motion-ease-out) both;
}
.island {
position: absolute;
width: 2.65rem;
height: 2.65rem;
border: 1px solid var(--border-strong);
border-radius: 0.45rem;
background: var(--bg-surface);
box-shadow: 0 0.65rem 1.25rem var(--shadow-color);
transform: rotate(45deg) translateY(0);
animation: islandFloat 2.2s var(--motion-ease-in-out) infinite alternate;
}
.island::after {
content: '';
position: absolute;
inset: 0.48rem;
border-radius: 0.25rem;
background: var(--accent-default);
opacity: 0.72;
}
.islandLeft {
top: 2.15rem;
left: 0.6rem;
}
.islandCenter {
top: 0.1rem;
left: 5rem;
animation-delay: -700ms;
}
.islandRight {
right: 0.6rem;
bottom: 0.1rem;
animation-delay: -1.4s;
}
.traveler {
position: absolute;
top: 2.25rem;
left: 2.25rem;
width: 0.72rem;
height: 0.72rem;
border: 2px solid var(--bg-page);
border-radius: 50%;
background: var(--text-primary);
box-shadow: 0 0.2rem 0.75rem var(--shadow-color);
animation: travelerJourney 2.4s var(--motion-ease-in-out) infinite alternate;
}
.signature {
margin-bottom: 0.65rem;
font-size: clamp(1.75rem, 6vw, 2.25rem);
line-height: 1.35;
color: var(--text-primary);
}
.message {
margin-bottom: 1.5rem;
color: var(--text-secondary);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.progressRow {
display: grid;
width: 100%;
grid-template-columns: 1fr 3rem;
align-items: center;
gap: 0.875rem;
}
.progressTrack {
height: 3px;
overflow: hidden;
border-radius: var(--radius-pill);
background: var(--border-default);
}
.progressFill {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
background: var(--accent-default);
transform-origin: left center;
transition: transform 180ms var(--motion-ease-out);
}
:global(html[dir='rtl']) .progressFill {
transform-origin: right center;
}
.progressValue {
color: var(--text-secondary);
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
text-align: end;
}
@keyframes riverArrive {
from {
opacity: 0;
transform: rotate(-8deg) scaleX(0.12);
}
to {
opacity: 1;
transform: rotate(-8deg) scaleX(0.92);
}
}
@keyframes islandFloat {
from {
transform: rotate(45deg) translate(0, 0);
}
to {
transform: rotate(45deg) translate(-0.16rem, -0.16rem);
}
}
@keyframes travelerJourney {
from {
transform: translate(0, 0);
}
to {
transform: translate(7.2rem, -1rem);
}
}
@media (max-width: 650px) {
.scene {
margin-bottom: 1.25rem;
transform: scale(0.9);
}
.message {
margin-bottom: 1.25rem;
font-size: 0.6875rem;
}
}
@media (prefers-reduced-motion: reduce) {
.river,
.island,
.traveler {
animation: none;
}
.progressFill {
transition: none;
}
.loader[data-phase='leaving'] .content {
transform: none;
}
}

View File

@@ -1,181 +0,0 @@
'use client';
import { useEffect, useState, type CSSProperties } from 'react';
import { education } from '@/data/education';
import { experiences } from '@/data/experience';
import { milestones } from '@/data/milestones';
import { projects } from '@/data/projects';
import { skillsByDiscipline } from '@/data/skills';
import { messagesByLocale, type Locale } from '@/i18n/content';
import styles from './SiteLoader.module.css';
const DARK_PROJECT_IDS = new Set([
'qlockify',
'guilan-ace',
'zarinpal',
'interanet-solutions',
'subsource',
]);
const getDarkImagePath = (image: string) =>
image.replace(/(\.[^./]+)$/, '-dark$1');
const SITE_ASSETS = Array.from(
new Set([
'/images/avatar.png',
...projects.flatMap((project) => [
project.image,
...(DARK_PROJECT_IDS.has(project.id)
? [getDarkImagePath(project.image)]
: []),
]),
...Object.values(skillsByDiscipline).flatMap((discipline) =>
discipline.tools.map((tool) => tool.icon),
),
...experiences.map((experience) => experience.logo),
education.logo,
...milestones.map((milestone) => milestone.image),
]),
);
const waitForWindowLoad = () =>
new Promise<void>((resolve) => {
if (document.readyState === 'complete') {
resolve();
return;
}
window.addEventListener('load', () => resolve(), { once: true });
});
const waitForPaint = () =>
new Promise<void>((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
const preloadImage = (src: string, onSettled: () => void) =>
new Promise<void>((resolve) => {
const image = new window.Image();
let settled = false;
const settle = () => {
if (settled) return;
settled = true;
onSettled();
resolve();
};
image.decoding = 'async';
image.onload = () => {
if (typeof image.decode === 'function') {
image.decode().catch(() => undefined).finally(settle);
} else {
settle();
}
};
image.onerror = settle;
image.src = src;
});
type LoaderPhase = 'loading' | 'leaving' | 'hidden';
export function SiteLoader({ locale }: { locale: Locale }) {
const copy = messagesByLocale[locale].loader;
const [progress, setProgress] = useState(0);
const [phase, setPhase] = useState<LoaderPhase>('loading');
useEffect(() => {
let cancelled = false;
let exitTimer: number | undefined;
const startedAt = performance.now();
document.documentElement.dataset.siteLoading = 'true';
const loadSite = async () => {
let settledAssets = 0;
const reportAsset = () => {
settledAssets += 1;
if (!cancelled) {
setProgress(Math.round((settledAssets / SITE_ASSETS.length) * 92));
}
};
await Promise.all([
Promise.all(
SITE_ASSETS.map((asset) => preloadImage(asset, reportAsset)),
),
document.fonts?.ready ?? Promise.resolve(),
waitForWindowLoad(),
]);
await waitForPaint();
const minimumDisplayTime = 800;
const remainingTime = Math.max(
0,
minimumDisplayTime - (performance.now() - startedAt),
);
if (remainingTime > 0) {
await new Promise((resolve) => window.setTimeout(resolve, remainingTime));
}
if (cancelled) return;
setProgress(100);
document.documentElement.dataset.siteReady = 'true';
delete document.documentElement.dataset.siteLoading;
setPhase('leaving');
exitTimer = window.setTimeout(() => setPhase('hidden'), 500);
};
void loadSite();
return () => {
cancelled = true;
if (exitTimer !== undefined) window.clearTimeout(exitTimer);
};
}, []);
if (phase === 'hidden') return null;
const progressStyle = {
transform: `scaleX(${progress / 100})`,
} satisfies CSSProperties;
return (
<div
id="site-loader"
className={styles.loader}
data-phase={phase}
role="status"
aria-live="polite"
aria-busy={phase === 'loading'}
aria-label={copy.ariaLabel(progress)}
>
<div className={styles.grid} aria-hidden="true" />
<div className={styles.content}>
<div className={styles.scene} aria-hidden="true">
<span className={`${styles.island} ${styles.islandLeft}`} />
<span className={`${styles.island} ${styles.islandCenter}`} />
<span className={`${styles.island} ${styles.islandRight}`} />
<span className={styles.river} />
<span className={styles.traveler} />
</div>
<p className={`font-display ${styles.signature}`}>
{copy.signature}
</p>
<p className={styles.message}>{copy.message}</p>
<div className={styles.progressRow} aria-hidden="true">
<div className={styles.progressTrack}>
<span className={styles.progressFill} style={progressStyle} />
</div>
<span className={styles.progressValue}>{copy.progressLabel(progress)}</span>
</div>
</div>
</div>
);
}

View File

@@ -148,6 +148,7 @@
} }
.toolIconContainer { .toolIconContainer {
position: relative;
width: clamp(2.25rem, 42%, 3.5rem); width: clamp(2.25rem, 42%, 3.5rem);
height: clamp(2.25rem, 42%, 3.5rem); height: clamp(2.25rem, 42%, 3.5rem);
display: flex; display: flex;

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import Image from 'next/image'; import { ImageWithSkeleton } from './ImageWithSkeleton';
import { Code, BarChart2, Wand2 } from 'lucide-react'; import { Code, BarChart2, Wand2 } from 'lucide-react';
import { import {
skillsByDiscipline, skillsByDiscipline,
@@ -28,7 +28,7 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
}; };
return ( return (
<section id="skills" className={styles.skillsSection}> <section id="skills" className={styles.skillsSection} data-deferred-render data-looping-motion>
<div className="content-container"> <div className="content-container">
{/* Section Header */} {/* Section Header */}
<div className="section-header" data-reveal="soft"> <div className="section-header" data-reveal="soft">
@@ -90,12 +90,13 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
{activeDiscipline.tools.map((tool) => ( {activeDiscipline.tools.map((tool) => (
<div key={tool.id} className={styles.toolTile}> <div key={tool.id} className={styles.toolTile}>
<div className={styles.toolIconContainer}> <div className={styles.toolIconContainer}>
<Image <ImageWithSkeleton
src={tool.icon} src={tool.icon.src}
alt="" alt=""
width={64} fill
height={64} sizes="64px"
className={styles.toolIcon} className={styles.toolIcon}
skeletonShape="rounded"
/> />
</div> </div>
<span className={`${styles.toolLabel} ltr-token`} lang="en" dir="ltr"> <span className={`${styles.toolLabel} ltr-token`} lang="en" dir="ltr">

View File

@@ -269,7 +269,7 @@ const createAboutScene: SceneFactory = ({ pointer, theme, reduceMotion }) => {
}; };
}; };
export function AboutRiverScene() { export function AboutRiverScene({ onReady }: { onReady?: () => void }) {
const { theme } = useTheme(); const { theme } = useTheme();
return <ThreeCanvas className={styles.aboutScene} createScene={createAboutScene} theme={theme} />; return <ThreeCanvas className={styles.aboutScene} createScene={createAboutScene} theme={theme} onReady={onReady} />;
} }

View File

@@ -0,0 +1,40 @@
'use client';
import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import styles from './Scenes.module.css';
const AboutRiverScene = lazy(() =>
import('./AboutRiverScene').then((module) => ({ default: module.AboutRiverScene })),
);
export function DeferredAboutRiverScene() {
const hostRef = useRef<HTMLDivElement>(null);
const [shouldLoad, setShouldLoad] = useState(false);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const host = hostRef.current;
if (!host || shouldLoad) return;
const observer = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
setShouldLoad(true);
observer.disconnect();
},
{ rootMargin: '600px 0px' },
);
observer.observe(host);
return () => observer.disconnect();
}, [shouldLoad]);
return (
<div ref={hostRef} className={styles.aboutSceneBoundary} aria-busy={!isReady} data-looping-motion>
{!isReady && <span className={styles.sceneSkeleton} aria-hidden="true" />}
{shouldLoad && (
<Suspense fallback={null}>
<AboutRiverScene onReady={() => setIsReady(true)} />
</Suspense>
)}
</div>
);
}

View File

@@ -44,6 +44,29 @@
z-index: 0; z-index: 0;
} }
.aboutSceneBoundary {
position: absolute;
inset: 0;
z-index: 0;
overflow: hidden;
}
.sceneSkeleton {
position: absolute;
inset: 0;
background:
linear-gradient(100deg, transparent 24%, color-mix(in srgb, var(--bg-raised) 68%, transparent) 50%, transparent 76%),
var(--bg-page);
background-size: 200% 100%, auto;
animation: sceneSkeletonShimmer 1.5s ease-in-out infinite;
}
@keyframes sceneSkeletonShimmer {
to {
background-position: -200% 0, 0 0;
}
}
.aboutScene::after { .aboutScene::after {
content: ''; content: '';
position: absolute; position: absolute;
@@ -61,12 +84,21 @@
height: 40rem; height: 40rem;
} }
.aboutSceneBoundary {
bottom: auto;
height: 40rem;
}
.aboutScene canvas { .aboutScene canvas {
opacity: 0.56; opacity: 0.56;
} }
} }
@media (min-width: 1441px) { @media (min-width: 1441px) {
.aboutSceneBoundary {
overflow: visible;
}
.aboutScene { .aboutScene {
overflow: visible; overflow: visible;
left: 50%; left: 50%;
@@ -82,3 +114,9 @@
width: 100vw; width: 100vw;
} }
} }
@media (prefers-reduced-motion: reduce) {
.sceneSkeleton {
animation: none;
}
}

View File

@@ -26,6 +26,7 @@ interface ThreeCanvasProps {
className: string; className: string;
createScene: SceneFactory; createScene: SceneFactory;
theme: SceneTheme; theme: SceneTheme;
onReady?: () => void;
} }
function disposeObject(object: THREE.Object3D) { function disposeObject(object: THREE.Object3D) {
@@ -49,8 +50,13 @@ function disposeObject(object: THREE.Object3D) {
}); });
} }
export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps) { export function ThreeCanvas({ className, createScene, theme, onReady }: ThreeCanvasProps) {
const hostRef = useRef<HTMLDivElement>(null); const hostRef = useRef<HTMLDivElement>(null);
const onReadyRef = useRef(onReady);
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
useEffect(() => { useEffect(() => {
const host = hostRef.current; const host = hostRef.current;
@@ -63,6 +69,8 @@ export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps)
const clock = new THREE.Clock(); const clock = new THREE.Clock();
let frame = 0; let frame = 0;
let visible = true; let visible = true;
let pageVisible = !document.hidden;
let hasRendered = false;
renderer.setClearColor(0x000000, 0); renderer.setClearColor(0x000000, 0);
renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.outputColorSpace = THREE.SRGBColorSpace;
@@ -71,7 +79,13 @@ export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps)
renderer.domElement.setAttribute('aria-hidden', 'true'); renderer.domElement.setAttribute('aria-hidden', 'true');
host.appendChild(renderer.domElement); host.appendChild(renderer.domElement);
const render = () => renderer.render(controller.scene, controller.camera); const render = () => {
renderer.render(controller.scene, controller.camera);
if (!hasRendered) {
hasRendered = true;
onReadyRef.current?.();
}
};
const resize = () => { const resize = () => {
const bounds = host.getBoundingClientRect(); const bounds = host.getBoundingClientRect();
if (!bounds.width || !bounds.height) return; if (!bounds.width || !bounds.height) return;
@@ -83,7 +97,7 @@ export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps)
}; };
const animate = () => { const animate = () => {
if (!visible || reduceMotion) return; if (!visible || !pageVisible || reduceMotion) return;
const delta = Math.min(clock.getDelta(), 0.05); const delta = Math.min(clock.getDelta(), 0.05);
controller.update?.(clock.elapsedTime, delta); controller.update?.(clock.elapsedTime, delta);
render(); render();
@@ -97,7 +111,7 @@ export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps)
([entry]) => { ([entry]) => {
visible = entry.isIntersecting; visible = entry.isIntersecting;
window.cancelAnimationFrame(frame); window.cancelAnimationFrame(frame);
if (visible && !reduceMotion) { if (visible && pageVisible && !reduceMotion) {
clock.getDelta(); clock.getDelta();
frame = window.requestAnimationFrame(animate); frame = window.requestAnimationFrame(animate);
} }
@@ -106,6 +120,16 @@ export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps)
); );
visibilityObserver.observe(host); visibilityObserver.observe(host);
const onVisibilityChange = () => {
pageVisible = !document.hidden;
window.cancelAnimationFrame(frame);
if (visible && pageVisible && !reduceMotion) {
clock.getDelta();
frame = window.requestAnimationFrame(animate);
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
const onPointerMove = (event: PointerEvent) => { const onPointerMove = (event: PointerEvent) => {
if (reduceMotion || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return; if (reduceMotion || !window.matchMedia('(hover: hover) and (pointer: fine)').matches) return;
pointer.set( pointer.set(
@@ -121,6 +145,7 @@ export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps)
return () => { return () => {
window.cancelAnimationFrame(frame); window.cancelAnimationFrame(frame);
window.removeEventListener('pointermove', onPointerMove); window.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('visibilitychange', onVisibilityChange);
resizeObserver.disconnect(); resizeObserver.disconnect();
visibilityObserver.disconnect(); visibilityObserver.disconnect();
controller.dispose?.(); controller.dispose?.();

View File

@@ -0,0 +1,66 @@
.surface {
position: relative;
overflow: hidden;
background: var(--bg-subtle);
}
.surface::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--bg-raised) 72%, transparent) 50%, transparent 80%);
transform: translateX(-100%);
animation: skeletonShimmer 1.35s ease-in-out infinite;
}
.text { height: 0.8rem; border-radius: var(--radius-pill); }
.circle { aspect-ratio: 1; border-radius: 50%; }
.media { aspect-ratio: 16 / 9; border-radius: var(--radius-md); }
.card { min-height: 8rem; border: 1px solid var(--border-default); border-radius: var(--radius-lg); }
.control { width: 6rem; height: var(--size-button-height); border-radius: var(--radius-pill); }
.scene { width: 100%; height: 24rem; border-radius: var(--radius-lg); }
.route { width: 100%; max-width: var(--layout-frame); margin: 0 auto; background: var(--bg-page); }
.hero { min-height: 42rem; display: grid; place-items: center; padding: 4rem var(--layout-page-gutter); }
.heroStack { width: min(34rem, 100%); display: grid; justify-items: center; gap: 1rem; }
.avatar { width: 10.5rem; }
.wideText { width: min(26rem, 82%); height: 2rem; }
.mediumText { width: min(21rem, 68%); }
.controls { display: flex; gap: 0.75rem; }
.about { min-height: 54rem; padding: 8rem var(--layout-page-gutter); }
.section { padding: 7rem var(--layout-page-gutter); }
.header { display: grid; gap: 0.7rem; width: min(28rem, 82%); margin-bottom: 2.5rem; }
.grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.25rem; }
.stack { display: grid; gap: 1rem; }
.section[data-section='projects'] { min-height: 50rem; }
.section[data-section='experience'] { min-height: 130rem; }
.section[data-section='skills'] { min-height: 77rem; }
.section[data-section='education'] { min-height: 30rem; }
.section[data-section='milestones'] { min-height: 104rem; }
.section[data-section='footer'] { min-height: 38rem; }
@keyframes skeletonShimmer { to { transform: translateX(100%); } }
@media (max-width: 1024px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.section[data-section='experience'] { min-height: 144rem; }
.section[data-section='skills'] { min-height: 87rem; }
.section[data-section='milestones'] { min-height: 114rem; }
}
@media (max-width: 650px) {
.hero { min-height: 40rem; }
.about { min-height: 60rem; padding-block: 6rem; }
.section { padding-block: 5rem; }
.grid { grid-template-columns: 1fr; }
.section[data-section='projects'] { min-height: 47rem; }
.section[data-section='experience'] { min-height: 236rem; }
.section[data-section='skills'] { min-height: 212rem; }
.section[data-section='education'] { min-height: 64rem; }
.section[data-section='milestones'] { min-height: 231rem; }
.section[data-section='footer'] { min-height: 30rem; }
}
@media (prefers-reduced-motion: reduce) {
.surface::after { display: none; }
}

View File

@@ -0,0 +1,48 @@
import type { CSSProperties } from 'react';
import styles from './PortfolioSkeleton.module.css';
type SkeletonVariant = 'text' | 'circle' | 'media' | 'card' | 'control' | 'scene';
type SectionName = 'projects' | 'experience' | 'skills' | 'education' | 'milestones' | 'footer';
export function Skeleton({ variant, style }: { variant: SkeletonVariant; style?: CSSProperties }) {
return <span className={`${styles.surface} ${styles[variant]}`} style={style} aria-hidden="true" />;
}
export function SectionSkeleton({ section }: { section: SectionName }) {
const cardCount = section === 'experience' ? 6 : section === 'milestones' ? 5 : section === 'footer' ? 1 : 3;
return (
<section className={styles.section} data-section={section} aria-busy="true">
<div className={styles.header} aria-hidden="true">
<Skeleton variant="text" style={{ width: '30%' }} />
<Skeleton variant="text" style={{ width: '75%', height: '2rem' }} />
</div>
<div className={section === 'experience' || section === 'milestones' ? styles.stack : styles.grid}>
{Array.from({ length: cardCount }, (_, index) => (
<Skeleton key={index} variant={section === 'projects' ? 'media' : 'card'} />
))}
</div>
</section>
);
}
export function PortfolioSkeleton() {
return (
<main className={styles.route} aria-busy="true">
<section className={styles.hero}>
<div className={styles.heroStack} aria-hidden="true">
<Skeleton variant="circle" style={{ width: '10.5rem' }} />
<Skeleton variant="text" style={{ width: '82%', height: '2rem' }} />
<Skeleton variant="text" style={{ width: '65%' }} />
<div className={styles.controls}><Skeleton variant="control" /><Skeleton variant="control" /><Skeleton variant="control" /></div>
</div>
</section>
<section className={styles.about}><Skeleton variant="scene" /></section>
<SectionSkeleton section="projects" />
<SectionSkeleton section="experience" />
<SectionSkeleton section="skills" />
<SectionSkeleton section="education" />
<SectionSkeleton section="milestones" />
<SectionSkeleton section="footer" />
</main>
);
}

View File

@@ -1,8 +1,9 @@
'use client'; 'use client';
import React, { createContext, useContext, useEffect, useState } from 'react'; import React, { createContext, useContext, useSyncExternalStore } from 'react';
type Theme = 'light' | 'dark'; type Theme = 'light' | 'dark';
const THEME_EVENT = 'portfolio-theme-change';
interface ThemeContextType { interface ThemeContextType {
theme: Theme; theme: Theme;
@@ -16,26 +17,28 @@ const ThemeContext = createContext<ThemeContextType>({
setTheme: () => {}, setTheme: () => {},
}); });
const subscribe = (callback: () => void) => {
window.addEventListener(THEME_EVENT, callback);
return () => window.removeEventListener(THEME_EVENT, callback);
};
const getThemeSnapshot = (): Theme =>
document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
const getServerThemeSnapshot = (): Theme => 'light';
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [theme, setThemeState] = useState<Theme>('light'); const theme = useSyncExternalStore(subscribe, getThemeSnapshot, getServerThemeSnapshot);
useEffect(() => { const setTheme = (nextTheme: Theme) => {
const savedTheme = localStorage.getItem('portfolio-theme') as Theme | null; localStorage.setItem('portfolio-theme', nextTheme);
const initialTheme: Theme = savedTheme === 'dark' ? 'dark' : 'light'; document.documentElement.dataset.theme = nextTheme;
setThemeState(initialTheme); requestAnimationFrame(() => {
document.documentElement.setAttribute('data-theme', initialTheme); setTimeout(() => window.dispatchEvent(new Event(THEME_EVENT)), 0);
}, []); });
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem('portfolio-theme', newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
}; };
const toggleTheme = () => { const toggleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light');
const nextTheme = theme === 'light' ? 'dark' : 'light';
setTheme(nextTheme);
};
return ( return (
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}> <ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>

View File

@@ -1,11 +1,12 @@
import type { Locale, Localized } from '@/i18n/content'; import type { Locale, Localized } from '@/i18n/content';
import { portfolioImage, type PortfolioImage } from '@/types/media';
export interface EducationItem { export interface EducationItem {
degree: string; degree: string;
institution: string; institution: string;
date: string; date: string;
description: string; description: string;
logo: string; logo: PortfolioImage;
} }
type EducationCopy = Omit<EducationItem, 'logo'>; type EducationCopy = Omit<EducationItem, 'logo'>;
@@ -29,7 +30,7 @@ const educationContent: Localized<EducationCopy> = {
export const getEducation = (locale: Locale): EducationItem => ({ export const getEducation = (locale: Locale): EducationItem => ({
...educationContent[locale], ...educationContent[locale],
logo: '/images/logo-guilan-crest.png', logo: portfolioImage('/images/logo-guilan-crest.png', 204, 144),
}); });
export const education = getEducation('en'); export const education = getEducation('en');

View File

@@ -1,4 +1,5 @@
import type { Locale, Localized } from '@/i18n/content'; import type { Locale, Localized } from '@/i18n/content';
import { portfolioImage, type PortfolioImage } from '@/types/media';
export interface ExperienceItem { export interface ExperienceItem {
id: string; id: string;
@@ -6,7 +7,7 @@ export interface ExperienceItem {
company: string; company: string;
date: string; date: string;
location: string; location: string;
logo: string; logo: PortfolioImage;
isCurrent?: boolean; isCurrent?: boolean;
summary: string; summary: string;
bullets: string[]; bullets: string[];
@@ -24,7 +25,7 @@ interface ExperienceDefinition extends Omit<ExperienceItem, keyof ExperienceCopy
const experienceDefinitions: ExperienceDefinition[] = [ const experienceDefinitions: ExperienceDefinition[] = [
{ {
id: 'quera', id: 'quera',
logo: '/images/logo-quera.png', logo: portfolioImage('/images/logo-quera.png', 72, 72),
isCurrent: true, isCurrent: true,
content: { content: {
en: { en: {
@@ -59,7 +60,7 @@ const experienceDefinitions: ExperienceDefinition[] = [
}, },
{ {
id: 'fiotrix', id: 'fiotrix',
logo: '/images/logo-fiotrix.png', logo: portfolioImage('/images/logo-fiotrix.png', 72, 72),
isCurrent: false, isCurrent: false,
content: { content: {
en: { en: {
@@ -100,7 +101,7 @@ const experienceDefinitions: ExperienceDefinition[] = [
}, },
{ {
id: 'baduno', id: 'baduno',
logo: '/images/logo-baduno.png', logo: portfolioImage('/images/logo-baduno.png', 72, 72),
isCurrent: false, isCurrent: false,
content: { content: {
en: { en: {
@@ -139,7 +140,7 @@ const experienceDefinitions: ExperienceDefinition[] = [
}, },
{ {
id: 'sharif-ta', id: 'sharif-ta',
logo: '/images/logo-sharif.png', logo: portfolioImage('/images/logo-sharif.png', 72, 72),
isCurrent: false, isCurrent: false,
content: { content: {
en: { en: {
@@ -170,7 +171,7 @@ const experienceDefinitions: ExperienceDefinition[] = [
}, },
{ {
id: 'guilan-assoc', id: 'guilan-assoc',
logo: '/images/logo-guilan-assoc.png', logo: portfolioImage('/images/logo-guilan-assoc.png', 72, 72),
isCurrent: false, isCurrent: false,
content: { content: {
en: { en: {
@@ -201,7 +202,7 @@ const experienceDefinitions: ExperienceDefinition[] = [
}, },
{ {
id: 'guilan-ta-head', id: 'guilan-ta-head',
logo: '/images/logo-guilan-ta.png', logo: portfolioImage('/images/logo-guilan-ta.png', 72, 72),
isCurrent: false, isCurrent: false,
content: { content: {
en: { en: {

View File

@@ -1,4 +1,5 @@
import type { Locale, Localized } from '@/i18n/content'; import type { Locale, Localized } from '@/i18n/content';
import { portfolioImage, type PortfolioImage } from '@/types/media';
export interface MilestoneItem { export interface MilestoneItem {
id: string; id: string;
@@ -6,7 +7,7 @@ export interface MilestoneItem {
issuer: string; issuer: string;
date: string; date: string;
description: string; description: string;
image: string; image: PortfolioImage;
link?: string; link?: string;
} }
@@ -22,7 +23,7 @@ interface MilestoneDefinition extends Omit<MilestoneItem, keyof MilestoneCopy> {
const milestoneDefinitions: MilestoneDefinition[] = [ const milestoneDefinitions: MilestoneDefinition[] = [
{ {
id: 'hackathon', id: 'hackathon',
image: '/images/cert-hackathon.png', image: portfolioImage('/images/cert-hackathon.png', 2159, 1510),
link: 'https://starcoach.ir/certificate/STC20263139', link: 'https://starcoach.ir/certificate/STC20263139',
content: { content: {
en: { en: {
@@ -43,7 +44,7 @@ const milestoneDefinitions: MilestoneDefinition[] = [
}, },
{ {
id: 'icpc-2025', id: 'icpc-2025',
image: '/images/cert-icpc-2025.png', image: portfolioImage('/images/cert-icpc-2025.png', 1082, 768),
link: 'https://www.linkedin.com/in/amiirkhl/overlay/Certifications/1331555202/treasury/?profileId=ACoAAEJsAwMBCv3L9dYcEXWam-l3pLOJ3UspX7w', link: 'https://www.linkedin.com/in/amiirkhl/overlay/Certifications/1331555202/treasury/?profileId=ACoAAEJsAwMBCv3L9dYcEXWam-l3pLOJ3UspX7w',
content: { content: {
en: { en: {
@@ -64,7 +65,7 @@ const milestoneDefinitions: MilestoneDefinition[] = [
}, },
{ {
id: 'pm-bootcamp', id: 'pm-bootcamp',
image: '/images/cert-bootcamp.png', image: portfolioImage('/images/cert-bootcamp.png', 3627, 2599),
link: 'https://www.linkedin.com/in/amiirkhl/overlay/Certifications/812043696/treasury/?profileId=ACoAAEJsAwMBCv3L9dYcEXWam-l3pLOJ3UspX7w', link: 'https://www.linkedin.com/in/amiirkhl/overlay/Certifications/812043696/treasury/?profileId=ACoAAEJsAwMBCv3L9dYcEXWam-l3pLOJ3UspX7w',
content: { content: {
en: { en: {
@@ -85,7 +86,7 @@ const milestoneDefinitions: MilestoneDefinition[] = [
}, },
{ {
id: 'icpc-2024', id: 'icpc-2024',
image: '/images/cert-icpc-2024.png', image: portfolioImage('/images/cert-icpc-2024.png', 1078, 768),
content: { content: {
en: { en: {
title: 'ICPC 2024 - Online Participant', title: 'ICPC 2024 - Online Participant',
@@ -105,7 +106,7 @@ const milestoneDefinitions: MilestoneDefinition[] = [
}, },
{ {
id: 'django-bootcamp', id: 'django-bootcamp',
image: '/images/cert-django.png', image: portfolioImage('/images/cert-django.png', 3508, 2480),
link: 'https://quera.org/certificate/F1mYkTZR/', link: 'https://quera.org/certificate/F1mYkTZR/',
content: { content: {
en: { en: {

View File

@@ -1,4 +1,5 @@
import type { Locale, Localized } from '@/i18n/content'; import type { Locale, Localized } from '@/i18n/content';
import { portfolioImage, type PortfolioImage } from '@/types/media';
export interface Project { export interface Project {
id: string; id: string;
@@ -7,7 +8,7 @@ export interface Project {
date: string; date: string;
description: string; description: string;
tags: string[]; tags: string[];
image: string; image: PortfolioImage;
} }
type ProjectCopy = Pick<Project, 'title' | 'date' | 'description'>; type ProjectCopy = Pick<Project, 'title' | 'date' | 'description'>;
@@ -21,7 +22,7 @@ const projectDefinitions: ProjectDefinition[] = [
id: 'qlockify', id: 'qlockify',
link: 'https://qlockfiy.ir', link: 'https://qlockfiy.ir',
tags: ['Django', 'React', 'Docker', 'UI/UX Design'], tags: ['Django', 'React', 'Docker', 'UI/UX Design'],
image: '/images/project-qlockify.png', image: portfolioImage('/images/project-qlockify.png', 359, 200),
content: { content: {
en: { en: {
title: 'Qlockify', title: 'Qlockify',
@@ -41,7 +42,7 @@ const projectDefinitions: ProjectDefinition[] = [
id: 'guilan-ace', id: 'guilan-ace',
link: 'https://east-guilan-ce.ir/', link: 'https://east-guilan-ce.ir/',
tags: ['Django Ninja', 'Next.js', 'Docker'], tags: ['Django Ninja', 'Next.js', 'Docker'],
image: '/images/project-guilan-ace.png', image: portfolioImage('/images/project-guilan-ace.png', 359, 200),
content: { content: {
en: { en: {
title: 'Guilan ACE', title: 'Guilan ACE',
@@ -61,7 +62,7 @@ const projectDefinitions: ProjectDefinition[] = [
id: 'zarinpal', id: 'zarinpal',
link: 'https://zarinpal.amiirkhl.ir/', link: 'https://zarinpal.amiirkhl.ir/',
tags: ['Go', 'SvelteKit', 'Docker'], tags: ['Go', 'SvelteKit', 'Docker'],
image: '/images/project-zarinpal.png', image: portfolioImage('/images/project-zarinpal.png', 359, 200),
content: { content: {
en: { en: {
title: 'Zarinpal Analytic Dashboard', title: 'Zarinpal Analytic Dashboard',
@@ -81,7 +82,7 @@ const projectDefinitions: ProjectDefinition[] = [
id: 'iran-implant', id: 'iran-implant',
link: 'http://impinternal.com/', link: 'http://impinternal.com/',
tags: ['Django', 'Nginx', 'Docker', 'Label'], tags: ['Django', 'Nginx', 'Docker', 'Label'],
image: '/images/project-iran-implant.png', image: portfolioImage('/images/project-iran-implant.png', 359, 200),
content: { content: {
en: { en: {
title: 'Iran Implant', title: 'Iran Implant',
@@ -100,7 +101,7 @@ const projectDefinitions: ProjectDefinition[] = [
{ {
id: 'interanet-solutions', id: 'interanet-solutions',
tags: ['Bash', 'Caddy', 'Docker', 'DevOps'], tags: ['Bash', 'Caddy', 'Docker', 'DevOps'],
image: '/images/project-interanet-solutions.png', image: portfolioImage('/images/project-interanet-solutions.png', 1683, 935),
content: { content: {
en: { en: {
title: 'Interanet Solutions', title: 'Interanet Solutions',
@@ -119,7 +120,7 @@ const projectDefinitions: ProjectDefinition[] = [
{ {
id: 'postgresql-job-queue', id: 'postgresql-job-queue',
tags: ['Go', 'SvelteKit', 'Docker'], tags: ['Go', 'SvelteKit', 'Docker'],
image: '/images/project-postgresql-job-queue.png', image: portfolioImage('/images/project-postgresql-job-queue.png', 359, 200),
content: { content: {
en: { en: {
title: 'PostgreSQL Job Queue', title: 'PostgreSQL Job Queue',
@@ -139,7 +140,7 @@ const projectDefinitions: ProjectDefinition[] = [
id: 'subsource', id: 'subsource',
link: 'http://subsource.net/', link: 'http://subsource.net/',
tags: ['Product Design', 'Figma', 'Prototyping'], tags: ['Product Design', 'Figma', 'Prototyping'],
image: '/images/project-subsource.png', image: portfolioImage('/images/project-subsource.png', 2560, 1440),
content: { content: {
en: { en: {
title: 'Subsource', title: 'Subsource',

View File

@@ -11,7 +11,7 @@ export interface SkillCategory {
export interface ToolItem { export interface ToolItem {
id: string; id: string;
name: string; name: string;
icon: string; icon: PortfolioImage;
} }
export interface SkillDiscipline { export interface SkillDiscipline {
@@ -28,13 +28,13 @@ export interface FilterTab {
const tool = (id: string, name: string, icon = id): ToolItem => ({ const tool = (id: string, name: string, icon = id): ToolItem => ({
id, id,
name, name,
icon: `/images/tools/${icon}.png`, icon: portfolioImage(`/images/tools/${icon}.png`, 72, 72),
}); });
const placeholderTool = (id: string, name: string): ToolItem => ({ const placeholderTool = (id: string, name: string): ToolItem => ({
id, id,
name, name,
icon: `/images/tools/${id}.png`, icon: portfolioImage(`/images/tools/${id}.png`, 72, 72),
}); });
export const skillsByDiscipline: Record<DisciplineId, SkillDiscipline> = { export const skillsByDiscipline: Record<DisciplineId, SkillDiscipline> = {
@@ -241,3 +241,4 @@ export const filterTabs: FilterTab[] = [
{ id: 'product-management', label: 'Product Management' }, { id: 'product-management', label: 'Product Management' },
{ id: 'design-ux', label: 'Design & UX' }, { id: 'design-ux', label: 'Design & UX' },
]; ];
import { portfolioImage, type PortfolioImage } from '@/types/media';

View File

@@ -1,14 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Pacifico&display=swap');
@import './tokens.css'; @import './tokens.css';
@font-face {
font-family: 'IRANYekanXVF';
src: url('/fonts/IRANYekanXVF.ttf') format('truetype-variations');
font-style: normal;
font-weight: 100 1000;
font-display: swap;
}
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
@@ -25,24 +16,12 @@ html[data-theme="dark"] {
color-scheme: dark; color-scheme: dark;
} }
@font-face {
font-family: 'Peyda';
src: url('/fonts/PeydaWeb-Black.woff2') format('woff2');
font-style: normal;
font-weight: 900;
font-display: swap;
}
html[data-site-loading='true'] {
overflow: hidden;
}
body { body {
width: 100%; width: 100%;
min-width: 320px; min-width: 320px;
background-color: var(--bg-page); background-color: var(--bg-page);
color: var(--text-primary); color: var(--text-primary);
font-family: 'IRANYekanXVF', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: var(--font-body), -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-synthesis: none; font-synthesis: none;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
@@ -57,7 +36,7 @@ html[dir='rtl'] body {
.ltr-token { .ltr-token {
direction: ltr; direction: ltr;
unicode-bidi: isolate; unicode-bidi: isolate;
font-family: 'IRANYekanXVF', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: var(--font-body), -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
} }
/* Master 1440px Fixed Canvas */ /* Master 1440px Fixed Canvas */
@@ -87,6 +66,45 @@ html[dir='rtl'] body {
position: relative; position: relative;
} }
[data-deferred-render] {
content-visibility: auto;
contain: layout style paint;
}
#projects { contain-intrinsic-size: auto 800px; }
#experience { contain-intrinsic-size: auto 2085px; }
#skills { contain-intrinsic-size: auto 1240px; }
#education { contain-intrinsic-size: auto 480px; }
#milestones { contain-intrinsic-size: auto 1670px; }
#footer { contain-intrinsic-size: auto 615px; }
@media (max-width: 1024px) {
#projects { contain-intrinsic-size: auto 750px; }
#experience { contain-intrinsic-size: auto 2310px; }
#skills { contain-intrinsic-size: auto 1400px; }
#education { contain-intrinsic-size: auto 575px; }
#milestones { contain-intrinsic-size: auto 1835px; }
#footer { contain-intrinsic-size: auto 575px; }
}
@media (max-width: 768px) {
#projects { contain-intrinsic-size: auto 730px; }
#experience { contain-intrinsic-size: auto 2790px; }
#skills { contain-intrinsic-size: auto 1880px; }
#education { contain-intrinsic-size: auto 1010px; }
#milestones { contain-intrinsic-size: auto 2340px; }
#footer { contain-intrinsic-size: auto 500px; }
}
@media (max-width: 650px) {
#projects { contain-intrinsic-size: auto 750px; }
#experience { contain-intrinsic-size: auto 3780px; }
#skills { contain-intrinsic-size: auto 3385px; }
#education { contain-intrinsic-size: auto 1010px; }
#milestones { contain-intrinsic-size: auto 3690px; }
#footer { contain-intrinsic-size: auto 475px; }
}
@media (max-width: 650px) { @media (max-width: 650px) {
.section-header { .section-header {
margin-bottom: var(--space-6); margin-bottom: var(--space-6);
@@ -158,63 +176,19 @@ html[dir='rtl'] .section-underline {
transform-origin: right center; transform-origin: right center;
} }
/* One-time viewport reveals. Content remains visible when JavaScript is unavailable. */
html[data-motion-ready='true'] [data-reveal] {
opacity: 0;
transform: translateY(1rem);
transition:
opacity var(--motion-duration-reveal) var(--motion-ease-out),
transform var(--motion-duration-reveal) var(--motion-ease-out);
transition-delay: var(--reveal-delay, 0ms);
will-change: opacity, transform;
}
html[data-motion-ready='true'] [data-reveal='soft'] {
transform: translateY(0.625rem);
}
html[data-motion-ready='true'] [data-reveal='scale'] {
transform: translateY(0.5rem) scale(0.97);
}
html[data-motion-ready='true'] [data-reveal='image'] {
transform: translateY(0.5rem) scale(0.985);
}
html[data-motion-ready='true'] [data-reveal][data-visible='true'] {
opacity: 1;
transform: translateY(0) scale(1);
will-change: auto;
}
html[data-motion-ready='true'] [data-visible='true'] .section-underline {
animation: motionUnderline 500ms var(--motion-ease-out) 120ms both;
}
@keyframes motionUnderline {
from {
opacity: 0;
transform: scaleX(0.25);
}
to {
opacity: 1;
transform: scaleX(1);
}
}
/* Keep the original Latin display face and use Peyda for Persian text. */ /* Keep the original Latin display face and use Peyda for Persian text. */
.font-display { .font-display {
font-family: 'Pacifico', cursive; font-family: var(--font-display), cursive;
font-weight: 400; font-weight: 400;
} }
html[lang='fa'] .font-display { html[lang='fa'] .font-display {
font-family: 'Peyda', 'IRANYekanXVF', sans-serif; font-family: var(--font-display), var(--font-body), sans-serif;
font-weight: 900; font-weight: 900;
} }
html[lang='fa'] .section-title { html[lang='fa'] .section-title {
font-family: 'Peyda', 'IRANYekanXVF', sans-serif; font-family: var(--font-display), var(--font-body), sans-serif;
font-weight: 900; font-weight: 900;
} }
@@ -230,7 +204,7 @@ html[lang='fa'] .section-title {
font-size: var(--font-size-button); font-size: var(--font-size-button);
line-height: var(--line-height-button); line-height: var(--line-height-button);
font-weight: 600; font-weight: 600;
font-family: 'IRANYekanXVF', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: var(--font-body), -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
transition: transition:

13
src/types/media.ts Normal file
View File

@@ -0,0 +1,13 @@
export interface PortfolioImage {
src: string;
width: number;
height: number;
blurDataURL?: string;
}
export const portfolioImage = (
src: string,
width: number,
height: number,
blurDataURL?: string,
): PortfolioImage => ({ src, width, height, blurDataURL });

View File

@@ -14,10 +14,21 @@ test('the isometric footer preserves the real contact destinations and email', a
test('the footer renders the IsometricFooter scene instead of preloading PNG artwork', async () => { test('the footer renders the IsometricFooter scene instead of preloading PNG artwork', async () => {
const footerSection = await readFile('src/components/FooterSection.tsx', 'utf8'); const footerSection = await readFile('src/components/FooterSection.tsx', 'utf8');
const loader = await readFile('src/components/SiteLoader.tsx', 'utf8');
assert.match(footerSection, /<IsometricFooter locale=\{locale\}\s*\/>/); assert.match(footerSection, /<IsometricFooter locale=\{locale\}\s*\/>/);
assert.doesNotMatch(footerSection, /next\/image/); assert.doesNotMatch(footerSection, /next\/image/);
assert.doesNotMatch(footerSection, /footer-background.*\.png/); assert.doesNotMatch(footerSection, /footer-background.*\.png/);
assert.doesNotMatch(loader, /footer-background.*\.png/); });
test('the footer and large SVG stay server-rendered with delegated interaction', async () => {
const [footer, scene, interaction] = await Promise.all([
readFile('src/components/IsometricFooter/IsometricFooter.tsx', 'utf8'),
readFile('src/components/IsometricFooter/IsometricScene.tsx', 'utf8'),
readFile('src/components/IsometricFooter/FooterInteraction.tsx', 'utf8'),
]);
assert.doesNotMatch(footer, /^['"]use client['"]/);
assert.doesNotMatch(scene, /^['"]use client['"]/);
assert.match(interaction, /^['"]use client['"]/);
assert.match(footer, /data-contact-id=/);
}); });

View File

@@ -0,0 +1,48 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
test('Next runs as a standalone server with the image optimizer enabled', async () => {
const [config, packageJson] = await Promise.all([
readFile('next.config.mjs', 'utf8'),
readFile('package.json', 'utf8').then(JSON.parse),
]);
assert.match(config, /output:\s*['"]standalone['"]/);
assert.doesNotMatch(config, /unoptimized:\s*true/);
assert.match(config, /formats:\s*\[['"]image\/webp['"]\]/);
assert.match(config, /qualities:\s*\[60,\s*75,\s*85\]/);
assert.ok(packageJson.dependencies.sharp, 'sharp must be a direct production dependency');
});
test('the blocking loader and eager preload manifest are removed', async () => {
const [root, page] = await Promise.all([
readFile('src/app/SiteRoot.tsx', 'utf8'),
readFile('src/app/PortfolioPage.tsx', 'utf8'),
]);
assert.doesNotMatch(root, /siteLoading|site-loader/);
assert.doesNotMatch(page, /SiteLoader/);
});
test('Three.js is only imported by the deferred scene implementation', async () => {
const [boundary, scene] = await Promise.all([
readFile('src/components/scenes/DeferredAboutRiverScene.tsx', 'utf8'),
readFile('src/components/scenes/AboutRiverScene.tsx', 'utf8'),
]);
assert.doesNotMatch(boundary, /from ['"]three['"]/);
assert.match(boundary, /import\(['"].\/AboutRiverScene['"]\)/);
assert.match(boundary, /rootMargin:\s*['"]600px/);
assert.match(scene, /from ['"]three['"]/);
});
test('local font loading replaces remote CSS imports and TTF delivery', async () => {
const [root, css] = await Promise.all([
readFile('src/app/SiteRoot.tsx', 'utf8'),
readFile('src/styles/globals.css', 'utf8'),
]);
assert.match(root, /localFont/);
assert.doesNotMatch(css, /fonts\.googleapis\.com|@font-face|IRANYekanXVF\.ttf/);
});

View File

@@ -0,0 +1,91 @@
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { access, cp, readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
const projectRoot = path.resolve('.');
const standaloneRoot = path.join(projectRoot, '.next', 'standalone');
const port = 43217;
const origin = `http://127.0.0.1:${port}`;
async function waitForServer(process, getLogs) {
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
if (process.exitCode !== null) throw new Error('standalone server exited early');
try {
const response = await fetch(origin, { signal: AbortSignal.timeout(10_000) });
if (response.ok) return;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 150));
}
throw new Error(`standalone server did not become ready\n${getLogs()}`);
}
test('the standalone server renders both locales and optimizes local images', async (t) => {
await access(path.join(standaloneRoot, 'server.js'));
await cp(path.join(projectRoot, 'public'), path.join(standaloneRoot, 'public'), { recursive: true });
await cp(
path.join(projectRoot, '.next', 'static'),
path.join(standaloneRoot, '.next', 'static'),
{ recursive: true },
);
const server = spawn(process.execPath, ['server.js'], {
cwd: standaloneRoot,
env: { ...process.env, HOSTNAME: '127.0.0.1', PORT: String(port) },
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverLogs = '';
server.stdout.on('data', (chunk) => { serverLogs += chunk; });
server.stderr.on('data', (chunk) => { serverLogs += chunk; });
t.after(() => server.kill());
await waitForServer(server, () => serverLogs);
const [englishResponse, persianResponse] = await Promise.all([
fetch(`${origin}/`),
fetch(`${origin}/fa`),
]);
assert.equal(englishResponse.status, 200);
assert.equal(persianResponse.status, 200);
const [englishHtml, persianHtml] = await Promise.all([
englishResponse.text(),
persianResponse.text(),
]);
assert.match(englishHtml, /<html[^>]*lang="en"[^>]*dir="ltr"/i);
assert.match(englishHtml, /href="\/fa"/i);
assert.match(englishHtml, /hreflang="fa"/i);
assert.match(englishHtml, />Software Engineer</);
assert.doesNotMatch(englishHtml, /id="site-loader"/);
assert.match(persianHtml, /<html[^>]*lang="fa"[^>]*dir="rtl"/i);
assert.match(persianHtml, /سلام، من امیرحسین خلیلی هستم/);
const staticAsset = englishHtml.match(/(?:src|href)="(\/_next\/static\/[^"?#]+)/)?.[1];
assert.ok(staticAsset, 'expected a built static asset');
assert.equal((await fetch(`${origin}${staticAsset}`)).status, 200);
const optimized = await fetch(
`${origin}/_next/image?url=%2Fimages%2Favatar.png&w=256&q=75`,
{ headers: { Accept: 'image/webp' } },
);
assert.equal(optimized.status, 200);
assert.equal(optimized.headers.get('content-type'), 'image/webp');
assert.match(optimized.headers.get('cache-control') ?? '', /max-age=/);
});
test('Persian source copy uses standard characters and punctuation', async () => {
const localizedFiles = [
'src/i18n/content.ts',
'src/data/projects.ts',
'src/data/experience.ts',
'src/data/education.ts',
'src/data/milestones.ts',
];
const source = (
await Promise.all(localizedFiles.map((file) => readFile(file, 'utf8')))
).join('\n');
assert.doesNotMatch(source, /[يك]/, 'use Persian ی and ک instead of Arabic forms');
assert.doesNotMatch(source, /[—–]/, 'use Persian punctuation instead of em/en dashes');
});

View File

@@ -1,61 +0,0 @@
import assert from 'node:assert/strict';
import { access, readFile } from 'node:fs/promises';
import path from 'node:path';
import test from 'node:test';
const exportRoot = path.resolve('out');
test('the production build exports a usable home page', async () => {
const indexPath = path.join(exportRoot, 'index.html');
const html = await readFile(indexPath, 'utf8');
assert.match(html, /<!DOCTYPE html>/i);
assert.match(html, /<html[^>]*lang=/i);
const assetPaths = [
...html.matchAll(/(?:src|href)="(\/_next\/static\/[^"?#]+)/g),
].map((match) => match[1]);
assert.ok(assetPaths.length > 0, 'expected the home page to reference built assets');
await Promise.all(
[...new Set(assetPaths)].map((assetPath) =>
access(path.join(exportRoot, assetPath.replace(/^\//, ''))),
),
);
});
test('the production build exports reciprocal English and Persian pages', async () => {
const englishHtml = await readFile(path.join(exportRoot, 'index.html'), 'utf8');
const persianHtml = await readFile(path.join(exportRoot, 'fa.html'), 'utf8');
assert.match(englishHtml, /<html[^>]*lang="en"[^>]*dir="ltr"/i);
assert.match(englishHtml, /href="\/fa"[^>]*hreflang="fa"/i);
assert.match(englishHtml, />Software Engineer</);
assert.match(englishHtml, />Quera</);
assert.doesNotMatch(englishHtml, />مهندس نرم‌افزار</);
assert.doesNotMatch(englishHtml, />کوئرا</);
assert.match(persianHtml, /<html[^>]*lang="fa"[^>]*dir="rtl"/i);
assert.match(persianHtml, /href="\/"[^>]*hreflang="en"/i);
assert.match(persianHtml, /سلام، من امیرحسین خلیلی هستم/);
assert.match(persianHtml, /از پیکسل تا محصول/);
assert.match(persianHtml, /بهمن ۱۴۰۴ تا امروز/);
assert.match(persianHtml, /آبان ۱۴۰۳ تا امروز/);
});
test('Persian source copy uses standard characters and punctuation', async () => {
const localizedFiles = [
'src/i18n/content.ts',
'src/data/projects.ts',
'src/data/experience.ts',
'src/data/education.ts',
'src/data/milestones.ts',
];
const source = (
await Promise.all(localizedFiles.map((file) => readFile(file, 'utf8')))
).join('\n');
assert.doesNotMatch(source, /[يك]/, 'use Persian ی and ک instead of Arabic forms');
assert.doesNotMatch(source, /[—–]/, 'use Persian punctuation instead of em/en dashes');
});