feat(loading): preload portfolio assets before reveal
Some checks failed
Test and deploy / Verify production build (push) Has been cancelled
Test and deploy / Deploy production (push) Has been cancelled

This commit is contained in:
2026-09-07 00:20:06 +03:30
parent b0c9ad1296
commit 7fb58ff718
6 changed files with 434 additions and 0 deletions

View File

@@ -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({
</head>
<body>
<ThemeProvider>{children}</ThemeProvider>
<noscript>
<style>{`#site-loader { display: none !important; }`}</style>
</noscript>
</body>
</html>
);

View File

@@ -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 (
<main className="desktop-canvas">
<SiteLoader />
<MotionController />
{/* 1. Hero & About Me River Journey (Seamless Scenic Background) */}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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<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() {
const [progress, setProgress] = useState(0);
const [phase, setPhase] = useState<LoaderPhase>('loading');
useEffect(() => {
let cancelled = false;
let exitTimer: ReturnType<typeof window.setTimeout> | 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 (
<div
id="site-loader"
className={styles.loader}
data-phase={phase}
role="status"
aria-live="polite"
aria-busy={phase === 'loading'}
aria-label={`Loading portfolio: ${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}`}>
Amirhossein Khalili
</p>
<p className={styles.message}>Assembling the experience</p>
<div className={styles.progressRow} aria-hidden="true">
<div className={styles.progressTrack}>
<span className={styles.progressFill} style={progressStyle} />
</div>
<span className={styles.progressValue}>{progress}%</span>
</div>
</div>
</div>
);
}

View File

@@ -25,6 +25,10 @@ html[data-theme="dark"] {
color-scheme: dark;
}
html[data-site-loading='true'] {
overflow: hidden;
}
body {
width: 100%;
min-width: 320px;