Skip to content
Open
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
5 changes: 3 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { NavBar } from 'ui/nav_bar/nav_bar';
import './globals.css';
import styles from './layout.module.css';
import { MaintenanceBanner } from 'app/maintenance_banner';
import { SkeletonProvider } from 'app/skeleton_provider';

export const metadata: Metadata = {
title: 'ParaDB',
Expand All @@ -26,13 +27,13 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<body>
<ApiProvider>
<SessionProvider>
<div className={styles.skeleton}>
<SkeletonProvider className={styles.skeleton}>
{flags.get('showMaintenanceBanner') ? (
<MaintenanceBanner message={flags.get('maintenanceBannerMessage')} />
) : null}
<NavBar />
<div className={styles.content}>{children}</div>
</div>
</SkeletonProvider>
</SessionProvider>
</ApiProvider>
</body>
Expand Down
16 changes: 13 additions & 3 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import classNames from 'classnames';
import { action, computed, observable, reaction } from 'mobx';
import { observer } from 'mobx-react';
import { useSearchParams } from 'next/navigation';
import React, { Suspense, useState } from 'react';
import React, { Suspense, useEffect, useState } from 'react';
import { Difficulty, PDMap } from 'schema/maps';
import { Button } from 'ui/base/button/button';
import { metrics } from 'ui/base/design_system/design_tokens';
Expand All @@ -18,6 +18,8 @@ import { KnownDifficulty, difficultyColors, parseDifficulty } from 'utils/diffic
import { RoutePath, routeFor } from 'utils/routes';
import styles from './page.module.css';
import { Search } from './search';
import useInfiniteScroll from 'hooks/useInfiniteScroll';
import { useSkeletonRef } from 'app/skeleton_provider';

export default function Page() {
return (
Expand Down Expand Up @@ -62,7 +64,15 @@ const Home = observer(() => {
);
const [presenter] = useState(() => new MapListPresenter(api, store));

React.useEffect(() => {
const skeletonRef = useSkeletonRef();

useInfiniteScroll(skeletonRef, () => {
if (store.hasMore && !store.loadingMore) {
presenter.onLoadMore();
}
});

useEffect(() => {
if (store.maps == null) {
presenter.onSearch('search');
}
Expand Down Expand Up @@ -122,7 +132,7 @@ const MapListTable = observer((props: { store: MapListStore; presenter: MapListP
const scrollableTable = observable.box(false);
const tableScrollContainerRef = React.createRef<HTMLDivElement>();
// TODO: avoid mixing mobx and hooks
React.useEffect(() => {
useEffect(() => {
const dispose = reaction(
() => store.maps,
() => {
Expand Down
33 changes: 33 additions & 0 deletions src/app/skeleton_provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
'use client';

import React, { createContext, useContext, useRef, RefObject } from 'react';

type SkeletonContextValue = RefObject<HTMLDivElement | null>;

const SkeletonContext = createContext<SkeletonContextValue | null>(null);

export function useSkeletonRef(): SkeletonContextValue {
const context = useContext(SkeletonContext);
if (!context) {
throw new Error('useSkeletonRef must be used within a SkeletonProvider');
}
return context;
}

export function SkeletonProvider({
children,
className,
}: {
children: React.ReactNode;
className: string;
}) {
const skeletonRef = useRef<HTMLDivElement | null>(null);

return (
<SkeletonContext.Provider value={skeletonRef}>
<div id="skeleton" ref={skeletonRef} className={className}>
{children}
</div>
</SkeletonContext.Provider>
);
}
74 changes: 74 additions & 0 deletions src/hooks/useInfiniteScroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { RefObject, useEffect, useEffectEvent, useRef } from 'react';

/**
* The space in pixels from the bottom of the container to trigger the callback.
*/
export const DEFAULT_THRESHOLD = 100;

/**
* Default debounce delay in milliseconds.
*/
export const DEFAULT_DEBOUNCE_MS = 150;

export type ScrollContainerRef = RefObject<HTMLElement | null> | (() => HTMLElement | null);

export interface UseInfiniteScrollOptions {
/** The distance from the bottom of the container to trigger the callback. */
threshold?: number;
/** Debounce delay in milliseconds. */
debounceMs?: number;
}

/**
* Hook to handle infinite scrolling on a scrollable container.
* It calls the provided callback when the user scrolls near the bottom of the container.
* @param scrollContainerRef - A ref or getter function for the scrollable container element.
* @param callback - The function to call when the user scrolls near the bottom.
* @param options - Optional configuration (threshold).
*/
const useInfiniteScroll = (
scrollContainerRef: ScrollContainerRef,
callback: () => void,
options: UseInfiniteScrollOptions = {}
) => {
const { threshold = DEFAULT_THRESHOLD, debounceMs = DEFAULT_DEBOUNCE_MS } = options;
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const handleScrollCallback = useEffectEvent(callback);

useEffect(() => {
const target =
typeof scrollContainerRef === 'function' ? scrollContainerRef() : scrollContainerRef.current;

if (!target) {
return;
}

const handleScroll = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}

timeoutRef.current = setTimeout(() => {
const scrollTop = target.scrollTop;
const scrollHeight = target.scrollHeight;
const clientHeight = target.clientHeight;

if (scrollHeight - scrollTop <= clientHeight + threshold) {
handleScrollCallback();
}
}, debounceMs);
};

target.addEventListener('scroll', handleScroll);

return () => {
target.removeEventListener('scroll', handleScroll);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [scrollContainerRef, threshold, debounceMs]);
};

export default useInfiniteScroll;