fix: stabilize portfolio scrolling and loading
Remove skeleton swaps and deferred layout estimates, restore reveal state, and use native proximity scrolling for project cards. Also make the Docker build npm registry configurable.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { ImageWithSkeleton } from './ImageWithSkeleton';
|
||||
import Image from 'next/image';
|
||||
import { getEducation } from '@/data/education';
|
||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
||||
import styles from './EducationSection.module.css';
|
||||
@@ -8,7 +8,7 @@ export const EducationSection = ({ locale }: { locale: Locale }) => {
|
||||
const copy = messagesByLocale[locale].education;
|
||||
|
||||
return (
|
||||
<section id="education" className={styles.educationSection} data-deferred-render data-looping-motion>
|
||||
<section id="education" className={styles.educationSection}>
|
||||
<div className="content-container">
|
||||
<div className="section-header" data-reveal="soft">
|
||||
<div className="section-title-wrap">
|
||||
@@ -21,13 +21,12 @@ export const EducationSection = ({ locale }: { locale: Locale }) => {
|
||||
<div className={styles.card} data-reveal-group>
|
||||
<div className={styles.crestCol} data-reveal="image">
|
||||
<div className={styles.crestContainer}>
|
||||
<ImageWithSkeleton
|
||||
<Image
|
||||
src={education.logo.src}
|
||||
alt={education.institution}
|
||||
fill
|
||||
sizes="(max-width: 650px) 12rem, 16.5rem"
|
||||
className={styles.crestImage}
|
||||
skeletonShape="rounded"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ImageWithSkeleton } from './ImageWithSkeleton';
|
||||
import Image from 'next/image';
|
||||
import { getExperiences } from '@/data/experience';
|
||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
||||
import styles from './ExperienceSection.module.css';
|
||||
@@ -8,7 +8,7 @@ export const ExperienceSection = ({ locale }: { locale: Locale }) => {
|
||||
const copy = messagesByLocale[locale].experience;
|
||||
|
||||
return (
|
||||
<section id="experience" className={styles.experienceSection} data-deferred-render data-looping-motion>
|
||||
<section id="experience" className={styles.experienceSection}>
|
||||
<div className="content-container">
|
||||
<div className="section-header" data-reveal="soft">
|
||||
<div className="section-title-wrap">
|
||||
@@ -31,6 +31,7 @@ export const ExperienceSection = ({ locale }: { locale: Locale }) => {
|
||||
className={`${styles.nodeDot} ${
|
||||
item.isCurrent ? styles.nodeCurrent : ''
|
||||
}`}
|
||||
data-looping-motion={item.isCurrent ? '' : undefined}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{index < experiences.length - 1 && (
|
||||
@@ -65,13 +66,12 @@ export const ExperienceSection = ({ locale }: { locale: Locale }) => {
|
||||
|
||||
<div className={styles.roleIdentityRow}>
|
||||
<div className={styles.logoContainer}>
|
||||
<ImageWithSkeleton
|
||||
<Image
|
||||
src={item.logo.src}
|
||||
alt={copy.logoAlt(item.company)}
|
||||
fill
|
||||
sizes="72px"
|
||||
className={styles.logoImage}
|
||||
skeletonShape="rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import styles from './FooterSection.module.css';
|
||||
|
||||
export const FooterSection = ({ locale }: { locale: Locale }) => {
|
||||
return (
|
||||
<footer id="footer" className={styles.footerSection} data-deferred-render data-looping-motion>
|
||||
<footer id="footer" className={styles.footerSection}>
|
||||
<IsometricFooter locale={locale} />
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -284,15 +284,6 @@
|
||||
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;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BookOpenText, GraduationCap, MessageCircle } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
||||
import { HeroControls } from './HeroControls';
|
||||
import { ImageWithSkeleton } from './ImageWithSkeleton';
|
||||
import styles from './HeroSection.module.css';
|
||||
|
||||
export const HeroSection = ({ locale }: { locale: Locale }) => {
|
||||
@@ -14,13 +14,12 @@ export const HeroSection = ({ locale }: { locale: Locale }) => {
|
||||
<div className={styles.avatarWrapper}>
|
||||
<div className={styles.avatarRingOuter}>
|
||||
<div className={styles.avatarContainer}>
|
||||
<ImageWithSkeleton
|
||||
<Image
|
||||
src="/images/avatar.png"
|
||||
alt={copy.avatarAlt}
|
||||
fill
|
||||
sizes="192px"
|
||||
preload
|
||||
skeletonShape="circle"
|
||||
className={styles.avatarImg}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
.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;
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -36,7 +36,6 @@
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 clamp(16px, 2.5vw, 24px) 0;
|
||||
word-break: normal;
|
||||
animation: footerFadeIn 0.7s ease-out forwards;
|
||||
}
|
||||
|
||||
:global(html[lang='fa']) .headerTitle {
|
||||
@@ -50,7 +49,6 @@
|
||||
margin: 0 0 16px 0;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
animation: footerFadeIn 0.7s 0.15s ease-out both;
|
||||
}
|
||||
|
||||
/* Email Pill Button */
|
||||
@@ -58,7 +56,6 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
animation: emailScaleIn 0.6s 0.25s ease-out both;
|
||||
}
|
||||
|
||||
.emailPill {
|
||||
@@ -313,31 +310,6 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Animations
|
||||
-------------------------------------------------------------------------- */
|
||||
@keyframes footerFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes emailScaleIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------------
|
||||
Responsive Media Queries
|
||||
-------------------------------------------------------------------------- */
|
||||
|
||||
@@ -52,16 +52,16 @@ export default function IsometricFooter({ locale }: { locale: Locale }) {
|
||||
<FooterInteraction className={styles.footerContainer}>
|
||||
{/* Top Header & Contact Section */}
|
||||
<header className={styles.footerHeader}>
|
||||
<h2 className={styles.headerTitle}>
|
||||
<h2 className={styles.headerTitle} data-reveal="soft">
|
||||
{copy.title}
|
||||
</h2>
|
||||
|
||||
<p className={styles.headerSubtitle}>
|
||||
<p className={styles.headerSubtitle} data-reveal="soft">
|
||||
{copy.subtitle}
|
||||
</p>
|
||||
|
||||
{/* Email Pill Button */}
|
||||
<div className={styles.emailWrapper}>
|
||||
<div className={styles.emailWrapper} data-reveal="scale">
|
||||
<a
|
||||
href="mailto:amirhosseinkhalili901@gmail.com"
|
||||
className={styles.emailPill}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ImageWithSkeleton } from './ImageWithSkeleton';
|
||||
import Image from 'next/image';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { getMilestones } from '@/data/milestones';
|
||||
import { messagesByLocale, type Locale } from '@/i18n/content';
|
||||
@@ -10,7 +10,7 @@ export const MilestonesSection = ({ locale }: { locale: Locale }) => {
|
||||
const copy = messagesByLocale[locale].milestones;
|
||||
|
||||
return (
|
||||
<section id="milestones" className={styles.milestonesSection} data-deferred-render data-looping-motion>
|
||||
<section id="milestones" className={styles.milestonesSection}>
|
||||
<div className="content-container">
|
||||
{/* Section Header */}
|
||||
<div className="section-header" data-reveal="soft">
|
||||
@@ -27,13 +27,12 @@ export const MilestonesSection = ({ locale }: { locale: Locale }) => {
|
||||
<div key={item.id} className={styles.certCard}>
|
||||
{/* Thumbnail */}
|
||||
<div className={styles.thumbnailContainer} data-reveal="image">
|
||||
<ImageWithSkeleton
|
||||
<Image
|
||||
src={item.image.src}
|
||||
alt={item.title}
|
||||
fill
|
||||
sizes="(max-width: 650px) calc(100vw - 3rem), (max-width: 1024px) 44vw, 520px"
|
||||
className={styles.certImage}
|
||||
skeletonShape="rounded"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -60,22 +60,40 @@ export const MotionController = () => {
|
||||
const reduceMotion = window.matchMedia(
|
||||
'(prefers-reduced-motion: reduce)',
|
||||
).matches;
|
||||
if (reduceMotion) {
|
||||
return () => {
|
||||
cancelAnimationFrame(anchorFrame);
|
||||
anchorObserver?.disconnect();
|
||||
window.removeEventListener('hashchange', queueAnchorRestore);
|
||||
};
|
||||
}
|
||||
|
||||
const elements = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(REVEAL_SELECTOR),
|
||||
);
|
||||
|
||||
if (reduceMotion) {
|
||||
elements.forEach((element) => {
|
||||
element.dataset.visible = 'true';
|
||||
});
|
||||
document.documentElement.dataset.motionReady = 'true';
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(anchorFrame);
|
||||
anchorObserver?.disconnect();
|
||||
window.removeEventListener('hashchange', queueAnchorRestore);
|
||||
delete document.documentElement.dataset.motionReady;
|
||||
};
|
||||
}
|
||||
|
||||
const initialRevealBoundary = window.innerHeight * 0.92;
|
||||
elements.forEach((element) => {
|
||||
const bounds = element.getBoundingClientRect();
|
||||
if (bounds.top <= initialRevealBoundary && bounds.bottom >= 0) {
|
||||
element.dataset.visible = 'true';
|
||||
}
|
||||
});
|
||||
document.documentElement.dataset.motionReady = 'true';
|
||||
|
||||
const revealObserver = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (!entry.isIntersecting) return;
|
||||
const element = entry.target as HTMLElement;
|
||||
element.dataset.visible = 'true';
|
||||
const siblingIndex = element.parentElement
|
||||
? Array.from(element.parentElement.children).indexOf(element)
|
||||
: 0;
|
||||
@@ -95,7 +113,9 @@ export const MotionController = () => {
|
||||
},
|
||||
{ rootMargin: '0px 0px -8% 0px', threshold: 0.12 },
|
||||
);
|
||||
elements.forEach((element) => revealObserver.observe(element));
|
||||
elements.forEach((element) => {
|
||||
if (element.dataset.visible !== 'true') revealObserver.observe(element);
|
||||
});
|
||||
|
||||
const loopingScopes = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('[data-looping-motion]'),
|
||||
@@ -134,6 +154,7 @@ export const MotionController = () => {
|
||||
revealObserver.disconnect();
|
||||
loopingObserver.disconnect();
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
delete document.documentElement.dataset.motionReady;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useState, type FC } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { useTheme } from '@/context/ThemeContext';
|
||||
import type { PortfolioImage } from '@/types/media';
|
||||
import { ImageWithSkeleton } from './ImageWithSkeleton';
|
||||
|
||||
interface ProjectThumbnailProps {
|
||||
image: PortfolioImage;
|
||||
@@ -20,7 +20,7 @@ export const ProjectThumbnail: FC<ProjectThumbnailProps> = ({ image, title, clas
|
||||
const useDarkImage = theme === 'dark' && unavailableDarkImage !== darkImage;
|
||||
|
||||
return (
|
||||
<ImageWithSkeleton
|
||||
<Image
|
||||
key={useDarkImage ? darkImage : image.src}
|
||||
src={useDarkImage ? darkImage : image.src}
|
||||
alt={title}
|
||||
@@ -29,7 +29,7 @@ export const ProjectThumbnail: FC<ProjectThumbnailProps> = ({ image, title, clas
|
||||
quality={75}
|
||||
draggable={false}
|
||||
className={className}
|
||||
skeletonShape="rounded"
|
||||
placeholder={!useDarkImage && image.blurDataURL ? 'blur' : 'empty'}
|
||||
blurDataURL={!useDarkImage ? image.blurDataURL : undefined}
|
||||
onError={() => {
|
||||
if (useDarkImage) setUnavailableDarkImage(darkImage);
|
||||
|
||||
@@ -82,10 +82,12 @@
|
||||
cursor: grab;
|
||||
scrollbar-width: none;
|
||||
overscroll-behavior-inline: contain;
|
||||
touch-action: pan-y;
|
||||
scroll-snap-type: x proximity;
|
||||
touch-action: pan-x pan-y pinch-zoom;
|
||||
user-select: none;
|
||||
container-type: inline-size;
|
||||
container-name: carousel;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.carouselViewport::-webkit-scrollbar {
|
||||
@@ -346,7 +348,6 @@
|
||||
}
|
||||
|
||||
.carouselViewport {
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-padding-inline: 0;
|
||||
}
|
||||
|
||||
@@ -426,8 +427,6 @@
|
||||
margin-inline: 0;
|
||||
padding-inline: 0;
|
||||
scroll-padding-inline: 0;
|
||||
scroll-snap-type: x mandatory;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.carouselTrack {
|
||||
@@ -446,7 +445,6 @@
|
||||
min-height: 26.25rem;
|
||||
box-sizing: border-box;
|
||||
scroll-snap-align: start;
|
||||
scroll-snap-stop: always;
|
||||
}
|
||||
|
||||
.mediaContainer {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { ProjectThumbnail } from './ProjectThumbnail';
|
||||
import styles from './ProjectsSection.module.css';
|
||||
|
||||
const MIN_SCROLLBAR_THUMB_PERCENT = 12.5;
|
||||
const prefersReducedMotion = () =>
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const getLogicalScrollLeft = (viewport: HTMLDivElement, isRtl: boolean) =>
|
||||
isRtl ? -viewport.scrollLeft : viewport.scrollLeft;
|
||||
@@ -30,13 +32,8 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
||||
active: false,
|
||||
moved: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
startLogicalScrollLeft: 0,
|
||||
lastX: 0,
|
||||
lastTime: 0,
|
||||
velocity: 0,
|
||||
pointerId: -1,
|
||||
directionLocked: null as 'horizontal' | 'vertical' | null,
|
||||
});
|
||||
const scrollbarDragState = useRef({
|
||||
active: false,
|
||||
@@ -101,6 +98,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
window.cancelAnimationFrame(scrollFrameRef.current);
|
||||
scrollFrameRef.current = 0;
|
||||
};
|
||||
}, [syncScrollState]);
|
||||
|
||||
@@ -120,11 +118,16 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
||||
maxScroll,
|
||||
),
|
||||
);
|
||||
setLogicalScrollLeft(viewport, nextScroll, isRtl, 'smooth');
|
||||
setLogicalScrollLeft(
|
||||
viewport,
|
||||
nextScroll,
|
||||
isRtl,
|
||||
prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
);
|
||||
};
|
||||
|
||||
const handlePointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0 && event.pointerType === 'mouse') return;
|
||||
if (event.pointerType !== 'mouse' || event.button !== 0) return;
|
||||
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
@@ -133,14 +136,11 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
||||
active: true,
|
||||
moved: false,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
startLogicalScrollLeft: getLogicalScrollLeft(viewport, isRtl),
|
||||
lastX: event.clientX,
|
||||
lastTime: performance.now(),
|
||||
velocity: 0,
|
||||
pointerId: event.pointerId,
|
||||
directionLocked: null,
|
||||
};
|
||||
viewport.setPointerCapture(event.pointerId);
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
@@ -148,59 +148,23 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
||||
if (!viewport || !dragState.current.active) return;
|
||||
|
||||
const deltaX = event.clientX - dragState.current.startX;
|
||||
const deltaY = event.clientY - dragState.current.startY;
|
||||
|
||||
// Detect user intent: horizontal carousel swipe vs vertical page scroll
|
||||
if (!dragState.current.directionLocked) {
|
||||
if (Math.abs(deltaX) < 6 && Math.abs(deltaY) < 6) return;
|
||||
|
||||
if (Math.abs(deltaY) > Math.abs(deltaX)) {
|
||||
// User intends to scroll page vertically, release touch
|
||||
dragState.current.directionLocked = 'vertical';
|
||||
dragState.current.active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// User intends horizontal carousel drag
|
||||
dragState.current.directionLocked = 'horizontal';
|
||||
try {
|
||||
viewport.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// Ignore if pointer capture unavailable
|
||||
}
|
||||
setIsDragging(true);
|
||||
}
|
||||
|
||||
if (dragState.current.directionLocked === 'horizontal') {
|
||||
const now = performance.now();
|
||||
const dt = Math.max(now - dragState.current.lastTime, 1);
|
||||
const dx = event.clientX - dragState.current.lastX;
|
||||
dragState.current.velocity = dx / dt;
|
||||
dragState.current.lastX = event.clientX;
|
||||
dragState.current.lastTime = now;
|
||||
|
||||
if (Math.abs(deltaX) > 4) dragState.current.moved = true;
|
||||
const dragDirection = isRtl ? 1 : -1;
|
||||
setLogicalScrollLeft(
|
||||
viewport,
|
||||
dragState.current.startLogicalScrollLeft + deltaX * dragDirection,
|
||||
isRtl,
|
||||
);
|
||||
}
|
||||
if (Math.abs(deltaX) > 4) dragState.current.moved = true;
|
||||
const dragDirection = isRtl ? 1 : -1;
|
||||
setLogicalScrollLeft(
|
||||
viewport,
|
||||
dragState.current.startLogicalScrollLeft + deltaX * dragDirection,
|
||||
isRtl,
|
||||
);
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const stopDragging = (event: React.PointerEvent<HTMLDivElement>) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport || !dragState.current.active) {
|
||||
if (!viewport || !dragState.current.active || dragState.current.pointerId !== event.pointerId) {
|
||||
setIsDragging(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const wasHorizontal = dragState.current.directionLocked === 'horizontal';
|
||||
const moved = dragState.current.moved;
|
||||
const velocity = dragState.current.velocity;
|
||||
const clientX = event.clientX ?? dragState.current.lastX;
|
||||
const totalDeltaX = clientX - dragState.current.startX;
|
||||
const pointerId = dragState.current.pointerId;
|
||||
|
||||
dragState.current.active = false;
|
||||
@@ -212,39 +176,6 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
||||
}
|
||||
}
|
||||
setIsDragging(false);
|
||||
|
||||
if (!wasHorizontal || !moved) return;
|
||||
|
||||
// Calculate snap target with velocity fling
|
||||
const track = viewport.firstElementChild as HTMLElement | null;
|
||||
const card = track?.firstElementChild as HTMLElement | null;
|
||||
if (!track || !card) return;
|
||||
|
||||
const cardWidth = card.getBoundingClientRect().width;
|
||||
const gap = Number.parseFloat(window.getComputedStyle(track).columnGap) || 0;
|
||||
const step = cardWidth + gap;
|
||||
if (step <= 0) return;
|
||||
|
||||
const currentScroll = getLogicalScrollLeft(viewport, isRtl);
|
||||
let targetIndex: number;
|
||||
if (Math.abs(velocity) > 0.2 || Math.abs(totalDeltaX) > cardWidth * 0.18) {
|
||||
const isForwardGesture = isRtl
|
||||
? totalDeltaX > 0 || velocity > 0.2
|
||||
: totalDeltaX < 0 || velocity < -0.2;
|
||||
|
||||
if (isForwardGesture) {
|
||||
targetIndex = Math.ceil(currentScroll / step);
|
||||
} else {
|
||||
targetIndex = Math.floor(currentScroll / step);
|
||||
}
|
||||
} else {
|
||||
targetIndex = Math.round(currentScroll / step);
|
||||
}
|
||||
|
||||
const maxIndex = projects.length - 1;
|
||||
targetIndex = Math.max(0, Math.min(targetIndex, maxIndex));
|
||||
|
||||
setLogicalScrollLeft(viewport, targetIndex * step, isRtl, 'smooth');
|
||||
};
|
||||
|
||||
const handleClickCapture = (event: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -355,7 +286,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
||||
viewport,
|
||||
event.key === 'Home' ? 0 : viewport.scrollWidth - viewport.clientWidth,
|
||||
isRtl,
|
||||
'smooth',
|
||||
prefersReducedMotion() ? 'auto' : 'smooth',
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -365,8 +296,6 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
||||
id="projects"
|
||||
className={styles.projectsSection}
|
||||
aria-labelledby="projects-title"
|
||||
data-deferred-render
|
||||
data-looping-motion
|
||||
>
|
||||
<div className="content-container">
|
||||
<div className={styles.headerRow} data-reveal="soft">
|
||||
@@ -420,6 +349,7 @@ export const ProjectsSection = ({ locale }: { locale: Locale }) => {
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={stopDragging}
|
||||
onPointerCancel={stopDragging}
|
||||
onLostPointerCapture={stopDragging}
|
||||
onClickCapture={handleClickCapture}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { ImageWithSkeleton } from './ImageWithSkeleton';
|
||||
import Image from 'next/image';
|
||||
import { Code, BarChart2, Wand2 } from 'lucide-react';
|
||||
import {
|
||||
skillsByDiscipline,
|
||||
@@ -28,7 +28,7 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<section id="skills" className={styles.skillsSection} data-deferred-render data-looping-motion>
|
||||
<section id="skills" className={styles.skillsSection}>
|
||||
<div className="content-container">
|
||||
{/* Section Header */}
|
||||
<div className="section-header" data-reveal="soft">
|
||||
@@ -90,13 +90,12 @@ export const SkillsSection = ({ locale }: { locale: Locale }) => {
|
||||
{activeDiscipline.tools.map((tool) => (
|
||||
<div key={tool.id} className={styles.toolTile}>
|
||||
<div className={styles.toolIconContainer}>
|
||||
<ImageWithSkeleton
|
||||
<Image
|
||||
src={tool.icon.src}
|
||||
alt=""
|
||||
fill
|
||||
sizes="64px"
|
||||
className={styles.toolIcon}
|
||||
skeletonShape="rounded"
|
||||
/>
|
||||
</div>
|
||||
<span className={`${styles.toolLabel} ltr-token`} lang="en" dir="ltr">
|
||||
|
||||
@@ -269,7 +269,7 @@ const createAboutScene: SceneFactory = ({ pointer, theme, reduceMotion }) => {
|
||||
};
|
||||
};
|
||||
|
||||
export function AboutRiverScene({ onReady }: { onReady?: () => void }) {
|
||||
export function AboutRiverScene() {
|
||||
const { theme } = useTheme();
|
||||
return <ThreeCanvas className={styles.aboutScene} createScene={createAboutScene} theme={theme} onReady={onReady} />;
|
||||
return <ThreeCanvas className={styles.aboutScene} createScene={createAboutScene} theme={theme} />;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ const AboutRiverScene = lazy(() =>
|
||||
export function DeferredAboutRiverScene() {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const [shouldLoad, setShouldLoad] = useState(false);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
@@ -28,11 +27,10 @@ export function DeferredAboutRiverScene() {
|
||||
}, [shouldLoad]);
|
||||
|
||||
return (
|
||||
<div ref={hostRef} className={styles.aboutSceneBoundary} aria-busy={!isReady} data-looping-motion>
|
||||
{!isReady && <span className={styles.sceneSkeleton} aria-hidden="true" />}
|
||||
<div ref={hostRef} className={styles.aboutSceneBoundary}>
|
||||
{shouldLoad && (
|
||||
<Suspense fallback={null}>
|
||||
<AboutRiverScene onReady={() => setIsReady(true)} />
|
||||
<AboutRiverScene />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -51,22 +51,6 @@
|
||||
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 {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -114,9 +98,3 @@
|
||||
width: 100vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sceneSkeleton {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ interface ThreeCanvasProps {
|
||||
className: string;
|
||||
createScene: SceneFactory;
|
||||
theme: SceneTheme;
|
||||
onReady?: () => void;
|
||||
}
|
||||
|
||||
function disposeObject(object: THREE.Object3D) {
|
||||
@@ -50,13 +49,8 @@ function disposeObject(object: THREE.Object3D) {
|
||||
});
|
||||
}
|
||||
|
||||
export function ThreeCanvas({ className, createScene, theme, onReady }: ThreeCanvasProps) {
|
||||
export function ThreeCanvas({ className, createScene, theme }: ThreeCanvasProps) {
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
const onReadyRef = useRef(onReady);
|
||||
|
||||
useEffect(() => {
|
||||
onReadyRef.current = onReady;
|
||||
}, [onReady]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
@@ -70,7 +64,6 @@ export function ThreeCanvas({ className, createScene, theme, onReady }: ThreeCan
|
||||
let frame = 0;
|
||||
let visible = true;
|
||||
let pageVisible = !document.hidden;
|
||||
let hasRendered = false;
|
||||
|
||||
renderer.setClearColor(0x000000, 0);
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
@@ -81,10 +74,6 @@ export function ThreeCanvas({ className, createScene, theme, onReady }: ThreeCan
|
||||
|
||||
const render = () => {
|
||||
renderer.render(controller.scene, controller.camera);
|
||||
if (!hasRendered) {
|
||||
hasRendered = true;
|
||||
onReadyRef.current?.();
|
||||
}
|
||||
};
|
||||
const resize = () => {
|
||||
const bounds = host.getBoundingClientRect();
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
.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; }
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user