Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"linter": {
"enabled": true,
"rules": {
"recommended": true
"recommended": true,
"complexity": "warn"
}
},
"javascript": {
Expand Down
109 changes: 34 additions & 75 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,102 +19,61 @@ interface AppProps {
onReady?: () => void;
}

function App({ onReady }: AppProps) {
const [isScrolledPastHero, setIsScrolledPastHero] = useState(false);
const heroSectionRef = useRef<HTMLElement | null>(null);
const location = useLocation();
const doubleRAF = (cb: () => void) => requestAnimationFrame(() => requestAnimationFrame(cb));

useEffect(() => {
const handleScroll = () => {
if (heroSectionRef.current) {
const heroBottom = heroSectionRef.current.offsetTop + heroSectionRef.current.offsetHeight;
const scrollY = window.scrollY;
setIsScrolledPastHero(scrollY > heroBottom);
}
};

window.addEventListener('scroll', handleScroll);
handleScroll(); // Check initial state
function scrollToWithOffset(element: Element) {
const top = element.getBoundingClientRect().top + globalThis.pageYOffset - 100;
globalThis.scrollTo({ top, behavior: 'smooth' });
}

return () => {
window.removeEventListener('scroll', handleScroll);
function useHeroScroll(heroRef: React.RefObject<HTMLElement | null>) {
const [isPast, setIsPast] = useState(false);
useEffect(() => {
const check = () => {
if (!heroRef.current) return;
setIsPast(globalThis.scrollY > heroRef.current.offsetTop + heroRef.current.offsetHeight);
};
}, []);
globalThis.addEventListener('scroll', check);
check();
return () => globalThis.removeEventListener('scroll', check);
}, [heroRef]);
return isPast;
}

// Handle hash scrolling from navigation state and URL hash
function useHashScroll(location: ReturnType<typeof useLocation>) {
useEffect(() => {
// Only handle on main page
if (location.pathname !== '/') return;

// Check both navigation state and URL hash
const state = location.state as { scrollToHash?: string } | null;
const hashFromState = state?.scrollToHash;
const hashFromUrl = location.hash;

// Also check window.location.hash as fallback (for direct navigation)
const hashFromWindow = window.location.hash;

const hash = hashFromState || hashFromUrl || hashFromWindow;
const hash = state?.scrollToHash || location.hash || globalThis.location.hash;
if (!hash) return;

// Remove # if present and ensure it starts with #
const cleanHash = hash.startsWith('#') ? hash : `#${hash}`;

// Helper function to scroll to element with offset
const scrollToElement = (element: Element) => {
const elementTop = element.getBoundingClientRect().top + window.pageYOffset;
const offset = 100; // Offset for header
window.scrollTo({
top: elementTop - offset,
behavior: 'smooth',
});
};

// Helper function to execute after double RAF (ensures DOM is painted)
const executeAfterDoubleRAF = (callback: () => void) => {
requestAnimationFrame(() => {
requestAnimationFrame(callback);
});
};

// Robust scrolling: retry until element exists or timeout
const maxAttempts = 50; // Increased attempts for slower renders
let attempts = 0;

const tryScroll = () => {
attempts++;
const element = document.querySelector(cleanHash);

if (element) {
// Element found, scroll to it with slight offset for header
executeAfterDoubleRAF(() => scrollToElement(element));
const el = document.querySelector(cleanHash);
if (el) {
doubleRAF(() => scrollToWithOffset(el));
return;
}

// Element not found yet, retry after a short delay
if (attempts < maxAttempts) {
setTimeout(tryScroll, appConfig.animations.scrollRetryDelayCrossPage);
}
if (attempts < 50) setTimeout(tryScroll, appConfig.animations.scrollRetryDelayCrossPage);
};

// Start trying after a delay to allow DOM to render (longer delay for cross-page navigation)
setTimeout(tryScroll, appConfig.animations.scrollInitialDelayCrossPage);
}, [location.state, location.hash, location.pathname]);
}

// Signal that App is ready (after initial render)
function useAppReady(onReady?: () => void) {
useEffect(() => {
if (!onReady) return;

// Helper function to execute after double RAF (ensures DOM is painted)
const executeAfterDoubleRAF = (callback: () => void) => {
requestAnimationFrame(() => {
requestAnimationFrame(callback);
});
};

// Use requestAnimationFrame to ensure DOM is painted
executeAfterDoubleRAF(onReady);
doubleRAF(onReady);
}, [onReady]);
}

function App({ onReady }: AppProps) {
const heroSectionRef = useRef<HTMLElement | null>(null);
const location = useLocation();
const isScrolledPastHero = useHeroScroll(heroSectionRef);
useHashScroll(location);
useAppReady(onReady);

return (
<div
Expand Down
71 changes: 29 additions & 42 deletions src/components/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -164,86 +164,73 @@ function FooterColumn({ data }: { data: ColumnData }) {
);
}

export function Footer() {
const currentYear = new Date().getFullYear();
function isPageAtBottom(margin = 10) {
const { scrollTop, scrollHeight, clientHeight } = document.documentElement;
return (window.pageYOffset || scrollTop) + clientHeight >= scrollHeight - margin;
}

function useFooterState() {
const [isExpanded, setIsExpanded] = useState(false);
const [isPulling, setIsPulling] = useState(false);
const [pullDistance, setPullDistance] = useState(0);
const footerRef = useRef<HTMLElement>(null);
const startYRef = useRef<number>(0);
const lastScrollTopRef = useRef<number>(0);
const startYRef = useRef(0);
const lastScrollTopRef = useRef(0);

useEffect(() => {
const handleScroll = () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 10;

// Auto-expand when scrolled to bottom
if (isAtBottom && !isExpanded) {
setIsExpanded(true);
}
// Auto-collapse when scrolling up (if not at bottom)
else if (!isAtBottom && scrollTop < lastScrollTopRef.current && isExpanded) {
if (isPageAtBottom() && !isExpanded) setIsExpanded(true);
else if (!isPageAtBottom() && scrollTop < lastScrollTopRef.current && isExpanded)
setIsExpanded(false);
}

lastScrollTopRef.current = scrollTop;
};

window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [isExpanded]);

useEffect(() => {
const footer = footerRef.current;
if (!footer) return;

const handleTouchStart = (e: TouchEvent) => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const scrollHeight = document.documentElement.scrollHeight;
const clientHeight = document.documentElement.clientHeight;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 50;

if (isAtBottom) {
const onTouchStart = (e: TouchEvent) => {
if (isPageAtBottom(50)) {
startYRef.current = e.touches[0].clientY;
setIsPulling(true);
}
};

const handleTouchMove = (e: TouchEvent) => {
const onTouchMove = (e: TouchEvent) => {
if (!isPulling) return;

const currentY = e.touches[0].clientY;
const distance = Math.max(0, currentY - startYRef.current);
setPullDistance(Math.min(distance, 150)); // Max pull distance

const distance = Math.max(0, e.touches[0].clientY - startYRef.current);
setPullDistance(Math.min(distance, 150));
if (distance > 50 && !isExpanded) {
setIsExpanded(true);
setIsPulling(false);
setPullDistance(0);
}
};

const handleTouchEnd = () => {
const onTouchEnd = () => {
if (isPulling) {
setIsPulling(false);
setPullDistance(0);
}
};

footer.addEventListener('touchstart', handleTouchStart);
footer.addEventListener('touchmove', handleTouchMove);
footer.addEventListener('touchend', handleTouchEnd);

footer.addEventListener('touchstart', onTouchStart);
footer.addEventListener('touchmove', onTouchMove);
footer.addEventListener('touchend', onTouchEnd);
return () => {
footer.removeEventListener('touchstart', handleTouchStart);
footer.removeEventListener('touchmove', handleTouchMove);
footer.removeEventListener('touchend', handleTouchEnd);
footer.removeEventListener('touchstart', onTouchStart);
footer.removeEventListener('touchmove', onTouchMove);
footer.removeEventListener('touchend', onTouchEnd);
};
}, [isPulling, isExpanded]);

return { isExpanded, isPulling, pullDistance, footerRef };
}

export function Footer() {
const currentYear = new Date().getFullYear();
const { isExpanded, isPulling, pullDistance, footerRef } = useFooterState();

return (
<footer
ref={footerRef}
Expand Down
Loading
Loading