From 7fb58ff718a7144afe31c82e46581cef0d95d702 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Mon, 7 Sep 2026 00:20:06 +0330 Subject: [PATCH] feat(loading): preload portfolio assets before reveal --- src/app/layout.tsx | 4 + src/app/page.tsx | 2 + src/components/HeroSection.module.css | 9 + src/components/SiteLoader.module.css | 234 ++++++++++++++++++++++++++ src/components/SiteLoader.tsx | 181 ++++++++++++++++++++ src/styles/globals.css | 4 + 6 files changed, 434 insertions(+) create mode 100644 src/components/SiteLoader.module.css create mode 100644 src/components/SiteLoader.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4f3f3c8..c584f72 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -16,6 +16,7 @@ export const metadata: Metadata = { }; const themeScript = ` + document.documentElement.dataset.siteLoading = 'true'; try { const savedTheme = localStorage.getItem('portfolio-theme'); const systemTheme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; @@ -35,6 +36,9 @@ export default function RootLayout({ {children} + ); diff --git a/src/app/page.tsx b/src/app/page.tsx index c957622..f4d3f02 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,10 +7,12 @@ import { EducationSection } from '@/components/EducationSection'; import { MilestonesSection } from '@/components/MilestonesSection'; import { FooterSection } from '@/components/FooterSection'; import { MotionController } from '@/components/MotionController'; +import { SiteLoader } from '@/components/SiteLoader'; export default function Home() { return (
+ {/* 1. Hero & About Me River Journey (Seamless Scenic Background) */} diff --git a/src/components/HeroSection.module.css b/src/components/HeroSection.module.css index 05178fe..2ea957b 100644 --- a/src/components/HeroSection.module.css +++ b/src/components/HeroSection.module.css @@ -181,6 +181,15 @@ animation-delay: 200ms; } +:global(html[data-site-loading='true']) .topBar, +:global(html[data-site-loading='true']) .avatarWrapper, +:global(html[data-site-loading='true']) .heroTitle, +:global(html[data-site-loading='true']) .heroSubtitle, +:global(html[data-site-loading='true']) .actionButtons { + opacity: 0; + animation: none; +} + @keyframes heroContentEnter { from { opacity: 0; diff --git a/src/components/SiteLoader.module.css b/src/components/SiteLoader.module.css new file mode 100644 index 0000000..4b7b167 --- /dev/null +++ b/src/components/SiteLoader.module.css @@ -0,0 +1,234 @@ +.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); +} + +.progressValue { + color: var(--text-secondary); + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + text-align: right; +} + +@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; + } +} diff --git a/src/components/SiteLoader.tsx b/src/components/SiteLoader.tsx new file mode 100644 index 0000000..8ca9e2c --- /dev/null +++ b/src/components/SiteLoader.tsx @@ -0,0 +1,181 @@ +'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 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', + '/assets/footer-background 1.png', + '/assets/footer-background-dark.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((resolve) => { + if (document.readyState === 'complete') { + resolve(); + return; + } + + window.addEventListener('load', () => resolve(), { once: true }); + }); + +const waitForPaint = () => + new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())); + }); + +const preloadImage = (src: string, onSettled: () => void) => + new Promise((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() { + const [progress, setProgress] = useState(0); + const [phase, setPhase] = useState('loading'); + + useEffect(() => { + let cancelled = false; + let exitTimer: ReturnType | 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) window.clearTimeout(exitTimer); + }; + }, []); + + if (phase === 'hidden') return null; + + const progressStyle = { + transform: `scaleX(${progress / 100})`, + } satisfies CSSProperties; + + return ( +
+ + ); +} diff --git a/src/styles/globals.css b/src/styles/globals.css index 69153c0..4fe72b7 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -25,6 +25,10 @@ html[data-theme="dark"] { color-scheme: dark; } +html[data-site-loading='true'] { + overflow: hidden; +} + body { width: 100%; min-width: 320px;