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 AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
||||||
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
||||||
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
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 { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
|
||||||
import { ServerRoute as ApiTestServerRouteImport } from './routes/api/test'
|
import { ServerRoute as ApiTestServerRouteImport } from './routes/api/test'
|
||||||
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
||||||
@@ -92,6 +93,12 @@ const AuthedAdminPreviewRoute = AuthedAdminPreviewRouteImport.update({
|
|||||||
path: '/preview',
|
path: '/preview',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedAdminTournamentsIndexRoute =
|
||||||
|
AuthedAdminTournamentsIndexRouteImport.update({
|
||||||
|
id: '/tournaments/',
|
||||||
|
path: '/tournaments/',
|
||||||
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
|
} as any)
|
||||||
const AuthedAdminTournamentsIdRoute =
|
const AuthedAdminTournamentsIdRoute =
|
||||||
AuthedAdminTournamentsIdRouteImport.update({
|
AuthedAdminTournamentsIdRouteImport.update({
|
||||||
id: '/tournaments/$id',
|
id: '/tournaments/$id',
|
||||||
@@ -139,6 +146,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/admin/': typeof AuthedAdminIndexRoute
|
'/admin/': typeof AuthedAdminIndexRoute
|
||||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
||||||
|
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
@@ -152,6 +160,7 @@ export interface FileRoutesByTo {
|
|||||||
'/admin': typeof AuthedAdminIndexRoute
|
'/admin': typeof AuthedAdminIndexRoute
|
||||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
||||||
|
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -168,6 +177,7 @@ export interface FileRoutesById {
|
|||||||
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
||||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||||
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
||||||
|
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -184,6 +194,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin/'
|
| '/admin/'
|
||||||
| '/tournaments'
|
| '/tournaments'
|
||||||
| '/admin/tournaments/$id'
|
| '/admin/tournaments/$id'
|
||||||
|
| '/admin/tournaments'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/login'
|
| '/login'
|
||||||
@@ -197,6 +208,7 @@ export interface FileRouteTypes {
|
|||||||
| '/admin'
|
| '/admin'
|
||||||
| '/tournaments'
|
| '/tournaments'
|
||||||
| '/admin/tournaments/$id'
|
| '/admin/tournaments/$id'
|
||||||
|
| '/admin/tournaments'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_authed'
|
| '/_authed'
|
||||||
@@ -212,6 +224,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/admin/'
|
| '/_authed/admin/'
|
||||||
| '/_authed/tournaments/'
|
| '/_authed/tournaments/'
|
||||||
| '/_authed/admin/tournaments/$id'
|
| '/_authed/admin/tournaments/$id'
|
||||||
|
| '/_authed/admin/tournaments/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -359,6 +372,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminPreviewRouteImport
|
preLoaderRoute: typeof AuthedAdminPreviewRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
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': {
|
'/_authed/admin/tournaments/$id': {
|
||||||
id: '/_authed/admin/tournaments/$id'
|
id: '/_authed/admin/tournaments/$id'
|
||||||
path: '/tournaments/$id'
|
path: '/tournaments/$id'
|
||||||
@@ -412,12 +432,14 @@ interface AuthedAdminRouteChildren {
|
|||||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||||
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
||||||
AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute
|
AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute
|
||||||
|
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
||||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||||
AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute,
|
AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute,
|
||||||
|
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
|
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 { routerWithQueryClient } from '@tanstack/react-router-with-query'
|
||||||
import { routeTree } from './routeTree.gen'
|
import { routeTree } from './routeTree.gen'
|
||||||
import { DefaultCatchBoundary } from '../components/DefaultCatchBoundary'
|
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() {
|
export function createRouter() {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -21,7 +21,7 @@ export function createRouter() {
|
|||||||
return routerWithQueryClient(
|
return routerWithQueryClient(
|
||||||
createTanStackRouter({
|
createTanStackRouter({
|
||||||
routeTree,
|
routeTree,
|
||||||
context: { queryClient, auth: undefined!, header: defaultHeaderConfig, refresh: { toRefresh: [] } },
|
context: { queryClient, auth: undefined!, header: defaultHeaderConfig, refresh: [], withPadding: true },
|
||||||
defaultPreload: 'intent',
|
defaultPreload: 'intent',
|
||||||
defaultErrorComponent: DefaultCatchBoundary,
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
scrollRestoration: true,
|
scrollRestoration: true,
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ export const Route = createRootRouteWithContext<{
|
|||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
auth: AuthContextType,
|
auth: AuthContextType,
|
||||||
header: HeaderConfig,
|
header: HeaderConfig,
|
||||||
refresh: { toRefresh: string[] }
|
refresh: string[]
|
||||||
|
withPadding: boolean
|
||||||
}>()({
|
}>()({
|
||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [
|
meta: [
|
||||||
@@ -89,6 +90,7 @@ function RootComponent() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// todo: analytics -> process.env data-website-id
|
||||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html {...mantineHtmlProps} style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
|
<html {...mantineHtmlProps} style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
|
||||||
@@ -96,6 +98,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|||||||
<HeadContent />
|
<HeadContent />
|
||||||
<ColorSchemeScript />
|
<ColorSchemeScript />
|
||||||
<link rel="stylesheet" href="/styles.css" />
|
<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>
|
</head>
|
||||||
<body style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
|
<body style={{ overflowX: 'hidden', overflowY: 'hidden', position: 'fixed', width: '100%' }}>
|
||||||
<div className='app'>
|
<div className='app'>
|
||||||
|
|||||||
@@ -1,22 +1,6 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router"
|
import { createFileRoute } from "@tanstack/react-router"
|
||||||
import { Title } from "@mantine/core";
|
import { AdminPage } from "@/features/admin";
|
||||||
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";
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/admin/")({
|
export const Route = createFileRoute("/_authed/admin/")({
|
||||||
loader: async ({ context }) => {
|
component: () => <AdminPage />,
|
||||||
const { queryClient } = context;
|
|
||||||
await queryClient.ensureQueryData(playerQueries.list())
|
|
||||||
},
|
|
||||||
component: RouteComponent,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
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 }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
await context.queryClient.ensureQueryData(tournamentQueries.list());
|
await context.queryClient.ensureQueryData(tournamentQueries.list());
|
||||||
},
|
},
|
||||||
|
loader: () => ({
|
||||||
|
withPadding: false
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
return (
|
return (
|
||||||
<Page noPadding>
|
<>
|
||||||
<Box h='60vh' p="md">
|
<Box h='60vh' p="md">
|
||||||
<Text m='16vh' fw={500}>Some Content Here</Text>
|
<Text m='16vh' fw={500}>Some Content Here</Text>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -24,6 +27,6 @@ function Home() {
|
|||||||
<Divider />
|
<Divider />
|
||||||
<ListLink label="All Tournaments" to="/tournaments" Icon={TrophyIcon} />
|
<ListLink label="All Tournaments" to="/tournaments" Icon={TrophyIcon} />
|
||||||
</Box>
|
</Box>
|
||||||
</Page>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import Page from "@/components/page";
|
|||||||
import Profile from "@/features/players/components/profile";
|
import Profile from "@/features/players/components/profile";
|
||||||
import { playerQueries } from "@/features/players/queries";
|
import { playerQueries } from "@/features/players/queries";
|
||||||
import { redirect, createFileRoute } from "@tanstack/react-router";
|
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")({
|
export const Route = createFileRoute("/_authed/profile/$playerId")({
|
||||||
|
validateSearch: searchSchema,
|
||||||
beforeLoad: async ({ params, context }) => {
|
beforeLoad: async ({ params, context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
const player = await queryClient.ensureQueryData(playerQueries.details(params.playerId))
|
const player = await queryClient.ensureQueryData(playerQueries.details(params.playerId))
|
||||||
@@ -18,12 +24,10 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({
|
|||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
settingsLink: context?.auth.user.id === params.playerId ? 'settings' : undefined
|
settingsLink: context?.auth.user.id === params.playerId ? 'settings' : undefined
|
||||||
},
|
},
|
||||||
refresh: {
|
refresh: [playerQueries.details(params.playerId).queryKey]
|
||||||
toRefresh: [playerQueries.details(params.playerId).queryKey],
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
component: () => {
|
component: () => {
|
||||||
const { player } = Route.useRouteContext();
|
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 AccentColorPicker from "@/features/settings/components/accent-color-picker"
|
||||||
import { SignOutIcon } from "@phosphor-icons/react"
|
import { SignOutIcon } from "@phosphor-icons/react"
|
||||||
import ListLink from "@/components/list-link"
|
import ListLink from "@/components/list-link"
|
||||||
import Page from "@/components/page"
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/_authed/settings")({
|
export const Route = createFileRoute("/_authed/settings")({
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
@@ -12,12 +11,13 @@ export const Route = createFileRoute("/_authed/settings")({
|
|||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
},
|
},
|
||||||
|
withPadding: false
|
||||||
}),
|
}),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
return <Page noPadding>
|
return <>
|
||||||
<Box px='md' py='sm' style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
|
<Box px='md' py='sm' style={{ borderBottom: '1px solid var(--mantine-color-default-border)' }}>
|
||||||
<Title order={3}>Appearance</Title>
|
<Title order={3}>Appearance</Title>
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -30,5 +30,5 @@ function RouteComponent() {
|
|||||||
to='/logout'
|
to='/logout'
|
||||||
Icon={SignOutIcon}
|
Icon={SignOutIcon}
|
||||||
/>
|
/>
|
||||||
</Page>
|
</>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import Page from "@/components/page";
|
|||||||
import TeamProfile from "@/features/teams/components/team-profile";
|
import TeamProfile from "@/features/teams/components/team-profile";
|
||||||
import { teamQueries } from "@/features/teams/queries";
|
import { teamQueries } from "@/features/teams/queries";
|
||||||
import { redirect, createFileRoute } from "@tanstack/react-router";
|
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")({
|
export const Route = createFileRoute("/_authed/teams/$teamId")({
|
||||||
|
validateSearch: searchSchema,
|
||||||
beforeLoad: async ({ params, context }) => {
|
beforeLoad: async ({ params, context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
const team = await queryClient.ensureQueryData(teamQueries.details(params.teamId))
|
const team = await queryClient.ensureQueryData(teamQueries.details(params.teamId))
|
||||||
@@ -15,12 +21,10 @@ export const Route = createFileRoute("/_authed/teams/$teamId")({
|
|||||||
collapsed: true,
|
collapsed: true,
|
||||||
withBackButton: true
|
withBackButton: true
|
||||||
},
|
},
|
||||||
refresh: {
|
refresh: [teamQueries.details(params.teamId).queryKey]
|
||||||
toRefresh: [teamQueries.details(params.teamId).queryKey],
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
component: () => {
|
component: () => {
|
||||||
const { team } = Route.useRouteContext();
|
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 Button from '@/components/button';
|
||||||
import Avatar from '@/components/avatar';
|
import Avatar from '@/components/avatar';
|
||||||
import Profile from '@/features/tournaments/components/profile';
|
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')({
|
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
||||||
|
validateSearch: searchSchema,
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
await queryClient.ensureQueryData(tournamentQueries.details(params.tournamentId))
|
await queryClient.ensureQueryData(tournamentQueries.details(params.tournamentId))
|
||||||
@@ -22,16 +28,13 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
|||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined
|
settingsLink: context.auth.roles.includes("Admin") ? `/admin/tournaments/${params.tournamentId}` : undefined
|
||||||
},
|
},
|
||||||
refresh: {
|
refresh: tournamentQueries.details(params.tournamentId).queryKey,
|
||||||
toRefresh: tournamentQueries.details(params.tournamentId).queryKey,
|
withPadding: false
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { data: tournament } = useQuery(tournamentQueries.details(Route.useParams().tournamentId));
|
const { data: tournament } = useQuery(tournamentQueries.details(Route.useParams().tournamentId));
|
||||||
return <Page noPadding>
|
return <Profile tournament={tournament!} />
|
||||||
<Profile tournament={tournament!} />
|
|
||||||
</Page>
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,7 @@ export const Route = createFileRoute('/_authed/tournaments/')({
|
|||||||
withBackButton: true,
|
withBackButton: true,
|
||||||
title: 'Tournaments',
|
title: 'Tournaments',
|
||||||
},
|
},
|
||||||
refresh: {
|
refresh: tournamentQueries.list().queryKey
|
||||||
toRefresh: tournamentQueries.list().queryKey,
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
component: RouteComponent,
|
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 { CaretRightIcon, Icon } from "@phosphor-icons/react";
|
||||||
import { Link, useNavigate } from "@tanstack/react-router";
|
import { Link, useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
@@ -12,15 +12,18 @@ const ListLink = ({ label, to, Icon }: ListLinkProps) => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<>
|
||||||
w='100%'
|
<NavLink
|
||||||
p='md'
|
w='100%'
|
||||||
component={'button'}
|
p='md'
|
||||||
onClick={() => navigate({ to })}
|
component={'button'}
|
||||||
label={<Text fw={500} size='md'>{label}</Text>}
|
onClick={() => navigate({ to })}
|
||||||
leftSection={<Icon weight='bold' size={20} />}
|
label={<Text fw={500} size='md'>{label}</Text>}
|
||||||
rightSection={<CaretRightIcon size={20} />}
|
leftSection={<Icon weight='bold' size={20} />}
|
||||||
/>
|
rightSection={<CaretRightIcon size={20} />}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Container, ContainerProps } from "@mantine/core";
|
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 {
|
interface PageProps extends ContainerProps, React.PropsWithChildren {
|
||||||
noPadding?: boolean;
|
noPadding?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Page = ({ children, noPadding, ...props }: PageProps) => {
|
const Page = ({ children, noPadding, ...props }: PageProps) => {
|
||||||
const headerConfig = useHeaderConfig();
|
const { header } = useRouterConfig();
|
||||||
return <Container px={noPadding ? 0 : 'md'} pt={headerConfig.collapsed ? 60 : 0} pb={20} m={0} maw={600} mx='auto' {...props}>
|
return <Container px={noPadding ? 0 : 'md'} pt={header.collapsed ? 60 : 0} pb={20} m={0} maw={600} mx='auto' {...props}>
|
||||||
{children}
|
{children}
|
||||||
</Container>
|
</Container>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const SlidePanelField = ({
|
|||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
border: error ? '1px solid var(--mantine-color-error)' : '1px solid var(--mantine-color-dimmed)',
|
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)',
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
textAlign: 'left',
|
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 { Carousel } from "@mantine/carousel";
|
||||||
import { useState, useEffect, ReactNode, useRef } from "react";
|
import { useState, useEffect, ReactNode, useRef } from "react";
|
||||||
|
import { useRouter } from "@tanstack/react-router";
|
||||||
|
|
||||||
interface TabItem {
|
interface TabItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -11,94 +12,71 @@ interface SwipeableTabsProps {
|
|||||||
tabs: TabItem[];
|
tabs: TabItem[];
|
||||||
defaultTab?: number;
|
defaultTab?: number;
|
||||||
onTabChange?: (index: number, tab: TabItem) => void;
|
onTabChange?: (index: number, tab: TabItem) => void;
|
||||||
scrollPosition?: { x: number; y: number };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: SwipeableTabsProps) {
|
function SwipeableTabs({ tabs, defaultTab = 0, onTabChange }: SwipeableTabsProps) {
|
||||||
const [activeTab, setActiveTab] = useState(defaultTab);
|
const router = useRouter();
|
||||||
|
const search = router.state.location.search as any;
|
||||||
const [embla, setEmbla] = useState<any>(null);
|
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 [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
|
||||||
const [controlsRefs, setControlsRefs] = useState<Record<number, HTMLSpanElement | 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 slideRefs = useRef<Record<number, HTMLDivElement | null>>({});
|
||||||
const [activeSlideHeight, setActiveSlideHeight] = useState<number | 'auto'>('auto');
|
const [carouselHeight, setCarouselHeight] = useState<number | 'auto'>('auto');
|
||||||
const stickyThreshold = 0;
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!embla) return;
|
if (!embla) return;
|
||||||
|
|
||||||
const onSelect = () => {
|
const onSelect = () => {
|
||||||
const newIndex = embla.selectedScrollSnap();
|
const newIndex = embla.selectedScrollSnap();
|
||||||
setActiveTab(newIndex);
|
changeTab(newIndex);
|
||||||
|
|
||||||
// Update height based on active slide content
|
|
||||||
const activeSlideRef = slideRefs.current[newIndex];
|
|
||||||
if (activeSlideRef) {
|
|
||||||
const height = activeSlideRef.scrollHeight;
|
|
||||||
setActiveSlideHeight(height);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
embla.on("select", onSelect);
|
embla.on("select", onSelect);
|
||||||
|
return () => embla.off("select", onSelect);
|
||||||
|
}, [embla, activeTab, tabs]);
|
||||||
|
|
||||||
return () => {
|
useEffect(() => {
|
||||||
embla.off("select", onSelect);
|
const newActiveTab = getActiveTabFromUrl();
|
||||||
};
|
if (newActiveTab !== activeTab) {
|
||||||
}, [embla]);
|
setActiveTab(newActiveTab);
|
||||||
|
embla?.scrollTo(newActiveTab);
|
||||||
|
}
|
||||||
|
}, [search?.tab]);
|
||||||
|
|
||||||
// Update height when activeTab changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeSlideRef = slideRefs.current[activeTab];
|
const activeSlideRef = slideRefs.current[activeTab];
|
||||||
if (activeSlideRef) {
|
if (activeSlideRef) {
|
||||||
const height = activeSlideRef.scrollHeight;
|
const height = activeSlideRef.scrollHeight;
|
||||||
setActiveSlideHeight(height);
|
setCarouselHeight(height);
|
||||||
}
|
}
|
||||||
}, [activeTab]);
|
}, [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) => {
|
const setControlRef = (index: number) => (node: HTMLSpanElement | null) => {
|
||||||
controlsRefs[index] = node;
|
controlsRefs[index] = node;
|
||||||
setControlsRefs(controlsRefs);
|
setControlsRefs(controlsRefs);
|
||||||
@@ -110,24 +88,15 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
{isSticky && (
|
|
||||||
<Box style={{ height: '60px' }} />
|
|
||||||
)}
|
|
||||||
<Box
|
<Box
|
||||||
ref={(node) => {
|
ref={setRootRef}
|
||||||
setRootRef(node);
|
pos="sticky"
|
||||||
tabsRef.current = node;
|
top={0}
|
||||||
}}
|
|
||||||
pos={isSticky ? "fixed" : "relative"}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
marginBottom: 'var(--mantine-spacing-md)',
|
marginBottom: 'var(--mantine-spacing-md)',
|
||||||
top: isSticky ? 0 : 'auto',
|
|
||||||
left: isSticky ? 0 : 'auto',
|
|
||||||
right: isSticky ? 0 : 'auto',
|
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
backgroundColor: 'var(--mantine-color-body)',
|
backgroundColor: 'var(--mantine-color-body)'
|
||||||
transition: 'all 200ms ease',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<FloatingIndicator
|
<FloatingIndicator
|
||||||
@@ -143,7 +112,7 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
|
|||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
key={`${tab.label}-${index}`}
|
key={`${tab.label}-${index}`}
|
||||||
onClick={() => handleTabChange(index)}
|
onClick={() => changeTab(index)}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 'var(--mantine-spacing-sm) var(--mantine-spacing-md)',
|
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-blue-6)'
|
||||||
: 'var(--mantine-color-text)',
|
: 'var(--mantine-color-text)',
|
||||||
fontWeight: activeTab === index ? 600 : 400,
|
fontWeight: activeTab === index ? 600 : 400,
|
||||||
transition: 'color 200ms ease',
|
transition: 'color 200ms ease, font-weight 200ms ease',
|
||||||
backgroundColor: 'transparent',
|
backgroundColor: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
@@ -183,19 +151,15 @@ function SwipeableTabs({ tabs, defaultTab = 0, onTabChange, scrollPosition }: Sw
|
|||||||
initialSlide={activeTab}
|
initialSlide={activeTab}
|
||||||
style={{
|
style={{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
height: activeSlideHeight === 'auto' ? 'auto' : `${activeSlideHeight}px`,
|
height: carouselHeight === 'auto' ? 'auto' : `${carouselHeight}px`,
|
||||||
transition: 'height 300ms ease'
|
transition: 'height 300ms ease'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{tabs.map((tab, index) => (
|
{tabs.map((tab, index) => (
|
||||||
<Carousel.Slide key={`${tab.label}-content-${index}`} style={{ height: 'auto' }}>
|
<Carousel.Slide key={`${tab.label}-content-${index}`}>
|
||||||
<Box
|
<Box
|
||||||
ref={setSlideRef(index)}
|
ref={setSlideRef(index)}
|
||||||
style={{
|
style={{ height: 'auto' }}
|
||||||
minHeight: 'fit-content',
|
|
||||||
height: 'auto',
|
|
||||||
visibility: index === activeTab ? 'visible' : 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{tab.content}
|
{tab.content}
|
||||||
</Box>
|
</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";
|
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 { PropsWithChildren, useState } from 'react';
|
||||||
import Header from './header';
|
import Header from './header';
|
||||||
import Navbar from './navbar';
|
import Navbar from './navbar';
|
||||||
import useHeaderConfig from '../hooks/use-header-config';
|
|
||||||
import Pullable from './pullable';
|
import Pullable from './pullable';
|
||||||
import useVisualViewportSize from '../hooks/use-visual-viewport-size';
|
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 Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||||
const headerConfig = useHeaderConfig();
|
const { header } = useRouterConfig();
|
||||||
const viewport = useVisualViewportSize();
|
const viewport = useVisualViewportSize();
|
||||||
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
|
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const { withPadding } = useRouterConfig();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
id='app-shell'
|
id='app-shell'
|
||||||
layout='alt'
|
layout='alt'
|
||||||
header={{ height: 60, collapsed: headerConfig.collapsed }}
|
header={{ height: 60, collapsed: header.collapsed }}
|
||||||
navbar={{
|
navbar={{
|
||||||
width: { base: 0, sm: 100, md: 200, lg: 300 },
|
width: { base: 0, sm: 100, md: 200, lg: 300 },
|
||||||
breakpoint: 'sm',
|
breakpoint: 'sm',
|
||||||
@@ -31,7 +33,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
mah='100dvh'
|
mah='100dvh'
|
||||||
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
|
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
|
||||||
>
|
>
|
||||||
<Header scrollPosition={scrollPosition} {...headerConfig} />
|
<Header scrollPosition={scrollPosition} {...header} />
|
||||||
<AppShell.Main
|
<AppShell.Main
|
||||||
pos='relative'
|
pos='relative'
|
||||||
h='100%'
|
h='100%'
|
||||||
@@ -41,7 +43,9 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
style={{ transition: 'none' }}
|
style={{ transition: 'none' }}
|
||||||
>
|
>
|
||||||
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}>
|
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}>
|
||||||
{children}
|
<Page noPadding={!withPadding}>
|
||||||
|
{children}
|
||||||
|
</Page>
|
||||||
</Pullable>
|
</Pullable>
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ActionIcon, Box, Button, Flex, ScrollArea } from "@mantine/core";
|
import { ActionIcon, Box, Button, Flex, ScrollArea } from "@mantine/core";
|
||||||
import { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { PropsWithChildren, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import useAppShellHeight from "@/hooks/use-appshell-height";
|
import useAppShellHeight from "@/hooks/use-appshell-height";
|
||||||
import useRefreshConfig from "@/features/core/hooks/use-refresh-config";
|
|
||||||
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
|
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import useRouterConfig from "../hooks/use-router-config";
|
||||||
|
|
||||||
const THRESHOLD = 80;
|
const THRESHOLD = 80;
|
||||||
|
|
||||||
@@ -20,20 +20,20 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
|||||||
const height = useAppShellHeight();
|
const height = useAppShellHeight();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [scrolling, setScrolling] = useState(false);
|
const [scrolling, setScrolling] = useState(false);
|
||||||
const { toRefresh } = useRefreshConfig();
|
const { refresh } = useRouterConfig();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
|
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
|
||||||
|
|
||||||
const onTrigger = useCallback(async () => {
|
const onTrigger = useCallback(async () => {
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
if (toRefresh.length > 0) {
|
if (refresh.length > 0) {
|
||||||
// TODO: Remove this after testing - or does the delay help ux?
|
// TODO: Remove this after testing - or does the delay help ux?
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
await queryClient.refetchQueries({ queryKey: toRefresh, exact: true});
|
await queryClient.refetchQueries({ queryKey: refresh, exact: true});
|
||||||
}
|
}
|
||||||
setIsRefreshing(false);
|
setIsRefreshing(false);
|
||||||
}, [toRefresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isRefreshing && scrollY > THRESHOLD) {
|
if (!isRefreshing && scrollY > THRESHOLD) {
|
||||||
@@ -43,7 +43,7 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
|||||||
|
|
||||||
const iconOpacity = useMemo(() => {
|
const iconOpacity = useMemo(() => {
|
||||||
if (isRefreshing) return 1;
|
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 clampedValue = Math.max(5, Math.min(THRESHOLD, scrollY));
|
||||||
|
|
||||||
const min = 5;
|
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' }}>
|
<Box pt='1rem'pb='0.285rem' mih={height} style={{ boxSizing: 'content-box' }}>
|
||||||
{ /* TODO: Remove this debug button */}
|
{ /* 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 />
|
<ArrowClockwiseIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
{children}
|
{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";
|
import { useMemo } from "react";
|
||||||
|
|
||||||
export const useLinks = (userId: number, roles: string[]) =>
|
export const useLinks = (userId: number, roles: string[]) =>
|
||||||
@@ -9,6 +9,11 @@ export const useLinks = (userId: number, roles: string[]) =>
|
|||||||
href: '/',
|
href: '/',
|
||||||
Icon: HouseIcon
|
Icon: HouseIcon
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Leaderboard',
|
||||||
|
href: '/leaderboard',
|
||||||
|
Icon: RankingIcon
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Tournaments',
|
label: 'Tournaments',
|
||||||
href: '/tournaments',
|
href: '/tournaments',
|
||||||
@@ -18,11 +23,6 @@ export const useLinks = (userId: number, roles: string[]) =>
|
|||||||
label: 'Profile',
|
label: 'Profile',
|
||||||
href: `/profile/${userId}`,
|
href: `/profile/${userId}`,
|
||||||
Icon: UserCircleIcon
|
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>
|
if (loading) return <List>
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<ListItem py='xs'
|
<ListItem py='xs' key={`skeleton-${i}`}
|
||||||
icon={<Skeleton circle height={40} width={40} />}
|
icon={<Skeleton circle height={40} width={40} />}
|
||||||
>
|
>
|
||||||
<Skeleton height={20} width={200} />
|
<Skeleton height={20} width={200} />
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
|||||||
|
|
||||||
if (loading) return <List>
|
if (loading) return <List>
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<ListItem py='xs'
|
<ListItem key={`skeleton-${i}`} py='xs' icon={<Skeleton height={40} width={40} />}
|
||||||
icon={<Skeleton height={40} width={40} />}
|
|
||||||
>
|
>
|
||||||
<Skeleton height={35} width={200} />
|
<Skeleton height={35} width={200} />
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|||||||
@@ -3,17 +3,35 @@ import Header from "./header";
|
|||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
import { Team } from "../../types";
|
import { Team } from "../../types";
|
||||||
import PlayerList from "@/features/players/components/player-list";
|
import PlayerList from "@/features/players/components/player-list";
|
||||||
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
|
import TournamentList from "@/features/tournaments/components/tournament-list";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
team: Team;
|
team: Team;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamProfile = ({ team }: ProfileProps) => {
|
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 <>
|
return <>
|
||||||
<Header team={team} />
|
<Header team={team} />
|
||||||
<Box m='sm' mt='lg'>
|
<Box m='sm' mt='lg'>
|
||||||
<Text size='xl' fw={600}>Players</Text>
|
<SwipeableTabs tabs={tabs} />
|
||||||
<PlayerList players={team.players} />
|
|
||||||
</Box>
|
</Box>
|
||||||
</>;
|
</>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Player } from "@/features/players/types";
|
import { Player } from "@/features/players/types";
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { Tournament } from "../tournaments/types";
|
||||||
|
|
||||||
export interface Team {
|
export interface Team {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +19,7 @@ export interface Team {
|
|||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
players: Player[];
|
players: Player[];
|
||||||
|
tournaments: Tournament[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const teamInputSchema = z.object({
|
export const teamInputSchema = z.object({
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
|
|
||||||
if (loading) return <List>
|
if (loading) return <List>
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<ListItem py='xs'
|
<ListItem py='xs' key={`skeleton-${i}`}
|
||||||
icon={<Skeleton height={40} width={40} />}
|
icon={<Skeleton height={40} width={40} />}
|
||||||
>
|
>
|
||||||
<Skeleton height={20} width={200} />
|
<Skeleton height={20} width={200} />
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useIsMobile } from "./use-is-mobile";
|
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 useAppShellHeight = () => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const headerConfig = useHeaderConfig();
|
const { header } = useRouterConfig();
|
||||||
|
|
||||||
const height = useMemo(() =>
|
const height = useMemo(() => {
|
||||||
`calc(100dvh - var(--app-shell-header-height, 0px) - ${isMobile && !headerConfig.collapsed ? '4rem' : '0px'} - 1.285rem)`,
|
const appShellBottomPadding = isMobile ? '70px' : '0px';
|
||||||
[isMobile, headerConfig.collapsed]);
|
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;
|
return height;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { createTheme, MantineProvider as MantineProviderCore } from "@mantine/core";
|
import { createTheme, MantineProvider as MantineProviderCore } from "@mantine/core";
|
||||||
import ColorSchemeProvider from "./color-scheme-provider";
|
import ColorSchemeProvider from "./color-scheme-provider";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
const commonInputStyles = {
|
const commonInputStyles = {
|
||||||
label: {
|
label: {
|
||||||
@@ -45,10 +46,18 @@ const theme = createTheme({
|
|||||||
|
|
||||||
const MantineProvider = ({ children }: { children: React.ReactNode }) => {
|
const MantineProvider = ({ children }: { children: React.ReactNode }) => {
|
||||||
const { metadata } = useAuth()
|
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
|
return <MantineProviderCore
|
||||||
defaultColorScheme={metadata.colorScheme || 'auto'}
|
defaultColorScheme={colorScheme}
|
||||||
theme={{ ...theme, primaryColor: metadata.accentColor || 'blue' }}
|
theme={{ ...theme, primaryColor }}
|
||||||
>
|
>
|
||||||
<ColorSchemeProvider>
|
<ColorSchemeProvider>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export function transformTeam(record: any): Team {
|
|||||||
new Date(a.created!) < new Date(b.created!) ? -1 : 0
|
new Date(a.created!) < new Date(b.created!) ? -1 : 0
|
||||||
)
|
)
|
||||||
?.map(transformPlayer) ?? [];
|
?.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 {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -50,6 +56,7 @@ export function transformTeam(record: any): Team {
|
|||||||
created: record.created,
|
created: record.created,
|
||||||
updated: record.updated,
|
updated: record.updated,
|
||||||
players,
|
players,
|
||||||
|
tournaments
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user