perf: optimize portfolio runtime delivery
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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
25
Dockerfile
25
Dockerfile
@@ -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
36
PERFORMANCE.md
Normal 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.
|
||||||
15
compose.yaml
15
compose.yaml
@@ -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:
|
||||||
|
|||||||
@@ -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']),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
41
nginx.conf
41
nginx.conf
@@ -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
5
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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.
BIN
public/fonts/IRANYekanXVF.woff2
Normal file
BIN
public/fonts/IRANYekanXVF.woff2
Normal file
Binary file not shown.
BIN
public/fonts/Pacifico-LatinExt.woff2
Normal file
BIN
public/fonts/Pacifico-LatinExt.woff2
Normal file
Binary file not shown.
93
public/fonts/Pacifico-OFL.txt
Normal file
93
public/fonts/Pacifico-OFL.txt
Normal 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.
|
||||||
@@ -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
5
src/app/(en)/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PortfolioSkeleton } from '@/components/skeletons/PortfolioSkeleton';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <PortfolioSkeleton />;
|
||||||
|
}
|
||||||
5
src/app/(fa)/fa/loading.tsx
Normal file
5
src/app/(fa)/fa/loading.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { PortfolioSkeleton } from '@/components/skeletons/PortfolioSkeleton';
|
||||||
|
|
||||||
|
export default function Loading() {
|
||||||
|
return <PortfolioSkeleton />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
59
src/components/HeroControls.tsx
Normal file
59
src/components/HeroControls.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
73
src/components/ImageWithSkeleton.module.css
Normal file
73
src/components/ImageWithSkeleton.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/components/ImageWithSkeleton.tsx
Normal file
113
src/components/ImageWithSkeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/components/IsometricFooter/FooterInteraction.tsx
Normal file
29
src/components/IsometricFooter/FooterInteraction.tsx
Normal 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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
-------------------------------------------------------------------------- */
|
-------------------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
40
src/components/scenes/DeferredAboutRiverScene.tsx
Normal file
40
src/components/scenes/DeferredAboutRiverScene.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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?.();
|
||||||
|
|||||||
66
src/components/skeletons/PortfolioSkeleton.module.css
Normal file
66
src/components/skeletons/PortfolioSkeleton.module.css
Normal 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; }
|
||||||
|
}
|
||||||
48
src/components/skeletons/PortfolioSkeleton.tsx
Normal file
48
src/components/skeletons/PortfolioSkeleton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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
13
src/types/media.ts
Normal 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 });
|
||||||
@@ -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=/);
|
||||||
});
|
});
|
||||||
|
|||||||
48
tests/performance-architecture.test.mjs
Normal file
48
tests/performance-architecture.test.mjs
Normal 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/);
|
||||||
|
});
|
||||||
91
tests/runtime-server.test.mjs
Normal file
91
tests/runtime-server.test.mjs
Normal 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');
|
||||||
|
});
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user