Merge branch 'perf' into development

This commit is contained in:
yohlo
2026-07-12 21:28:46 -07:00
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 WORKDIR /app
COPY package.json bun.lockb* ./ COPY package.json bun.lock ./
RUN bun install --frozen-lockfile RUN bun install --frozen-lockfile
@@ -21,7 +21,6 @@ EXPOSE 3000
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3000 ENV PORT=3000
ENV NITRO_PORT=3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ 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))" 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", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev --host 0.0.0.0", "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" "start": "bun run server.ts"
}, },
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
"@mantine/carousel": "^8.2.4", "@mantine/carousel": "^8.2.4",
"@mantine/charts": "^8.2.4",
"@mantine/core": "^8.2.4", "@mantine/core": "^8.2.4",
"@mantine/dates": "^8.2.4", "@mantine/dates": "^8.2.4",
"@mantine/form": "^8.2.4", "@mantine/form": "^8.2.4",
"@mantine/hooks": "^8.2.4", "@mantine/hooks": "^8.2.4",
"@mantine/tiptap": "^8.2.4", "@mantine/tiptap": "^8.2.4",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@svgmoji/noto": "^3.2.0",
"@tanstack/react-devtools": "^0.7.6", "@tanstack/react-devtools": "^0.7.6",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.101.2",
"@tanstack/react-query-devtools": "^5.66.0", "@tanstack/react-query-devtools": "^5.101.2",
"@tanstack/react-router": "^1.143.6", "@tanstack/react-router": "^1.170.17",
"@tanstack/react-router-devtools": "^1.143.6", "@tanstack/react-router-devtools": "^1.167.0",
"@tanstack/react-router-ssr-query": "^1.143.6", "@tanstack/react-router-ssr-query": "^1.167.1",
"@tanstack/react-start": "^1.143.6", "@tanstack/react-start": "^1.168.27",
"@tanstack/react-virtual": "^3.13.12",
"@tiptap/pm": "^3.4.3", "@tiptap/pm": "^3.4.3",
"@tiptap/react": "^3.4.3", "@tiptap/react": "^3.4.3",
"@tiptap/starter-kit": "^3.4.3", "@tiptap/starter-kit": "^3.4.3",
"@types/bun": "^1.2.22", "@types/bun": "^1.2.22",
"@types/ioredis": "^4.28.10",
"browser-image-compression": "^2.0.2", "browser-image-compression": "^2.0.2",
"dotenv": "^17.2.2", "dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"facehash": "^0.0.7", "facehash": "^0.0.7",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
"pg": "^8.16.3",
"pocketbase": "^0.26.2", "pocketbase": "^0.26.2",
"react": "^19.0.0", "react": "^19.2.7",
"react-dom": "^19.0.0", "react-dom": "^19.2.7",
"react-imask": "^7.6.1", "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", "redaxios": "^0.5.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"supertokens-node": "^23.0.1", "supertokens-node": "^23.0.1",
"supertokens-web-js": "^0.15.0", "supertokens-web-js": "^0.15.0",
"twilio": "^5.8.0", "twilio": "^5.8.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"xlsx": "^0.18.5", "zod": "^4.0.15"
"zod": "^4.0.15",
"zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/router-plugin": "^1.132.2",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/pg": "^8.15.5", "@types/react": "^19.2.17",
"@types/react": "^19.0.8", "@types/react-dom": "^19.2.3",
"@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^5.0.0", "@vitejs/plugin-react": "^5.0.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "^1.0.0",
"dotenv-cli": "^10.0.0", "dotenv-cli": "^10.0.0",
"postcss": "^8.5.1", "postcss": "^8.5.1",
"postcss-preset-mantine": "^1.18.0", "postcss-preset-mantine": "^1.18.0",
@@ -71,8 +59,7 @@
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^7.1.7", "vite": "^7.1.7",
"vite-plugin-pwa": "^1.2.0",
"vite-tsconfig-paths": "^5.1.4", "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, gz,
etag, etag,
type: metadata.type, 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, size: bytes.byteLength,
} }
routes[route] = createResponseHandler(asset) routes[route] = createResponseHandler(asset)
@@ -529,14 +532,9 @@ async function initializeServer() {
// Fallback to TanStack Start handler for all other routes // Fallback to TanStack Start handler for all other routes
'/*': async (req: Request) => { '/*': async (req: Request) => {
try { try {
const h3Response = await handler.fetch(req) // Return the handler's Response as-is so streaming bodies
// (SSR streaming, SSE event streams) pass through unbuffered.
const body = await h3Response.arrayBuffer() return await handler.fetch(req)
return new Response(body, {
status: h3Response.status,
statusText: h3Response.statusText,
headers: h3Response.headers,
})
} catch (error) { } catch (error) {
log.error(`Server handler error: ${String(error)}`) log.error(`Server handler error: ${String(error)}`)
return new Response('Internal Server Error', { status: 500 }) return new Response('Internal Server Error', { status: 500 })
+1
View File
@@ -29,6 +29,7 @@ export function getRouter() {
fullWidth: false, fullWidth: false,
}, },
defaultPreload: "intent", defaultPreload: "intent",
defaultPreloadStaleTime: 60_000,
defaultErrorComponent: DefaultCatchBoundary, defaultErrorComponent: DefaultCatchBoundary,
scrollRestoration: true, scrollRestoration: true,
defaultViewTransition: false, defaultViewTransition: false,
+3
View File
@@ -153,6 +153,9 @@ export const Route = createRootRouteWithContext<{
function RootComponent() { function RootComponent() {
React.useEffect(() => { React.useEffect(() => {
ensureSuperTokensFrontend(); ensureSuperTokensFrontend();
if (import.meta.env.PROD && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
}, []); }, []);
return ( return (
+114 -50
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 { SpotifyAuth } from '@/lib/spotify/auth';
import { useAuth } from './auth-context'; import { useAuth } from './auth-context';
import { useConfig } from '@/hooks/use-config'; import { useConfig } from '@/hooks/use-config';
@@ -21,6 +21,62 @@ const defaultSpotifyState: SpotifyAuthState = {
export const SpotifyContext = createContext<SpotifyContextType | null>(null); 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 }) => { export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const { roles } = useAuth(); const { roles } = useAuth();
const isAdmin = roles?.includes('Admin') || false; const isAdmin = roles?.includes('Admin') || false;
@@ -41,9 +97,9 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const [isCaptureLoading, setIsCaptureLoading] = useState(false); const [isCaptureLoading, setIsCaptureLoading] = useState(false);
const [isResumeLoading, setIsResumeLoading] = useState(false); const [isResumeLoading, setIsResumeLoading] = useState(false);
const spotifyAuth = new SpotifyAuth( const spotifyAuth = useMemo(
config.spotifyClientId, () => new SpotifyAuth(config.spotifyClientId, config.spotifyRedirectUri),
config.spotifyRedirectUri [config.spotifyClientId, config.spotifyRedirectUri]
); );
useEffect(() => { 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=/;'; 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) => { const play = useCallback(async (deviceId?: string) => {
if (!authState.isAuthenticated) return; if (!authState.isAuthenticated) return;
@@ -351,11 +368,16 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const data = await makeSpotifyRequest('playback?type=state'); const data = await makeSpotifyRequest('playback?type=state');
const state = data.playbackState; const state = data.playbackState;
setPlaybackState(state); setPlaybackState((prev) => (deepEqual(prev, state ?? null) ? prev : state));
setCurrentTrack(state?.item || null); setCurrentTrack((prev) => {
const next = state?.item || null;
return deepEqual(prev, next) ? prev : next;
});
if (state?.device) { if (state?.device) {
setActiveDeviceState(state.device); setActiveDeviceState((prev) =>
deepEqual(prev, state.device) ? prev : state.device
);
} }
} catch (error) { } catch (error) {
console.warn('Failed to refresh playback state:', error); console.warn('Failed to refresh playback state:', error);
@@ -365,8 +387,24 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
useEffect(() => { useEffect(() => {
if (!authState.isAuthenticated) return; if (!authState.isAuthenticated) return;
const interval = setInterval(refreshPlaybackState, 5000); const poll = () => {
return () => clearInterval(interval); 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]); }, [authState.isAuthenticated, refreshPlaybackState]);
const capturePlaybackState = useCallback(async () => { const capturePlaybackState = useCallback(async () => {
@@ -432,7 +470,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
} }
}, []); }, []);
const contextValue: SpotifyContextType = { const contextValue: SpotifyContextType = useMemo(() => ({
...authState, ...authState,
currentTrack, currentTrack,
playbackState, playbackState,
@@ -461,7 +499,33 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
clearCapturedState, clearCapturedState,
// Search // Search
searchTracks, 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) { if (!isAdmin) {
return <>{children}</>; return <>{children}</>;
+1 -1
View File
@@ -16,7 +16,7 @@ const activitySearchParamsSchema = z.object({
}); });
export const searchActivities = createServerFn() export const searchActivities = createServerFn()
.inputValidator(activitySearchParamsSchema) .validator(activitySearchParamsSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<ActivityListResult>(async () => { toServerResult<ActivityListResult>(async () => {
@@ -47,6 +47,7 @@ export const BadgeIcon = ({ badge, filled, size = 48 }: BadgeIconProps & { size?
alt={badge.name} alt={badge.name}
width={size} width={size}
height={size} height={size}
loading="lazy"
onError={() => setImageError(true)} onError={() => setImageError(true)}
style={{ style={{
objectFit: 'contain', objectFit: 'contain',
+2 -2
View File
@@ -5,7 +5,7 @@ import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
export const getPlayerBadges = createServerFn() export const getPlayerBadges = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: playerId }) => .handler(async ({ data: playerId }) =>
toServerResult(() => pbAdmin.getPlayerBadgeProgress(playerId)) toServerResult(() => pbAdmin.getPlayerBadgeProgress(playerId))
@@ -28,7 +28,7 @@ export const getAllEarnedBadges = createServerFn()
.handler(async () => toServerResult(() => pbAdmin.listEarnedBadges())); .handler(async () => toServerResult(() => pbAdmin.listEarnedBadges()));
export const awardManualBadge = createServerFn() export const awardManualBadge = createServerFn()
.inputValidator(z.object({ .validator(z.object({
playerId: z.string(), playerId: z.string(),
badgeId: 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"); const logger = new Logger("Bracket Generation");
export const previewBracket = createServerFn() export const previewBracket = createServerFn()
.inputValidator(z.number()) .validator(z.number())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teams }) => .handler(async ({ data: teams }) =>
toServerResult(async () => { toServerResult(async () => {
+3 -5
View File
@@ -1,5 +1,5 @@
import { AppShell } from '@mantine/core'; import { AppShell } from '@mantine/core';
import { PropsWithChildren, useState } from 'react'; import { PropsWithChildren } from 'react';
import Header from './header'; import Header from './header';
import Navbar from './navbar'; import Navbar from './navbar';
import Pullable from './pullable'; import Pullable from './pullable';
@@ -8,10 +8,8 @@ import useRouterConfig from '../hooks/use-router-config';
import Page from '@/components/page'; import Page from '@/components/page';
const Layout: React.FC<PropsWithChildren> = ({ children }) => { const Layout: React.FC<PropsWithChildren> = ({ children }) => {
const { header } = useRouterConfig(); const { header, withPadding, fullWidth } = useRouterConfig();
const viewport = useVisualViewportSize(); const viewport = useVisualViewportSize();
const [scrollPosition, setScrollPosition] = useState({ x: 0, y: 0 });
const { withPadding, fullWidth } = useRouterConfig();
return ( return (
<AppShell <AppShell
@@ -47,7 +45,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
maw='100dvw' maw='100dvw'
style={{ transition: 'none', overflow: 'hidden' }} style={{ transition: 'none', overflow: 'hidden' }}
> >
<Pullable scrollPosition={scrollPosition} onScrollPositionChange={setScrollPosition}> <Pullable>
<Page noPadding={!withPadding} fullWidth={fullWidth}> <Page noPadding={!withPadding} fullWidth={fullWidth}>
{children} {children}
</Page> </Page>
@@ -19,14 +19,13 @@ export const NavLink = ({
include, include,
exclude, exclude,
}: NavLinkProps) => { }: NavLinkProps) => {
const router = useRouterState(); const pathname = useRouterState({ select: (s) => s.location.pathname });
const isActive = useMemo( const isActive = useMemo(
() => () =>
(!exclude?.some((e) => router.location.pathname.includes(e)) && (!exclude?.some((e) => pathname.includes(e)) &&
(router.location.pathname === href || (pathname === href || (pathname.includes(href) && href !== "/"))) ||
(router.location.pathname.includes(href) && href !== "/"))) || include?.includes(pathname),
include?.includes(router.location.pathname), [pathname, href]
[router.location.pathname, href]
); );
return ( return (
+12 -10
View File
@@ -8,15 +8,10 @@ import { useLocation } from "@tanstack/react-router";
const THRESHOLD = 80; 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 * 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 height = useAppShellHeight();
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [scrolling, setScrolling] = useState(false); const [scrolling, setScrolling] = useState(false);
@@ -25,7 +20,14 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
const location = useLocation(); const location = useLocation();
const scrollAreaRef = useRef<HTMLDivElement>(null); 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 () => { const onTrigger = useCallback(async () => {
setIsRefreshing(true); setIsRefreshing(true);
@@ -143,11 +145,11 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
viewport.scrollLeft = 0; viewport.scrollLeft = 0;
} }
} }
onScrollPositionChange({ x: 0, y: 0 }); setPullDistance(0);
}, 10); }, 10);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [location.pathname, onScrollPositionChange]); }, [location.pathname]);
return ( return (
@@ -175,7 +177,7 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
<ScrollArea <ScrollArea
ref={scrollAreaRef} ref={scrollAreaRef}
id='scroll-wrapper' id='scroll-wrapper'
onScrollPositionChange={onScrollPositionChange} onScrollPositionChange={handleScrollPositionChange}
type='never' mah='100%' h='100%' type='never' mah='100%' h='100%'
pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY} pt={(scrolling || scrollY > 40) || !isRefreshing ? 0 : 40 - scrollY}
styles={{ styles={{
@@ -1,4 +1,5 @@
import { useMatches } from "@tanstack/react-router"; import { useMatches } from "@tanstack/react-router";
import { useMemo } from "react";
import { HeaderConfig } from "../types/header-config"; import { HeaderConfig } from "../types/header-config";
export const defaultHeaderConfig: HeaderConfig = { export const defaultHeaderConfig: HeaderConfig = {
@@ -10,6 +11,7 @@ export const defaultHeaderConfig: HeaderConfig = {
const useRouterConfig = () => { const useRouterConfig = () => {
const matches = useMatches(); const matches = useMatches();
return useMemo(() => {
const matchesWithHeader = matches.filter((match) => const matchesWithHeader = matches.filter((match) =>
match?.loaderData && 'header' in match.loaderData match?.loaderData && 'header' in match.loaderData
); );
@@ -36,6 +38,7 @@ const useRouterConfig = () => {
withPadding: current && typeof current === 'object' && 'withPadding' in current ? current.withPadding : true, withPadding: current && typeof current === 'object' && 'withPadding' in current ? current.withPadding : true,
fullWidth: current && typeof current === 'object' && 'fullWidth' in current ? current.fullWidth : false, fullWidth: current && typeof current === 'object' && 'fullWidth' in current ? current.fullWidth : false,
}; };
}, [matches]);
} }
export default useRouterConfig; export default useRouterConfig;
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
const eventListerOptions = { const eventListerOptions = {
passive: true, passive: true,
@@ -11,25 +11,49 @@ const useVisualViewportSize = () => {
height: windowExists ? window.visualViewport?.height || 0 : 0, height: windowExists ? window.visualViewport?.height || 0 : 0,
top: windowExists ? window.visualViewport?.offsetTop || 0 : 0, top: windowExists ? window.visualViewport?.offsetTop || 0 : 0,
}); });
const rafRef = useRef<number | null>(null);
const setSize = useCallback(() => {
if (!windowExists) return;
setWindowSize({ width: window.visualViewport?.width || 0, height: window.visualViewport?.height || 0, top: window.visualViewport?.offsetTop || 0 });
}, []);
useEffect(() => { 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(); setSize();
window.visualViewport?.addEventListener('resize', setSize, eventListerOptions); window.visualViewport?.addEventListener('resize', scheduleSetSize, eventListerOptions);
window.visualViewport?.addEventListener('scroll', setSize, eventListerOptions);
return () => { return () => {
window.visualViewport?.removeEventListener('resize', setSize); window.visualViewport?.removeEventListener('resize', scheduleSetSize);
window.visualViewport?.removeEventListener('scroll', setSize); if (rafRef.current !== null) {
window.cancelAnimationFrame(rafRef.current);
rafRef.current = null;
} }
}, [setSize]); };
}, []);
return windowSize; return windowSize;
} }
@@ -4,7 +4,7 @@ import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types"; import { Match } from "../types";
import TeamAvatar from "@/components/team-avatar"; import TeamAvatar from "@/components/team-avatar";
import EmojiBar from "@/features/reactions/components/emoji-bar"; import EmojiBar from "@/features/reactions/components/emoji-bar";
import { Suspense } from "react"; import { memo, Suspense } from "react";
import { useSheet } from "@/hooks/use-sheet"; import { useSheet } from "@/hooks/use-sheet";
import Sheet from "@/components/sheet/sheet"; import Sheet from "@/components/sheet/sheet";
import TeamHeadToHeadSheet from "./team-head-to-head-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);
@@ -1,4 +1,5 @@
import { Stack, Text } from "@mantine/core"; import { Stack, Text } from "@mantine/core";
import { useMemo } from "react";
import { Match } from "../types"; import { Match } from "../types";
import MatchCard from "./match-card"; import MatchCard from "./match-card";
@@ -8,9 +9,15 @@ interface MatchListProps {
} }
const MatchList = ({ matches, hideH2H = false }: MatchListProps) => { const MatchList = ({ matches, hideH2H = false }: MatchListProps) => {
const filteredMatches = matches?.filter(match => const filteredMatches = useMemo(
() =>
[...(matches ?? [])]
.filter(match =>
match.home && match.away && !match.bye && match.status != "tbd" match.home && match.away && !match.bye && match.status != "tbd"
).sort((a, b) => a.start_time < b.start_time ? 1 : -1) || []; )
.sort((a, b) => a.start_time < b.start_time ? 1 : -1),
[matches]
);
if (!filteredMatches.length) { if (!filteredMatches.length) {
return undefined; return undefined;
+8 -8
View File
@@ -17,7 +17,7 @@ const orderedTeamsSchema = z.object({
}); });
export const generateTournamentBracket = createServerFn() export const generateTournamentBracket = createServerFn()
.inputValidator(orderedTeamsSchema) .validator(orderedTeamsSchema)
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { tournamentId, orderedTeamIds } }) => .handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -138,7 +138,7 @@ export const generateTournamentBracket = createServerFn()
); );
export const startMatch = createServerFn() export const startMatch = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -165,7 +165,7 @@ export const startMatch = createServerFn()
); );
export const populateKnockoutBracket = createServerFn() export const populateKnockoutBracket = createServerFn()
.inputValidator(z.object({ .validator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
})) }))
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
@@ -459,7 +459,7 @@ const endMatchSchema = z.object({
ot_count: z.number(), ot_count: z.number(),
}); });
export const endMatch = createServerFn() export const endMatch = createServerFn()
.inputValidator(endMatchSchema) .validator(endMatchSchema)
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) => .handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -546,7 +546,7 @@ const toggleReactionSchema = z.object({
}); });
export const toggleMatchReaction = createServerFn() export const toggleMatchReaction = createServerFn()
.inputValidator(toggleReactionSchema) .validator(toggleReactionSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { matchId, emoji }, context }) => .handler(async ({ data: { matchId, emoji }, context }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -606,7 +606,7 @@ export interface Reaction {
players: PlayerInfo[]; players: PlayerInfo[];
} }
export const getMatchReactions = createServerFn() export const getMatchReactions = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: matchId, context }) => .handler(async ({ data: matchId, context }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -647,7 +647,7 @@ const matchesBetweenPlayersSchema = z.object({
}); });
export const getMatchesBetweenPlayers = createServerFn() export const getMatchesBetweenPlayers = createServerFn()
.inputValidator(matchesBetweenPlayersSchema) .validator(matchesBetweenPlayersSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { player1Id, player2Id } }) => .handler(async ({ data: { player1Id, player2Id } }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -663,7 +663,7 @@ const matchesBetweenTeamsSchema = z.object({
}); });
export const getMatchesBetweenTeams = createServerFn() export const getMatchesBetweenTeams = createServerFn()
.inputValidator(matchesBetweenTeamsSchema) .validator(matchesBetweenTeamsSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { team1Id, team2Id } }) => .handler(async ({ data: { team1Id, team2Id } }) =>
toServerResult(async () => { toServerResult(async () => {
+50 -21
View File
@@ -3,13 +3,14 @@ import { createServerFn } from "@tanstack/react-start";
import { isRedirect } from "@tanstack/react-router"; import { isRedirect } from "@tanstack/react-router";
import { Player, playerInputSchema, playerUpdateSchema, PlayerStats } from "@/features/players/types"; import { Player, playerInputSchema, playerUpdateSchema, PlayerStats } from "@/features/players/types";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
import { logger } from "."; import { Logger } from "@/lib/logger";
import { getRequest } from "@tanstack/react-start/server"; import { getRequest } from "@tanstack/react-start/server";
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result"; import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { serverFnLoggingMiddleware } from "@/utils/activities"; import { serverFnLoggingMiddleware } from "@/utils/activities";
const logger = new Logger('Players');
export const fetchMe = createServerFn() export const fetchMe = createServerFn()
.handler(async () => .handler(async () =>
toServerResult(async () => { toServerResult(async () => {
@@ -18,10 +19,8 @@ export const fetchMe = createServerFn()
try { try {
const context = await getSessionContext(request); const context = await getSessionContext(request);
await pbAdmin.authPromise;
const result = await pbAdmin.getPlayerByAuthId(context.userAuthId);
return { return {
user: result || undefined, user: context.player || undefined,
roles: context.roles, roles: context.roles,
metadata: context.metadata, metadata: context.metadata,
phone: context.phone phone: context.phone
@@ -35,20 +34,25 @@ export const fetchMe = createServerFn()
); );
export const getPlayer = createServerFn() export const getPlayer = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<Player>(async () => await pbAdmin.getPlayer(data)) toServerResult<Player>(async () => {
const { pbAdmin } = await import("@/lib/pocketbase/client");
return await pbAdmin.getPlayer(data);
})
); );
export const updatePlayer = createServerFn() export const updatePlayer = createServerFn()
.inputValidator(playerUpdateSchema) .validator(playerUpdateSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
const userAuthId = context.userAuthId; const userAuthId = context.userAuthId;
if (!userAuthId) return; if (!userAuthId) return;
const { pbAdmin } = await import("@/lib/pocketbase/client");
const existing = await pbAdmin.getPlayerByAuthId(userAuthId); const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
if (!existing) return; if (!existing) return;
@@ -69,13 +73,15 @@ export const updatePlayer = createServerFn()
); );
export const createPlayer = createServerFn() export const createPlayer = createServerFn()
.inputValidator(playerInputSchema) .validator(playerInputSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
const userAuthId = context.userAuthId; const userAuthId = context.userAuthId;
if (!userAuthId) return; if (!userAuthId) return;
const { pbAdmin } = await import("@/lib/pocketbase/client");
const existing = await pbAdmin.getPlayerByAuthId(userAuthId); const existing = await pbAdmin.getPlayerByAuthId(userAuthId);
if (existing) return; if (existing) return;
@@ -94,13 +100,15 @@ export const createPlayer = createServerFn()
); );
export const associatePlayer = createServerFn() export const associatePlayer = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
const userAuthId = context.userAuthId; const userAuthId = context.userAuthId;
if (!userAuthId) return; if (!userAuthId) return;
const { pbAdmin } = await import("@/lib/pocketbase/client");
const p = await pbAdmin.getPlayer(data); const p = await pbAdmin.getPlayer(data);
await pbAdmin.updatePlayer(data, { await pbAdmin.updatePlayer(data, {
@@ -122,48 +130,69 @@ export const associatePlayer = createServerFn()
export const listPlayers = createServerFn() export const listPlayers = createServerFn()
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async () => .handler(async () =>
toServerResult(pbAdmin.listPlayers) toServerResult(async () => {
const { pbAdmin } = await import("@/lib/pocketbase/client");
return await pbAdmin.listPlayers();
})
); );
export const getUnassociatedPlayers = createServerFn() export const getUnassociatedPlayers = createServerFn()
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async () => .handler(async () =>
toServerResult(pbAdmin.getUnassociatedPlayers) toServerResult(async () => {
const { pbAdmin } = await import("@/lib/pocketbase/client");
return await pbAdmin.getUnassociatedPlayers();
})
); );
export const getPlayerStats = createServerFn() export const getPlayerStats = createServerFn()
.inputValidator(z.object({ .validator(z.object({
playerId: z.string(), playerId: z.string(),
viewType: z.enum(['all', 'mainline', 'regional']).optional() viewType: z.enum(['all', 'mainline', 'regional']).optional()
})) }))
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .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() export const getAllPlayerStats = createServerFn()
.inputValidator(z.enum(['all', 'mainline', 'regional']).optional()) .validator(z.enum(['all', 'mainline', 'regional']).optional())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .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() export const getPlayerMatches = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .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() export const getUnenrolledPlayers = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) => .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() export const getPlayersActivity = createServerFn()
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async () => .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 { Box, ColorSwatch, Group, Text } from '@mantine/core';
import { updateUserAccentColor } from '@/utils/supertokens'; import { updateUserAccentColor } from '@/features/settings/server';
import { useAuth } from '@/contexts/auth-context'; import { useAuth } from '@/contexts/auth-context';
const colors = ['blue', 'red', 'green', 'yellow', 'grape', 'orange', 'pink', 'lime']; const colors = ['blue', 'red', 'green', 'yellow', 'grape', 'orange', 'pink', 'lime'];
@@ -1,6 +1,6 @@
import { Center, Box, Text, SegmentedControl, MantineColorScheme } from '@mantine/core'; import { Center, Box, Text, SegmentedControl, MantineColorScheme } from '@mantine/core';
import { SunIcon, MoonIcon, Icon, MonitorIcon } from '@phosphor-icons/react' 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'; import { useAuth } from '@/contexts/auth-context';
interface ColorSchemeLabelProps { 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 { teamKeys } from "../../queries";
import SongPicker from "./song-picker"; import SongPicker from "./song-picker";
import PlayersPicker from "./players-picker"; import PlayersPicker from "./players-picker";
import imageCompression from "browser-image-compression";
interface TeamFormProps { interface TeamFormProps {
close: () => void; close: () => void;
@@ -120,6 +119,7 @@ const TeamForm = ({
}; };
try { try {
const { default: imageCompression } = await import("browser-image-compression");
processedLogo = await imageCompression(logo, compressionOptions); processedLogo = await imageCompression(logo, compressionOptions);
logger.info("image compressed", { logger.info("image compressed", {
originalSize: logo.size, originalSize: logo.size,
@@ -7,7 +7,6 @@ import { SpotifyTrack } from "@/lib/spotify/types";
import SongSearch from "./song-search"; import SongSearch from "./song-search";
import DurationPicker from "./duration-picker"; import DurationPicker from "./duration-picker";
import SongSummary from "./song-summary"; import SongSummary from "./song-summary";
import { MusicNote } from "@phosphor-icons/react/dist/ssr";
import { MusicNoteIcon } from "@phosphor-icons/react"; import { MusicNoteIcon } from "@phosphor-icons/react";
interface Song { interface Song {
+6 -6
View File
@@ -16,21 +16,21 @@ export const listTeamInfos = createServerFn()
); );
export const getTeam = createServerFn() export const getTeam = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) => .handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeam(teamId)) toServerResult(() => pbAdmin.getTeam(teamId))
); );
export const getTeamInfo = createServerFn() export const getTeamInfo = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) => .handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeamInfo(teamId)) toServerResult(() => pbAdmin.getTeamInfo(teamId))
); );
export const createTeam = createServerFn() export const createTeam = createServerFn()
.inputValidator(teamInputSchema) .validator(teamInputSchema)
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data, context }) => .handler(async ({ data, context }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -47,7 +47,7 @@ export const createTeam = createServerFn()
); );
export const updateTeam = createServerFn() export const updateTeam = createServerFn()
.inputValidator(z.object({ .validator(z.object({
id: z.string(), id: z.string(),
updates: teamUpdateSchema updates: teamUpdateSchema
})) }))
@@ -73,14 +73,14 @@ export const updateTeam = createServerFn()
); );
export const getTeamStats = createServerFn() export const getTeamStats = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teamId }) => .handler(async ({ data: teamId }) =>
toServerResult(() => pbAdmin.getTeamStats(teamId)) toServerResult(() => pbAdmin.getTeamStats(teamId))
); );
export const getTeamMatches = createServerFn() export const getTeamMatches = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<Match[]>(async () => await pbAdmin.getTeamMatches(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 { useSheet } from "@/hooks/use-sheet"
import { ListIcon } from "@phosphor-icons/react" import { ListIcon } from "@phosphor-icons/react"
import { useTournament } from "../../queries" import { useTournament } from "../../queries"
import { useEditor } from '@tiptap/react'; import { Button, Flex, Loader, Stack } from "@mantine/core"
import StarterKit from '@tiptap/starter-kit'; import { lazy, Suspense } from "react"
import { RichTextEditor } from '@mantine/tiptap';
import { Button, Stack } from "@mantine/core" const RulesContent = lazy(() => import("./rules-content"));
interface RulesListButtonProps { interface RulesListButtonProps {
tournamentId: string; tournamentId: string;
@@ -16,13 +16,6 @@ const RulesListButton: React.FC<RulesListButtonProps> = ({ tournamentId }) => {
const { data: tournament } = useTournament(tournamentId); const { data: tournament } = useTournament(tournamentId);
const { open, isOpen, toggle } = useSheet(); const { open, isOpen, toggle } = useSheet();
const editor = useEditor({
extensions: [StarterKit],
content: tournament?.rules || '',
editable: false,
immediatelyRender: false,
});
return ( return (
<> <>
<ListButton <ListButton
@@ -33,9 +26,15 @@ const RulesListButton: React.FC<RulesListButtonProps> = ({ tournamentId }) => {
<Sheet title="Tournament Rules" opened={isOpen} onChange={toggle}> <Sheet title="Tournament Rules" opened={isOpen} onChange={toggle}>
<Stack gap="xs"> <Stack gap="xs">
<RichTextEditor editor={editor}> <Suspense
<RichTextEditor.Content /> fallback={
</RichTextEditor> <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> <Button variant="subtle" c="red" onClick={toggle}>Close</Button>
</Stack> </Stack>
</Sheet> </Sheet>
+16 -16
View File
@@ -17,14 +17,14 @@ export const listTournaments = createServerFn()
); );
export const createTournament = createServerFn() export const createTournament = createServerFn()
.inputValidator(tournamentInputSchema) .validator(tournamentInputSchema)
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(() => pbAdmin.createTournament(data)) toServerResult(() => pbAdmin.createTournament(data))
); );
export const updateTournament = createServerFn() export const updateTournament = createServerFn()
.inputValidator(z.object({ .validator(z.object({
id: z.string(), id: z.string(),
updates: tournamentInputSchema.partial() updates: tournamentInputSchema.partial()
})) }))
@@ -34,7 +34,7 @@ export const updateTournament = createServerFn()
); );
export const getTournament = createServerFn() export const getTournament = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId, context }) => { .handler(async ({ data: tournamentId, context }) => {
const isAdmin = context.roles.includes("Admin"); const isAdmin = context.roles.includes("Admin");
@@ -47,7 +47,7 @@ export const getCurrentTournament = createServerFn()
); );
export const enrollTeam = createServerFn() export const enrollTeam = createServerFn()
.inputValidator(z.object({ .validator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
teamId: z.string() teamId: z.string()
})) }))
@@ -81,7 +81,7 @@ export const enrollTeam = createServerFn()
); );
export const unenrollTeam = createServerFn() export const unenrollTeam = createServerFn()
.inputValidator(z.object({ .validator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
teamId: z.string() teamId: z.string()
})) }))
@@ -91,21 +91,21 @@ export const unenrollTeam = createServerFn()
); );
export const getUnenrolledTeams = createServerFn() export const getUnenrolledTeams = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: tournamentId }) => .handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId)) toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
); );
export const getFreeAgents = createServerFn() export const getFreeAgents = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) => .handler(async ({ data: tournamentId }) =>
toServerResult(() => pbAdmin.getFreeAgents(tournamentId)) toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
); );
export const enrollFreeAgent = createServerFn() 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]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -119,7 +119,7 @@ export const enrollFreeAgent = createServerFn()
); );
export const unenrollFreeAgent = createServerFn() export const unenrollFreeAgent = createServerFn()
.inputValidator(z.object({ tournamentId: z.string() })) .validator(z.object({ tournamentId: z.string() }))
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -133,7 +133,7 @@ export const unenrollFreeAgent = createServerFn()
); );
export const generateRandomTeams = createServerFn() export const generateRandomTeams = createServerFn()
.inputValidator(z.object({ .validator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
seed: z.number().optional() seed: z.number().optional()
})) }))
@@ -313,7 +313,7 @@ export const generateRandomTeams = createServerFn()
); );
export const confirmTeamAssignments = createServerFn() export const confirmTeamAssignments = createServerFn()
.inputValidator(z.object({ .validator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
assignments: z.array(z.object({ assignments: z.array(z.object({
player1Id: z.string(), player1Id: z.string(),
@@ -503,14 +503,14 @@ async function calculateGroupStandings(groupId: string): Promise<GroupStanding[]
} }
export const getGroupStandings = createServerFn() export const getGroupStandings = createServerFn()
.inputValidator(z.string()) .validator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: groupId }) => .handler(async ({ data: groupId }) =>
toServerResult(() => calculateGroupStandings(groupId)) toServerResult(() => calculateGroupStandings(groupId))
); );
export const generateKnockoutBracket = createServerFn() export const generateKnockoutBracket = createServerFn()
.inputValidator(z.object({ .validator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
})) }))
.middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware]) .middleware([superTokensAdminFunctionMiddleware, serverFnLoggingMiddleware])
@@ -711,7 +711,7 @@ export const generateKnockoutBracket = createServerFn()
); );
export const adminEnrollPlayer = createServerFn() export const adminEnrollPlayer = createServerFn()
.inputValidator(z.object({ .validator(z.object({
playerId: z.string(), playerId: z.string(),
tournamentId: z.string() tournamentId: z.string()
})) }))
@@ -724,7 +724,7 @@ export const adminEnrollPlayer = createServerFn()
); );
export const adminUnenrollPlayer = createServerFn() export const adminUnenrollPlayer = createServerFn()
.inputValidator(z.object({ .validator(z.object({
playerId: z.string(), playerId: z.string(),
tournamentId: z.string() tournamentId: z.string()
})) }))
@@ -737,7 +737,7 @@ export const adminUnenrollPlayer = createServerFn()
); );
export const generateGroupStage = createServerFn() export const generateGroupStage = createServerFn()
.inputValidator(z.object({ .validator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
groupConfig: z.object({ groupConfig: z.object({
num_groups: z.number(), num_groups: z.number(),
+21 -2
View File
@@ -4,11 +4,30 @@ const useNow = () => {
const [now, setNow] = useState(new Date()); const [now, setNow] = useState(new Date());
useEffect(() => { 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(() => { const intervalId = setInterval(() => {
setNow(new Date()); if (document.hidden) return;
tick();
}, 1000); }, 1000);
return () => clearInterval(intervalId); const handleVisibilityChange = () => {
if (!document.hidden) tick();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []); }, []);
return now; return now;
+28 -2
View File
@@ -14,6 +14,30 @@ type SSEEvent = {
type EventHandler = (event: SSEEvent, queryClient: ReturnType<typeof useQueryClient>, currentSessionId?: string) => void; 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> = { const eventHandlers: Record<string, EventHandler> = {
"connected": () => { "connected": () => {
logger.info("New Connection"); logger.info("New Connection");
@@ -21,8 +45,8 @@ const eventHandlers: Record<string, EventHandler> = {
"ping": () => {}, "ping": () => {},
"heartbeat": () => {}, "heartbeat": () => {},
"match": (event, queryClient) => { "match": (event, queryClient) => {
queryClient.invalidateQueries(tournamentQueries.details(event.tournamentId)) debouncedInvalidate(queryClient, tournamentQueries.details(event.tournamentId))
queryClient.invalidateQueries(tournamentQueries.current()) debouncedInvalidate(queryClient, tournamentQueries.current())
}, },
"reaction": (event, queryClient) => { "reaction": (event, queryClient) => {
queryClient.invalidateQueries(reactionQueries.match(event.matchId)); queryClient.invalidateQueries(reactionQueries.match(event.matchId));
@@ -104,6 +128,8 @@ export function useServerEvents() {
logger.info("Closing SSE connection"); logger.info("Closing SSE connection");
shouldConnectRef.current = false; shouldConnectRef.current = false;
clearPendingInvalidations();
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
timeoutRef.current = null; timeoutRef.current = null;
+7 -2
View File
@@ -4,7 +4,7 @@ import {
MantineProvider as MantineProviderCore, MantineProvider as MantineProviderCore,
} from "@mantine/core"; } from "@mantine/core";
import ColorSchemeProvider from "./color-scheme-provider"; import ColorSchemeProvider from "./color-scheme-provider";
import { useState, useEffect } from "react"; import { useState, useEffect, useMemo } from "react";
const commonInputStyles = { const commonInputStyles = {
label: { label: {
@@ -60,10 +60,15 @@ const MantineProvider = ({ children }: { children: React.ReactNode }) => {
const colorScheme = isHydrated ? metadata.colorScheme || "auto" : "auto"; const colorScheme = isHydrated ? metadata.colorScheme || "auto" : "auto";
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue"; const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
const themeWithPrimaryColor = useMemo(
() => ({ ...theme, primaryColor }),
[primaryColor]
);
return ( return (
<MantineProviderCore <MantineProviderCore
defaultColorScheme={colorScheme} defaultColorScheme={colorScheme}
theme={{ ...theme, primaryColor }} theme={themeWithPrimaryColor}
> >
<ColorSchemeProvider>{children}</ColorSchemeProvider> <ColorSchemeProvider>{children}</ColorSchemeProvider>
</MantineProviderCore> </MantineProviderCore>
+5 -4
View File
@@ -35,12 +35,13 @@ export function createGroupsService(pb: PocketBase) {
async deleteGroupsByTournament(tournamentId: string): Promise<void> { async deleteGroupsByTournament(tournamentId: string): Promise<void> {
logger.info("PocketBase | Deleting all groups for tournament", { tournamentId }); logger.info("PocketBase | Deleting all groups for tournament", { tournamentId });
const groups = await pb.collection("groups").getFullList({ const groups = await pb.collection("groups").getFullList({
filter: `tournament = "${tournamentId}"` filter: `tournament = "${tournamentId}"`,
fields: "id",
}); });
for (const group of groups) { await Promise.all(
await pb.collection("groups").delete(group.id); groups.map((group) => pb.collection("groups").delete(group.id))
} );
}, },
async getGroup(groupId: string): Promise<Group> { async getGroup(groupId: string): Promise<Group> {
+19 -12
View File
@@ -34,10 +34,19 @@ export function createTeamsService(pb: PocketBase) {
logger.info("PocketBase | Creating team", data); logger.info("PocketBase | Creating team", data);
try { try {
for (const playerId of data.players) { if (data.players.length > 0) {
const playerExists = await pb.collection("players").getOne(playerId).catch(() => null); const playerFilter = data.players
if (!playerExists) { .map((playerId) => `id = "${playerId}"`)
throw new Error(`Player with ID ${playerId} not found`); .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 players: data.players
}); });
for (const playerId of data.players) { await Promise.all(
await pb.collection("players").update(playerId, { data.players.map((playerId) =>
pb.collection("players").update(playerId, {
"teams+": result.id "teams+": result.id
}); })
} )
);
return transformTeam(await pb.collection("teams").getOne(result.id, { return transformTeam(await pb.collection("teams").getOne(result.id, {
expand: "players, tournaments" expand: "players, tournaments"
@@ -72,10 +83,6 @@ export function createTeamsService(pb: PocketBase) {
const result = await pb.collection("teams").update(id, data); 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, { const updated = await pb.collection("teams").getOne(result.id, {
expand: "players, tournaments", expand: "players, tournaments",
// @ts-ignore - Add cache busting // @ts-ignore - Add cache busting
+36 -17
View File
@@ -35,20 +35,23 @@ export function createTournamentsService(pb: PocketBase) {
return transformTournament(tournamentResult, isAdmin); return transformTournament(tournamentResult, isAdmin);
}, },
async getMostRecentTournament(): Promise<Tournament> { async getMostRecentTournament(): Promise<Tournament> {
const result = await pb const mostRecent = await pb
.collection("tournaments") .collection("tournaments")
.getFirstListItem('', .getFirstListItem('', {
{ fields: "id",
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
sort: "-start_time", 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; result.team_stats = teamStatsResult;
return transformTournament(result); return transformTournament(result);
@@ -61,17 +64,33 @@ export function createTournamentsService(pb: PocketBase) {
sort: "-start_time", sort: "-start_time",
}); });
const tournamentsWithStats = await Promise.all(result.map(async (tournament) => { let allTeamStats: any[] = [];
const teamStats = await pb.collection("team_stats_per_tournament").getFullList({ if (result.length > 0) {
filter: `tournament_id = "${tournament.id}"`, 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" sort: "-wins,-total_cups_made"
}); });
}
tournament.team_stats = teamStats; const statsByTournament = new Map<string, any[]>();
return tournament; 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> { async createTournament(data: TournamentInput): Promise<Tournament> {
const result = await pb const result = await pb
+18 -3
View File
@@ -3,6 +3,22 @@ import { ensureSuperTokensBackend } from "../server";
import { logger } from "../"; import { logger } from "../";
import SuperTokens from "supertokens-node"; 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 }) { export async function getSessionForStart(request: Request, options?: { sessionRequired?: boolean }) {
ensureSuperTokensBackend(); 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 { return {
hasToken: true, hasToken: true,
accessTokenPayload: session.accessTokenPayload, accessTokenPayload: session.accessTokenPayload,
userId, userId,
sessionHandle: session.accessTokenPayload?.sessionHandle, sessionHandle: session.accessTokenPayload?.sessionHandle,
phone: user?.phoneNumbers[0] phone
}; };
} }
@@ -1,6 +1,5 @@
import { logger } from "../../logger"; import { logger } from "../../logger";
import { ErrorType, ServerError, ServerResult } from "../types"; import { ErrorType, ServerError, ServerResult } from "../types";
import { pbAdmin } from "../../pocketbase/client";
import { getRequest } from "@tanstack/react-start/server"; import { getRequest } from "@tanstack/react-start/server";
import { isRedirect } from "@tanstack/react-router"; import { isRedirect } from "@tanstack/react-router";
@@ -51,7 +50,8 @@ export const toServerResult = async <T>(
fnName = serverFn.name || 'unknown'; fnName = serverFn.name || 'unknown';
} }
try { import("../../pocketbase/client")
.then(async ({ pbAdmin }) => {
await pbAdmin.authPromise; await pbAdmin.authPromise;
await pbAdmin.createActivity({ await pbAdmin.createActivity({
name: fnName, name: fnName,
@@ -64,8 +64,8 @@ export const toServerResult = async <T>(
userMessage: mappedError.userMessage, userMessage: mappedError.userMessage,
}, },
}); });
} catch (activityError) { })
} .catch(() => {});
return { success: false, error: mappedError }; return { success: false, error: mappedError };
} }
+20 -14
View File
@@ -1,6 +1,20 @@
import { pbAdmin } from "@/lib/pocketbase/client";
import { createMiddleware } from "@tanstack/react-start"; import { createMiddleware } from "@tanstack/react-start";
import { getRequest } from "@tanstack/react-start/server"; 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({ export const serverFnLoggingMiddleware = createMiddleware({
type: "function", type: "function",
@@ -11,6 +25,7 @@ export const serverFnLoggingMiddleware = createMiddleware({
const pathParts = url.pathname.split('/').filter(Boolean); const pathParts = url.pathname.split('/').filter(Boolean);
const serverFnName = pathParts[pathParts.length - 1] || 'unknown'; const serverFnName = pathParts[pathParts.length - 1] || 'unknown';
const userId = (context as any)?.metadata?.player_id || 'unknown'; const userId = (context as any)?.metadata?.player_id || 'unknown';
const userAgent = request.headers.get('user-agent') || undefined;
const startTime = Date.now(); const startTime = Date.now();
@@ -18,38 +33,29 @@ export const serverFnLoggingMiddleware = createMiddleware({
const result = await next(); const result = await next();
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
recordActivity({
try {
await pbAdmin.authPromise;
await pbAdmin.createActivity({
name: serverFnName, name: serverFnName,
player: userId !== 'unknown' ? userId : undefined, player: userId !== 'unknown' ? userId : undefined,
duration, duration,
success: true, success: true,
arguments: data, arguments: data,
user_agent: request.headers.get('user-agent') || undefined, user_agent: userAgent,
}); });
} catch (activityError) {
}
return result; return result;
} catch (error) { } catch (error) {
const duration = Date.now() - startTime; const duration = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
try { recordActivity({
await pbAdmin.authPromise;
await pbAdmin.createActivity({
name: serverFnName, name: serverFnName,
player: userId !== 'unknown' ? userId : undefined, player: userId !== 'unknown' ? userId : undefined,
duration, duration,
success: false, success: false,
error: errorMessage, error: errorMessage,
arguments: data, arguments: data,
user_agent: request.headers.get('user-agent') || undefined, user_agent: userAgent,
}); });
} catch (activityError) {
}
throw error; 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,
};
};
+13 -132
View File
@@ -3,100 +3,20 @@ import {
createServerFn, createServerFn,
createServerOnlyFn, createServerOnlyFn,
} from "@tanstack/react-start"; } from "@tanstack/react-start";
import { getRequest, setResponseHeader } from "@tanstack/react-start/server"; import { getRequest } 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 { Logger } from "@/lib/logger"; import { Logger } from "@/lib/logger";
import z from "zod"; import z from "zod";
import { serverFnLoggingMiddleware } from "./activities";
import { pbAdmin } from "@/lib/pocketbase/client";
const logger = new Logger("Middleware"); const logger = new Logger("Middleware");
export const PUBLIC_ROUTES = ["/login", "/logout", "/refresh-session"]; export const PUBLIC_ROUTES = ["/login", "/logout", "/refresh-session"];
const verifySuperTokensSession = async ( export const getSessionContext = createServerOnlyFn(
request: Request async (request: Request, options?: { isServerFunction?: boolean }) => {
) => { const { getSessionContextCore } = await import("./supertokens-core.server");
let session = await getSessionForStart(request, { sessionRequired: false }); return getSessionContextCore(request, options);
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 } = 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({ export const superTokensRequestMiddleware = createMiddleware({
type: "request", type: "request",
@@ -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" }) export const setUserMetadata = createServerFn({ method: "POST" })
.inputValidator( .validator(
z z
.object({ .object({
first_name: z first_name: z
@@ -195,7 +110,11 @@ export const setUserMetadata = createServerFn({ method: "POST" })
const { userAuthId, metadata } = context; const { userAuthId, metadata } = context;
if (!userAuthId) return; if (!userAuthId) return;
await UserMetadata.updateUserMetadata(userAuthId, { const { updateUserMetadataFields } = await import(
"./supertokens-core.server"
);
await updateUserMetadataFields(userAuthId, {
first_name: data.first_name, first_name: data.first_name,
last_name: data.last_name, last_name: data.last_name,
player_id: data.player_id, 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 { defineConfig } from 'vite'
import tsConfigPaths from 'vite-tsconfig-paths' import tsConfigPaths from 'vite-tsconfig-paths'
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import { VitePWA } from 'vite-plugin-pwa'
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
server: { server: {
@@ -19,46 +18,10 @@ export default defineConfig(({ mode }) => ({
tanstackStart({ tanstackStart({
srcDirectory: 'src/app', srcDirectory: 'src/app',
}), }),
react(), react({
VitePWA({ babel: {
registerType: 'autoUpdate', plugins: ['babel-plugin-react-compiler'],
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]
}
}
}
]
},
devOptions: {
enabled: false
}
})
] ]
})) }))