Merge branch 'perf' into development
@@ -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))"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 436 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 273 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 211 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 189 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 207 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 147 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 284 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 31 KiB |
@@ -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 })
|
||||
|
||||
@@ -29,6 +29,7 @@ export function getRouter() {
|
||||
fullWidth: false,
|
||||
},
|
||||
defaultPreload: "intent",
|
||||
defaultPreloadStaleTime: 60_000,
|
||||
defaultErrorComponent: DefaultCatchBoundary,
|
||||
scrollRestoration: true,
|
||||
defaultViewTransition: false,
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -351,11 +368,16 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
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}</>;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
}))
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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,6 +11,7 @@ export const defaultHeaderConfig: HeaderConfig = {
|
||||
const useRouterConfig = () => {
|
||||
const matches = useMatches();
|
||||
|
||||
return useMemo(() => {
|
||||
const matchesWithHeader = matches.filter((match) =>
|
||||
match?.loaderData && 'header' in match.loaderData
|
||||
);
|
||||
@@ -36,6 +38,7 @@ const useRouterConfig = () => {
|
||||
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);
|
||||
window.visualViewport?.removeEventListener('resize', scheduleSetSize);
|
||||
if (rafRef.current !== null) {
|
||||
window.cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
}, [setSize]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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);
|
||||
|
||||
@@ -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 =>
|
||||
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) || [];
|
||||
)
|
||||
.sort((a, b) => a.start_time < b.start_time ? 1 : -1),
|
||||
[matches]
|
||||
);
|
||||
|
||||
if (!filteredMatches.length) {
|
||||
return undefined;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -3,13 +3,14 @@ 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 () =>
|
||||
toServerResult(async () => {
|
||||
@@ -18,10 +19,8 @@ export const fetchMe = createServerFn()
|
||||
try {
|
||||
const context = await getSessionContext(request);
|
||||
|
||||
await pbAdmin.authPromise;
|
||||
const result = await pbAdmin.getPlayerByAuthId(context.userAuthId);
|
||||
return {
|
||||
user: result || undefined,
|
||||
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))
|
||||
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 }) =>
|
||||
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 }) =>
|
||||
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,13 +100,15 @@ export const createPlayer = createServerFn()
|
||||
);
|
||||
|
||||
export const associatePlayer = createServerFn()
|
||||
.inputValidator(z.string())
|
||||
.validator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware, serverFnLoggingMiddleware])
|
||||
.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, {
|
||||
@@ -122,48 +130,69 @@ export const associatePlayer = createServerFn()
|
||||
export const listPlayers = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult(pbAdmin.listPlayers)
|
||||
toServerResult(async () => {
|
||||
const { pbAdmin } = await import("@/lib/pocketbase/client");
|
||||
return await pbAdmin.listPlayers();
|
||||
})
|
||||
);
|
||||
|
||||
export const getUnassociatedPlayers = createServerFn()
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async () =>
|
||||
toServerResult(pbAdmin.getUnassociatedPlayers)
|
||||
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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -4,11 +4,30 @@ 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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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, {
|
||||
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
|
||||
|
||||
@@ -35,20 +35,23 @@ 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",
|
||||
.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;
|
||||
|
||||
return transformTournament(result);
|
||||
@@ -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
|
||||
|
||||
@@ -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,7 +50,8 @@ export const toServerResult = async <T>(
|
||||
fnName = serverFn.name || 'unknown';
|
||||
}
|
||||
|
||||
try {
|
||||
import("../../pocketbase/client")
|
||||
.then(async ({ pbAdmin }) => {
|
||||
await pbAdmin.authPromise;
|
||||
await pbAdmin.createActivity({
|
||||
name: fnName,
|
||||
@@ -64,8 +64,8 @@ export const toServerResult = async <T>(
|
||||
userMessage: mappedError.userMessage,
|
||||
},
|
||||
});
|
||||
} catch (activityError) {
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return { success: false, error: mappedError };
|
||||
}
|
||||
|
||||
@@ -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,38 +33,29 @@ export const serverFnLoggingMiddleware = createMiddleware({
|
||||
const result = await next();
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
|
||||
try {
|
||||
await pbAdmin.authPromise;
|
||||
await pbAdmin.createActivity({
|
||||
recordActivity({
|
||||
name: serverFnName,
|
||||
player: userId !== 'unknown' ? userId : undefined,
|
||||
duration,
|
||||
success: true,
|
||||
arguments: data,
|
||||
user_agent: request.headers.get('user-agent') || undefined,
|
||||
user_agent: userAgent,
|
||||
});
|
||||
} catch (activityError) {
|
||||
}
|
||||
|
||||
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({
|
||||
recordActivity({
|
||||
name: serverFnName,
|
||||
player: userId !== 'unknown' ? userId : undefined,
|
||||
duration,
|
||||
success: false,
|
||||
error: errorMessage,
|
||||
arguments: data,
|
||||
user_agent: request.headers.get('user-agent') || undefined,
|
||||
user_agent: userAgent,
|
||||
});
|
||||
} catch (activityError) {
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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",
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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
|
||||
react({
|
||||
babel: {
|
||||
plugins: ['babel-plugin-react-compiler'],
|
||||
},
|
||||
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
|
||||
}
|
||||
})
|
||||
}),
|
||||
]
|
||||
}))
|
||||
|
||||