142 lines
4.4 KiB
TypeScript
142 lines
4.4 KiB
TypeScript
import { ActionIcon, Box, Button, Flex, ScrollArea } from "@mantine/core";
|
|
import { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import useAppShellHeight from "@/hooks/use-appshell-height";
|
|
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
|
|
import { useQueryClient } from "@tanstack/react-query";
|
|
import useRouterConfig from "../hooks/use-router-config";
|
|
import { useLocation } from "@tanstack/react-router";
|
|
|
|
const THRESHOLD = 80;
|
|
|
|
interface PullableProps extends PropsWithChildren {
|
|
scrollPosition: { x: number, y: number };
|
|
onScrollPositionChange: (position: { x: number, y: number }) => void;
|
|
}
|
|
|
|
/**
|
|
* Pullable is a component that allows the user to pull down to refresh the page
|
|
*/
|
|
const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => {
|
|
const height = useAppShellHeight();
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [scrolling, setScrolling] = useState(false);
|
|
const { refresh } = useRouterConfig();
|
|
const queryClient = useQueryClient();
|
|
const location = useLocation();
|
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
|
|
|
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
|
|
|
|
const onTrigger = useCallback(async () => {
|
|
setIsRefreshing(true);
|
|
if (refresh.length > 0) {
|
|
// TODO: Remove this after testing - or does the delay help ux?
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
refresh.forEach(async (queryKey) => {
|
|
const keyArray = Array.isArray(queryKey) ? queryKey : [queryKey];
|
|
await queryClient.refetchQueries({ queryKey: keyArray, exact: true });
|
|
});
|
|
}
|
|
setIsRefreshing(false);
|
|
}, [refresh]);
|
|
|
|
useEffect(() => {
|
|
if (!isRefreshing && scrollY > THRESHOLD) {
|
|
onTrigger();
|
|
}
|
|
}, [scrollY, isRefreshing, onTrigger]);
|
|
|
|
const iconOpacity = useMemo(() => {
|
|
if (isRefreshing) return 1;
|
|
if (refresh.length === 0) return 0;
|
|
const clampedValue = Math.max(5, Math.min(THRESHOLD, scrollY));
|
|
|
|
const min = 5;
|
|
const max = THRESHOLD;
|
|
const range = max - min;
|
|
|
|
return (clampedValue - min) / range;
|
|
}, [scrollY, isRefreshing])
|
|
|
|
useEffect(() => {
|
|
const scrollWrapper = document.getElementById('scroll-wrapper');
|
|
if (scrollWrapper) {
|
|
scrollWrapper.addEventListener('touchstart', () => {
|
|
setScrolling(true);
|
|
});
|
|
scrollWrapper.addEventListener('touchend', () => {
|
|
setScrolling(false);
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (typeof window === 'undefined') return;
|
|
|
|
const ac = new AbortController();
|
|
const options = {
|
|
passive: true,
|
|
signal: ac.signal
|
|
};
|
|
|
|
window.addEventListener('touchstart', () => setScrolling(true), options);
|
|
window.addEventListener('touchend', () => setScrolling(false), options);
|
|
|
|
return () => void ac.abort();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
const timeoutId = setTimeout(() => {
|
|
if (scrollAreaRef.current) {
|
|
const viewport = scrollAreaRef.current.querySelector('.mantine-ScrollArea-viewport') as HTMLElement;
|
|
if (viewport) {
|
|
viewport.scrollTop = 0;
|
|
viewport.scrollLeft = 0;
|
|
}
|
|
}
|
|
onScrollPositionChange({ x: 0, y: 0 });
|
|
}, 10);
|
|
|
|
return () => clearTimeout(timeoutId);
|
|
}, [location.pathname, onScrollPositionChange]);
|
|
|
|
|
|
return (
|
|
<>
|
|
<Flex
|
|
pos='absolute'
|
|
justify='center'
|
|
align='center'
|
|
w='100%'
|
|
display={scrollY > 20 || isRefreshing ? 'flex' : 'none'}
|
|
opacity={iconOpacity}
|
|
style={{ zIndex: 10 }}
|
|
>
|
|
<SpinnerIcon
|
|
weight="bold"
|
|
size={iconOpacity * 28}
|
|
color='var(--mantine-color-dimmed)'
|
|
style={{
|
|
marginTop: 8,
|
|
transform: iconOpacity === 1 ? undefined : `rotate(${iconOpacity * 360}deg)`,
|
|
animation: iconOpacity === 1 ? 'spin 1s linear infinite' : undefined,
|
|
}}
|
|
/>
|
|
</Flex>
|
|
<ScrollArea
|
|
ref={scrollAreaRef}
|
|
id='scroll-wrapper'
|
|
onScrollPositionChange={onScrollPositionChange}
|
|
type='never' mah='100%' h='100%'
|
|
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
|
|
>
|
|
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}>
|
|
{children}
|
|
</Box>
|
|
</ScrollArea>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export default Pullable;
|