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,31 @@
import { Outlet, useRouter } from '@tanstack/react-router';
import { AnimatePresence, motion } from 'framer-motion';
const AnimatedOutlet = () => {
const router = useRouter();
return (
<AnimatePresence mode="wait">
<motion.div
key={router.state.location.pathname}
initial={{ x: '100%', opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: '-100%', opacity: 0 }}
transition={{
type: 'tween',
duration: 0.3,
ease: 'easeInOut'
}}
style={{
position: 'absolute',
width: '100%',
height: '100%'
}}
>
<Outlet />
</motion.div>
</AnimatePresence>
);
}
export default AnimatedOutlet;

View File

@@ -0,0 +1,25 @@
import { Box } from "@mantine/core"
import { ArrowLeftIcon } from "@phosphor-icons/react"
import { useRouter } from "@tanstack/react-router"
interface BackButtonProps {
offsetY: number;
}
const BackButton = ({ offsetY }: BackButtonProps) => {
const router = useRouter()
return (
<Box
style={{ cursor: 'pointer', zIndex: 1000, transform: `translateY(-${offsetY}px)` }}
onClick={() => router.history.back()}
pos='absolute'
left={{ base: 0, sm: 100, md: 200, lg: 300 }}
m={20}
>
<ArrowLeftIcon weight='bold' size={20} />
</Box>
);
}
export default BackButton;

View File

@@ -0,0 +1,26 @@
import { Title, AppShell, Flex } from "@mantine/core";
import { HeaderConfig } from "../types/header-config";
import BackButton from "./back-button";
import { useMemo } from "react";
interface HeaderProps extends HeaderConfig {
scrollPosition: { x: number, y: number };
}
const Header = ({ withBackButton, collapsed, title, scrollPosition }: HeaderProps) => {
const offsetY = useMemo(() => {
return collapsed ? scrollPosition.y : 0;
}, [collapsed, scrollPosition.y]);
return (
<>
{withBackButton && <BackButton offsetY={offsetY} />}
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
<Flex justify='center' align='center' h='100%' px='md'>
<Title order={2}>{title}</Title>
</Flex>
</AppShell.Header>
</>
);
}
export default Header;

View File

@@ -0,0 +1,53 @@
import { AppShell } from '@mantine/core';
import { PropsWithChildren, useState } from 'react';
import Header from './header';
import Navbar from './navbar';
import useHeaderConfig from '../hooks/use-header-config';
import Pullable from './pullable';
import useVisualViewportSize from '../hooks/use-visual-viewport-size';
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const headerConfig = useHeaderConfig();
const viewport = useVisualViewportSize();
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
return (
<AppShell
id='app-shell'
layout='alt'
header={{ height: 60, collapsed: headerConfig.collapsed }}
navbar={{
width: { base: 0, sm: 100, md: 200, lg: 300 },
breakpoint: 'sm',
collapsed: { mobile: true },
}}
aside={{
width: { base: 0, sm: 100, md: 200, lg: 300 },
breakpoint: 'sm',
collapsed: { desktop: false, mobile: true }
}}
pos='relative'
h='100dvh'
mah='100dvh'
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
>
<Header scrollPosition={scrollPosition} {...headerConfig} />
<AppShell.Main
pos='relative'
h='100%'
mah='100%'
pb={{ base: 70, md: 0 }}
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
style={{ transition: 'none' }}
>
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}>
{children}
</Pullable>
</AppShell.Main>
<Navbar />
<AppShell.Aside withBorder />
</AppShell>
);
}
export default Layout;

View File

@@ -0,0 +1 @@
export * from './nav-link';

View File

@@ -0,0 +1,28 @@
import { Flex, Box, Text } from "@mantine/core";
import { Link, useRouterState } from "@tanstack/react-router";
import styles from './styles.module.css';
import { Icon } from "@phosphor-icons/react";
import { useMemo } from "react";
interface NavLinkProps {
href: string;
label: string;
Icon: Icon;
}
export const NavLink = ({ href, label, Icon }: NavLinkProps) => {
const router = useRouterState();
const isActive = useMemo(() => router.location.pathname === href || (router.location.pathname.includes(href) && href !== '/'), [router.location.pathname, href]);
return (
<Box component={Link} to={href}
className={styles.navLinkBox}
p={{ base: 0, sm: 8 }}
>
<Flex direction={{ base: 'column', md: 'row' }} align='center' gap={{ base: 0, md: 'xs' }}>
<Icon weight={isActive ? 'fill' : 'regular'} size={28} style={{ color: isActive ? 'var(--mantine-primary-color-filled)' : undefined }} />
<Text visibleFrom='md' ta='center' size='md' fw={isActive ? 800 : 500} c={isActive ? 'var(--mantine-primary-color-filled)' : undefined}>{label}</Text>
</Flex>
</Box>
)
}

View File

@@ -0,0 +1,6 @@
.navLinkBox {
text-decoration: none;
border-radius: var(--mantine-radius-md);
color: unset;
width: fit-content;
}

View File

@@ -0,0 +1,39 @@
import { AppShell, ScrollArea, Stack, Group, Paper } from "@mantine/core";
import { Link } from "@tanstack/react-router";
import { NavLink } from "./nav-link";
import { useIsMobile } from "@/hooks/use-is-mobile";
import { useAuth } from "@/contexts/auth-context";
import { useLinks } from "../hooks/use-links";
const Navbar = () => {
const { user, roles } = useAuth()
const isMobile = useIsMobile();
const links = useLinks(user?.id, roles);
console.log('rendered')
if (isMobile) return (
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 2rem)' shadow='sm' pos='fixed' m='1rem' bottom='0' style={{ zIndex: 10 }}>
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
</Group>
</Paper>
)
return <AppShell.Navbar p="xs" role='navigation'>
<AppShell.Section grow component={ScrollArea}>
<Stack gap='xs' mx='auto' w='fit-content' justify='end' mt='md'>
{links.map((link) => (
<NavLink key={link.href} {...link} />
))}
</Stack>
</AppShell.Section>
<AppShell.Section>
<Link to="/logout">Logout</Link>
</AppShell.Section>
</AppShell.Navbar>
}
export default Navbar;

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;

View File

@@ -0,0 +1,27 @@
import { isMatch, useMatches } from "@tanstack/react-router";
import { HeaderConfig } from "../types/header-config";
export const defaultHeaderConfig: HeaderConfig = {
title: 'Starter App',
withBackButton: false,
collapsed: false,
}
const useHeaderConfig = () => {
const matches = useMatches();
const matchesWithHeader = matches.filter((match) =>
isMatch(match, 'loaderData.header'),
)
const config = matchesWithHeader.reduce((acc, match) => {
return {
...acc,
...match?.loaderData?.header,
}
}, defaultHeaderConfig) as HeaderConfig;
return config;
}
export default useHeaderConfig;

View File

@@ -0,0 +1,38 @@
import { GearIcon, HouseIcon, QuestionIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
export const useLinks = (userId: number, roles: string[]) =>
useMemo(() => {
const links = [
{
label: 'Home',
href: '/',
Icon: HouseIcon
},
{
label: 'Tournaments',
href: '/tournaments',
Icon: TrophyIcon
},
{
label: 'Profile',
href: `/profile/${userId}`,
Icon: UserCircleIcon
},
{
label: 'Settings',
href: '/settings',
Icon: GearIcon
}
]
if (roles.includes('Admin')) {
links.push({
label: 'Admin',
href: '/admin',
Icon: ShieldIcon
})
}
return links;
}, [userId, roles]);

View File

@@ -0,0 +1,24 @@
import { isMatch, useMatches } from "@tanstack/react-router";
export const defaultRefreshConfig: { toRefresh: string[] } = {
toRefresh: [],
}
const useRefreshConfig = () => {
const matches = useMatches();
const matchesWithRefresh = matches.filter((match) =>
isMatch(match, 'loaderData.refresh'),
)
const config = matchesWithRefresh.reduce((acc, match) => {
return {
...acc,
...match?.loaderData?.refresh,
}
}, defaultRefreshConfig) as { toRefresh: string[] };
return config;
}
export default useRefreshConfig;

View File

@@ -0,0 +1,31 @@
import { useCallback, useEffect, useState } from 'react';
const eventListerOptions = {
passive: true,
};
const useVisualViewportSize = () => {
const windowExists = typeof window !== 'undefined';
const [windowSize, setWindowSize] = useState({
width: windowExists ? window.visualViewport?.width || 0 : 0,
height: windowExists ? window.visualViewport?.height || 0 : 0,
top: windowExists ? window.visualViewport?.offsetTop || 0 : 0,
});
const setSize = useCallback(() => {
if (!windowExists) return;
setWindowSize({ width: window.visualViewport?.width || 0, height: window.visualViewport?.height || 0, top: window.visualViewport?.offsetTop || 0 });
}, []);
useEffect(() => {
if (!windowExists) return;
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
return () => {
window.visualViewport?.removeEventListener('resize', setSize);
}
}, []);
return windowSize;
}
export default useVisualViewportSize;

View File

@@ -0,0 +1,7 @@
interface HeaderConfig {
title?: string;
withBackButton?: boolean;
collapsed?: boolean;
}
export type { HeaderConfig };

View File

@@ -0,0 +1,9 @@
import { HeaderConfig } from "./header-config";
interface RouteConfig {
header?: HeaderConfig;
refreshQueryKeys?: string[];
padding?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | number;
}
export type { RouteConfig };

View File

@@ -0,0 +1,11 @@
import {
MantineColorSchemeManager,
} from '@mantine/core';
export const fakeColorSchemeManager: MantineColorSchemeManager = {
get: (defaultValue) => defaultValue,
set: (value) => { },
subscribe: (onUpdate) => { },
unsubscribe: () => { },
clear: () => { },
}