feat(loading): preload portfolio assets before reveal
This commit is contained in:
@@ -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;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user