Compare commits

12 Commits

Author SHA1 Message Date
yohlo
75479be334 router config changes 2025-08-26 22:47:25 -05:00
yohlo
fcdb33a4b6 admin progress 2025-08-26 21:50:56 -05:00
yohlo
7226fb33f4 refactor swipeable tabs 2025-08-25 23:50:36 -05:00
yohlo
f5e4e5b214 umami 2025-08-25 23:39:40 -05:00
yohlo
7b0153e04f umami 2025-08-25 23:32:23 -05:00
yohlo
38fb060b78 better admin page/list link 2025-08-25 22:49:19 -05:00
yohlo
555d79b6db update nav links 2025-08-25 22:38:08 -05:00
yohlo
c9df4947bd swipeable tabs use url 2025-08-25 22:22:33 -05:00
yohlo
44417d063b basic team profile 2025-08-25 22:08:43 -05:00
yohlo
d845254c3d fix key error in skeleton loaders 2025-08-25 19:38:21 -05:00
yohlo
4faa853c4c fix hydration warning 2025-08-25 19:36:47 -05:00
yohlo
ce63c02d8e fix appshell height 2025-08-25 19:33:47 -05:00
33 changed files with 351 additions and 229 deletions

View File

@@ -23,6 +23,7 @@ import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_aut
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
import { ServerRoute as ApiTestServerRouteImport } from './routes/api/test'
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
@@ -92,6 +93,12 @@ const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
path: '/preview',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsIndexRoute =
AuthedAdminTournamentsIndexRouteImport.update({
id: '/tournaments/',
path: '/tournaments/',
getParentRoute: () => AuthedAdminRoute,
} as any)
const AuthedAdminTournamentsIdRoute =
AuthedAdminTournamentsIdRouteImport.update({
id: '/tournaments/$id',
@@ -139,6 +146,7 @@ export interface FileRoutesByFullPath {
'/admin/': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
@@ -152,6 +160,7 @@ export interface FileRoutesByTo {
'/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -168,6 +177,7 @@ export interface FileRoutesById {
'/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -184,6 +194,7 @@ export interface FileRouteTypes {
| '/admin/'
| '/tournaments'
| '/admin/tournaments/$id'
| '/admin/tournaments'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
@@ -197,6 +208,7 @@ export interface FileRouteTypes {
| '/admin'
| '/tournaments'
| '/admin/tournaments/$id'
| '/admin/tournaments'
id:
| '__root__'
| '/_authed'
@@ -212,6 +224,7 @@ export interface FileRouteTypes {
| '/_authed/admin/'
| '/_authed/tournaments/'
| '/_authed/admin/tournaments/$id'
| '/_authed/admin/tournaments/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -359,6 +372,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminPreviewRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/tournaments/': {
id: '/_authed/admin/tournaments/'
path: '/tournaments'
fullPath: '/admin/tournaments'
preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport
parentRoute: typeof AuthedAdminRoute
}
'/_authed/admin/tournaments/$id': {
id: '/_authed/admin/tournaments/$id'
path: '/tournaments/$id'
@@ -412,12 +432,14 @@ interface AuthedAdminRouteChildren {
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
}
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
}
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(

View File

@@ -3,7 +3,7 @@ import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routerWithQueryClient } from '@tanstack/react-router-with-query'
import { routeTree } from './routeTree.gen'
import { DefaultCatchBoundary } from '../components/DefaultCatchBoundary'
import { defaultHeaderConfig } from '@/features/core/hooks/use-header-config'
import { defaultHeaderConfig } from '@/features/core/hooks/use-router-config'
export function createRouter() {
const queryClient = new QueryClient({
@@ -21,7 +21,7 @@ export function createRouter() {
return routerWithQueryClient(
createTanStackRouter({
routeTree,
context: { queryClient, auth: undefined!, header: defaultHeaderConfig, refresh: { toRefresh: [] } },
context: { queryClient, auth: undefined!, header: defaultHeaderConfig, refresh: [], withPadding: true },
defaultPreload: 'intent',
defaultErrorComponent: DefaultCatchBoundary,
scrollRestoration: true,

View File

@@ -21,7 +21,8 @@ export const Route = createRootRouteWithContext<{
queryClient: QueryClient,
auth: AuthContextType,
header: HeaderConfig,
refresh: { toRefresh: string[] }
refresh: string[]
withPadding: boolean
}>()({
head: () => ({
meta: [
@@ -89,6 +90,7 @@ function RootComponent() {
)
}
// todo: analytics -> process.env data-website-id
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html {...mantineHtmlProps} style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
@@ -96,6 +98,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<HeadContent />
<ColorSchemeScript />
<link rel="stylesheet" href="/styles.css" />
<script defer src="https://analytics.yohler.net/script.js" data-website-id="0280f304-17a6-400c-8021-4d83a62d0c1b"></script>
</head>
<body style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
<div className='app'>

View File

@@ -1,22 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"
import { Title } from "@mantine/core";
import Page from "@/components/page";
import { playerQueries } from "@/features/players/queries";
import { useQuery } from "@tanstack/react-query";
import PlayerList from "@/features/players/components/player-list";
import { AdminPage } from "@/features/admin";
export const Route = createFileRoute("/_authed/admin/")({
loader: async ({ context }) => {
const { queryClient } = context;
await queryClient.ensureQueryData(playerQueries.list())
},
component: RouteComponent,
component: () => <AdminPage />,
})
function RouteComponent() {
const { data: players, isLoading } = useQuery(playerQueries.list());
return <Page>
<Title order={2} mb='md'>Players</Title>
<PlayerList players={players!} loading={isLoading} />
</Page>
}

View File

@@ -0,0 +1,24 @@
import Page from "@/components/page";
import ManageTournaments from "@/features/admin/components/manage-tournaments";
import { tournamentQueries } from "@/features/tournaments/queries";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/_authed/admin/tournaments/")({
beforeLoad: async ({ context }) => {
const { queryClient } = context;
await queryClient.ensureQueryData(tournamentQueries.list());
},
loader: () => ({
header: {
withBackButton: true,
title: "Manage Tournaments",
},
refresh: tournamentQueries.list().queryKey,
withPadding: false
}),
component: RouteComponent,
});
function RouteComponent() {
return <ManageTournaments />
}

View File

@@ -10,11 +10,14 @@ export const Route = createFileRoute("/_authed/")({
beforeLoad: async ({ context }) => {
await context.queryClient.ensureQueryData(tournamentQueries.list());
},
loader: () => ({
withPadding: false
})
});
function Home() {
return (
<Page noPadding>
<>
<Box h='60vh' p="md">
<Text m='16vh' fw={500}>Some Content Here</Text>
</Box>
@@ -24,6 +27,6 @@ function Home() {
<Divider />
<ListLink label="All Tournaments" to="/tournaments" Icon={TrophyIcon} />
</Box>
</Page>
</>
);
}

View File

@@ -2,8 +2,14 @@ import Page from "@/components/page";
import Profile from "@/features/players/components/profile";
import { playerQueries } from "@/features/players/queries";
import { redirect, createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const searchSchema = z.object({
tab: z.string().optional(),
});
export const Route = createFileRoute("/_authed/profile/$playerId")({
validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => {
const { queryClient } = context;
const player = await queryClient.ensureQueryData(playerQueries.details(params.playerId))
@@ -18,12 +24,10 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
withBackButton: true,
settingsLink: context?.auth.user.id === params.playerId ? 'settings' : undefined
},
refresh: {
toRefresh: [playerQueries.details(params.playerId).queryKey],
}
refresh: [playerQueries.details(params.playerId).queryKey]
}),
component: () => {
const { player } = Route.useRouteContext();
return <Page><Profile player={player} /></Page>
return <Profile player={player} />
},
})

View File

@@ -4,7 +4,6 @@ import { ColorSchemePicker } from "@/features/settings/components/color-scheme-p
import AccentColorPicker from "@/features/settings/components/accent-color-picker"
import { SignOutIcon } from "@phosphor-icons/react"
import ListLink from "@/components/list-link"
import Page from "@/components/page"
export const Route = createFileRoute("/_authed/settings")({
loader: () => ({
@@ -12,12 +11,13 @@ export const Route = createFileRoute("/_authed/settings")({
title: 'Settings',
withBackButton: true,
},
withPadding: false
}),
component: RouteComponent,
})
function RouteComponent() {
return <Page noPadding>
return <>
<Box px='md' py='sm' style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
<Title order={3}>Appearance</Title>
<Stack>
@@ -30,5 +30,5 @@ function RouteComponent() {
to='/logout'
Icon={SignOutIcon}
/>
</Page>
</>
}

View File

@@ -2,8 +2,14 @@ import Page from "@/components/page";
import TeamProfile from "@/features/teams/components/team-profile";
import { teamQueries } from "@/features/teams/queries";
import { redirect, createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const searchSchema = z.object({
tab: z.string().optional(),
});
export const Route = createFileRoute("/_authed/teams/$teamId")({
validateSearch: searchSchema,
beforeLoad: async ({ params, context }) => {
const { queryClient } = context;
const team = await queryClient.ensureQueryData(teamQueries.details(params.teamId))
@@ -15,12 +21,10 @@ export const Route = createFileRoute("/_authed/teams/$teamId")({
collapsed: true,
withBackButton: true
},
refresh: {
toRefresh: [teamQueries.details(params.teamId).queryKey],
}
refresh: [teamQueries.details(params.teamId).queryKey]
}),
component: () => {
const { team } = Route.useRouteContext();
return <Page><TeamProfile team={team} /></Page>
return <TeamProfile team={team} />
},
})

View File

@@ -10,8 +10,14 @@ import TeamList from '@/features/teams/components/team-list';
import Button from '@/components/button';
import Avatar from '@/components/avatar';
import Profile from '@/features/tournaments/components/profile';
import { z } from "zod";
const searchSchema = z.object({
tab: z.string().optional(),
});
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
validateSearch: searchSchema,
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
await queryClient.ensureQueryData(tournamentQueries.details(params.tournamentId))
@@ -22,16 +28,13 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
withBackButton: true,
settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined
},
refresh: {
toRefresh: tournamentQueries.details(params.tournamentId).queryKey,
}
refresh: tournamentQueries.details(params.tournamentId).queryKey,
withPadding: false
}),
component: RouteComponent,
})
function RouteComponent() {
const { data: tournament } = useQuery(tournamentQueries.details(Route.useParams().tournamentId));
return <Page noPadding>
<Profile tournament={tournament!} />
</Page>
return <Profile tournament={tournament!} />
}

View File

@@ -21,9 +21,7 @@ export const Route = createFileRoute('/_authed/tournaments/')({
withBackButton: true,
title: 'Tournaments',
},
refresh: {
toRefresh: tournamentQueries.list().queryKey,
}
refresh: tournamentQueries.list().queryKey
}),
component: RouteComponent,
})

View File

@@ -0,0 +1,30 @@
import { Divider, Group, Text, UnstyledButton } from "@mantine/core";
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
interface ListButtonProps {
label: string;
Icon: Icon;
handleClick: () => void;
}
const ListButton = ({ label, handleClick, Icon }: ListButtonProps) => {
return (
<>
<UnstyledButton
w='100%'
p='md'
component={'button'}
onClick={handleClick}
>
<Group>
<Icon weight='bold' size={20} />
<Text fw={500} size='md'>{label}</Text>
<CaretRightIcon style={{ marginLeft: 'auto' }} size={20} />
</Group>
</UnstyledButton>
<Divider />
</>
)
}
export default ListButton;

View File

@@ -1,4 +1,4 @@
import { NavLink, Text } from "@mantine/core";
import { Divider, NavLink, Text } from "@mantine/core";
import { CaretRightIcon, Icon } from "@phosphor-icons/react";
import { Link, useNavigate } from "@tanstack/react-router";
@@ -12,15 +12,18 @@ const ListLink = ({ label, to, Icon }: ListLinkProps) => {
const navigate = useNavigate();
return (
<NavLink
w='100%'
p='md'
component={'button'}
onClick={() => navigate({ to })}
label={<Text fw={500} size='md'>{label}</Text>}
leftSection={<Icon weight='bold' size={20} />}
rightSection={<CaretRightIcon size={20} />}
/>
<>
<NavLink
w='100%'
p='md'
component={'button'}
onClick={() => navigate({ to })}
label={<Text fw={500} size='md'>{label}</Text>}
leftSection={<Icon weight='bold' size={20} />}
rightSection={<CaretRightIcon size={20} />}
/>
<Divider />
</>
)
}

View File

@@ -1,13 +1,13 @@
import { Container, ContainerProps } from "@mantine/core";
import useHeaderConfig from "@/features/core/hooks/use-header-config";
import useRouterConfig from "@/features/core/hooks/use-router-config";
interface PageProps extends ContainerProps, React.PropsWithChildren {
noPadding?: boolean;
}
const Page = ({ children, noPadding, ...props }: PageProps) => {
const headerConfig = useHeaderConfig();
return <Container px={noPadding ? 0 : 'md'} pt={headerConfig.collapsed ? 60 : 0} pb={20} m={0} maw={600} mx='auto' {...props}>
const { header } = useRouterConfig();
return <Container px={noPadding ? 0 : 'md'} pt={header.collapsed ? 60 : 0} pb={20} m={0} maw={600} mx='auto' {...props}>
{children}
</Container>
}

View File

@@ -68,7 +68,7 @@ const SlidePanelField = ({
style={{
width: '100%',
border: error ? '1px solid var(--mantine-color-error)' : '1px solid var(--mantine-color-dimmed)',
borderRadius: 'var(--mantine-radius-lg)',
borderRadius: 'var(--mantine-radius-md)',
backgroundColor: 'var(--mantine-color-body)',
textAlign: 'left',
}}

View File

@@ -1,6 +1,7 @@
import { FloatingIndicator, UnstyledButton, Box, Text, ScrollArea } from "@mantine/core";
import { FloatingIndicator, UnstyledButton, Box, Text } from "@mantine/core";
import { Carousel } from "@mantine/carousel";
import { useState, useEffect, ReactNode, useRef } from "react";
import { useRouter } from "@tanstack/react-router";
interface TabItem {
label: string;
@@ -11,94 +12,71 @@ interface SwipeableTabsProps {
tabs: TabItem[];
defaultTab?: number;
onTabChange?: (index: number, tab: TabItem) => void;
scrollPosition?: { x: number; y: number };
}
function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: SwipeableTabsProps) {
const [activeTab, setActiveTab] = useState(defaultTab);
function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps) {
const router = useRouter();
const search = router.state.location.search as any;
const [embla, setEmbla] = useState<any>(null);
const getActiveTabFromUrl = () => {
const urlTab = search?.tab;
if (typeof urlTab === 'string') {
const tabIndex = tabs.findIndex(tab => tab.label.toLowerCase() === urlTab.toLowerCase());
return tabIndex !== -1 ? tabIndex : defaultTab;
}
return defaultTab;
};
const [activeTab, setActiveTab] = useState(getActiveTabFromUrl);
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const [controlsRefs, setControlsRefs] = useState<Record<number, HTMLSpanElement | null>>({});
const [isSticky, setIsSticky] = useState(false);
const tabsRef = useRef<HTMLDivElement | null>(null);
const originalPositionRef = useRef<number | null>(null);
const slideRefs = useRef<Record<number, HTMLDivElement | null>>({});
const [activeSlideHeight, setActiveSlideHeight] = useState<number | 'auto'>('auto');
const stickyThreshold = 0;
const [carouselHeight, setCarouselHeight] = useState<number | 'auto'>('auto');
const changeTab = (index: number) => {
if (index === activeTab || index < 0 || index >= tabs.length) return;
setActiveTab(index);
embla?.scrollTo(index);
onTabChange?.(index, tabs[index]);
const tabLabel = tabs[index].label.toLowerCase();
if (typeof window !== 'undefined') {
const url = new URL(window.location.href);
url.searchParams.set('tab', tabLabel);
window.history.replaceState(null, '', url.toString());
}
};
useEffect(() => {
if (!embla) return;
const onSelect = () => {
const newIndex = embla.selectedScrollSnap();
setActiveTab(newIndex);
// Update height based on active slide content
const activeSlideRef = slideRefs.current[newIndex];
if (activeSlideRef) {
const height = activeSlideRef.scrollHeight;
setActiveSlideHeight(height);
}
changeTab(newIndex);
};
embla.on("select", onSelect);
return () => {
embla.off("select", onSelect);
};
}, [embla]);
return () => embla.off("select", onSelect);
}, [embla, activeTab, tabs]);
useEffect(() => {
const newActiveTab = getActiveTabFromUrl();
if (newActiveTab !== activeTab) {
setActiveTab(newActiveTab);
embla?.scrollTo(newActiveTab);
}
}, [search?.tab]);
// Update height when activeTab changes
useEffect(() => {
const activeSlideRef = slideRefs.current[activeTab];
if (activeSlideRef) {
const height = activeSlideRef.scrollHeight;
setActiveSlideHeight(height);
setCarouselHeight(height);
}
}, [activeTab]);
useEffect(() => {
if (scrollPosition) {
setIsSticky(scrollPosition.y > stickyThreshold);
return;
}
const scrollWrapper = document.getElementById('scroll-wrapper');
let viewport = scrollWrapper!.querySelector('.mantine-ScrollArea-viewport') as HTMLElement;
const handleScroll = () => {
if (!tabsRef.current) return;
const scrollTop = viewport.scrollTop;
const viewportRect = viewport.getBoundingClientRect();
if (originalPositionRef.current === null && !isSticky) {
const tabsRect = tabsRef.current.getBoundingClientRect();
originalPositionRef.current = tabsRect.top - viewportRect.top + scrollTop;
}
setIsSticky(
originalPositionRef.current !== null
&& scrollTop >= (originalPositionRef.current - viewportRect.top - stickyThreshold)
);
};
handleScroll();
viewport.addEventListener('scroll', handleScroll, { passive: true });
return () => {
viewport.removeEventListener('scroll', handleScroll);
};
}, [stickyThreshold, isSticky, scrollPosition]);
const handleTabChange = (index: number) => {
if (index !== activeTab && index >= 0 && index < tabs.length) {
setActiveTab(index);
embla?.scrollTo(index);
onTabChange?.(index, tabs[index]);
}
};
const setControlRef = (index: number) => (node: HTMLSpanElement | null) => {
controlsRefs[index] = node;
setControlsRefs(controlsRefs);
@@ -110,24 +88,15 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
return (
<Box>
{isSticky && (
<Box style={{ height: '60px' }} />
)}
<Box
ref={(node) => {
setRootRef(node);
tabsRef.current = node;
}}
pos={isSticky ? "fixed" : "relative"}
ref={setRootRef}
pos="sticky"
top={0}
style={{
display: 'flex',
marginBottom: 'var(--mantine-spacing-md)',
top: isSticky ? 0 : 'auto',
left: isSticky ? 0 : 'auto',
right: isSticky ? 0 : 'auto',
zIndex: 100,
backgroundColor: 'var(--mantine-color-body)',
transition: 'all 200ms ease',
backgroundColor: 'var(--mantine-color-body)'
}}
>
<FloatingIndicator
@@ -143,7 +112,7 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
{tabs.map((tab, index) => (
<UnstyledButton
key={`${tab.label}-${index}`}
onClick={() => handleTabChange(index)}
onClick={() => changeTab(index)}
style={{
flex: 1,
padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-md)',
@@ -152,11 +121,10 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
? 'var(--mantine-color-blue-6)'
: 'var(--mantine-color-text)',
fontWeight: activeTab === index ? 600 : 400,
transition: 'color 200ms ease',
transition: 'color 200ms ease, font-weight 200ms ease',
backgroundColor: 'transparent',
border: 'none',
borderRadius: 0,
}}
>
<Text
@@ -183,19 +151,15 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
initialSlide={activeTab}
style={{
overflow: 'hidden',
height: activeSlideHeight === 'auto' ? 'auto' : `${activeSlideHeight}px`,
height: carouselHeight === 'auto' ? 'auto' : `${carouselHeight}px`,
transition: 'height 300ms ease'
}}
>
{tabs.map((tab, index) => (
<Carousel.Slide key={`${tab.label}-content-${index}`} style={{ height: 'auto' }}>
<Carousel.Slide key={`${tab.label}-content-${index}`}>
<Box
ref={setSlideRef(index)}
style={{
minHeight: 'fit-content',
height: 'auto',
visibility: index === activeTab ? 'visible' : 'hidden'
}}
style={{ height: 'auto' }}
>
{tab.content}
</Box>

View File

@@ -0,0 +1,18 @@
import { Title, List, Divider } from "@mantine/core";
import ListLink from "@/components/list-link";
import Page from "@/components/page";
import { TrophyIcon, UsersFourIcon, UsersThreeIcon } from "@phosphor-icons/react";
import ListButton from "@/components/list-button";
const AdminPage = () => {
return (
<Page noPadding>
<Title pl='sm' order={2} mb="md">Admin</Title>
<List>
<ListLink label="Manage Tournaments" Icon={TrophyIcon} to="/admin/tournaments" />
</List>
</Page>
);
};
export default AdminPage;

View File

@@ -0,0 +1,24 @@
import { List } from "@mantine/core";
import Page from "@/components/page";
import { TrophyIcon, UsersThreeIcon } from "@phosphor-icons/react";
import ListButton from "@/components/list-button";
import { useQuery, useSuspenseQuery } from "@tanstack/react-query";
import { tournamentQueries } from "@/features/tournaments/queries";
const ManageTournaments = () => {
const { data: tournaments } = useSuspenseQuery(tournamentQueries.list());
return (
<List>
<ListButton
label="Edit Enrolled Teams"
Icon={UsersThreeIcon}
handleClick={console.log}
/>
{tournaments.map(t => (
<ListButton label={t.name} Icon={TrophyIcon} handleClick={console.log} />
))}
</List>
);
};
export default ManageTournaments;

View File

@@ -1,3 +1,7 @@
import { Logger } from "@/lib/logger";
export const logger = new Logger('Admin');
export const logger = new Logger('Admin');
export { default as CreateTournament } from './components/create-tournament';
export { default as EditTournament } from './components/edit-tournament';
export { default as AdminPage } from './components/admin-page';

View File

@@ -2,20 +2,22 @@ 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';
import useRouterConfig from '../hooks/use-router-config';
import Page from '@/components/page';
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const headerConfig = useHeaderConfig();
const { header } = useRouterConfig();
const viewport = useVisualViewportSize();
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
const { withPadding } = useRouterConfig();
return (
<AppShell
id='app-shell'
layout='alt'
header={{ height: 60, collapsed: headerConfig.collapsed }}
header={{ height: 60, collapsed: header.collapsed }}
navbar={{
width: { base: 0, sm: 100, md: 200, lg: 300 },
breakpoint: 'sm',
@@ -31,7 +33,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
mah='100dvh'
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
>
<Header scrollPosition={scrollPosition} {...headerConfig} />
<Header scrollPosition={scrollPosition} {...header} />
<AppShell.Main
pos='relative'
h='100%'
@@ -41,7 +43,9 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
style={{ transition: 'none' }}
>
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}>
{children}
<Page noPadding={!withPadding}>
{children}
</Page>
</Pullable>
</AppShell.Main>
<Navbar />

View File

@@ -1,9 +1,9 @@
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";
import useRouterConfig from "../hooks/use-router-config";
const THRESHOLD = 80;
@@ -20,20 +20,20 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
const height = useAppShellHeight();
const [isRefreshing, setIsRefreshing] = useState(false);
const [scrolling, setScrolling] = useState(false);
const { toRefresh } = useRefreshConfig();
const { refresh } = useRouterConfig();
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) {
if (refresh.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});
await queryClient.refetchQueries({ queryKey: refresh, exact: true});
}
setIsRefreshing(false);
}, [toRefresh]);
}, [refresh]);
useEffect(() => {
if (!isRefreshing && scrollY > THRESHOLD) {
@@ -43,7 +43,7 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
const iconOpacity = useMemo(() => {
if (isRefreshing) return 1;
if (toRefresh.length === 0) return 0;
if (refresh.length === 0) return 0;
const clampedValue = Math.max(5, Math.min(THRESHOLD, scrollY));
const min = 5;
@@ -111,7 +111,7 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
>
<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)'>
<ActionIcon display={!!refresh.length ? 'unset' : 'none' } style={{ zIndex: 1000 }} pos='absolute' top={8} left='calc(50% - 24px)' onClick={onTrigger} variant='filled' color='var(--mantine-color-dimmed)'>
<ArrowClockwiseIcon />
</ActionIcon>
{children}

View File

@@ -1,27 +0,0 @@
import { isMatch, useMatches } from "@tanstack/react-router";
import { HeaderConfig } from "../types/header-config";
export const defaultHeaderConfig: HeaderConfig = {
title: 'FLXN',
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

@@ -1,4 +1,4 @@
import { GearIcon, HouseIcon, QuestionIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { HouseIcon, RankingIcon, ShieldIcon, TrophyIcon, UserCircleIcon } from "@phosphor-icons/react";
import { useMemo } from "react";
export const useLinks = (userId: number, roles: string[]) =>
@@ -9,6 +9,11 @@ export const useLinks = (userId: number, roles: string[]) =>
href: '/',
Icon: HouseIcon
},
{
label: 'Leaderboard',
href: '/leaderboard',
Icon: RankingIcon
},
{
label: 'Tournaments',
href: '/tournaments',
@@ -18,11 +23,6 @@ export const useLinks = (userId: number, roles: string[]) =>
label: 'Profile',
href: `/profile/${userId}`,
Icon: UserCircleIcon
},
{
label: 'Settings',
href: '/settings',
Icon: GearIcon
}
]

View File

@@ -1,24 +0,0 @@
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,40 @@
import { useMatches } from "@tanstack/react-router";
import { HeaderConfig } from "../types/header-config";
export const defaultHeaderConfig: HeaderConfig = {
title: 'FLXN',
withBackButton: false,
collapsed: false,
}
const useRouterConfig = () => {
const matches = useMatches();
const matchesWithHeader = matches.filter((match) =>
match?.loaderData && 'header' in match.loaderData
);
const headerConfig = matchesWithHeader.reduce((acc, match) => {
const loaderData = match?.loaderData;
if (loaderData && typeof loaderData === 'object' && 'header' in loaderData) {
const header = loaderData.header;
if (header && typeof header === 'object') {
return {
...acc,
...header,
}
}
}
return acc;
}, defaultHeaderConfig);
const current = matches[matches.length - 1]?.loaderData;
return {
header: headerConfig,
refresh: current && typeof current === 'object' && 'refresh' in current ? current.refresh : [],
withPadding: current && typeof current === 'object' && 'withPadding' in current ? current.withPadding : true
};
}
export default useRouterConfig;

View File

@@ -13,7 +13,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => (
<ListItem py='xs'
<ListItem py='xs' key={`skeleton-${i}`}
icon={<Skeleton circle height={40} width={40} />}
>
<Skeleton height={20} width={200} />

View File

@@ -13,8 +13,7 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => {
if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => (
<ListItem py='xs'
icon={<Skeleton height={40} width={40} />}
<ListItem key={`skeleton-${i}`} py='xs' icon={<Skeleton height={40} width={40} />}
>
<Skeleton height={35} width={200} />
</ListItem>

View File

@@ -3,17 +3,35 @@ import Header from "./header";
import TeamList from "@/features/teams/components/team-list";
import { Team } from "../../types";
import PlayerList from "@/features/players/components/player-list";
import SwipeableTabs from "@/components/swipeable-tabs";
import TournamentList from "@/features/tournaments/components/tournament-list";
interface ProfileProps {
team: Team;
}
const TeamProfile = ({ team }: ProfileProps) => {
console.log(team);
const tabs = [
{
label: "Overview",
content: <Text p="md">Stats/Badges will go here</Text>
},
{
label: "Matches",
content: <Text p="md">Matches feed will go here</Text>
},
{
label: "Tournaments",
content: <>
<TournamentList tournaments={team.tournaments || []} />
</>
}
];
return <>
<Header team={team} />
<Box m='sm' mt='lg'>
<Text size='xl' fw={600}>Players</Text>
<PlayerList players={team.players} />
<SwipeableTabs tabs={tabs} />
</Box>
</>;
};

View File

@@ -1,5 +1,6 @@
import { Player } from "@/features/players/types";
import { z } from 'zod';
import { Tournament } from "../tournaments/types";
export interface Team {
id: string;
@@ -18,6 +19,7 @@ export interface Team {
created: string;
updated: string;
players: Player[];
tournaments: Tournament[];
}
export const teamInputSchema = z.object({

View File

@@ -13,7 +13,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
if (loading) return <List>
{Array.from({ length: 10 }).map((_, i) => (
<ListItem py='xs'
<ListItem py='xs' key={`skeleton-${i}`}
icon={<Skeleton height={40} width={40} />}
>
<Skeleton height={20} width={200} />

View File

@@ -1,13 +1,19 @@
import { useMemo } from "react";
import { useIsMobile } from "./use-is-mobile";
import useHeaderConfig from "@/features/core/hooks/use-header-config";
import useRouterConfig from "@/features/core/hooks/use-router-config";
const useAppShellHeight = () => {
const isMobile = useIsMobile();
const headerConfig = useHeaderConfig();
const { header } = useRouterConfig();
const height = useMemo(() =>
`calc(100dvh - var(--app-shell-header-height, 0px) - ${isMobile && !headerConfig.collapsed ? '4rem' : '0px'} - 1.285rem)`,
[isMobile, headerConfig.collapsed]);
const height = useMemo(() => {
const appShellBottomPadding = isMobile ? '70px' : '0px';
const pageBottomPadding = '20px';
const mobileNavbar = isMobile && !header.collapsed ? '4rem' : '0px';
const pullablePadding = '1.285rem';
return `calc(100dvh - var(--app-shell-header-height, 0px) - ${mobileNavbar} - ${pullablePadding} - ${appShellBottomPadding} - ${pageBottomPadding})`;
}, [isMobile, header.collapsed]);
return height;
}

View File

@@ -1,6 +1,7 @@
import { useAuth } from "@/contexts/auth-context";
import { createTheme, MantineProvider as MantineProviderCore } from "@mantine/core";
import ColorSchemeProvider from "./color-scheme-provider";
import { useState, useEffect } from "react";
const commonInputStyles = {
label: {
@@ -45,10 +46,18 @@ const theme = createTheme({
const MantineProvider = ({ children }: { children: React.ReactNode }) => {
const { metadata } = useAuth()
const [isHydrated, setIsHydrated] = useState(false)
useEffect(() => {
setIsHydrated(true)
}, [])
const colorScheme = isHydrated ? (metadata.colorScheme || 'auto') : 'auto'
const primaryColor = isHydrated ? (metadata.accentColor || 'blue') : 'blue'
return <MantineProviderCore
defaultColorScheme={metadata.colorScheme || 'auto'}
theme={{ ...theme, primaryColor: metadata.accentColor || 'blue' }}
defaultColorScheme={colorScheme}
theme={{ ...theme, primaryColor }}
>
<ColorSchemeProvider>
{children}

View File

@@ -32,6 +32,12 @@ export function transformTeam(record: any): Team {
new Date(a.created!) < new Date(b.created!) ? -1 : 0
)
?.map(transformPlayer) ?? [];
const tournaments =
record.expand?.tournaments
?.sort((a: Tournament, b: Tournament) =>
new Date(a.created!) < new Date(b.created!) ? -1 : 0
)
?.map(transformTournament) ?? [];
return {
id: record.id,
@@ -50,6 +56,7 @@ export function transformTeam(record: any): Team {
created: record.created,
updated: record.updated,
players,
tournaments
};
}