feat(loading): preload portfolio assets before reveal
This commit is contained in:
@@ -16,6 +16,7 @@ export const metadata: Metadata = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const themeScript = `
|
const themeScript = `
|
||||||
|
document.documentElement.dataset.siteLoading = 'true';
|
||||||
try {
|
try {
|
||||||
const savedTheme = localStorage.getItem('portfolio-theme');
|
const savedTheme = localStorage.getItem('portfolio-theme');
|
||||||
const systemTheme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
const systemTheme = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
@@ -35,6 +36,9 @@ export default function RootLayout({
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<ThemeProvider>{children}</ThemeProvider>
|
<ThemeProvider>{children}</ThemeProvider>
|
||||||
|
<noscript>
|
||||||
|
<style>{`#site-loader { display: none !important; }`}</style>
|
||||||
|
</noscript>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ 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';
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main className="desktop-canvas">
|
<main className="desktop-canvas">
|
||||||
|
<SiteLoader />
|
||||||
<MotionController />
|
<MotionController />
|
||||||
|
|
||||||
{/* 1. Hero & About Me River Journey (Seamless Scenic Background) */}
|
{/* 1. Hero & About Me River Journey (Seamless Scenic Background) */}
|
||||||
|
|||||||
@@ -181,6 +181,15 @@
|
|||||||
animation-delay: 200ms;
|
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 {
|
@keyframes heroContentEnter {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|||||||
234
src/components/SiteLoader.module.css
Normal file
234
src/components/SiteLoader.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
181
src/components/SiteLoader.tsx
Normal file
181
src/components/SiteLoader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,10 @@ html[data-theme="dark"] {
|
|||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html[data-site-loading='true'] {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
|
|||||||
Reference in New Issue
Block a user