init
This commit is contained in:
31
src/features/core/components/animated-outlet.tsx
Normal file
31
src/features/core/components/animated-outlet.tsx
Normal 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;
|
||||
25
src/features/core/components/back-button.tsx
Normal file
25
src/features/core/components/back-button.tsx
Normal 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;
|
||||
26
src/features/core/components/header.tsx
Normal file
26
src/features/core/components/header.tsx
Normal 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;
|
||||
53
src/features/core/components/layout.tsx
Normal file
53
src/features/core/components/layout.tsx
Normal 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;
|
||||
1
src/features/core/components/nav-link/index.ts
Normal file
1
src/features/core/components/nav-link/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './nav-link';
|
||||
28
src/features/core/components/nav-link/nav-link.tsx
Normal file
28
src/features/core/components/nav-link/nav-link.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
src/features/core/components/nav-link/styles.module.css
Normal file
6
src/features/core/components/nav-link/styles.module.css
Normal file
@@ -0,0 +1,6 @@
|
||||
.navLinkBox {
|
||||
text-decoration: none;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
color: unset;
|
||||
width: fit-content;
|
||||
}
|
||||
39
src/features/core/components/navbar.tsx
Normal file
39
src/features/core/components/navbar.tsx
Normal 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;
|
||||
124
src/features/core/components/pullable.tsx
Normal file
124
src/features/core/components/pullable.tsx
Normal 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;
|
||||
27
src/features/core/hooks/use-header-config.ts
Normal file
27
src/features/core/hooks/use-header-config.ts
Normal 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;
|
||||
38
src/features/core/hooks/use-links.ts
Normal file
38
src/features/core/hooks/use-links.ts
Normal 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]);
|
||||
24
src/features/core/hooks/use-refresh-config.ts
Normal file
24
src/features/core/hooks/use-refresh-config.ts
Normal 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;
|
||||
31
src/features/core/hooks/use-visual-viewport-size.ts
Normal file
31
src/features/core/hooks/use-visual-viewport-size.ts
Normal 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;
|
||||
7
src/features/core/types/header-config.ts
Normal file
7
src/features/core/types/header-config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
interface HeaderConfig {
|
||||
title?: string;
|
||||
withBackButton?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
export type { HeaderConfig };
|
||||
9
src/features/core/types/route-config.ts
Normal file
9
src/features/core/types/route-config.ts
Normal 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 };
|
||||
11
src/features/core/utils/fake-scheme-manager.ts
Normal file
11
src/features/core/utils/fake-scheme-manager.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import {
|
||||
MantineColorSchemeManager,
|
||||
} from '@mantine/core';
|
||||
|
||||
export const fakeColorSchemeManager: MantineColorSchemeManager = {
|
||||
get: (defaultValue) => defaultValue,
|
||||
set: (value) => { },
|
||||
subscribe: (onUpdate) => { },
|
||||
unsubscribe: () => { },
|
||||
clear: () => { },
|
||||
}
|
||||
Reference in New Issue
Block a user