perf upgrade

This commit is contained in:
yohlo
2026-07-12 21:26:16 -07:00
parent ec334bbed4
commit 778f0f7994
74 changed files with 1522 additions and 1014 deletions
+1 -2
View File
@@ -2,7 +2,7 @@ FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lockb* ./
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
@@ -21,7 +21,6 @@ EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
ENV NITRO_PORT=3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD bun -e "fetch('http://localhost:3000/api/health').then(r => r.ok ? process.exit(0) : process.exit(1)).catch(() => process.exit(1))"
+747 -508
View File
File diff suppressed because it is too large Load Diff
+14 -27
View File
@@ -5,65 +5,53 @@
"type": "module",
"scripts": {
"dev": "vite dev --host 0.0.0.0",
"build": "vite build && tsc --noEmit",
"build": "vite build && tsc --noEmit && bun scripts/generate-sw.mjs",
"start": "bun run server.ts"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
"@mantine/carousel": "^8.2.4",
"@mantine/charts": "^8.2.4",
"@mantine/core": "^8.2.4",
"@mantine/dates": "^8.2.4",
"@mantine/form": "^8.2.4",
"@mantine/hooks": "^8.2.4",
"@mantine/tiptap": "^8.2.4",
"@phosphor-icons/react": "^2.1.10",
"@svgmoji/noto": "^3.2.0",
"@tanstack/react-devtools": "^0.7.6",
"@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0",
"@tanstack/react-router": "^1.143.6",
"@tanstack/react-router-devtools": "^1.143.6",
"@tanstack/react-router-ssr-query": "^1.143.6",
"@tanstack/react-start": "^1.143.6",
"@tanstack/react-virtual": "^3.13.12",
"@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.101.2",
"@tanstack/react-router": "^1.170.17",
"@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/react-router-ssr-query": "^1.167.1",
"@tanstack/react-start": "^1.168.27",
"@tiptap/pm": "^3.4.3",
"@tiptap/react": "^3.4.3",
"@tiptap/starter-kit": "^3.4.3",
"@types/bun": "^1.2.22",
"@types/ioredis": "^4.28.10",
"browser-image-compression": "^2.0.2",
"dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0",
"facehash": "^0.0.7",
"framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
"pg": "^8.16.3",
"pocketbase": "^0.26.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-imask": "^7.6.1",
"react-scan": "^0.4.3",
"react-use-draggable-scroll": "^0.4.7",
"recharts": "^3.1.2",
"redaxios": "^0.5.1",
"sonner": "^2.0.7",
"supertokens-node": "^23.0.1",
"supertokens-web-js": "^0.15.0",
"twilio": "^5.8.0",
"vaul": "^1.1.2",
"xlsx": "^0.18.5",
"zod": "^4.0.15",
"zustand": "^5.0.7"
"zod": "^4.0.15"
},
"devDependencies": {
"@tanstack/router-plugin": "^1.132.2",
"@types/node": "^22.5.4",
"@types/pg": "^8.15.5",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^10.0.0",
"postcss": "^8.5.1",
"postcss-preset-mantine": "^1.18.0",
@@ -71,8 +59,7 @@
"tsx": "^4.20.3",
"typescript": "^5.7.2",
"vite": "^7.1.7",
"vite-plugin-pwa": "^1.2.0",
"vite-tsconfig-paths": "^5.1.4",
"workbox-window": "^7.4.0"
"workbox-build": "^7.4.1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 273 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 189 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 284 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 31 KiB

+7 -9
View File
@@ -343,7 +343,10 @@ async function initializeStaticRoutes(
gz,
etag,
type: metadata.type,
immutable: true,
// Only Vite's content-hashed output under /assets/ is safe to
// cache immutably; unhashed files (favicon, manifest, images)
// can change between deploys and must revalidate.
immutable: route.startsWith('/assets/'),
size: bytes.byteLength,
}
routes[route] = createResponseHandler(asset)
@@ -529,14 +532,9 @@ async function initializeServer() {
// Fallback to TanStack Start handler for all other routes
'/*': async (req: Request) => {
try {
const h3Response = await handler.fetch(req)
const body = await h3Response.arrayBuffer()
return new Response(body, {
status: h3Response.status,
statusText: h3Response.statusText,
headers: h3Response.headers,
})
// Return the handler's Response as-is so streaming bodies
// (SSR streaming, SSE event streams) pass through unbuffered.
return await handler.fetch(req)
} catch (error) {
log.error(`Server handler error: ${String(error)}`)
return new Response('Internal Server Error', { status: 500 })
+1
View File
@@ -29,6 +29,7 @@ export function getRouter() {
fullWidth: false,
},
defaultPreload: "intent",
defaultPreloadStaleTime: 60_000,
defaultErrorComponent: DefaultCatchBoundary,
scrollRestoration: true,
defaultViewTransition: false,
+3
View File
@@ -153,6 +153,9 @@ export const Route = createRootRouteWithContext<{
function RootComponent() {
React.useEffect(() => {
ensureSuperTokensFrontend();
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
}, []);
return (
+116 -52
View File
@@ -1,4 +1,4 @@
import { createContext, useCallback, useEffect, useState, PropsWithChildren } from 'react';
import { createContext, useCallback, useEffect, useMemo, useState, PropsWithChildren } from 'react';
import { SpotifyAuth } from '@/lib/spotify/auth';
import { useAuth } from './auth-context';
import { useConfig } from '@/hooks/use-config';
@@ -21,6 +21,62 @@ const defaultSpotifyState: SpotifyAuthState = {
export const SpotifyContext = createContext<SpotifyContextType | null>(null);
const deepEqual = (a: unknown, b: unknown): boolean => {
if (a === b) return true;
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
return false;
}
if (Array.isArray(a) !== Array.isArray(b)) return false;
const aKeys = Object.keys(a as Record<string, unknown>);
const bKeys = Object.keys(b as Record<string, unknown>);
if (aKeys.length !== bKeys.length) return false;
return aKeys.every((key) =>
deepEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key]
)
);
};
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
const response = await fetch(`/api/spotify/${endpoint}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
let errorMessage = 'Request failed';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
} catch {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
throw new Error(errorMessage);
}
if (response.status === 204 || response.headers.get('content-length') === '0') {
return {};
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
console.warn(`Non-JSON response from ${endpoint}:`, contentType);
return {};
}
try {
return await response.json();
} catch (error) {
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
return {};
}
};
export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false;
@@ -41,9 +97,9 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const [isCaptureLoading, setIsCaptureLoading] = useState(false);
const [isResumeLoading, setIsResumeLoading] = useState(false);
const spotifyAuth = new SpotifyAuth(
config.spotifyClientId,
config.spotifyRedirectUri
const spotifyAuth = useMemo(
() => new SpotifyAuth(config.spotifyClientId, config.spotifyRedirectUri),
[config.spotifyClientId, config.spotifyRedirectUri]
);
useEffect(() => {
@@ -126,45 +182,6 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
document.cookie = 'spotify_refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
}, []);
const makeSpotifyRequest = async (endpoint: string, options: RequestInit = {}) => {
const response = await fetch(`/api/spotify/${endpoint}`, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
});
if (!response.ok) {
let errorMessage = 'Request failed';
try {
const errorData = await response.json();
errorMessage = errorData.error || errorMessage;
} catch {
errorMessage = `HTTP ${response.status}: ${response.statusText}`;
}
throw new Error(errorMessage);
}
if (response.status === 204 || response.headers.get('content-length') === '0') {
return {};
}
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
console.warn(`Non-JSON response from ${endpoint}:`, contentType);
return {};
}
try {
return await response.json();
} catch (error) {
console.warn(`Failed to parse JSON response from ${endpoint}:`, error);
return {};
}
};
const play = useCallback(async (deviceId?: string) => {
if (!authState.isAuthenticated) return;
@@ -350,12 +367,17 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
try {
const data = await makeSpotifyRequest('playback?type=state');
const state = data.playbackState;
setPlaybackState(state);
setCurrentTrack(state?.item || null);
setPlaybackState((prev) => (deepEqual(prev, state ?? null) ? prev : state));
setCurrentTrack((prev) => {
const next = state?.item || null;
return deepEqual(prev, next) ? prev : next;
});
if (state?.device) {
setActiveDeviceState(state.device);
setActiveDeviceState((prev) =>
deepEqual(prev, state.device) ? prev : state.device
);
}
} catch (error) {
console.warn('Failed to refresh playback state:', error);
@@ -365,8 +387,24 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
useEffect(() => {
if (!authState.isAuthenticated) return;
const interval = setInterval(refreshPlaybackState, 5000);
return () => clearInterval(interval);
const poll = () => {
if (document.hidden) return;
refreshPlaybackState();
};
const handleVisibilityChange = () => {
if (!document.hidden) {
refreshPlaybackState();
}
};
const interval = setInterval(poll, 5000);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
clearInterval(interval);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [authState.isAuthenticated, refreshPlaybackState]);
const capturePlaybackState = useCallback(async () => {
@@ -432,7 +470,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
}
}, []);
const contextValue: SpotifyContextType = {
const contextValue: SpotifyContextType = useMemo(() => ({
...authState,
currentTrack,
playbackState,
@@ -461,7 +499,33 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
clearCapturedState,
// Search
searchTracks,
};
}), [
authState,
currentTrack,
playbackState,
devices,
activeDevice,
isLoading,
error,
capturedState,
isCaptureLoading,
isResumeLoading,
login,
logout,
play,
playTrack,
pause,
skipNext,
skipPrevious,
setVolume,
getDevices,
setActiveDevice,
refreshPlaybackState,
capturePlaybackState,
resumePlaybackState,
clearCapturedState,
searchTracks,
]);
if (!isAdmin) {
return <>{children}</>;
+1 -1
View File
@@ -16,7 +16,7 @@ const activitySearchParamsSchema = z.object({
});
export const searchActivities = createServerFn()
.inputValidator(activitySearchParamsSchema)
.validator(activitySearchParamsSchema)
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<ActivityListResult>(async () => {
@@ -47,6 +47,7 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?
alt={badge.name}
width={size}
height={size}
loading="lazy"
onError={() => setImageError(true)}
style={{
objectFit: 'contain',
+2 -2
View File
@@ -5,7 +5,7 @@ import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
export const getPlayerBadges = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: playerId }) =>
toServerResult(() => pbAdmin.getPlayerBadgeProgress(playerId))
@@ -28,7 +28,7 @@ export const getAllEarnedBadges = createServerFn()
.handler(async () => toServerResult(() => pbAdmin.listEarnedBadges()));
export const awardManualBadge = createServerFn()
.inputValidator(z.object({
.validator(z.object({
playerId: z.string(),
badgeId: z.string(),
}))
+1 -1
View File
@@ -9,7 +9,7 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
const logger = new Logger("Bracket Generation");
export const previewBracket = createServerFn()
.inputValidator(z.number())
.validator(z.number())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teams }) =>
toServerResult(async () => {
+3 -5
View File
@@ -1,5 +1,5 @@
import { AppShell } from '@mantine/core';
import { PropsWithChildren, useState } from 'react';
import { PropsWithChildren } from 'react';
import Header from './header';
import Navbar from './navbar';
import Pullable from './pullable';
@@ -8,10 +8,8 @@ import useRouterConfig from '../hooks/use-router-config';
import Page from '@/components/page';
const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const { header } = useRouterConfig();
const { header, withPadding, fullWidth } = useRouterConfig();
const viewport = useVisualViewportSize();
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
const { withPadding, fullWidth } = useRouterConfig();
return (
<AppShell
@@ -47,7 +45,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
maw='100dvw'
style={{ transition: 'none', overflow: 'hidden' }}
>
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}>
<Pullable>
<Page noPadding={!withPadding} fullWidth={fullWidth}>
{children}
</Page>
@@ -19,14 +19,13 @@ export const NavLink = ({
include,
exclude,
}: NavLinkProps) => {
const router = useRouterState();
const pathname = useRouterState({ select: (s) => s.location.pathname });
const isActive = useMemo(
() =>
(!exclude?.some((e) => router.location.pathname.includes(e)) &&
(router.location.pathname === href ||
(router.location.pathname.includes(href) && href !== "/"))) ||
include?.includes(router.location.pathname),
[router.location.pathname, href]
(!exclude?.some((e) => pathname.includes(e)) &&
(pathname === href || (pathname.includes(href) && href !== "/"))) ||
include?.includes(pathname),
[pathname, href]
);
return (
+12 -10
View File
@@ -8,15 +8,10 @@ import { useLocation } from "@tanstack/react-router";
const THRESHOLD = 80;
interface PullableProps extends PropsWithChildren {
scrollPosition: { x: number, y: number };
onScrollPositionChange: (position: { x: number, y: number }) => void;
}
/**
* Pullable is a component that allows the user to pull down to refresh the page
*/
const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollPositionChange }) => {
const Pullable: React.FC<PropsWithChildren> = ({ children }) => {
const height = useAppShellHeight();
const [isRefreshing, setIsRefreshing] = useState(false);
const [scrolling, setScrolling] = useState(false);
@@ -25,7 +20,14 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
const location = useLocation();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
const [pullDistance, setPullDistance] = useState(0);
const handleScrollPositionChange = useCallback((position: { x: number, y: number }) => {
const next = position.y < 0 ? Math.abs(position.y) : 0;
setPullDistance((prev) => (prev === next ? prev : next));
}, []);
const scrollY = scrolling ? pullDistance : 0;
const onTrigger = useCallback(async () => {
setIsRefreshing(true);
@@ -143,11 +145,11 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
viewport.scrollLeft = 0;
}
}
onScrollPositionChange({ x: 0, y: 0 });
setPullDistance(0);
}, 10);
return () => clearTimeout(timeoutId);
}, [location.pathname, onScrollPositionChange]);
}, [location.pathname]);
return (
@@ -175,7 +177,7 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
<ScrollArea
ref={scrollAreaRef}
id='scroll-wrapper'
onScrollPositionChange={onScrollPositionChange}
onScrollPositionChange={handleScrollPositionChange}
type='never' mah='100%' h='100%'
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
styles={{
+24 -21
View File
@@ -1,4 +1,5 @@
import { useMatches } from "@tanstack/react-router";
import { useMemo } from "react";
import { HeaderConfig } from "../types/header-config";
export const defaultHeaderConfig: HeaderConfig = {
@@ -10,32 +11,34 @@ export const defaultHeaderConfig: HeaderConfig = {
const useRouterConfig = () => {
const matches = useMatches();
const matchesWithHeader = matches.filter((match) =>
match?.loaderData && 'header' in match.loaderData
);
return useMemo(() => {
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,
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);
return acc;
}, defaultHeaderConfig);
const current = matches[matches.length - 1]?.loaderData;
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,
fullWidth: current && typeof current === 'object' && 'fullWidth' in current ? current.fullWidth : false,
};
return {
header: headerConfig,
refresh: current && typeof current === 'object' && 'refresh' in current ? current.refresh : [],
withPadding: current && typeof current === 'object' && 'withPadding' in current ? current.withPadding : true,
fullWidth: current && typeof current === 'object' && 'fullWidth' in current ? current.fullWidth : false,
};
}, [matches]);
}
export default useRouterConfig;
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
const eventListerOptions = {
passive: true,
@@ -11,25 +11,49 @@ const useVisualViewportSize = () => {
height: windowExists ? window.visualViewport?.height || 0 : 0,
top: windowExists ? window.visualViewport?.offsetTop || 0 : 0,
});
const setSize = useCallback(() => {
if (!windowExists) return;
setWindowSize({ width: window.visualViewport?.width || 0, height: window.visualViewport?.height || 0, top: window.visualViewport?.offsetTop || 0 });
}, []);
const rafRef = useRef<number | null>(null);
useEffect(() => {
if (!windowExists) return;
if (typeof window === 'undefined') return;
const setSize = () => {
setWindowSize((prev) => {
const next = {
width: window.visualViewport?.width || 0,
height: window.visualViewport?.height || 0,
top: window.visualViewport?.offsetTop || 0,
};
if (
prev.width === next.width &&
prev.height === next.height &&
prev.top === next.top
) {
return prev;
}
return next;
});
};
const scheduleSetSize = () => {
if (rafRef.current !== null) return;
rafRef.current = window.requestAnimationFrame(() => {
rafRef.current = null;
setSize();
});
};
setSize();
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions);
window.visualViewport?.addEventListener('scroll', setSize, eventListerOptions);
window.visualViewport?.addEventListener('resize', scheduleSetSize, eventListerOptions);
return () => {
window.visualViewport?.removeEventListener('resize', setSize);
window.visualViewport?.removeEventListener('scroll', setSize);
}
}, [setSize]);
window.visualViewport?.removeEventListener('resize', scheduleSetSize);
if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
};
}, []);
return windowSize;
}
@@ -4,7 +4,7 @@ import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types";
import TeamAvatar from "@/components/team-avatar";
import EmojiBar from "@/features/reactions/components/emoji-bar";
import { Suspense } from "react";
import { memo, Suspense } from "react";
import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet";
import TeamHeadToHeadSheet from "./team-head-to-head-sheet";
@@ -237,4 +237,4 @@ const MatchCard = ({ match, hideH2H = false }: MatchCardProps) => {
);
};
export default MatchCard;
export default memo(MatchCard);
+10 -3
View File
@@ -1,4 +1,5 @@
import { Stack, Text } from "@mantine/core";
import { useMemo } from "react";
import { Match } from "../types";
import MatchCard from "./match-card";
@@ -8,9 +9,15 @@ interface MatchListProps {
}
const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
const filteredMatches = matches?.filter(match =>
match.home && match.away && !match.bye && match.status != "tbd"
).sort((a, b) => a.start_time < b.start_time ? 1 : -1) || [];
const filteredMatches = useMemo(
() =>
[...(matches ?? [])]
.filter(match =>
match.home && match.away && !match.bye && match.status != "tbd"
)
.sort((a, b) => a.start_time < b.start_time ? 1 : -1),
[matches]
);
if (!filteredMatches.length) {
return undefined;
+8 -8
View File
@@ -17,7 +17,7 @@ const orderedTeamsSchema = z.object({
});
export const generateTournamentBracket = createServerFn()
.inputValidator(orderedTeamsSchema)
.validator(orderedTeamsSchema)
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
toServerResult(async () => {
@@ -138,7 +138,7 @@ export const generateTournamentBracket = createServerFn()
);
export const startMatch = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) =>
toServerResult(async () => {
@@ -165,7 +165,7 @@ export const startMatch = createServerFn()
);
export const populateKnockoutBracket = createServerFn()
.inputValidator(z.object({
.validator(z.object({
tournamentId: z.string(),
}))
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
@@ -459,7 +459,7 @@ const endMatchSchema = z.object({
ot_count: z.number(),
});
export const endMatch = createServerFn()
.inputValidator(endMatchSchema)
.validator(endMatchSchema)
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
toServerResult(async () => {
@@ -546,7 +546,7 @@ const toggleReactionSchema = z.object({
});
export const toggleMatchReaction = createServerFn()
.inputValidator(toggleReactionSchema)
.validator(toggleReactionSchema)
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { matchId, emoji }, context }) =>
toServerResult(async () => {
@@ -606,7 +606,7 @@ export interface Reaction {
players: PlayerInfo[];
}
export const getMatchReactions = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: matchId, context }) =>
toServerResult(async () => {
@@ -647,7 +647,7 @@ const matchesBetweenPlayersSchema = z.object({
});
export const getMatchesBetweenPlayers = createServerFn()
.inputValidator(matchesBetweenPlayersSchema)
.validator(matchesBetweenPlayersSchema)
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { player1Id, player2Id } }) =>
toServerResult(async () => {
@@ -663,7 +663,7 @@ const matchesBetweenTeamsSchema = z.object({
});
export const getMatchesBetweenTeams = createServerFn()
.inputValidator(matchesBetweenTeamsSchema)
.validator(matchesBetweenTeamsSchema)
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { team1Id, team2Id } }) =>
toServerResult(async () => {
+62 -33
View File
@@ -3,26 +3,25 @@ import { createServerFn } from "@tanstack/react-start";
import { isRedirect } from "@tanstack/react-router";
import { Player, playerInputSchema, playerUpdateSchema, PlayerStats } from "@/features/players/types";
import { Match } from "@/features/matches/types";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod";
import { logger } from ".";
import { Logger } from "@/lib/logger";
import { getRequest } from "@tanstack/react-start/server";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { serverFnLoggingMiddleware } from "@/utils/activities";
const logger = new Logger('Players');
export const fetchMe = createServerFn()
.handler(async () =>
.handler(async () =>
toServerResult(async () => {
const request = getRequest();
try {
const context = await getSessionContext(request);
await pbAdmin.authPromise;
const result = await pbAdmin.getPlayerByAuthId(context.userAuthId);
return {
user: result || undefined,
roles: context.roles,
user: context.player || undefined,
roles: context.roles,
metadata: context.metadata,
phone: context.phone
};
@@ -35,20 +34,25 @@ export const fetchMe = createServerFn()
);
export const getPlayer = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
.handler(async ({ data }) =>
toServerResult<Player>(async () => {
const { pbAdmin } = await import("@/lib/pocketbase/client");
return await pbAdmin.getPlayer(data);
})
);
export const updatePlayer = createServerFn()
.inputValidator(playerUpdateSchema)
.validator(playerUpdateSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) =>
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
if (!userAuthId) return;
const { pbAdmin } = await import("@/lib/pocketbase/client");
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
if (!existing) return;
@@ -69,13 +73,15 @@ export const updatePlayer = createServerFn()
);
export const createPlayer = createServerFn()
.inputValidator(playerInputSchema)
.validator(playerInputSchema)
.middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) =>
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
if (!userAuthId) return;
const { pbAdmin } = await import("@/lib/pocketbase/client");
const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
if (existing) return;
@@ -94,21 +100,23 @@ export const createPlayer = createServerFn()
);
export const associatePlayer = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) =>
.handler(async ({ context, data }) =>
toServerResult(async () => {
const userAuthId = context.userAuthId;
if (!userAuthId) return;
const { pbAdmin } = await import("@/lib/pocketbase/client");
const p = await pbAdmin.getPlayer(data);
await pbAdmin.updatePlayer(data, {
auth_id: userAuthId
});
await setUserMetadata({ data: {
player_id: data,
await setUserMetadata({ data: {
player_id: data,
first_name: p?.first_name,
last_name: p?.last_name
}});
@@ -121,49 +129,70 @@ export const associatePlayer = createServerFn()
export const listPlayers = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () =>
toServerResult(pbAdmin.listPlayers)
.handler(async () =>
toServerResult(async () => {
const { pbAdmin } = await import("@/lib/pocketbase/client");
return await pbAdmin.listPlayers();
})
);
export const getUnassociatedPlayers = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () =>
toServerResult(pbAdmin.getUnassociatedPlayers)
.handler(async () =>
toServerResult(async () => {
const { pbAdmin } = await import("@/lib/pocketbase/client");
return await pbAdmin.getUnassociatedPlayers();
})
);
export const getPlayerStats = createServerFn()
.inputValidator(z.object({
.validator(z.object({
playerId: z.string(),
viewType: z.enum(['all', 'mainline', 'regional']).optional()
}))
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data.playerId, data.viewType))
toServerResult<PlayerStats>(async () => {
const { pbAdmin } = await import("@/lib/pocketbase/client");
return await pbAdmin.getPlayerStats(data.playerId, data.viewType);
})
);
export const getAllPlayerStats = createServerFn()
.inputValidator(z.enum(['all', 'mainline', 'regional']).optional())
.validator(z.enum(['all', 'mainline', 'regional']).optional())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<PlayerStats[]>(async () => await pbAdmin.getAllPlayerStats(data))
toServerResult<PlayerStats[]>(async () => {
const { pbAdmin } = await import("@/lib/pocketbase/client");
return await pbAdmin.getAllPlayerStats(data);
})
);
export const getPlayerMatches = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
toServerResult<Match[]>(async () => {
const { pbAdmin } = await import("@/lib/pocketbase/client");
return await pbAdmin.getPlayerMatches(data);
})
);
export const getUnenrolledPlayers = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) =>
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
toServerResult(async () => {
const { pbAdmin } = await import("@/lib/pocketbase/client");
return await pbAdmin.getUnenrolledPlayers(tournamentId);
})
);
export const getPlayersActivity = createServerFn()
.middleware([superTokensFunctionMiddleware])
.handler(async () =>
toServerResult<Player[]>(async () => await pbAdmin.getPlayersActivity())
toServerResult<Player[]>(async () => {
const { pbAdmin } = await import("@/lib/pocketbase/client");
return await pbAdmin.getPlayersActivity();
})
);
@@ -1,5 +1,5 @@
import { Box, ColorSwatch, Group, Text } from '@mantine/core';
import { updateUserAccentColor } from '@/utils/supertokens';
import { updateUserAccentColor } from '@/features/settings/server';
import { useAuth } from '@/contexts/auth-context';
const colors = ['blue', 'red', 'green', 'yellow', 'grape', 'orange', 'pink', 'lime'];
@@ -1,6 +1,6 @@
import { Center, Box, Text, SegmentedControl, MantineColorScheme } from '@mantine/core';
import { SunIcon, MoonIcon, Icon, MonitorIcon } from '@phosphor-icons/react'
import { updateUserColorScheme } from '@/utils/supertokens';
import { updateUserColorScheme } from '@/features/settings/server';
import { useAuth } from '@/contexts/auth-context';
interface ColorSchemeLabelProps {
+49
View File
@@ -0,0 +1,49 @@
import { createServerFn } from "@tanstack/react-start";
import { superTokensFunctionMiddleware } from "@/utils/supertokens";
import { serverFnLoggingMiddleware } from "@/utils/activities";
export const updateUserColorScheme = createServerFn({ method: "POST" })
.validator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
const { updateUserMetadataFields } = await import(
"@/utils/supertokens-core.server"
);
await updateUserMetadataFields(userAuthId, {
colorScheme: data,
});
return {
metadata: {
...metadata,
colorScheme: data,
},
};
});
export const updateUserAccentColor = createServerFn({ method: "POST" })
.validator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
const { updateUserMetadataFields } = await import(
"@/utils/supertokens-core.server"
);
await updateUserMetadataFields(userAuthId, {
accentColor: data,
});
return {
metadata: {
...metadata,
accentColor: data,
},
};
});
@@ -14,7 +14,6 @@ import { TeamInput } from "../../types";
import { teamKeys } from "../../queries";
import SongPicker from "./song-picker";
import PlayersPicker from "./players-picker";
import imageCompression from "browser-image-compression";
interface TeamFormProps {
close: () => void;
@@ -120,6 +119,7 @@ const TeamForm = ({
};
try {
const { default: imageCompression } = await import("browser-image-compression");
processedLogo = await imageCompression(logo, compressionOptions);
logger.info("image compressed", {
originalSize: logo.size,
@@ -7,7 +7,6 @@ import { SpotifyTrack } from "@/lib/spotify/types";
import SongSearch from "./song-search";
import DurationPicker from "./duration-picker";
import SongSummary from "./song-summary";
import { MusicNote } from "@phosphor-icons/react/dist/ssr";
import { MusicNoteIcon } from "@phosphor-icons/react";
interface Song {
+6 -6
View File
@@ -16,21 +16,21 @@ export const listTeamInfos = createServerFn()
);
export const getTeam = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeam(teamId))
);
export const getTeamInfo = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeamInfo(teamId))
);
export const createTeam = createServerFn()
.inputValidator(teamInputSchema)
.validator(teamInputSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data, context }) =>
toServerResult(async () => {
@@ -47,7 +47,7 @@ export const createTeam = createServerFn()
);
export const updateTeam = createServerFn()
.inputValidator(z.object({
.validator(z.object({
id: z.string(),
updates: teamUpdateSchema
}))
@@ -73,14 +73,14 @@ export const updateTeam = createServerFn()
);
export const getTeamStats = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeamStats(teamId))
);
export const getTeamMatches = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) =>
toServerResult<Match[]>(async () => await pbAdmin.getTeamMatches(data))
@@ -0,0 +1,24 @@
import { useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { RichTextEditor } from '@mantine/tiptap';
interface RulesContentProps {
content: string;
}
const RulesContent = ({ content }: RulesContentProps) => {
const editor = useEditor({
extensions: [StarterKit],
content,
editable: false,
immediatelyRender: false,
});
return (
<RichTextEditor editor={editor}>
<RichTextEditor.Content />
</RichTextEditor>
);
};
export default RulesContent;
@@ -3,10 +3,10 @@ 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"
import { Button, Flex, Loader, Stack } from "@mantine/core"
import { lazy, Suspense } from "react"
const RulesContent = lazy(() => import("./rules-content"));
interface RulesListButtonProps {
tournamentId: string;
@@ -16,13 +16,6 @@ const RulesListButton: React.FC<RulesListButtonProps> = ({ tournamentId }) => {
const { data: tournament } = useTournament(tournamentId);
const { open, isOpen, toggle } = useSheet();
const editor = useEditor({
extensions: [StarterKit],
content: tournament?.rules || '',
editable: false,
immediatelyRender: false,
});
return (
<>
<ListButton
@@ -33,9 +26,15 @@ const RulesListButton: React.FC<RulesListButtonProps> = ({ tournamentId }) => {
<Sheet title="Tournament Rules" opened={isOpen} onChange={toggle}>
<Stack gap="xs">
<RichTextEditor editor={editor}>
<RichTextEditor.Content />
</RichTextEditor>
<Suspense
fallback={
<Flex justify="center" align="center" w="100%" style={{ minHeight: '25vh' }}>
<Loader size="lg" />
</Flex>
}
>
<RulesContent content={tournament?.rules || ''} />
</Suspense>
<Button variant="subtle" c="red" onClick={toggle}>Close</Button>
</Stack>
</Sheet>
@@ -43,4 +42,4 @@ const RulesListButton: React.FC<RulesListButtonProps> = ({ tournamentId }) => {
)
}
export default RulesListButton;
export default RulesListButton;
+16 -16
View File
@@ -17,14 +17,14 @@ export const listTournaments = createServerFn()
);
export const createTournament = createServerFn()
.inputValidator(tournamentInputSchema)
.validator(tournamentInputSchema)
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) =>
toServerResult(() => pbAdmin.createTournament(data))
);
export const updateTournament = createServerFn()
.inputValidator(z.object({
.validator(z.object({
id: z.string(),
updates: tournamentInputSchema.partial()
}))
@@ -34,7 +34,7 @@ export const updateTournament = createServerFn()
);
export const getTournament = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId, context }) => {
const isAdmin = context.roles.includes("Admin");
@@ -47,7 +47,7 @@ export const getCurrentTournament = createServerFn()
);
export const enrollTeam = createServerFn()
.inputValidator(z.object({
.validator(z.object({
tournamentId: z.string(),
teamId: z.string()
}))
@@ -81,7 +81,7 @@ export const enrollTeam = createServerFn()
);
export const unenrollTeam = createServerFn()
.inputValidator(z.object({
.validator(z.object({
tournamentId: z.string(),
teamId: z.string()
}))
@@ -91,21 +91,21 @@ export const unenrollTeam = createServerFn()
);
export const getUnenrolledTeams = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
);
export const getFreeAgents = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
);
export const enrollFreeAgent = createServerFn()
.inputValidator(z.object({ phone: z.string(), tournamentId: z.string() }))
.validator(z.object({ phone: z.string(), tournamentId: z.string() }))
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) =>
toServerResult(async () => {
@@ -119,7 +119,7 @@ export const enrollFreeAgent = createServerFn()
);
export const unenrollFreeAgent = createServerFn()
.inputValidator(z.object({ tournamentId: z.string() }))
.validator(z.object({ tournamentId: z.string() }))
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) =>
toServerResult(async () => {
@@ -133,7 +133,7 @@ export const unenrollFreeAgent = createServerFn()
);
export const generateRandomTeams = createServerFn()
.inputValidator(z.object({
.validator(z.object({
tournamentId: z.string(),
seed: z.number().optional()
}))
@@ -313,7 +313,7 @@ export const generateRandomTeams = createServerFn()
);
export const confirmTeamAssignments = createServerFn()
.inputValidator(z.object({
.validator(z.object({
tournamentId: z.string(),
assignments: z.array(z.object({
player1Id: z.string(),
@@ -503,14 +503,14 @@ async function calculateGroupStandings(groupId: string): Promise<GroupStanding[]
}
export const getGroupStandings = createServerFn()
.inputValidator(z.string())
.validator(z.string())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: groupId }) =>
toServerResult(() => calculateGroupStandings(groupId))
);
export const generateKnockoutBracket = createServerFn()
.inputValidator(z.object({
.validator(z.object({
tournamentId: z.string(),
}))
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
@@ -711,7 +711,7 @@ export const generateKnockoutBracket = createServerFn()
);
export const adminEnrollPlayer = createServerFn()
.inputValidator(z.object({
.validator(z.object({
playerId: z.string(),
tournamentId: z.string()
}))
@@ -724,7 +724,7 @@ export const adminEnrollPlayer = createServerFn()
);
export const adminUnenrollPlayer = createServerFn()
.inputValidator(z.object({
.validator(z.object({
playerId: z.string(),
tournamentId: z.string()
}))
@@ -737,7 +737,7 @@ export const adminUnenrollPlayer = createServerFn()
);
export const generateGroupStage = createServerFn()
.inputValidator(z.object({
.validator(z.object({
tournamentId: z.string(),
groupConfig: z.object({
num_groups: z.number(),
+22 -3
View File
@@ -4,15 +4,34 @@ const useNow = () => {
const [now, setNow] = useState(new Date());
useEffect(() => {
const tick = () => {
setNow((prev) => {
const next = new Date();
return Math.floor(prev.getTime() / 1000) === Math.floor(next.getTime() / 1000)
? prev
: next;
});
};
const intervalId = setInterval(() => {
setNow(new Date());
if (document.hidden) return;
tick();
}, 1000);
return () => clearInterval(intervalId);
const handleVisibilityChange = () => {
if (!document.hidden) tick();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
return now;
}
export default useNow;
export default useNow;
+28 -2
View File
@@ -14,6 +14,30 @@ type SSEEvent = {
type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryClient>, currentSessionId?: string) => void;
const INVALIDATE_DEBOUNCE_MS = 1500;
const invalidateTimers = new Map<string, ReturnType<typeof setTimeout>>();
function debouncedInvalidate(
queryClient: ReturnType<typeof useQueryClient>,
filters: { queryKey: readonly unknown[] }
) {
const key = JSON.stringify(filters.queryKey);
const existing = invalidateTimers.get(key);
if (existing) clearTimeout(existing);
invalidateTimers.set(key, setTimeout(() => {
invalidateTimers.delete(key);
queryClient.invalidateQueries(filters);
}, INVALIDATE_DEBOUNCE_MS));
}
function clearPendingInvalidations() {
for (const timer of invalidateTimers.values()) {
clearTimeout(timer);
}
invalidateTimers.clear();
}
const eventHandlers: Record<string, EventHandler> = {
"connected": () => {
logger.info("New Connection");
@@ -21,8 +45,8 @@ const eventHandlers: Record<string, EventHandler> = {
"ping": () => {},
"heartbeat": () => {},
"match": (event, queryClient) => {
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId))
queryClient.invalidateQueries(tournamentQueries.current())
debouncedInvalidate(queryClient, tournamentQueries.details(event.tournamentId))
debouncedInvalidate(queryClient, tournamentQueries.current())
},
"reaction": (event, queryClient) => {
queryClient.invalidateQueries(reactionQueries.match(event.matchId));
@@ -104,6 +128,8 @@ export function useServerEvents() {
logger.info("Closing SSE connection");
shouldConnectRef.current = false;
clearPendingInvalidations();
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
+7 -2
View File
@@ -4,7 +4,7 @@ import {
MantineProvider as MantineProviderCore,
} from "@mantine/core";
import ColorSchemeProvider from "./color-scheme-provider";
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
const commonInputStyles = {
label: {
@@ -60,10 +60,15 @@ const MantineProvider = ({ children }: { children: React.ReactNode }) => {
const colorScheme = isHydrated ? metadata.colorScheme || "auto" : "auto";
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
const themeWithPrimaryColor = useMemo(
() => ({ ...theme, primaryColor }),
[primaryColor]
);
return (
<MantineProviderCore
defaultColorScheme={colorScheme}
theme={{ ...theme, primaryColor }}
theme={themeWithPrimaryColor}
>
<ColorSchemeProvider>{children}</ColorSchemeProvider>
</MantineProviderCore>
+5 -4
View File
@@ -35,12 +35,13 @@ export function createGroupsService(pb: PocketBase) {
async deleteGroupsByTournament(tournamentId: string): Promise<void> {
logger.info("PocketBase | Deleting all groups for tournament", { tournamentId });
const groups = await pb.collection("groups").getFullList({
filter: `tournament = "${tournamentId}"`
filter: `tournament = "${tournamentId}"`,
fields: "id",
});
for (const group of groups) {
await pb.collection("groups").delete(group.id);
}
await Promise.all(
groups.map((group) => pb.collection("groups").delete(group.id))
);
},
async getGroup(groupId: string): Promise<Group> {
+20 -13
View File
@@ -34,10 +34,19 @@ export function createTeamsService(pb: PocketBase) {
logger.info("PocketBase | Creating team", data);
try {
for (const playerId of data.players) {
const playerExists = await pb.collection("players").getOne(playerId).catch(() => null);
if (!playerExists) {
throw new Error(`Player with ID ${playerId} not found`);
if (data.players.length > 0) {
const playerFilter = data.players
.map((playerId) => `id = "${playerId}"`)
.join(" || ");
const existingPlayers = await pb.collection("players").getFullList({
filter: playerFilter,
fields: "id",
});
if (existingPlayers.length !== data.players.length) {
const foundIds = new Set(existingPlayers.map((player) => player.id));
const missingId = data.players.find((playerId) => !foundIds.has(playerId));
throw new Error(`Player with ID ${missingId} not found`);
}
}
@@ -46,11 +55,13 @@ export function createTeamsService(pb: PocketBase) {
players: data.players
});
for (const playerId of data.players) {
await pb.collection("players").update(playerId, {
"teams+": result.id
});
}
await Promise.all(
data.players.map((playerId) =>
pb.collection("players").update(playerId, {
"teams+": result.id
})
)
);
return transformTeam(await pb.collection("teams").getOne(result.id, {
expand: "players, tournaments"
@@ -72,10 +83,6 @@ export function createTeamsService(pb: PocketBase) {
const result = await pb.collection("teams").update(id, data);
if (data instanceof FormData && data.has('logo')) {
await new Promise(resolve => setTimeout(resolve, 100));
}
const updated = await pb.collection("teams").getOne(result.id, {
expand: "players, tournaments",
// @ts-ignore - Add cache busting
+37 -18
View File
@@ -35,19 +35,22 @@ export function createTournamentsService(pb: PocketBase) {
return transformTournament(tournamentResult, isAdmin);
},
async getMostRecentTournament(): Promise<Tournament> {
const result = await pb
const mostRecent = await pb
.collection("tournaments")
.getFirstListItem('',
{
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
sort: "-start_time",
}
);
.getFirstListItem('', {
fields: "id",
sort: "-start_time",
});
const teamStatsResult = await pb.collection("team_stats_per_tournament").getFullList({
filter: `tournament_id = "${result.id}"`,
sort: "-wins,-total_cups_made"
});
const [result, teamStatsResult] = await Promise.all([
pb.collection("tournaments").getOne(mostRecent.id, {
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
}),
pb.collection("team_stats_per_tournament").getFullList({
filter: `tournament_id = "${mostRecent.id}"`,
sort: "-wins,-total_cups_made"
}),
]);
result.team_stats = teamStatsResult;
@@ -61,17 +64,33 @@ export function createTournamentsService(pb: PocketBase) {
sort: "-start_time",
});
const tournamentsWithStats = await Promise.all(result.map(async (tournament) => {
const teamStats = await pb.collection("team_stats_per_tournament").getFullList({
filter: `tournament_id = "${tournament.id}"`,
let allTeamStats: any[] = [];
if (result.length > 0) {
const statsFilter = result
.map((tournament) => `tournament_id = "${tournament.id}"`)
.join(" || ");
allTeamStats = await pb.collection("team_stats_per_tournament").getFullList({
filter: statsFilter,
sort: "-wins,-total_cups_made"
});
}
tournament.team_stats = teamStats;
return tournament;
}));
const statsByTournament = new Map<string, any[]>();
for (const stat of allTeamStats) {
const key = stat.tournament_id as string;
const group = statsByTournament.get(key);
if (group) {
group.push(stat);
} else {
statsByTournament.set(key, [stat]);
}
}
return tournamentsWithStats.map(transformTournamentInfo);
for (const tournament of result) {
tournament.team_stats = statsByTournament.get(tournament.id) ?? [];
}
return result.map(transformTournamentInfo);
},
async createTournament(data: TournamentInput): Promise<Tournament> {
const result = await pb
+18 -3
View File
@@ -3,6 +3,22 @@ import { ensureSuperTokensBackend } from "../server";
import { logger } from "../";
import SuperTokens from "supertokens-node";
const USER_PHONE_TTL_MS = 60 * 1000;
const userPhoneCache = new Map<string, { phone: string | undefined; expiresAt: number }>();
async function getUserPhone(userId: string): Promise<string | undefined> {
const now = Date.now();
const cached = userPhoneCache.get(userId);
if (cached && cached.expiresAt > now) {
return cached.phone;
}
const user = await SuperTokens.getUser(userId);
const phone = user?.phoneNumbers[0];
userPhoneCache.set(userId, { phone, expiresAt: now + USER_PHONE_TTL_MS });
return phone;
}
export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) {
ensureSuperTokensBackend();
@@ -52,15 +68,14 @@ export async function getSessionForStart(request: Request, options?: { sessionRe
}
}
const user = await SuperTokens.getUser(userId);
const phone = await getUserPhone(userId);
return {
hasToken: true,
accessTokenPayload: session.accessTokenPayload,
userId,
sessionHandle: session.accessTokenPayload?.sessionHandle,
phone: user?.phoneNumbers[0]
phone
};
}
@@ -1,6 +1,5 @@
import { logger } from "../../logger";
import { ErrorType, ServerError, ServerResult } from "../types";
import { pbAdmin } from "../../pocketbase/client";
import { getRequest } from "@tanstack/react-start/server";
import { isRedirect } from "@tanstack/react-router";
@@ -51,21 +50,22 @@ export const toServerResult = async <T>(
fnName = serverFn.name || 'unknown';
}
try {
await pbAdmin.authPromise;
await pbAdmin.createActivity({
name: fnName,
duration,
success: false,
error: mappedError.message,
arguments: {
errorType: mappedError.code,
statusCode: mappedError.statusCode,
userMessage: mappedError.userMessage,
},
});
} catch (activityError) {
}
import("../../pocketbase/client")
.then(async ({ pbAdmin }) => {
await pbAdmin.authPromise;
await pbAdmin.createActivity({
name: fnName,
duration,
success: false,
error: mappedError.message,
arguments: {
errorType: mappedError.code,
statusCode: mappedError.statusCode,
userMessage: mappedError.userMessage,
},
});
})
.catch(() => {});
return { success: false, error: mappedError };
}
+34 -28
View File
@@ -1,6 +1,20 @@
import { pbAdmin } from "@/lib/pocketbase/client";
import { createMiddleware } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server";
import { Logger } from "@/lib/logger";
import type { ActivityInput } from "@/lib/pocketbase/services/activities";
const logger = new Logger("Activities");
const recordActivity = (activity: ActivityInput) => {
import("@/lib/pocketbase/client")
.then(async ({ pbAdmin }) => {
await pbAdmin.authPromise;
await pbAdmin.createActivity(activity);
})
.catch((activityError) => {
logger.error("Failed to record activity", activityError);
});
};
export const serverFnLoggingMiddleware = createMiddleware({
type: "function",
@@ -11,6 +25,7 @@ export const serverFnLoggingMiddleware = createMiddleware({
const pathParts = url.pathname.split('/').filter(Boolean);
const serverFnName = pathParts[pathParts.length - 1] || 'unknown';
const userId = (context as any)?.metadata?.player_id || 'unknown';
const userAgent = request.headers.get('user-agent') || undefined;
const startTime = Date.now();
@@ -18,39 +33,30 @@ export const serverFnLoggingMiddleware = createMiddleware({
const result = await next();
const duration = Date.now() - startTime;
try {
await pbAdmin.authPromise;
await pbAdmin.createActivity({
name: serverFnName,
player: userId !== 'unknown' ? userId : undefined,
duration,
success: true,
arguments: data,
user_agent: request.headers.get('user-agent') || undefined,
});
} catch (activityError) {
}
recordActivity({
name: serverFnName,
player: userId !== 'unknown' ? userId : undefined,
duration,
success: true,
arguments: data,
user_agent: userAgent,
});
return result;
} catch (error) {
const duration = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
try {
await pbAdmin.authPromise;
await pbAdmin.createActivity({
name: serverFnName,
player: userId !== 'unknown' ? userId : undefined,
duration,
success: false,
error: errorMessage,
arguments: data,
user_agent: request.headers.get('user-agent') || undefined,
});
} catch (activityError) {
}
recordActivity({
name: serverFnName,
player: userId !== 'unknown' ? userId : undefined,
duration,
success: false,
error: errorMessage,
arguments: data,
user_agent: userAgent,
});
throw error;
}
});
});
+141
View File
@@ -0,0 +1,141 @@
import { redirect } from "@tanstack/react-router";
import UserRoles from "supertokens-node/recipe/userroles";
import UserMetadata from "supertokens-node/recipe/usermetadata";
import { getSessionForStart } from "@/lib/supertokens/recipes/start-session";
import { Logger } from "@/lib/logger";
import { pbAdmin } from "@/lib/pocketbase/client";
import { PUBLIC_ROUTES } from "./supertokens";
import type { Player } from "@/features/players/types";
const logger = new Logger("Middleware");
const ROLES_METADATA_TTL_MS = 60 * 1000;
const rolesMetadataCache = new Map<
string,
{ roles: string[]; metadata: any; expiresAt: number }
>();
export const invalidateUserCache = (userAuthId: string) => {
rolesMetadataCache.delete(userAuthId);
};
export const fetchUserRoles = async (userAuthId: string) => {
const response = await UserRoles.getRolesForUser("public", userAuthId);
return response;
};
const getRolesAndMetadata = async (userAuthId: string) => {
const now = Date.now();
const cached = rolesMetadataCache.get(userAuthId);
if (cached && cached.expiresAt > now) {
return cached;
}
const [{ roles }, { metadata }] = await Promise.all([
fetchUserRoles(userAuthId),
UserMetadata.getUserMetadata(userAuthId),
]);
const entry = { roles, metadata, expiresAt: now + ROLES_METADATA_TTL_MS };
rolesMetadataCache.set(userAuthId, entry);
return entry;
};
export const updateUserMetadataFields = async (
userAuthId: string,
fields: Record<string, any>
) => {
await UserMetadata.updateUserMetadata(userAuthId, fields);
invalidateUserCache(userAuthId);
};
const verifySuperTokensSession = async (request: Request) => {
let session = await getSessionForStart(request, { sessionRequired: false });
if (session?.needsRefresh) {
logger.info("Session needs refresh - redirecting to client");
return { context: { session: { tryRefresh: true } } };
}
const userAuthId = session?.userId;
if (!userAuthId || !session) {
logger.info("No authenticated user");
return { context: { userAuthId: null, roles: [] } };
}
const { roles, metadata } = await getRolesAndMetadata(userAuthId);
return {
context: {
userAuthId,
roles,
metadata,
phone: session.phone,
session: {
accessTokenPayload: session.accessTokenPayload,
sessionHandle: session.sessionHandle,
},
},
};
};
const LAST_ACTIVITY_THROTTLE_MS = 5 * 60 * 1000;
const lastActivityWriteAt = new Map<string, number>();
export const getSessionContextCore = async (
request: Request,
options?: { isServerFunction?: boolean }
) => {
const session = await verifySuperTokensSession(request);
if (session.context.session?.tryRefresh) {
if (options?.isServerFunction) {
throw new Error("SESSION_REFRESH_REQUIRED");
}
const url = new URL(request.url);
if (PUBLIC_ROUTES.some((route) => url.pathname.startsWith(route))) {
throw new Error("Unauthenticated");
}
const from = url.pathname + url.search;
throw redirect({
to: "/refresh-session",
search: from === "/" ? {} : { redirect: from },
});
}
if (!session.context.userAuthId) {
logger.error("Unauthenticated user", session.context);
throw new Error("Unauthenticated");
}
let player: Player | null = null;
try {
player = await pbAdmin.getPlayerByAuthId(session.context.userAuthId);
if (player) {
const now = Date.now();
const lastWrite = lastActivityWriteAt.get(session.context.userAuthId) ?? 0;
if (now - lastWrite > LAST_ACTIVITY_THROTTLE_MS) {
lastActivityWriteAt.set(session.context.userAuthId, now);
await pbAdmin.updatePlayer(player.id, {
last_activity: new Date().toISOString(),
});
}
}
} catch (error) {
logger.error("Failed to update player last_activity", error);
}
return {
userAuthId: session.context.userAuthId,
roles: session.context.roles,
metadata: session.context.metadata,
phone: session.context.phone,
player,
};
};
+21 -140
View File
@@ -3,100 +3,20 @@ import {
createServerFn,
createServerOnlyFn,
} from "@tanstack/react-start";
import { getRequest, setResponseHeader } from "@tanstack/react-start/server";
import { redirect as redirect } from "@tanstack/react-router";
import UserRoles from "supertokens-node/recipe/userroles";
import UserMetadata from "supertokens-node/recipe/usermetadata";
import { getSessionForStart } from "@/lib/supertokens/recipes/start-session";
import { getRequest } from "@tanstack/react-start/server";
import { Logger } from "@/lib/logger";
import z from "zod";
import { serverFnLoggingMiddleware } from "./activities";
import { pbAdmin } from "@/lib/pocketbase/client";
const logger = new Logger("Middleware");
export const PUBLIC_ROUTES = ["/login", "/logout", "/refresh-session"];
const verifySuperTokensSession = async (
request: Request
) => {
let session = await getSessionForStart(request, { sessionRequired: false });
if (session?.needsRefresh) {
logger.info("Session needs refresh - redirecting to client");
return { context: { session: { tryRefresh: true } } };
export const getSessionContext = createServerOnlyFn(
async (request: Request, options?: { isServerFunction?: boolean }) => {
const { getSessionContextCore } = await import("./supertokens-core.server");
return getSessionContextCore(request, options);
}
const userAuthId = session?.userId;
if (!userAuthId || !session) {
logger.info("No authenticated user");
return { context: { userAuthId: null, roles: [] } };
}
const { roles } = await fetchUserRoles(userAuthId);
const { metadata } = await UserMetadata.getUserMetadata(userAuthId);
return {
context: {
userAuthId,
roles,
metadata,
phone: session.phone,
session: {
accessTokenPayload: session.accessTokenPayload,
sessionHandle: session.sessionHandle,
},
},
};
};
export const getSessionContext = createServerOnlyFn(async (request: Request, options?: { isServerFunction?: boolean }) => {
const session = await verifySuperTokensSession(request);
if (session.context.session?.tryRefresh) {
if (options?.isServerFunction) {
throw new Error("SESSION_REFRESH_REQUIRED");
}
const url = new URL(request.url);
if (PUBLIC_ROUTES.some((route) => url.pathname.startsWith(route))) {
throw new Error("Unauthenticated");
}
const from = url.pathname + url.search;
throw redirect({
to: "/refresh-session",
search: from === "/" ? {} : { redirect: from },
});
}
if (!session.context.userAuthId) {
logger.error("Unauthenticated user", session.context);
throw new Error("Unauthenticated");
}
const context = {
userAuthId: session.context.userAuthId,
roles: session.context.roles,
metadata: session.context.metadata,
phone: session.context.phone
};
try {
const player = await pbAdmin.getPlayerByAuthId(session.context.userAuthId);
if (player) {
await pbAdmin.updatePlayer(player.id, {
last_activity: new Date().toISOString(),
});
}
} catch (error) {
logger.error("Failed to update player last_activity", error);
}
return context;
});
);
export const superTokensRequestMiddleware = createMiddleware({
type: "request",
@@ -109,18 +29,18 @@ export const superTokensFunctionMiddleware = createMiddleware({
type: "function",
}).server(async ({ next }) => {
const request = getRequest();
try {
const context = await getSessionContext(request, { isServerFunction: true });
return next({ context });
} catch (error: any) {
if (error.message === "SESSION_REFRESH_REQUIRED") {
throw new Response(
JSON.stringify({
JSON.stringify({
error: "SESSION_REFRESH_REQUIRED",
message: "Session needs to be refreshed"
message: "Session needs to be refreshed"
}),
{
{
status: 401,
headers: { "Content-Type": "application/json" }
}
@@ -134,7 +54,7 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({
type: "function",
}).server(async ({ next }) => {
const request = getRequest();
try {
const context = await getSessionContext(request, { isServerFunction: true });
@@ -147,11 +67,11 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({
} catch (error: any) {
if (error.message === "SESSION_REFRESH_REQUIRED") {
throw new Response(
JSON.stringify({
JSON.stringify({
error: "SESSION_REFRESH_REQUIRED",
message: "Session needs to be refreshed"
message: "Session needs to be refreshed"
}),
{
{
status: 401,
headers: { "Content-Type": "application/json" }
}
@@ -161,13 +81,8 @@ export const superTokensAdminFunctionMiddleware = createMiddleware({
}
});
export const fetchUserRoles = async (userAuthId: string) => {
const response = await UserRoles.getRolesForUser("public", userAuthId);
return response;
};
export const setUserMetadata = createServerFn({ method: "POST" })
.inputValidator(
.validator(
z
.object({
first_name: z
@@ -195,7 +110,11 @@ export const setUserMetadata = createServerFn({ method: "POST" })
const { userAuthId, metadata } = context;
if (!userAuthId) return;
await UserMetadata.updateUserMetadata(userAuthId, {
const { updateUserMetadataFields } = await import(
"./supertokens-core.server"
);
await updateUserMetadataFields(userAuthId, {
first_name: data.first_name,
last_name: data.last_name,
player_id: data.player_id,
@@ -208,41 +127,3 @@ export const setUserMetadata = createServerFn({ method: "POST" })
},
};
});
export const updateUserColorScheme = createServerFn({ method: "POST" })
.inputValidator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
await UserMetadata.updateUserMetadata(userAuthId, {
colorScheme: data,
});
return {
metadata: {
...metadata,
colorScheme: data,
},
};
});
export const updateUserAccentColor = createServerFn({ method: "POST" })
.inputValidator((data: string) => data)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => {
const { userAuthId, metadata } = context;
if (!userAuthId) return;
await UserMetadata.updateUserMetadata(userAuthId, {
accentColor: data,
});
return {
metadata: {
...metadata,
accentColor: data,
},
};
});
+4 -41
View File
@@ -2,7 +2,6 @@ import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import { defineConfig } from 'vite'
import tsConfigPaths from 'vite-tsconfig-paths'
import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig(({ mode }) => ({
server: {
@@ -19,46 +18,10 @@ export default defineConfig(({ mode }) => ({
tanstackStart({
srcDirectory: 'src/app',
}),
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: false,
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff,woff2}'],
runtimeCaching: [
{
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'google-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
handler: 'CacheFirst',
options: {
cacheName: 'gstatic-fonts-cache',
expiration: {
maxEntries: 10,
maxAgeSeconds: 60 * 60 * 24 * 365 // 1 year
},
cacheableResponse: {
statuses: [0, 200]
}
}
}
]
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
devOptions: {
enabled: false
}
})
}),
]
}))