Compare commits
12 Commits
a413d4421b
...
75479be334
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75479be334 | ||
|
|
fcdb33a4b6 | ||
|
|
7226fb33f4 | ||
|
|
f5e4e5b214 | ||
|
|
7b0153e04f | ||
|
|
38fb060b78 | ||
|
|
555d79b6db | ||
|
|
c9df4947bd | ||
|
|
44417d063b | ||
|
|
d845254c3d | ||
|
|
4faa853c4c | ||
|
|
ce63c02d8e |
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
24
src/app/routes/_authed/admin/tournaments/index.tsx
Normal file
24
src/app/routes/_authed/admin/tournaments/index.tsx
Normal 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 />
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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!} />
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
30
src/components/list-button.tsx
Normal file
30
src/components/list-button.tsx
Normal 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;
|
||||
@@ -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 />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
18
src/features/admin/components/admin-page.tsx
Normal file
18
src/features/admin/components/admin-page.tsx
Normal 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;
|
||||
24
src/features/admin/components/manage-tournaments.tsx
Normal file
24
src/features/admin/components/manage-tournaments.tsx
Normal 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;
|
||||
@@ -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';
|
||||
@@ -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 />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -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;
|
||||
40
src/features/core/hooks/use-router-config.ts
Normal file
40
src/features/core/hooks/use-router-config.ts
Normal 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;
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user