diff --git a/src/app/routeTree.gen.ts b/src/app/routeTree.gen.ts index 78b6498..1c86190 100644 --- a/src/app/routeTree.gen.ts +++ b/src/app/routeTree.gen.ts @@ -26,6 +26,7 @@ import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$t 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 AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket' import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id' import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id' import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo' @@ -118,6 +119,12 @@ const AuthedAdminTournamentsIndexRoute = path: '/tournaments/', getParentRoute: () => AuthedAdminRoute, } as any) +const AuthedTournamentsIdBracketRoute = + AuthedTournamentsIdBracketRouteImport.update({ + id: '/tournaments/$id/bracket', + path: '/tournaments/$id/bracket', + getParentRoute: () => AuthedRoute, + } as any) const AuthedAdminTournamentsIdRoute = AuthedAdminTournamentsIdRouteImport.update({ id: '/tournaments/$id', @@ -206,6 +213,7 @@ export interface FileRoutesByFullPath { '/admin/': typeof AuthedAdminIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute + '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute } @@ -223,6 +231,7 @@ export interface FileRoutesByTo { '/admin': typeof AuthedAdminIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute + '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute } @@ -243,6 +252,7 @@ export interface FileRoutesById { '/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute + '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute } @@ -263,6 +273,7 @@ export interface FileRouteTypes { | '/admin/' | '/tournaments' | '/admin/tournaments/$id' + | '/tournaments/$id/bracket' | '/admin/tournaments' | '/admin/tournaments/run/$id' fileRoutesByTo: FileRoutesByTo @@ -280,6 +291,7 @@ export interface FileRouteTypes { | '/admin' | '/tournaments' | '/admin/tournaments/$id' + | '/tournaments/$id/bracket' | '/admin/tournaments' | '/admin/tournaments/run/$id' id: @@ -299,6 +311,7 @@ export interface FileRouteTypes { | '/_authed/admin/' | '/_authed/tournaments/' | '/_authed/admin/tournaments/$id' + | '/_authed/tournaments/$id/bracket' | '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/run/$id' fileRoutesById: FileRoutesById @@ -512,6 +525,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedAdminTournamentsIndexRouteImport parentRoute: typeof AuthedAdminRoute } + '/_authed/tournaments/$id/bracket': { + id: '/_authed/tournaments/$id/bracket' + path: '/tournaments/$id/bracket' + fullPath: '/tournaments/$id/bracket' + preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport + parentRoute: typeof AuthedRoute + } '/_authed/admin/tournaments/$id': { id: '/_authed/admin/tournaments/$id' path: '/tournaments/$id' @@ -639,6 +659,7 @@ interface AuthedRouteChildren { AuthedTeamsTeamIdRoute: typeof AuthedTeamsTeamIdRoute AuthedTournamentsTournamentIdRoute: typeof AuthedTournamentsTournamentIdRoute AuthedTournamentsIndexRoute: typeof AuthedTournamentsIndexRoute + AuthedTournamentsIdBracketRoute: typeof AuthedTournamentsIdBracketRoute } const AuthedRouteChildren: AuthedRouteChildren = { @@ -650,6 +671,7 @@ const AuthedRouteChildren: AuthedRouteChildren = { AuthedTeamsTeamIdRoute: AuthedTeamsTeamIdRoute, AuthedTournamentsTournamentIdRoute: AuthedTournamentsTournamentIdRoute, AuthedTournamentsIndexRoute: AuthedTournamentsIndexRoute, + AuthedTournamentsIdBracketRoute: AuthedTournamentsIdBracketRoute, } const AuthedRouteWithChildren = diff --git a/src/app/routes/_authed/index.tsx b/src/app/routes/_authed/index.tsx index 3cdf42f..853cc4e 100644 --- a/src/app/routes/_authed/index.tsx +++ b/src/app/routes/_authed/index.tsx @@ -22,7 +22,7 @@ export const Route = createFileRoute("/_authed/")({ function Home() { const { data: tournament } = useCurrentTournament(); - if (!tournament.matches || tournament.matches.length === 0) { + if (!tournament.matches || tournament.matches.length !== 0) { return ; } diff --git a/src/app/routes/_authed/profile.$playerId.tsx b/src/app/routes/_authed/profile.$playerId.tsx index f108e88..d88e56f 100644 --- a/src/app/routes/_authed/profile.$playerId.tsx +++ b/src/app/routes/_authed/profile.$playerId.tsx @@ -1,7 +1,10 @@ import Profile from "@/features/players/components/profile"; +import HeaderSkeleton from "@/features/players/components/profile/header-skeleton"; +import ProfileSkeleton from "@/features/players/components/profile/skeleton"; import { playerKeys, playerQueries } from "@/features/players/queries"; import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch"; import { createFileRoute } from "@tanstack/react-router"; +import { Suspense } from "react"; import { z } from "zod"; const searchSchema = z.object({ @@ -35,6 +38,8 @@ export const Route = createFileRoute("/_authed/profile/$playerId")({ }), component: () => { const { playerId } = Route.useParams(); - return ; + return }> + + ; }, }); diff --git a/src/app/routes/_authed/tournaments/$id.bracket.tsx b/src/app/routes/_authed/tournaments/$id.bracket.tsx new file mode 100644 index 0000000..24a810d --- /dev/null +++ b/src/app/routes/_authed/tournaments/$id.bracket.tsx @@ -0,0 +1,82 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { + tournamentQueries, + useTournament, +} from "@/features/tournaments/queries"; +import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure"; +import SeedTournament from "@/features/tournaments/components/seed-tournament"; +import { Container } from "@mantine/core"; +import { useMemo } from "react"; +import { BracketData } from "@/features/bracket/types"; +import { Match } from "@/features/matches/types"; +import BracketView from "@/features/bracket/components/bracket-view"; +import { SpotifyControlsBar } from "@/features/spotify/components"; + +export const Route = createFileRoute("/_authed/tournaments/$id/bracket")({ + beforeLoad: async ({ context, params }) => { + const { queryClient } = context; + const tournament = await ensureServerQueryData( + queryClient, + tournamentQueries.details(params.id) + ); + if (!tournament) throw redirect({ to: "/admin/tournaments" }); + return { + tournament, + }; + }, + loader: ({ context }) => ({ + fullWidth: true, + withPadding: false, + showSpotifyPanel: true, + header: { + withBackButton: true, + title: `${context.tournament.name}`, + }, + }), + component: RouteComponent, +}); + +function RouteComponent() { + const { id } = Route.useParams(); + const { data: tournament } = useTournament(id); + + const bracket: BracketData = useMemo(() => { + if (!tournament.matches || tournament.matches.length === 0) { + return { winners: [], losers: [] }; + } + + const winnersMap = new Map(); + const losersMap = new Map(); + + tournament.matches + .sort((a, b) => a.lid - b.lid) + .forEach((match) => { + if (!match.is_losers_bracket) { + if (!winnersMap.has(match.round)) { + winnersMap.set(match.round, []); + } + winnersMap.get(match.round)!.push(match); + } else { + if (!losersMap.has(match.round)) { + losersMap.set(match.round, []); + } + losersMap.get(match.round)!.push(match); + } + }); + + const winners = Array.from(winnersMap.entries()) + .sort(([a], [b]) => a - b) + .map(([, matches]) => matches); + + const losers = Array.from(losersMap.entries()) + .sort(([a], [b]) => a - b) + .map(([, matches]) => matches); + return { winners, losers }; + }, [tournament.matches]); + + return ( + + + + ); +} diff --git a/src/app/routes/api/spotify/callback.ts b/src/app/routes/api/spotify/callback.ts index f1b887b..0fbcaae 100644 --- a/src/app/routes/api/spotify/callback.ts +++ b/src/app/routes/api/spotify/callback.ts @@ -7,7 +7,6 @@ const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI! export const ServerRoute = createServerFileRoute('/api/spotify/callback').methods({ GET: async ({ request }: { request: Request }) => { - // Helper function to get return path from state parameter const getReturnPath = (state: string | null): string => { if (!state) return '/'; try { @@ -26,7 +25,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/callback').method const returnPath = getReturnPath(state); - // Check for OAuth errors if (error) { console.error('Spotify OAuth error:', error) return new Response(null, { @@ -54,7 +52,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/callback').method has_state: !!state, }) - // Exchange code for tokens const tokenResponse = await fetch('https://accounts.spotify.com/api/token', { method: 'POST', headers: { @@ -77,7 +74,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/callback').method redirect_uri: SPOTIFY_REDIRECT_URI, }) - // Return more detailed error info const errorParam = encodeURIComponent(`${tokenResponse.status}: ${errorText}`) return new Response(null, { status: 302, @@ -97,7 +93,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/callback').method console.log('Decoded return path:', returnPath); - // Create response with redirect to original path const response = new Response(null, { status: 302, headers: { @@ -105,14 +100,12 @@ export const ServerRoute = createServerFileRoute('/api/spotify/callback').method }, }) - // Set secure cookies for tokens const isSecure = process.env.NODE_ENV === 'production' const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}` response.headers.append('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`) if (tokens.refresh_token) { - // Refresh token doesn't expire, set longer max age const refreshCookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${60 * 60 * 24 * 30}` // 30 days response.headers.append('Set-Cookie', `spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`) } @@ -120,7 +113,6 @@ export const ServerRoute = createServerFileRoute('/api/spotify/callback').method return response } catch (error) { console.error('Spotify callback error:', error) - // Try to get return path from query params if available, otherwise use default const url = new URL(request.url); const state = url.searchParams.get('state'); const returnPath = getReturnPath(state); diff --git a/src/components/rich-text-editor.tsx b/src/components/rich-text-editor.tsx index 74fd872..a08103c 100644 --- a/src/components/rich-text-editor.tsx +++ b/src/components/rich-text-editor.tsx @@ -34,7 +34,7 @@ export function RichTextEditor({ - + ); } diff --git a/src/features/matches/components/match-card.tsx b/src/features/matches/components/match-card.tsx index 80e8d5c..6f1fa3e 100644 --- a/src/features/matches/components/match-card.tsx +++ b/src/features/matches/components/match-card.tsx @@ -36,7 +36,7 @@ const MatchCard = ({ match }: MatchCardProps) => { color="red" processing position="top-end" - offset={2} + offset={24} > { + return ( + + + + + + + ); +}; + +export default HeaderSkeleton; \ No newline at end of file diff --git a/src/features/players/components/profile/header.tsx b/src/features/players/components/profile/header.tsx index ad82b04..c51cc90 100644 --- a/src/features/players/components/profile/header.tsx +++ b/src/features/players/components/profile/header.tsx @@ -33,7 +33,7 @@ const Header = ({ player }: HeaderProps) => { return ( <> - + {name} diff --git a/src/features/players/components/profile/skeleton.tsx b/src/features/players/components/profile/skeleton.tsx new file mode 100644 index 0000000..0373ca4 --- /dev/null +++ b/src/features/players/components/profile/skeleton.tsx @@ -0,0 +1,42 @@ +import { Box, Flex, Loader } from "@mantine/core"; +import Header from "./header"; +import SwipeableTabs from "@/components/swipeable-tabs"; +import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries"; +import TeamList from "@/features/teams/components/team-list"; +import StatsOverview from "@/shared/components/stats-overview"; +import MatchList from "@/features/matches/components/match-list"; +import HeaderSkeleton from "./header-skeleton"; + +const SkeletonLoader = () => ( + + + +) + +const ProfileSkeleton = () => { + const tabs = [ + { + label: "Overview", + content: , + }, + { + label: "Matches", + content: , + }, + { + label: "Teams", + content: , + }, + ]; + + return ( + <> + + + + + + ); +}; + +export default ProfileSkeleton; diff --git a/src/features/teams/components/team-form/index.tsx b/src/features/teams/components/team-form/index.tsx index acad779..6bd189a 100644 --- a/src/features/teams/components/team-form/index.tsx +++ b/src/features/teams/components/team-form/index.tsx @@ -87,9 +87,9 @@ const TeamForm = ({ const form = useForm(config); const queryClient = useQueryClient(); - const { mutate: createTournament, isPending: createPending } = + const { mutate: createTeam, isPending: createPending } = useCreateTeam(); - const { mutate: updateTournament, isPending: updatePending } = useUpdateTeam( + const { mutate: updateTeam, isPending: updatePending } = useUpdateTeam( teamId! ); @@ -99,7 +99,7 @@ const TeamForm = ({ async (values: TeamInput) => { const { logo, ...teamData } = values; - const mutation = isEditMode ? updateTournament : createTournament; + const mutation = isEditMode ? updateTeam : createTeam; const errorMessage = isEditMode ? "Failed to update team" : "Failed to create team"; @@ -156,7 +156,7 @@ const TeamForm = ({ }, }); }, - [isEditMode, createTournament, updateTournament, queryClient] + [isEditMode, createTeam, updateTeam, queryClient] ); return ( diff --git a/src/features/tournaments/components/edit-rules.tsx b/src/features/tournaments/components/edit-rules.tsx new file mode 100644 index 0000000..51816d0 --- /dev/null +++ b/src/features/tournaments/components/edit-rules.tsx @@ -0,0 +1,44 @@ +import { + Stack, + Button +} from "@mantine/core"; +import { useState, useCallback } from "react"; +import { useTournament } from "../queries"; +import useUpdateTournament from "../hooks/use-update-tournament"; +import { RichTextEditor } from "@/components/rich-text-editor"; + +interface EditRulesProps { + tournamentId: string; + onClose?: () => void +} + +const EditRules = ({ tournamentId, onClose }: EditRulesProps) => { + const [search, setSearch] = useState(""); + + const { data: tournament, isLoading: tournamentLoading } = + useTournament(tournamentId); + + const { mutate: updateTournament, isPending: updatePending } = useUpdateTournament(tournamentId); + const [value, setValue] = useState(tournament.rules); + + const handleSubmit = useCallback( + (rules?: string) => { + updateTournament({ rules }, { + onSuccess: () => { + onClose?.(); + } + }); + }, + [updateTournament, tournamentId] + ); + + return ( + + + + + + ); +}; + +export default EditRules; diff --git a/src/features/tournaments/components/manage-tournament.tsx b/src/features/tournaments/components/manage-tournament.tsx index 783b0ad..584e4da 100644 --- a/src/features/tournaments/components/manage-tournament.tsx +++ b/src/features/tournaments/components/manage-tournament.tsx @@ -14,6 +14,7 @@ import EditEnrolledTeams from "./edit-enrolled-teams"; import ListLink from "@/components/list-link"; import { RichTextEditor } from "@/components/rich-text-editor"; import React from "react"; +import EditRules from "./edit-rules"; interface ManageTournamentProps { tournamentId: string; @@ -90,9 +91,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => { opened={editRulesOpened} onChange={closeEditRules} > - - - {v} + = ({ tournament, @@ -42,8 +43,6 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({ queryClient.invalidateQueries({ queryKey: tournamentKeys.current }) } - console.log(userTeam) - return (
@@ -102,7 +101,12 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({ Icon={UsersIcon} /> )} - {}} /> + + diff --git a/src/features/tournaments/components/upcoming-tournament/rules-list-button.tsx b/src/features/tournaments/components/upcoming-tournament/rules-list-button.tsx new file mode 100644 index 0000000..23791c6 --- /dev/null +++ b/src/features/tournaments/components/upcoming-tournament/rules-list-button.tsx @@ -0,0 +1,45 @@ +import ListButton from "@/components/list-button" +import Sheet from "@/components/sheet/sheet" +import { useSheet } from "@/hooks/use-sheet" +import { ListIcon } from "@phosphor-icons/react" +import { useTournament } from "../../queries" +import { useEditor } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import { RichTextEditor } from '@mantine/tiptap'; +import { Button, Stack } from "@mantine/core" + +interface RulesListButtonProps { + tournamentId: string; +} + +const RulesListButton: React.FC = ({ tournamentId }) => { + const { data: tournament } = useTournament(tournamentId); + const { open, isOpen, toggle } = useSheet(); + + const editor = useEditor({ + extensions: [StarterKit], + content: tournament?.rules || '', + editable: false, + }); + + return ( + <> + + + + + + + + + + + + ) +} + +export default RulesListButton; \ No newline at end of file