This commit is contained in:
yohlo
2025-08-20 22:35:40 -05:00
commit f51c278cd3
169 changed files with 8173 additions and 0 deletions

View File

@@ -0,0 +1,124 @@
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 useRefreshConfig from "@/features/core/hooks/use-refresh-config";
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
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
* TODO: Need to figure out why it isn't disabled when onRefresh is undefined
*/
const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => {
const height = useAppShellHeight();
const [isRefreshing, setIsRefreshing] = useState(false);
const [scrolling, setScrolling] = useState(false);
const { toRefresh } = useRefreshConfig();
const queryClient = useQueryClient();
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
const onTrigger = useCallback(async () => {
setIsRefreshing(true);
if (toRefresh.length > 0) {
// TODO: Remove this after testing - or does the delay help ux?
await new Promise(resolve => setTimeout(resolve, 1000));
await queryClient.refetchQueries({ queryKey: toRefresh, exact: true});
}
setIsRefreshing(false);
}, [toRefresh]);
useEffect(() => {
if (!isRefreshing && scrollY > THRESHOLD) {
onTrigger();
}
}, [scrollY, isRefreshing, onTrigger]);
const iconOpacity = useMemo(() => {
if (isRefreshing) return 1;
if (toRefresh.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();
}, []);
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
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' }}>
{ /* TODO: Remove this debug button */}
<ActionIcon style={{ zIndex: 1000 }} pos='absolute' top={8} left='calc(50% - 24px)' onClick={onTrigger} variant='filled' color='var(--mantine-color-dimmed)'>
<ArrowClockwiseIcon />
</ActionIcon>
{children}
</Box>
</ScrollArea>
</>
)
}
export default Pullable;