12 Commits

Author SHA1 Message Date
yohlo
48aeaabeea improvements 2025-09-29 11:43:48 -05:00
yohlo
a4b9fe9065 updated bracket 2025-09-29 10:50:18 -05:00
yohlo
31e50af593 team logo compression, play around with style 2025-09-29 10:20:54 -05:00
yohlo
39053cadaa avatr contain 2025-09-26 12:55:04 -05:00
yohlo
ea6656aa33 avatar modal 2025-09-25 21:24:57 -05:00
yohlo
92c4987372 bun 2025-09-25 16:42:55 -05:00
yohlo
b3ebf46afa new typeahead 2025-09-25 16:11:54 -05:00
yohlo
c0ef535001 bug fixes 2025-09-25 15:49:09 -05:00
yohlo
81329e4354 fix refresh issue 2025-09-24 12:20:36 -05:00
yohlo
36f3bb77d4 updates 2025-09-24 11:02:56 -05:00
yohlo
6760ea46f9 update query integration 2025-09-24 08:04:09 -05:00
yohlo
e4164cbc71 attempted upgrade 2025-09-24 00:13:41 -05:00
68 changed files with 3759 additions and 1641 deletions

3
.gitignore vendored
View File

@@ -19,4 +19,5 @@ yarn.lock
/playwright/.cache/ /playwright/.cache/
/scripts/ /scripts/
/pb_data/ /pb_data/
/.tanstack/ /.tanstack/
/dist/

1245
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"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",
"start": "node .output/server/index.mjs" "start": "bun run .output/server/index.mjs",
"start:node": "node .output/server/index.mjs"
}, },
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@hello-pangea/dnd": "^18.0.1",
@@ -21,14 +22,17 @@
"@svgmoji/noto": "^3.2.0", "@svgmoji/noto": "^3.2.0",
"@tanstack/react-query": "^5.66.0", "@tanstack/react-query": "^5.66.0",
"@tanstack/react-query-devtools": "^5.66.0", "@tanstack/react-query-devtools": "^5.66.0",
"@tanstack/react-router": "1.130.12", "@tanstack/react-router": "^1.130.12",
"@tanstack/react-router-devtools": "1.130.13", "@tanstack/react-router-devtools": "^1.130.13",
"@tanstack/react-router-with-query": "1.130.12", "@tanstack/react-router-with-query": "^1.130.12",
"@tanstack/react-start": "1.130.15", "@tanstack/react-start": "^1.132.2",
"@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/ioredis": "^4.28.10", "@types/ioredis": "^4.28.10",
"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",
"framer-motion": "^12.23.12", "framer-motion": "^12.23.12",
@@ -51,6 +55,8 @@
"zustand": "^5.0.7" "zustand": "^5.0.7"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/react-router-ssr-query": "^1.132.2",
"@tanstack/router-plugin": "^1.132.2",
"@types/node": "^22.5.4", "@types/node": "^22.5.4",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"@types/react": "^19.0.8", "@types/react": "^19.0.8",
@@ -63,7 +69,7 @@
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"tsx": "^4.20.3", "tsx": "^4.20.3",
"typescript": "^5.7.2", "typescript": "^5.7.2",
"vite": "^6.3.5", "vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4" "vite-tsconfig-paths": "^5.1.4"
} }
} }

364
server.ts Normal file
View File

@@ -0,0 +1,364 @@
/**
* TanStack Start Production Server with Bun
*
* A high-performance production server for TanStack Start applications that
* implements intelligent static asset loading with configurable memory management.
*
* Features:
* - Hybrid loading strategy (preload small files, serve large files on-demand)
* - Configurable file filtering with include/exclude patterns
* - Memory-efficient response generation
* - Production-ready caching headers
*
* Environment Variables:
*
* PORT (number)
* - Server port number
* - Default: 3000
*
* STATIC_PRELOAD_MAX_BYTES (number)
* - Maximum file size in bytes to preload into memory
* - Files larger than this will be served on-demand from disk
* - Default: 5242880 (5MB)
* - Example: STATIC_PRELOAD_MAX_BYTES=5242880 (5MB)
*
* STATIC_PRELOAD_INCLUDE (string)
* - Comma-separated list of glob patterns for files to include
* - If specified, only matching files are eligible for preloading
* - Patterns are matched against filenames only, not full paths
* - Example: STATIC_PRELOAD_INCLUDE="*.js,*.css,*.woff2"
*
* STATIC_PRELOAD_EXCLUDE (string)
* - Comma-separated list of glob patterns for files to exclude
* - Applied after include patterns
* - Patterns are matched against filenames only, not full paths
* - Example: STATIC_PRELOAD_EXCLUDE="*.map,*.txt"
*
* STATIC_PRELOAD_VERBOSE (boolean)
* - Enable detailed logging of loaded and skipped files
* - Default: false
* - Set to "true" to enable verbose output
*
* Usage:
* bun run server.ts
*/
import { readdir } from 'node:fs/promises'
import { join } from 'node:path'
// Configuration
const PORT = Number(process.env.PORT ?? 3000)
const CLIENT_DIR = './dist/client'
const SERVER_ENTRY = './dist/server/server.js'
// Preloading configuration from environment variables
const MAX_PRELOAD_BYTES = Number(
process.env.STATIC_PRELOAD_MAX_BYTES ?? 5 * 1024 * 1024, // 5MB default
)
// Parse comma-separated include patterns (no defaults)
const INCLUDE_PATTERNS = (process.env.STATIC_PRELOAD_INCLUDE ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(globToRegExp)
// Parse comma-separated exclude patterns (no defaults)
const EXCLUDE_PATTERNS = (process.env.STATIC_PRELOAD_EXCLUDE ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map(globToRegExp)
// Verbose logging flag
const VERBOSE = process.env.STATIC_PRELOAD_VERBOSE === 'true'
/**
* Convert a simple glob pattern to a regular expression
* Supports * wildcard for matching any characters
*/
function globToRegExp(glob: string): RegExp {
// Escape regex special chars except *, then replace * with .*
const escaped = glob
.replace(/[-/\\^$+?.()|[\]{}]/g, '\\$&')
.replace(/\*/g, '.*')
return new RegExp(`^${escaped}$`, 'i')
}
/**
* Metadata for preloaded static assets
*/
interface AssetMetadata {
route: string
size: number
type: string
}
/**
* Result of static asset preloading process
*/
interface PreloadResult {
routes: Record<string, () => Response>
loaded: Array<AssetMetadata>
skipped: Array<AssetMetadata>
}
/**
* Check if a file should be included based on configured patterns
*/
function shouldInclude(relativePath: string): boolean {
const fileName = relativePath.split(/[/\\]/).pop() ?? relativePath
// If include patterns are specified, file must match at least one
if (INCLUDE_PATTERNS.length > 0) {
if (!INCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
return false
}
}
// If exclude patterns are specified, file must not match any
if (EXCLUDE_PATTERNS.some((pattern) => pattern.test(fileName))) {
return false
}
return true
}
/**
* Build static routes with intelligent preloading strategy
* Small files are loaded into memory, large files are served on-demand
*/
async function buildStaticRoutes(clientDir: string): Promise<PreloadResult> {
const routes: Record<string, () => Response> = {}
const loaded: Array<AssetMetadata> = []
const skipped: Array<AssetMetadata> = []
console.log(`📦 Loading static assets from ${clientDir}...`)
console.log(
` Max preload size: ${(MAX_PRELOAD_BYTES / 1024 / 1024).toFixed(2)} MB`,
)
if (INCLUDE_PATTERNS.length > 0) {
console.log(
` Include patterns: ${process.env.STATIC_PRELOAD_INCLUDE ?? ''}`,
)
}
if (EXCLUDE_PATTERNS.length > 0) {
console.log(
` Exclude patterns: ${process.env.STATIC_PRELOAD_EXCLUDE ?? ''}`,
)
}
let totalPreloadedBytes = 0
try {
// Read all files recursively
const files = await readdir(clientDir, { recursive: true })
for (const relativePath of files) {
const filepath = join(clientDir, relativePath)
const route = '/' + relativePath.replace(/\\/g, '/') // Handle Windows paths
try {
// Get file metadata
const file = Bun.file(filepath)
// Skip if file doesn't exist or is empty
if (!(await file.exists()) || file.size === 0) {
continue
}
const metadata: AssetMetadata = {
route,
size: file.size,
type: file.type || 'application/octet-stream',
}
// Determine if file should be preloaded
const matchesPattern = shouldInclude(relativePath)
const withinSizeLimit = file.size <= MAX_PRELOAD_BYTES
if (matchesPattern && withinSizeLimit) {
// Preload small files into memory
const bytes = await file.bytes()
routes[route] = () =>
new Response(bytes, {
headers: {
'Content-Type': metadata.type,
'Cache-Control': 'public, max-age=31536000, immutable',
},
})
loaded.push({ ...metadata, size: bytes.byteLength })
totalPreloadedBytes += bytes.byteLength
} else {
// Serve large or filtered files on-demand
routes[route] = () => {
const fileOnDemand = Bun.file(filepath)
return new Response(fileOnDemand, {
headers: {
'Content-Type': metadata.type,
'Cache-Control': 'public, max-age=3600',
},
})
}
skipped.push(metadata)
}
} catch (error: unknown) {
if (error instanceof Error && error.name !== 'EISDIR') {
console.error(`❌ Failed to load ${filepath}:`, error)
}
}
}
// Always show file overview in Vite-like format first
if (loaded.length > 0 || skipped.length > 0) {
const allFiles = [...loaded, ...skipped].sort((a, b) =>
a.route.localeCompare(b.route),
)
// Calculate max path length for alignment
const maxPathLength = Math.min(
Math.max(...allFiles.map((f) => f.route.length)),
60,
)
// Format file size with KB and gzip estimation
const formatFileSize = (bytes: number) => {
const kb = bytes / 1024
// Rough gzip estimation (typically 30-70% compression)
const gzipKb = kb * 0.35
return {
size: kb < 100 ? kb.toFixed(2) : kb.toFixed(1),
gzip: gzipKb < 100 ? gzipKb.toFixed(2) : gzipKb.toFixed(1),
}
}
if (loaded.length > 0) {
console.log('\n📁 Preloaded into memory:')
loaded
.sort((a, b) => a.route.localeCompare(b.route))
.forEach((file) => {
const { size, gzip } = formatFileSize(file.size)
const paddedPath = file.route.padEnd(maxPathLength)
const sizeStr = `${size.padStart(7)} kB`
const gzipStr = `gzip: ${gzip.padStart(6)} kB`
console.log(` ${paddedPath} ${sizeStr}${gzipStr}`)
})
}
if (skipped.length > 0) {
console.log('\n💾 Served on-demand:')
skipped
.sort((a, b) => a.route.localeCompare(b.route))
.forEach((file) => {
const { size, gzip } = formatFileSize(file.size)
const paddedPath = file.route.padEnd(maxPathLength)
const sizeStr = `${size.padStart(7)} kB`
const gzipStr = `gzip: ${gzip.padStart(6)} kB`
console.log(` ${paddedPath} ${sizeStr}${gzipStr}`)
})
}
// Show detailed verbose info if enabled
if (VERBOSE) {
console.log('\n📊 Detailed file information:')
allFiles.forEach((file) => {
const isPreloaded = loaded.includes(file)
const status = isPreloaded ? '[MEMORY]' : '[ON-DEMAND]'
const reason =
!isPreloaded && file.size > MAX_PRELOAD_BYTES
? ' (too large)'
: !isPreloaded
? ' (filtered)'
: ''
console.log(
` ${status.padEnd(12)} ${file.route} - ${file.type}${reason}`,
)
})
}
}
// Log summary after the file list
console.log() // Empty line for separation
if (loaded.length > 0) {
console.log(
`✅ Preloaded ${String(loaded.length)} files (${(totalPreloadedBytes / 1024 / 1024).toFixed(2)} MB) into memory`,
)
} else {
console.log(' No files preloaded into memory')
}
if (skipped.length > 0) {
const tooLarge = skipped.filter((f) => f.size > MAX_PRELOAD_BYTES).length
const filtered = skipped.length - tooLarge
console.log(
` ${String(skipped.length)} files will be served on-demand (${String(tooLarge)} too large, ${String(filtered)} filtered)`,
)
}
} catch (error) {
console.error(`❌ Failed to load static files from ${clientDir}:`, error)
}
return { routes, loaded, skipped }
}
/**
* Start the production server
*/
async function startServer() {
console.log('🚀 Starting production server...')
// Load TanStack Start server handler
let handler: { fetch: (request: Request) => Response | Promise<Response> }
try {
const serverModule = (await import(SERVER_ENTRY)) as {
default: { fetch: (request: Request) => Response | Promise<Response> }
}
handler = serverModule.default
console.log('✅ TanStack Start handler loaded')
} catch (error) {
console.error('❌ Failed to load server handler:', error)
process.exit(1)
}
// Build static routes with intelligent preloading
const { routes } = await buildStaticRoutes(CLIENT_DIR)
// Create Bun server
const server = Bun.serve({
port: PORT,
routes: {
// Serve static assets (preloaded or on-demand)
...routes,
// Fallback to TanStack Start handler for all other routes
'/*': (request) => {
try {
return handler.fetch(request)
} catch (error) {
console.error('Server handler error:', error)
return new Response('Internal Server Error', { status: 500 })
}
},
},
// Global error handler
error(error) {
console.error('Uncaught server error:', error)
return new Response('Internal Server Error', { status: 500 })
},
})
console.log(
`\n🚀 Server running at http://localhost:${String(server.port)}\n`,
)
}
// Start the server
startServer().catch((error: unknown) => {
console.error('Failed to start server:', error)
process.exit(1)
})

View File

@@ -8,8 +8,6 @@
// You should NOT make any changes in this file as it will be overwritten. // You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { createServerRootRoute } from '@tanstack/react-start/server'
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as RefreshSessionRouteImport } from './routes/refresh-session' import { Route as RefreshSessionRouteImport } from './routes/refresh-session'
import { Route as LogoutRouteImport } from './routes/logout' import { Route as LogoutRouteImport } from './routes/logout'
@@ -21,6 +19,16 @@ import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin' import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index' import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo'
import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo'
import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search'
import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resume'
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId' import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId' import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId' import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
@@ -28,21 +36,9 @@ import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/p
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index' import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket' import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index' import { Route as AuthedAdminTournamentsIdIndexRouteImport } from './routes/_authed/admin/tournaments/$id/index'
import { Route as ApiFilesCollectionRecordIdFileRouteImport } from './routes/api/files/$collection/$recordId/$file'
import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id' import { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams' import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
import { ServerRoute as ApiSpotifySearchServerRouteImport } from './routes/api/spotify/search'
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture'
import { ServerRoute as ApiSpotifyCallbackServerRouteImport } from './routes/api/spotify/callback'
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file'
const rootServerRouteImport = createServerRootRoute()
const RefreshSessionRoute = RefreshSessionRouteImport.update({ const RefreshSessionRoute = RefreshSessionRouteImport.update({
id: '/refresh-session', id: '/refresh-session',
@@ -93,6 +89,57 @@ const AuthedAdminIndexRoute = AuthedAdminIndexRouteImport.update({
path: '/', path: '/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const ApiTournamentsUploadLogoRoute =
ApiTournamentsUploadLogoRouteImport.update({
id: '/api/tournaments/upload-logo',
path: '/api/tournaments/upload-logo',
getParentRoute: () => rootRouteImport,
} as any)
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
id: '/api/teams/upload-logo',
path: '/api/teams/upload-logo',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyTokenRoute = ApiSpotifyTokenRouteImport.update({
id: '/api/spotify/token',
path: '/api/spotify/token',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifySearchRoute = ApiSpotifySearchRouteImport.update({
id: '/api/spotify/search',
path: '/api/spotify/search',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyResumeRoute = ApiSpotifyResumeRouteImport.update({
id: '/api/spotify/resume',
path: '/api/spotify/resume',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyPlaybackRoute = ApiSpotifyPlaybackRouteImport.update({
id: '/api/spotify/playback',
path: '/api/spotify/playback',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyCaptureRoute = ApiSpotifyCaptureRouteImport.update({
id: '/api/spotify/capture',
path: '/api/spotify/capture',
getParentRoute: () => rootRouteImport,
} as any)
const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
id: '/api/spotify/callback',
path: '/api/spotify/callback',
getParentRoute: () => rootRouteImport,
} as any)
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
id: '/api/events/$',
path: '/api/events/$',
getParentRoute: () => rootRouteImport,
} as any)
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedTournamentsTournamentIdRoute = const AuthedTournamentsTournamentIdRoute =
AuthedTournamentsTournamentIdRouteImport.update({ AuthedTournamentsTournamentIdRouteImport.update({
id: '/tournaments/$tournamentId', id: '/tournaments/$tournamentId',
@@ -132,6 +179,12 @@ const AuthedAdminTournamentsIdIndexRoute =
path: '/tournaments/$id/', path: '/tournaments/$id/',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const ApiFilesCollectionRecordIdFileRoute =
ApiFilesCollectionRecordIdFileRouteImport.update({
id: '/api/files/$collection/$recordId/$file',
path: '/api/files/$collection/$recordId/$file',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedAdminTournamentsRunIdRoute = const AuthedAdminTournamentsRunIdRoute =
AuthedAdminTournamentsRunIdRouteImport.update({ AuthedAdminTournamentsRunIdRouteImport.update({
id: '/tournaments/run/$id', id: '/tournaments/run/$id',
@@ -144,66 +197,6 @@ const AuthedAdminTournamentsIdTeamsRoute =
path: '/tournaments/$id/teams', path: '/tournaments/$id/teams',
getParentRoute: () => AuthedAdminRoute, getParentRoute: () => AuthedAdminRoute,
} as any) } as any)
const ApiTournamentsUploadLogoServerRoute =
ApiTournamentsUploadLogoServerRouteImport.update({
id: '/api/tournaments/upload-logo',
path: '/api/tournaments/upload-logo',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiTeamsUploadLogoServerRoute =
ApiTeamsUploadLogoServerRouteImport.update({
id: '/api/teams/upload-logo',
path: '/api/teams/upload-logo',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
id: '/api/spotify/token',
path: '/api/spotify/token',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiSpotifySearchServerRoute = ApiSpotifySearchServerRouteImport.update({
id: '/api/spotify/search',
path: '/api/spotify/search',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({
id: '/api/spotify/resume',
path: '/api/spotify/resume',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiSpotifyPlaybackServerRoute =
ApiSpotifyPlaybackServerRouteImport.update({
id: '/api/spotify/playback',
path: '/api/spotify/playback',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiSpotifyCaptureServerRoute = ApiSpotifyCaptureServerRouteImport.update({
id: '/api/spotify/capture',
path: '/api/spotify/capture',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiSpotifyCallbackServerRoute =
ApiSpotifyCallbackServerRouteImport.update({
id: '/api/spotify/callback',
path: '/api/spotify/callback',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({
id: '/api/events/$',
path: '/api/events/$',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiAuthSplatServerRoute = ApiAuthSplatServerRouteImport.update({
id: '/api/auth/$',
path: '/api/auth/$',
getParentRoute: () => rootServerRouteImport,
} as any)
const ApiFilesCollectionRecordIdFileServerRoute =
ApiFilesCollectionRecordIdFileServerRouteImport.update({
id: '/api/files/$collection/$recordId/$file',
path: '/api/files/$collection/$recordId/$file',
getParentRoute: () => rootServerRouteImport,
} as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
'/login': typeof LoginRoute '/login': typeof LoginRoute
@@ -217,12 +210,23 @@ export interface FileRoutesByFullPath {
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin/': typeof AuthedAdminIndexRoute '/admin/': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
@@ -236,12 +240,23 @@ export interface FileRoutesByTo {
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/admin': typeof AuthedAdminIndexRoute '/admin': typeof AuthedAdminIndexRoute
'/tournaments': typeof AuthedTournamentsIndexRoute '/tournaments': typeof AuthedTournamentsIndexRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute '/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute '/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
@@ -258,12 +273,23 @@ export interface FileRoutesById {
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute '/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute '/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute '/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
'/api/auth/$': typeof ApiAuthSplatRoute
'/api/events/$': typeof ApiEventsSplatRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
'/api/spotify/search': typeof ApiSpotifySearchRoute
'/api/spotify/token': typeof ApiSpotifyTokenRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
'/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/admin/': typeof AuthedAdminIndexRoute
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute '/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute '/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute '/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute '/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute '/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute '/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@@ -280,12 +306,23 @@ export interface FileRouteTypes {
| '/profile/$playerId' | '/profile/$playerId'
| '/teams/$teamId' | '/teams/$teamId'
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/admin/' | '/admin/'
| '/tournaments' | '/tournaments'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id' | '/admin/tournaments/$id'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: to:
@@ -299,12 +336,23 @@ export interface FileRouteTypes {
| '/profile/$playerId' | '/profile/$playerId'
| '/teams/$teamId' | '/teams/$teamId'
| '/tournaments/$tournamentId' | '/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/admin' | '/admin'
| '/tournaments' | '/tournaments'
| '/tournaments/$id/bracket' | '/tournaments/$id/bracket'
| '/admin/tournaments' | '/admin/tournaments'
| '/admin/tournaments/$id/teams' | '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id' | '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id' | '/admin/tournaments/$id'
id: id:
| '__root__' | '__root__'
@@ -320,12 +368,23 @@ export interface FileRouteTypes {
| '/_authed/profile/$playerId' | '/_authed/profile/$playerId'
| '/_authed/teams/$teamId' | '/_authed/teams/$teamId'
| '/_authed/tournaments/$tournamentId' | '/_authed/tournaments/$tournamentId'
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/_authed/admin/' | '/_authed/admin/'
| '/_authed/tournaments/' | '/_authed/tournaments/'
| '/_authed/tournaments/$id/bracket' | '/_authed/tournaments/$id/bracket'
| '/_authed/admin/tournaments/' | '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/teams' | '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id' | '/_authed/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/_authed/admin/tournaments/$id/' | '/_authed/admin/tournaments/$id/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@@ -334,101 +393,17 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRoute LoginRoute: typeof LoginRoute
LogoutRoute: typeof LogoutRoute LogoutRoute: typeof LogoutRoute
RefreshSessionRoute: typeof RefreshSessionRoute RefreshSessionRoute: typeof RefreshSessionRoute
} ApiAuthSplatRoute: typeof ApiAuthSplatRoute
export interface FileServerRoutesByFullPath { ApiEventsSplatRoute: typeof ApiEventsSplatRoute
'/api/auth/$': typeof ApiAuthSplatServerRoute ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
'/api/events/$': typeof ApiEventsSplatServerRoute ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute ApiSpotifyResumeRoute: typeof ApiSpotifyResumeRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
'/api/spotify/search': typeof ApiSpotifySearchServerRoute ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
}
export interface FileServerRoutesByTo {
'/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
}
export interface FileServerRoutesById {
__root__: typeof rootServerRouteImport
'/api/auth/$': typeof ApiAuthSplatServerRoute
'/api/events/$': typeof ApiEventsSplatServerRoute
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
}
export interface FileServerRouteTypes {
fileServerRoutesByFullPath: FileServerRoutesByFullPath
fullPaths:
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file'
fileServerRoutesByTo: FileServerRoutesByTo
to:
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file'
id:
| '__root__'
| '/api/auth/$'
| '/api/events/$'
| '/api/spotify/callback'
| '/api/spotify/capture'
| '/api/spotify/playback'
| '/api/spotify/resume'
| '/api/spotify/search'
| '/api/spotify/token'
| '/api/teams/upload-logo'
| '/api/tournaments/upload-logo'
| '/api/files/$collection/$recordId/$file'
fileServerRoutesById: FileServerRoutesById
}
export interface RootServerRouteChildren {
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
ApiSpotifyCallbackServerRoute: typeof ApiSpotifyCallbackServerRoute
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
ApiSpotifySearchServerRoute: typeof ApiSpotifySearchServerRoute
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
ApiTeamsUploadLogoServerRoute: typeof ApiTeamsUploadLogoServerRoute
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
@@ -503,6 +478,76 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminIndexRouteImport preLoaderRoute: typeof AuthedAdminIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/api/tournaments/upload-logo': {
id: '/api/tournaments/upload-logo'
path: '/api/tournaments/upload-logo'
fullPath: '/api/tournaments/upload-logo'
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
parentRoute: typeof rootRouteImport
}
'/api/teams/upload-logo': {
id: '/api/teams/upload-logo'
path: '/api/teams/upload-logo'
fullPath: '/api/teams/upload-logo'
preLoaderRoute: typeof ApiTeamsUploadLogoRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/token': {
id: '/api/spotify/token'
path: '/api/spotify/token'
fullPath: '/api/spotify/token'
preLoaderRoute: typeof ApiSpotifyTokenRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/search': {
id: '/api/spotify/search'
path: '/api/spotify/search'
fullPath: '/api/spotify/search'
preLoaderRoute: typeof ApiSpotifySearchRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/resume': {
id: '/api/spotify/resume'
path: '/api/spotify/resume'
fullPath: '/api/spotify/resume'
preLoaderRoute: typeof ApiSpotifyResumeRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/playback': {
id: '/api/spotify/playback'
path: '/api/spotify/playback'
fullPath: '/api/spotify/playback'
preLoaderRoute: typeof ApiSpotifyPlaybackRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/capture': {
id: '/api/spotify/capture'
path: '/api/spotify/capture'
fullPath: '/api/spotify/capture'
preLoaderRoute: typeof ApiSpotifyCaptureRouteImport
parentRoute: typeof rootRouteImport
}
'/api/spotify/callback': {
id: '/api/spotify/callback'
path: '/api/spotify/callback'
fullPath: '/api/spotify/callback'
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
parentRoute: typeof rootRouteImport
}
'/api/events/$': {
id: '/api/events/$'
path: '/api/events/$'
fullPath: '/api/events/$'
preLoaderRoute: typeof ApiEventsSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
fullPath: '/api/auth/$'
preLoaderRoute: typeof ApiAuthSplatRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/tournaments/$tournamentId': { '/_authed/tournaments/$tournamentId': {
id: '/_authed/tournaments/$tournamentId' id: '/_authed/tournaments/$tournamentId'
path: '/tournaments/$tournamentId' path: '/tournaments/$tournamentId'
@@ -552,6 +597,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport
parentRoute: typeof AuthedAdminRoute parentRoute: typeof AuthedAdminRoute
} }
'/api/files/$collection/$recordId/$file': {
id: '/api/files/$collection/$recordId/$file'
path: '/api/files/$collection/$recordId/$file'
fullPath: '/api/files/$collection/$recordId/$file'
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
parentRoute: typeof rootRouteImport
}
'/_authed/admin/tournaments/run/$id': { '/_authed/admin/tournaments/run/$id': {
id: '/_authed/admin/tournaments/run/$id' id: '/_authed/admin/tournaments/run/$id'
path: '/tournaments/run/$id' path: '/tournaments/run/$id'
@@ -568,87 +620,6 @@ declare module '@tanstack/react-router' {
} }
} }
} }
declare module '@tanstack/react-start/server' {
interface ServerFileRoutesByPath {
'/api/tournaments/upload-logo': {
id: '/api/tournaments/upload-logo'
path: '/api/tournaments/upload-logo'
fullPath: '/api/tournaments/upload-logo'
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/teams/upload-logo': {
id: '/api/teams/upload-logo'
path: '/api/teams/upload-logo'
fullPath: '/api/teams/upload-logo'
preLoaderRoute: typeof ApiTeamsUploadLogoServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/spotify/token': {
id: '/api/spotify/token'
path: '/api/spotify/token'
fullPath: '/api/spotify/token'
preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/spotify/search': {
id: '/api/spotify/search'
path: '/api/spotify/search'
fullPath: '/api/spotify/search'
preLoaderRoute: typeof ApiSpotifySearchServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/spotify/resume': {
id: '/api/spotify/resume'
path: '/api/spotify/resume'
fullPath: '/api/spotify/resume'
preLoaderRoute: typeof ApiSpotifyResumeServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/spotify/playback': {
id: '/api/spotify/playback'
path: '/api/spotify/playback'
fullPath: '/api/spotify/playback'
preLoaderRoute: typeof ApiSpotifyPlaybackServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/spotify/capture': {
id: '/api/spotify/capture'
path: '/api/spotify/capture'
fullPath: '/api/spotify/capture'
preLoaderRoute: typeof ApiSpotifyCaptureServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/spotify/callback': {
id: '/api/spotify/callback'
path: '/api/spotify/callback'
fullPath: '/api/spotify/callback'
preLoaderRoute: typeof ApiSpotifyCallbackServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/events/$': {
id: '/api/events/$'
path: '/api/events/$'
fullPath: '/api/events/$'
preLoaderRoute: typeof ApiEventsSplatServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/auth/$': {
id: '/api/auth/$'
path: '/api/auth/$'
fullPath: '/api/auth/$'
preLoaderRoute: typeof ApiAuthSplatServerRouteImport
parentRoute: typeof rootServerRouteImport
}
'/api/files/$collection/$recordId/$file': {
id: '/api/files/$collection/$recordId/$file'
path: '/api/files/$collection/$recordId/$file'
fullPath: '/api/files/$collection/$recordId/$file'
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileServerRouteImport
parentRoute: typeof rootServerRouteImport
}
}
}
interface AuthedAdminRouteChildren { interface AuthedAdminRouteChildren {
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
@@ -704,24 +675,27 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRoute, LoginRoute: LoginRoute,
LogoutRoute: LogoutRoute, LogoutRoute: LogoutRoute,
RefreshSessionRoute: RefreshSessionRoute, RefreshSessionRoute: RefreshSessionRoute,
ApiAuthSplatRoute: ApiAuthSplatRoute,
ApiEventsSplatRoute: ApiEventsSplatRoute,
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
ApiSpotifyResumeRoute: ApiSpotifyResumeRoute,
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>() ._addFileTypes<FileRouteTypes>()
const rootServerRouteChildren: RootServerRouteChildren = {
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute, import type { getRouter } from './router.tsx'
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute, import type { createStart } from '@tanstack/react-start'
ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute, declare module '@tanstack/react-start' {
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute, interface Register {
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute, ssr: true
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute, router: Awaited<ReturnType<typeof getRouter>>
ApiSpotifySearchServerRoute: ApiSpotifySearchServerRoute, }
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute,
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
ApiFilesCollectionRecordIdFileServerRoute:
ApiFilesCollectionRecordIdFileServerRoute,
} }
export const serverRouteTree = rootServerRouteImport
._addFileChildren(rootServerRouteChildren)
._addFileTypes<FileServerRouteTypes>()

View File

@@ -1,15 +1,11 @@
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { createRouter as createTanStackRouter } from "@tanstack/react-router"; import { createRouter as createTanStackRouter } from "@tanstack/react-router";
import { routerWithQueryClient } from "@tanstack/react-router-with-query"; import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
import { routeTree } from "./routeTree.gen"; import { routeTree } from "./routeTree.gen";
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary"; import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config"; import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
import dotenv from 'dotenv'; export function getRouter() {
dotenv.config();
export function createRouter() {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {
@@ -22,28 +18,32 @@ export function createRouter() {
}, },
}); });
return routerWithQueryClient( const router = createTanStackRouter({
createTanStackRouter({ routeTree,
routeTree, context: {
context: { queryClient,
queryClient, auth: undefined!,
auth: undefined!, header: defaultHeaderConfig,
header: defaultHeaderConfig, refresh: [],
refresh: [], withPadding: true,
withPadding: true, fullWidth: false,
fullWidth: false, },
}, defaultPreload: "intent",
defaultPreload: "intent", defaultErrorComponent: DefaultCatchBoundary,
defaultErrorComponent: DefaultCatchBoundary, scrollRestoration: true,
scrollRestoration: true, defaultViewTransition: false,
defaultViewTransition: false, });
}),
setupRouterSsrQueryIntegration({
router,
queryClient queryClient
); })
return router;
} }
declare module "@tanstack/react-router" { declare module "@tanstack/react-router" {
interface Register { interface Register {
router: ReturnType<typeof createRouter>; router: ReturnType<typeof getRouter>;
} }
} }

View File

@@ -83,12 +83,20 @@ export const Route = createRootRouteWithContext<{
return {}; return {};
} }
// https://github.com/TanStack/router/discussions/3531 if (location.pathname === '/login' || location.pathname === '/logout') {
const auth = await ensureServerQueryData( return {};
context.queryClient, }
playerQueries.auth()
); try {
return { auth }; // https://github.com/TanStack/router/discussions/3531
const auth = await ensureServerQueryData(
context.queryClient,
playerQueries.auth()
);
return { auth };
} catch (error) {
return {};
}
}, },
pendingComponent: () => <Providers><FullScreenLoader /></Providers>, pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
}); });

View File

@@ -1,5 +1,5 @@
// API file that handles all supertokens auth routes // API file that handles all supertokens auth routes
import { createServerFileRoute } from '@tanstack/react-start/server'; import { createFileRoute } from '@tanstack/react-router';
import { handleAuthAPIRequest } from 'supertokens-node/custom' import { handleAuthAPIRequest } from 'supertokens-node/custom'
import { ensureSuperTokensBackend } from '@/lib/supertokens/server' import { ensureSuperTokensBackend } from '@/lib/supertokens/server'
@@ -12,12 +12,16 @@ const handleRequest = async ({ request }: {request: Request}) => {
console.log("Handling auth request:", request.method, request.url); console.log("Handling auth request:", request.method, request.url);
return superTokensHandler(request); return superTokensHandler(request);
}; };
export const ServerRoute = createServerFileRoute('/api/auth/$').methods({ export const Route = createFileRoute('/api/auth/$')({
GET: handleRequest, server: {
POST: handleRequest, handlers: {
PUT: handleRequest, GET: handleRequest,
DELETE: handleRequest, POST: handleRequest,
PATCH: handleRequest, PUT: handleRequest,
OPTIONS: handleRequest, DELETE: handleRequest,
HEAD: handleRequest, PATCH: handleRequest,
OPTIONS: handleRequest,
HEAD: handleRequest,
}
}
}) })

View File

@@ -1,66 +1,61 @@
import { createServerFileRoute } from "@tanstack/react-start/server"; import { createFileRoute } from "@tanstack/react-router";
import { serverEvents, type ServerEvent } from "@/lib/events/emitter"; import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { superTokensRequestMiddleware } from "@/utils/supertokens"; import { superTokensRequestMiddleware } from "@/utils/supertokens";
export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({ export const Route = createFileRoute("/api/events/$")({
GET: ({ request, context }) => { server: {
logger.info('ServerEvents | New connection', context?.userAuthId); middleware: [superTokensRequestMiddleware],
handlers: {
const stream = new ReadableStream({ GET: ({ request, context }) => {
start(controller) { const stream = new ReadableStream({
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`; start(controller) {
controller.enqueue(new TextEncoder().encode(connectMessage)); const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
controller.enqueue(new TextEncoder().encode(connectMessage));
const handleEvent = (event: ServerEvent) => { const handleEvent = (event: ServerEvent) => {
logger.info('ServerEvents | Event received', event); logger.info("ServerEvents | Event received", event);
const message = `data: ${JSON.stringify(event)}\n\n`; const message = `data: ${JSON.stringify(event)}\n\n`;
try { try {
controller.enqueue(new TextEncoder().encode(message)); controller.enqueue(new TextEncoder().encode(message));
} catch (error) { } catch (error) {
logger.error("ServerEvents | Error sending SSE message", error); logger.error("ServerEvents | Error sending SSE message", error);
} }
}; };
serverEvents.on("test", handleEvent); serverEvents.on("test", handleEvent);
serverEvents.on("match", handleEvent); serverEvents.on("match", handleEvent);
serverEvents.on("reaction", handleEvent); serverEvents.on("reaction", handleEvent);
const pingInterval = setInterval(() => { const pingInterval = setInterval(() => {
try { try {
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`; const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
controller.enqueue(new TextEncoder().encode(pingMessage)); controller.enqueue(new TextEncoder().encode(pingMessage));
} catch (e) { } catch (e) {
clearInterval(pingInterval); clearInterval(pingInterval);
controller.close(); }
} }, 30000);
}, 30000);
const cleanup = () => { const cleanup = () => {
serverEvents.off("test", handleEvent); serverEvents.off("test", handleEvent);
clearInterval(pingInterval); clearInterval(pingInterval);
try { };
logger.info('ServerEvents | Closing connection', context?.userAuthId);
controller.close();
} catch (e) {
logger.error('ServerEvents | Error closing controller', e);
}
};
request.signal?.addEventListener("abort", cleanup); request.signal?.addEventListener("abort", cleanup);
return cleanup;
},
});
return cleanup; return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control",
},
});
}, },
}); },
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Cache-Control",
},
});
}, },
}); });

View File

@@ -1,95 +1,113 @@
import { createServerFileRoute } from "@tanstack/react-start/server"; import { createFileRoute } from "@tanstack/react-router";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
export const ServerRoute = createServerFileRoute("/api/files/$collection/$recordId/$file").methods({ export const Route = createFileRoute(
GET: async ({ params, request }) => { "/api/files/$collection/$recordId/$file"
try { )({
const { collection, recordId, file } = params; server: {
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://127.0.0.1:8090'; handlers: {
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`; GET: async ({ params, request }) => {
try {
logger.info('File proxy', { const { collection, recordId, file } = params;
collection, const pocketbaseUrl =
recordId, process.env.POCKETBASE_URL || "http://127.0.0.1:8090";
file, const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
targetUrl: fileUrl
});
const response = await fetch(fileUrl, { logger.info("File proxy", {
method: 'GET', collection,
headers: { recordId,
...(request.headers.get('range') && { 'Range': request.headers.get('range')! }), file,
...(request.headers.get('if-none-match') && { 'If-None-Match': request.headers.get('if-none-match')! }), targetUrl: fileUrl,
...(request.headers.get('if-modified-since') && { 'If-Modified-Since': request.headers.get('if-modified-since')! }), });
},
});
if (!response.ok) { const response = await fetch(fileUrl, {
logger.error('PocketBase file request failed', { method: "GET",
status: response.status, headers: {
statusText: response.statusText, ...(request.headers.get("range") && {
url: fileUrl Range: request.headers.get("range")!,
}); }),
...(request.headers.get("if-none-match") && {
"If-None-Match": request.headers.get("if-none-match")!,
}),
...(request.headers.get("if-modified-since") && {
"If-Modified-Since": request.headers.get("if-modified-since")!,
}),
},
});
if (response.status === 404) { if (!response.ok) {
return new Response('File not found', { status: 404 }); logger.error("PocketBase file request failed", {
status: response.status,
statusText: response.statusText,
url: fileUrl,
});
if (response.status === 404) {
return new Response("File not found", { status: 404 });
}
return new Response(`PocketBase error: ${response.statusText}`, {
status: response.status,
});
}
const body = response.body;
const responseHeaders = new Headers();
const headers = [
"content-type",
"content-length",
"content-disposition",
"etag",
"last-modified",
"cache-control",
"accept-ranges",
"content-range",
];
headers.forEach((header) => {
const value = response.headers.get(header);
if (value) {
responseHeaders.set(header, value);
}
});
responseHeaders.set("Access-Control-Allow-Origin", "*");
responseHeaders.set(
"Access-Control-Allow-Methods",
"GET, HEAD, OPTIONS"
);
responseHeaders.set(
"Access-Control-Allow-Headers",
"Range, If-None-Match, If-Modified-Since"
);
logger.info("File proxy response", {
status: response.status,
contentType: response.headers.get("content-type"),
contentLength: response.headers.get("content-length"),
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders,
});
} catch (error) {
logger.error("File proxy error", error);
return new Response("Internal server error", { status: 500 });
} }
},
return new Response(`PocketBase error: ${response.statusText}`, { OPTIONS: () => {
status: response.status return new Response(null, {
status: 200,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Max-Age": "86400",
},
}); });
} },
},
const body = response.body;
const responseHeaders = new Headers();
const headers = [
'content-type',
'content-length',
'content-disposition',
'etag',
'last-modified',
'cache-control',
'accept-ranges',
'content-range'
];
headers.forEach(header => {
const value = response.headers.get(header);
if (value) {
responseHeaders.set(header, value);
}
});
responseHeaders.set('Access-Control-Allow-Origin', '*');
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
responseHeaders.set('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Modified-Since');
logger.info('File proxy response', {
status: response.status,
contentType: response.headers.get('content-type'),
contentLength: response.headers.get('content-length')
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: responseHeaders
});
} catch (error) {
logger.error('File proxy error', error);
return new Response('Internal server error', { status: 500 });
}
}, },
});
OPTIONS: () => {
return new Response(null, {
status: 200,
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Max-Age': '86400',
}
});
}
});

View File

@@ -1,127 +1,146 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createFileRoute } from "@tanstack/react-router";
import { SpotifyAuth } from '@/lib/spotify/auth' import { SpotifyAuth } from "@/lib/spotify/auth";
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID! const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET! const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI! const SPOTIFY_REDIRECT_URI = process.env.VITE_SPOTIFY_REDIRECT_URI!;
export const ServerRoute = createServerFileRoute('/api/spotify/callback').methods({ export const Route = createFileRoute("/api/spotify/callback")({
GET: async ({ request }: { request: Request }) => { server: {
const getReturnPath = (state: string | null): string => { handlers: {
if (!state) return '/'; GET: async ({ request }: { request: Request }) => {
try { const getReturnPath = (state: string | null): string => {
const decodedState = JSON.parse(atob(state)); if (!state) return "/";
return decodedState.returnPath || '/'; try {
} catch { const decodedState = JSON.parse(atob(state));
return '/'; return decodedState.returnPath || "/";
} } catch {
}; return "/";
}
};
try { try {
const url = new URL(request.url) const url = new URL(request.url);
const code = url.searchParams.get('code') const code = url.searchParams.get("code");
const state = url.searchParams.get('state') const state = url.searchParams.get("state");
const error = url.searchParams.get('error') const error = url.searchParams.get("error");
const returnPath = getReturnPath(state); const returnPath = getReturnPath(state);
if (error) { if (error) {
console.error('Spotify OAuth error:', error) console.error("Spotify OAuth error:", error);
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { headers: {
'Location': returnPath + '?spotify_error=' + encodeURIComponent(error), Location:
}, returnPath + "?spotify_error=" + encodeURIComponent(error),
}) },
} });
}
if (!code || !state) { if (!code || !state) {
console.error('Missing code or state:', { code: !!code, state: !!state }) console.error("Missing code or state:", {
return new Response(null, { code: !!code,
status: 302, state: !!state,
headers: { });
'Location': returnPath + '?spotify_error=missing_code_or_state', return new Response(null, {
}, status: 302,
}) headers: {
} Location: returnPath + "?spotify_error=missing_code_or_state",
},
});
}
console.log('Token exchange attempt:', { console.log("Token exchange attempt:", {
client_id: SPOTIFY_CLIENT_ID, client_id: SPOTIFY_CLIENT_ID,
redirect_uri: SPOTIFY_REDIRECT_URI, redirect_uri: SPOTIFY_REDIRECT_URI,
has_code: !!code, has_code: !!code,
has_state: !!state, has_state: !!state,
}) });
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', { const tokenResponse = await fetch(
method: 'POST', "https://accounts.spotify.com/api/token",
headers: { {
'Content-Type': 'application/x-www-form-urlencoded', method: "POST",
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`, headers: {
}, "Content-Type": "application/x-www-form-urlencoded",
body: new URLSearchParams({ Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
grant_type: 'authorization_code', },
code, body: new URLSearchParams({
redirect_uri: SPOTIFY_REDIRECT_URI, grant_type: "authorization_code",
}), code,
}) redirect_uri: SPOTIFY_REDIRECT_URI,
}),
}
);
if (!tokenResponse.ok) { if (!tokenResponse.ok) {
const errorText = await tokenResponse.text() const errorText = await tokenResponse.text();
console.error('Token exchange error:', { console.error("Token exchange error:", {
status: tokenResponse.status, status: tokenResponse.status,
statusText: tokenResponse.statusText, statusText: tokenResponse.statusText,
body: errorText, body: errorText,
redirect_uri: SPOTIFY_REDIRECT_URI, redirect_uri: SPOTIFY_REDIRECT_URI,
}) });
const errorParam = encodeURIComponent(`${tokenResponse.status}: ${errorText}`)
return new Response(null, {
status: 302,
headers: {
'Location': `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
},
})
}
const tokens = await tokenResponse.json() const errorParam = encodeURIComponent(
`${tokenResponse.status}: ${errorText}`
);
return new Response(null, {
status: 302,
headers: {
Location: `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
},
});
}
console.log('Token exchange successful:', { const tokens = await tokenResponse.json();
has_access_token: !!tokens.access_token,
has_refresh_token: !!tokens.refresh_token,
expires_in: tokens.expires_in,
})
console.log('Decoded return path:', returnPath); console.log("Token exchange successful:", {
has_access_token: !!tokens.access_token,
has_refresh_token: !!tokens.refresh_token,
expires_in: tokens.expires_in,
});
const response = new Response(null, { console.log("Decoded return path:", returnPath);
status: 302,
headers: {
'Location': returnPath + '?spotify_auth=success',
},
})
const isSecure = process.env.NODE_ENV === 'production' const response = new Response(null, {
const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}` status: 302,
headers: {
Location: returnPath + "?spotify_auth=success",
},
});
response.headers.append('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`) const isSecure = import.meta.env.NODE_ENV === "production";
const cookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokens.expires_in}`;
if (tokens.refresh_token) {
const refreshCookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${60 * 60 * 24 * 30}` // 30 days
response.headers.append('Set-Cookie', `spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`)
}
return response response.headers.append(
} catch (error) { "Set-Cookie",
console.error('Spotify callback error:', error) `spotify_access_token=${tokens.access_token}; ${cookieOptions}`
const url = new URL(request.url); );
const state = url.searchParams.get('state');
const returnPath = getReturnPath(state); if (tokens.refresh_token) {
return new Response(null, { const refreshCookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 30}`; // 30 days
status: 302, response.headers.append(
headers: { "Set-Cookie",
'Location': returnPath + '?spotify_error=callback_failed', `spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`
}, );
}) }
}
return response;
} catch (error) {
console.error("Spotify callback error:", error);
const url = new URL(request.url);
const state = url.searchParams.get("state");
const returnPath = getReturnPath(state);
return new Response(null, {
status: 302,
headers: {
Location: returnPath + "?spotify_error=callback_failed",
},
});
}
},
},
}, },
}) });

View File

@@ -1,59 +1,60 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createFileRoute } from "@tanstack/react-router";
import { SpotifyWebApiClient } from '@/lib/spotify/client' import { SpotifyWebApiClient } from "@/lib/spotify/client";
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types' import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
export const ServerRoute = createServerFileRoute('/api/spotify/capture').methods({ export const Route = createFileRoute("/api/spotify/capture")({
POST: async ({ request }: { request: Request }) => { server: {
try { handlers: {
// Get access token from cookies POST: async ({ request }: { request: Request }) => {
const cookies = request.headers.get('Cookie') || '' try {
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/) const cookies = request.headers.get("Cookie") || "";
const accessTokenMatch = cookies.match(
if (!accessTokenMatch) { /spotify_access_token=([^;]+)/
return new Response( );
JSON.stringify({ error: 'No access token found' }),
{ if (!accessTokenMatch) {
status: 401, return new Response(
headers: { 'Content-Type': 'application/json' } JSON.stringify({ error: "No access token found" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
} }
)
}
const accessToken = decodeURIComponent(accessTokenMatch[1]) const accessToken = decodeURIComponent(accessTokenMatch[1]);
const spotifyClient = new SpotifyWebApiClient(accessToken) const spotifyClient = new SpotifyWebApiClient(accessToken);
// Create a snapshot of the current playback state const snapshot = await spotifyClient.createPlaybackSnapshot();
const snapshot = await spotifyClient.createPlaybackSnapshot()
if (!snapshot) {
if (!snapshot) { return new Response(
return new Response( JSON.stringify({ error: "No active playback to capture" }),
JSON.stringify({ error: 'No active playback to capture' }), {
{ status: 400,
status: 400, headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' } }
);
} }
)
}
return new Response( return new Response(JSON.stringify({ snapshot }), {
JSON.stringify({ snapshot }), status: 200,
{ headers: { "Content-Type": "application/json" },
status: 200, });
headers: { 'Content-Type': 'application/json' } } catch (error) {
console.error("Spotify capture error:", error);
const errorMessage =
error instanceof Error
? error.message
: "Failed to capture playback state";
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
} }
) },
} catch (error) { },
console.error('Spotify capture error:', error)
const errorMessage = error instanceof Error ? error.message : 'Failed to capture playback state'
return new Response(
JSON.stringify({ error: errorMessage }),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
)
}
}, },
}) });

View File

@@ -1,202 +1,203 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createFileRoute } from "@tanstack/react-router";
import { SpotifyWebApiClient } from '@/lib/spotify/client' import { SpotifyWebApiClient } from "@/lib/spotify/client";
function getAccessTokenFromCookies(request: Request): string | null { function getAccessTokenFromCookies(request: Request): string | null {
const cookieHeader = request.headers.get('cookie') const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) return null if (!cookieHeader) return null;
const cookies = Object.fromEntries( const cookies = Object.fromEntries(
cookieHeader.split('; ').map(c => c.split('=')) cookieHeader.split("; ").map((c) => c.split("="))
) );
return cookies.spotify_access_token || null return cookies.spotify_access_token || null;
} }
export const ServerRoute = createServerFileRoute('/api/spotify/playback').methods({ export const Route = createFileRoute("/api/spotify/playback")({
POST: async ({ request }: { request: Request }) => { server: {
try { handlers: {
const accessToken = getAccessTokenFromCookies(request) POST: async ({ request }: { request: Request }) => {
if (!accessToken) { try {
return new Response( const accessToken = getAccessTokenFromCookies(request);
JSON.stringify({ error: 'No access token found' }), if (!accessToken) {
{
status: 401,
headers: { 'Content-Type': 'application/json' },
}
)
}
const body = await request.json()
const { action, deviceId, volumePercent, trackId, positionMs } = body
const spotifyClient = new SpotifyWebApiClient(accessToken)
switch (action) {
case 'play':
await spotifyClient.play(deviceId)
break
case 'playTrack':
if (!trackId) {
return new Response( return new Response(
JSON.stringify({ error: 'trackId is required for playTrack action' }), JSON.stringify({ error: "No access token found" }),
{ {
status: 400, status: 401,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
await spotifyClient.playTrack(trackId, deviceId, positionMs)
break const body = await request.json();
case 'pause': const { action, deviceId, volumePercent, trackId, positionMs } = body;
await spotifyClient.pause()
break const spotifyClient = new SpotifyWebApiClient(accessToken);
case 'next':
await spotifyClient.skipToNext() switch (action) {
break case "play":
case 'previous': await spotifyClient.play(deviceId);
await spotifyClient.skipToPrevious() break;
break case "playTrack":
case 'volume': if (!trackId) {
if (typeof volumePercent !== 'number') { return new Response(
JSON.stringify({
error: "trackId is required for playTrack action",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
await spotifyClient.playTrack(trackId, deviceId, positionMs);
break;
case "pause":
await spotifyClient.pause();
break;
case "next":
await spotifyClient.skipToNext();
break;
case "previous":
await spotifyClient.skipToPrevious();
break;
case "volume":
if (typeof volumePercent !== "number") {
return new Response(
JSON.stringify({ error: "volumePercent must be a number" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
await spotifyClient.setVolume(volumePercent);
break;
case "transfer":
if (!deviceId) {
return new Response(
JSON.stringify({
error: "deviceId is required for transfer action",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
await spotifyClient.transferPlayback(deviceId);
break;
default:
return new Response(JSON.stringify({ error: "Invalid action" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("Playback control error:", error);
if (error instanceof Error) {
if (error.message.includes("NO_ACTIVE_DEVICE")) {
return new Response(
JSON.stringify({
error:
"No active device found. Please select a device first.",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
if (error.message.includes("PREMIUM_REQUIRED")) {
return new Response(
JSON.stringify({
error: "Spotify Premium is required for playback control.",
}),
{
status: 403,
headers: { "Content-Type": "application/json" },
}
);
}
console.error("Full error details:", {
message: error.message,
stack: error.stack,
name: error.name,
});
}
return new Response(
JSON.stringify({
error: "Playback control failed",
details: error instanceof Error ? error.message : "Unknown error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
},
GET: async ({ request }: { request: Request }) => {
try {
const accessToken = getAccessTokenFromCookies(request);
if (!accessToken) {
return new Response( return new Response(
JSON.stringify({ error: 'volumePercent must be a number' }), JSON.stringify({ error: "No access token found" }),
{ {
status: 400, status: 401,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
} }
await spotifyClient.setVolume(volumePercent)
break const url = new URL(request.url);
case 'transfer': const type = url.searchParams.get("type");
if (!deviceId) {
return new Response( const spotifyClient = new SpotifyWebApiClient(accessToken);
JSON.stringify({ error: 'deviceId is required for transfer action' }),
{ if (type === "devices") {
status: 400, const devices = await spotifyClient.getDevices();
headers: { 'Content-Type': 'application/json' }, return new Response(JSON.stringify({ devices }), {
} status: 200,
) headers: { "Content-Type": "application/json" },
});
} else if (type === "state") {
const playbackState = await spotifyClient.getPlaybackState();
return new Response(JSON.stringify({ playbackState }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} else {
const [devices, playbackState] = await Promise.all([
spotifyClient.getDevices(),
spotifyClient.getPlaybackState(),
]);
return new Response(JSON.stringify({ devices, playbackState }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} }
await spotifyClient.transferPlayback(deviceId) } catch (error) {
break console.error("Get playback data error:", error);
default:
return new Response( return new Response(
JSON.stringify({ error: 'Invalid action' }), JSON.stringify({ error: "Failed to get playback data" }),
{ {
status: 400, status: 500,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
} }
) );
}
return new Response(
JSON.stringify({ success: true }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
} }
) },
} catch (error) { },
console.error('Playback control error:', error)
if (error instanceof Error) {
if (error.message.includes('NO_ACTIVE_DEVICE')) {
return new Response(
JSON.stringify({ error: 'No active device found. Please select a device first.' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
}
)
}
if (error.message.includes('PREMIUM_REQUIRED')) {
return new Response(
JSON.stringify({ error: 'Spotify Premium is required for playback control.' }),
{
status: 403,
headers: { 'Content-Type': 'application/json' },
}
)
}
console.error('Full error details:', {
message: error.message,
stack: error.stack,
name: error.name,
})
}
return new Response(
JSON.stringify({ error: 'Playback control failed', details: error instanceof Error ? error.message : 'Unknown error' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
}
}, },
});
GET: async ({ request }: { request: Request }) => {
try {
const accessToken = getAccessTokenFromCookies(request)
if (!accessToken) {
return new Response(
JSON.stringify({ error: 'No access token found' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' },
}
)
}
const url = new URL(request.url)
const type = url.searchParams.get('type')
const spotifyClient = new SpotifyWebApiClient(accessToken)
if (type === 'devices') {
const devices = await spotifyClient.getDevices()
return new Response(
JSON.stringify({ devices }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
} else if (type === 'state') {
const playbackState = await spotifyClient.getPlaybackState()
return new Response(
JSON.stringify({ playbackState }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
} else {
const [devices, playbackState] = await Promise.all([
spotifyClient.getDevices(),
spotifyClient.getPlaybackState(),
])
return new Response(
JSON.stringify({ devices, playbackState }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
}
} catch (error) {
console.error('Get playback data error:', error)
return new Response(
JSON.stringify({ error: 'Failed to get playback data' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
}
},
})

View File

@@ -1,72 +1,71 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createFileRoute } from "@tanstack/react-router";
import { SpotifyWebApiClient } from '@/lib/spotify/client' import { SpotifyWebApiClient } from "@/lib/spotify/client";
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types' import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
export const ServerRoute = createServerFileRoute('/api/spotify/resume').methods({ export const Route = createFileRoute("/api/spotify/resume")({
POST: async ({ request }: { request: Request }) => { server: {
try { handlers: {
// Get access token from cookies POST: async ({ request }: { request: Request }) => {
const cookies = request.headers.get('Cookie') || '' try {
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/) const cookies = request.headers.get("Cookie") || "";
const accessTokenMatch = cookies.match(
if (!accessTokenMatch) { /spotify_access_token=([^;]+)/
return new Response( );
JSON.stringify({ error: 'No access token found' }),
{ if (!accessTokenMatch) {
status: 401, return new Response(
headers: { 'Content-Type': 'application/json' } JSON.stringify({ error: "No access token found" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
} }
)
}
const accessToken = decodeURIComponent(accessTokenMatch[1]) const accessToken = decodeURIComponent(accessTokenMatch[1]);
const spotifyClient = new SpotifyWebApiClient(accessToken) const spotifyClient = new SpotifyWebApiClient(accessToken);
// Parse the request body to get the snapshot const body = await request.json();
const body = await request.json() const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot };
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot }
if (!snapshot) {
if (!snapshot) { return new Response(
return new Response( JSON.stringify({ error: "No snapshot provided" }),
JSON.stringify({ error: 'No snapshot provided' }), {
{ status: 400,
status: 400, headers: { "Content-Type": "application/json" },
headers: { 'Content-Type': 'application/json' } }
);
} }
)
}
// Restore the playback state from the snapshot await spotifyClient.restorePlaybackSnapshot(snapshot);
await spotifyClient.restorePlaybackSnapshot(snapshot)
return new Response( return new Response(JSON.stringify({ success: true }), {
JSON.stringify({ success: true }), status: 200,
{ headers: { "Content-Type": "application/json" },
status: 200, });
headers: { 'Content-Type': 'application/json' } } catch (error) {
console.error("Spotify resume error:", error);
let errorMessage = "Failed to resume playback state";
if (error instanceof Error) {
if (
error.message.includes("Premium") ||
error.message.includes("403")
) {
errorMessage = "Spotify premium required";
} else {
errorMessage = error.message;
}
}
return new Response(JSON.stringify({ error: errorMessage }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
} }
) },
} catch (error) { },
console.error('Spotify resume error:', error)
let errorMessage = 'Failed to resume playback state'
// Handle common Spotify Premium requirement error
if (error instanceof Error) {
if (error.message.includes('Premium') || error.message.includes('403')) {
errorMessage = 'Spotify Premium required for playback control'
} else {
errorMessage = error.message
}
}
return new Response(
JSON.stringify({ error: errorMessage }),
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
)
}
}, },
}) });

View File

@@ -1,81 +1,87 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createFileRoute } from "@tanstack/react-router";
// Function to get Client Credentials access token
async function getClientCredentialsToken(): Promise<string> { async function getClientCredentialsToken(): Promise<string> {
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID const clientId = process.env.VITE_SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
if (!clientId || !clientSecret) { if (!clientId || !clientSecret) {
throw new Error('Missing Spotify client credentials') throw new Error("Missing Spotify client credentials");
} }
const response = await fetch('https://accounts.spotify.com/api/token', { const response = await fetch("https://accounts.spotify.com/api/token", {
method: 'POST', method: "POST",
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', "Content-Type": "application/x-www-form-urlencoded",
'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`, Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
}, },
body: 'grant_type=client_credentials', body: "grant_type=client_credentials",
}) });
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to get Spotify access token') throw new Error("Failed to get Spotify access token");
} }
const data = await response.json() const data = await response.json();
return data.access_token return data.access_token;
} }
export const ServerRoute = createServerFileRoute('/api/spotify/search').methods({ export const Route = createFileRoute("/api/spotify/search")({
GET: async ({ request }: { request: Request }) => { server: {
try { handlers: {
const url = new URL(request.url) GET: async ({ request }: { request: Request }) => {
const query = url.searchParams.get('q') try {
const url = new URL(request.url);
const query = url.searchParams.get("q");
if (!query) { if (!query) {
return new Response( return new Response(
JSON.stringify({ error: 'Query parameter q is required' }), JSON.stringify({ error: "Query parameter q is required" }),
{ {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
}
);
} }
)
}
// Get client credentials access token // Get client credentials access token
const accessToken = await getClientCredentialsToken() const accessToken = await getClientCredentialsToken();
// Search using Spotify API directly // Search using Spotify API directly
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20` const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`;
const searchResponse = await fetch(searchUrl, { const searchResponse = await fetch(searchUrl, {
headers: { headers: {
'Authorization': `Bearer ${accessToken}`, Authorization: `Bearer ${accessToken}`,
}, },
}) });
if (!searchResponse.ok) { if (!searchResponse.ok) {
throw new Error('Spotify search request failed') throw new Error("Spotify search request failed");
} }
const searchResult = await searchResponse.json() const searchResult = await searchResponse.json();
return new Response( return new Response(
JSON.stringify({ tracks: searchResult.tracks.items }), JSON.stringify({ tracks: searchResult.tracks.items }),
{ {
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
console.error("Search error:", error);
return new Response(
JSON.stringify({
error: "Search failed",
details: error instanceof Error ? error.message : "Unknown error",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
} }
) },
} catch (error) { },
console.error('Search error:', error)
return new Response(
JSON.stringify({ error: 'Search failed', details: error instanceof Error ? error.message : 'Unknown error' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
}
}, },
}) });

View File

@@ -1,125 +1,131 @@
import { createServerFileRoute } from '@tanstack/react-start/server' import { createFileRoute } from "@tanstack/react-router";
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID! const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET! const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({ export const Route = createFileRoute("/api/spotify/token")({
POST: async ({ request }: { request: Request }) => { server: {
try { handlers: {
const body = await request.json() POST: async ({ request }: { request: Request }) => {
const { refresh_token } = body try {
const body = await request.json();
const { refresh_token } = body;
if (!refresh_token) { if (!refresh_token) {
return new Response( return new Response(
JSON.stringify({ error: 'refresh_token is required' }), JSON.stringify({ error: "refresh_token is required" }),
{ {
status: 400, status: 400,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
}
);
} }
)
}
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', { const tokenResponse = await fetch(
method: 'POST', "https://accounts.spotify.com/api/token",
headers: { {
'Content-Type': 'application/x-www-form-urlencoded', method: "POST",
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`, headers: {
}, "Content-Type": "application/x-www-form-urlencoded",
body: new URLSearchParams({ Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
grant_type: 'refresh_token', },
refresh_token, body: new URLSearchParams({
}), grant_type: "refresh_token",
}) refresh_token,
}),
}
);
if (!tokenResponse.ok) { if (!tokenResponse.ok) {
const error = await tokenResponse.json() const error = await tokenResponse.json();
console.error('Token refresh error:', error) console.error("Token refresh error:", error);
return new Response( return new Response(
JSON.stringify({ error: 'Failed to refresh token', details: error }), JSON.stringify({
{ error: "Failed to refresh token",
status: tokenResponse.status, details: error,
headers: { 'Content-Type': 'application/json' }, }),
{
status: tokenResponse.status,
headers: { "Content-Type": "application/json" },
}
);
} }
)
}
const tokens = await tokenResponse.json() const tokens = await tokenResponse.json();
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
access_token: tokens.access_token, access_token: tokens.access_token,
expires_in: tokens.expires_in, expires_in: tokens.expires_in,
scope: tokens.scope, scope: tokens.scope,
token_type: tokens.token_type, token_type: tokens.token_type,
}), }),
{ {
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
console.error("Token refresh endpoint error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
} }
) },
} catch (error) {
console.error('Token refresh endpoint error:', error) GET: async ({ request }: { request: Request }) => {
return new Response( try {
JSON.stringify({ error: 'Internal server error' }), const cookieHeader = request.headers.get("cookie");
{ if (!cookieHeader) {
status: 500, return new Response(JSON.stringify({ error: "No cookies found" }), {
headers: { 'Content-Type': 'application/json' }, status: 401,
headers: { "Content-Type": "application/json" },
});
}
const cookies = Object.fromEntries(
cookieHeader.split("; ").map((c: string) => c.split("="))
);
const accessToken = cookies.spotify_access_token;
const refreshToken = cookies.spotify_refresh_token;
if (!accessToken && !refreshToken) {
return new Response(
JSON.stringify({ error: "No Spotify tokens found" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
}
return new Response(
JSON.stringify({
access_token: accessToken || null,
refresh_token: refreshToken || null,
has_tokens: !!(accessToken || refreshToken),
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
} catch (error) {
console.error("Get tokens endpoint error:", error);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
} }
) },
} },
}, },
});
// GET endpoint to retrieve current tokens from cookies
GET: async ({ request }: { request: Request }) => {
try {
const cookieHeader = request.headers.get('cookie')
if (!cookieHeader) {
return new Response(
JSON.stringify({ error: 'No cookies found' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' },
}
)
}
const cookies = Object.fromEntries(
cookieHeader.split('; ').map((c: string) => c.split('='))
)
const accessToken = cookies.spotify_access_token
const refreshToken = cookies.spotify_refresh_token
if (!accessToken && !refreshToken) {
return new Response(
JSON.stringify({ error: 'No Spotify tokens found' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' },
}
)
}
return new Response(
JSON.stringify({
access_token: accessToken || null,
refresh_token: refreshToken || null,
has_tokens: !!(accessToken || refreshToken),
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
} catch (error) {
console.error('Get tokens endpoint error:', error)
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
}
)
}
},
})

View File

@@ -1,117 +1,148 @@
import { createServerFileRoute } from '@tanstack/react-start/server'; import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from '@/utils/supertokens'; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { pbAdmin } from '@/lib/pocketbase/client'; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from '@/lib/logger'; import { logger } from "@/lib/logger";
import { z } from 'zod'; import { z } from "zod";
const uploadSchema = z.object({ const uploadSchema = z.object({
teamId: z.string().min(1, 'Team ID is required'), teamId: z.string().min(1, "Team ID is required"),
}); });
export const ServerRoute = createServerFileRoute('/api/teams/upload-logo') export const Route = createFileRoute("/api/teams/upload-logo")({
.middleware([superTokensRequestMiddleware]) server: {
.methods({ middleware: [superTokensRequestMiddleware],
POST: async ({ request, context }) => { handlers: {
try { POST: async ({ request, context }) => {
const userId = context.userAuthId; try {
const isAdmin = context.roles.includes("Admin"); const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
if (!userId) return new Response('Unauthenticated', { status: 401 }); if (!userId) return new Response("Unauthenticated", { status: 401 });
const formData = await request.formData(); const formData = await request.formData();
const teamId = formData.get('teamId') as string; const teamId = formData.get("teamId") as string;
const logoFile = formData.get('logo') as File; const logoFile = formData.get("logo") as File;
const validationResult = uploadSchema.safeParse({ teamId }); const validationResult = uploadSchema.safeParse({ teamId });
if (!validationResult.success) { if (!validationResult.success) {
return new Response(JSON.stringify({ return new Response(
error: 'Invalid input', JSON.stringify({
details: validationResult.error.issues error: "Invalid input",
}), { details: validationResult.error.issues,
status: 400, }),
headers: { 'Content-Type': 'application/json' } {
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
if (!logoFile || logoFile.size === 0) {
return new Response(
JSON.stringify({
error: "Logo file is required",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const allowedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
];
if (!allowedTypes.includes(logoFile.type)) {
return new Response(
JSON.stringify({
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const maxSize = 10 * 1024 * 1024;
if (logoFile.size > maxSize) {
return new Response(
JSON.stringify({
error: "File too large. Maximum size is 10MB.",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const team = await pbAdmin.getTeam(teamId);
if (!team) {
return new Response(
JSON.stringify({
error: "Team not found",
}),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
);
}
const user = await pbAdmin.getPlayerByAuthId(userId);
if (!team.players.map((p) => p.id).includes(user?.id!) && !isAdmin)
return new Response("Unauthorized", { status: 403 });
logger.info("Uploading team logo", {
teamId,
fileName: logoFile.name,
fileSize: logoFile.size,
userId,
}); });
}
if (!logoFile || logoFile.size === 0) { const pbFormData = new FormData();
return new Response(JSON.stringify({ pbFormData.append("logo", logoFile);
error: 'Logo file is required'
}), { const updatedTeam = await pbAdmin.updateTeam(
status: 400, teamId,
headers: { 'Content-Type': 'application/json' } pbFormData as any
);
logger.info("Team logo uploaded successfully", {
teamId,
logo: updatedTeam.logo,
}); });
return new Response(
JSON.stringify({
success: true,
team: updatedTeam,
message: "Logo uploaded successfully",
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
} catch (error: any) {
logger.error("Error uploading team logo:", error);
return new Response(
JSON.stringify({
error: "Failed to upload logo",
message: error.message || "Unknown error occurred",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
} }
},
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']; },
if (!allowedTypes.includes(logoFile.type)) { },
return new Response(JSON.stringify({ });
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const maxSize = 10 * 1024 * 1024;
if (logoFile.size > maxSize) {
return new Response(JSON.stringify({
error: 'File too large. Maximum size is 10MB.'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const team = await pbAdmin.getTeam(teamId);
if (!team) {
return new Response(JSON.stringify({
error: 'Team not found'
}), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
const user = await pbAdmin.getPlayerByAuthId(context.userAuthId)
if (!team.players.map(p => p.id).includes(user!.id) && !isAdmin)
return new Response('Unauthorized', { status: 403 });
logger.info('Uploading team logo', {
teamId,
fileName: logoFile.name,
fileSize: logoFile.size,
userId
});
const pbFormData = new FormData();
pbFormData.append('logo', logoFile);
const updatedTeam= await pbAdmin.updateTeam(teamId, pbFormData as any);
logger.info('Team logo uploaded successfully', {
teamId,
logo: updatedTeam.logo
});
return new Response(JSON.stringify({
success: true,
team: updatedTeam,
message: 'Logo uploaded successfully'
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error: any) {
logger.error('Error uploading team logo:', error);
return new Response(JSON.stringify({
error: 'Failed to upload logo',
message: error.message || 'Unknown error occurred'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
});

View File

@@ -1,115 +1,145 @@
import { createServerFileRoute } from '@tanstack/react-start/server'; import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from '@/utils/supertokens'; import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { pbAdmin } from '@/lib/pocketbase/client'; import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from '@/lib/logger'; import { logger } from "@/lib/logger";
import { z } from 'zod'; import { z } from "zod";
const uploadSchema = z.object({ const uploadSchema = z.object({
tournamentId: z.string().min(1, 'Tournament ID is required'), tournamentId: z.string().min(1, "Tournament ID is required"),
}); });
export const ServerRoute = createServerFileRoute('/api/tournaments/upload-logo') export const Route = createFileRoute("/api/tournaments/upload-logo")({
.middleware([superTokensRequestMiddleware]) server: {
.methods({ middleware: [superTokensRequestMiddleware],
POST: async ({ request, context }) => { handlers: {
try { POST: async ({ request, context }) => {
const userId = context.userAuthId; try {
const isAdmin = context.roles.includes("Admin"); const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
if (!userId) return new Response('Unauthenticated', { status: 401 }); if (!userId) return new Response("Unauthenticated", { status: 401 });
if (!isAdmin) return new Response('Unauthorized', { status: 403 }); if (!isAdmin) return new Response("Unauthorized", { status: 403 });
const formData = await request.formData(); const formData = await request.formData();
const tournamentId = formData.get('tournamentId') as string; const tournamentId = formData.get("tournamentId") as string;
const logoFile = formData.get('logo') as File; const logoFile = formData.get("logo") as File;
const validationResult = uploadSchema.safeParse({ tournamentId }); const validationResult = uploadSchema.safeParse({ tournamentId });
if (!validationResult.success) { if (!validationResult.success) {
return new Response(JSON.stringify({ return new Response(
error: 'Invalid input', JSON.stringify({
details: validationResult.error.issues error: "Invalid input",
}), { details: validationResult.error.issues,
status: 400, }),
headers: { 'Content-Type': 'application/json' } {
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
if (!logoFile || logoFile.size === 0) {
return new Response(
JSON.stringify({
error: "Logo file is required",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const allowedTypes = [
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
];
if (!allowedTypes.includes(logoFile.type)) {
return new Response(
JSON.stringify({
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const maxSize = 10 * 1024 * 1024;
if (logoFile.size > maxSize) {
return new Response(
JSON.stringify({
error: "File too large. Maximum size is 10MB.",
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
const tournament = await pbAdmin.getTournament(tournamentId);
if (!tournament) {
return new Response(
JSON.stringify({
error: "Tournament not found",
}),
{
status: 404,
headers: { "Content-Type": "application/json" },
}
);
}
logger.info("Uploading tournament logo", {
tournamentId,
fileName: logoFile.name,
fileSize: logoFile.size,
userId,
}); });
}
if (!logoFile || logoFile.size === 0) { const pbFormData = new FormData();
return new Response(JSON.stringify({ pbFormData.append("logo", logoFile);
error: 'Logo file is required'
}), { const updatedTournament = await pbAdmin.updateTournament(
status: 400, tournamentId,
headers: { 'Content-Type': 'application/json' } pbFormData as any
);
logger.info("Tournament logo uploaded successfully", {
tournamentId,
logo: updatedTournament.logo,
}); });
return new Response(
JSON.stringify({
success: true,
tournament: updatedTournament,
message: "Logo uploaded successfully",
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
} catch (error: any) {
logger.error("Error uploading tournament logo:", error);
return new Response(
JSON.stringify({
error: "Failed to upload logo",
message: error.message || "Unknown error occurred",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
} }
},
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif']; },
if (!allowedTypes.includes(logoFile.type)) { },
return new Response(JSON.stringify({ });
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const maxSize = 10 * 1024 * 1024;
if (logoFile.size > maxSize) {
return new Response(JSON.stringify({
error: 'File too large. Maximum size is 10MB.'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const tournament = await pbAdmin.getTournament(tournamentId);
if (!tournament) {
return new Response(JSON.stringify({
error: 'Tournament not found'
}), {
status: 404,
headers: { 'Content-Type': 'application/json' }
});
}
logger.info('Uploading tournament logo', {
tournamentId,
fileName: logoFile.name,
fileSize: logoFile.size,
userId
});
const pbFormData = new FormData();
pbFormData.append('logo', logoFile);
const updatedTournament = await pbAdmin.updateTournament(tournamentId, pbFormData as any);
logger.info('Tournament logo uploaded successfully', {
tournamentId,
logo: updatedTournament.logo
});
return new Response(JSON.stringify({
success: true,
tournament: updatedTournament,
message: 'Logo uploaded successfully'
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error: any) {
logger.error('Error uploading tournament logo:', error);
return new Response(JSON.stringify({
error: 'Failed to upload logo',
message: error.message || 'Unknown error occurred'
}), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
}
});

View File

@@ -1,38 +1,33 @@
import { createFileRoute } from '@tanstack/react-router' import { createFileRoute } from '@tanstack/react-router'
import { useEffect } from 'react' import { useEffect, useRef } from 'react'
import FullScreenLoader from '@/components/full-screen-loader' import FullScreenLoader from '@/components/full-screen-loader'
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session' import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
import { resetRefreshFlag } from '@/lib/supertokens/client'
export const Route = createFileRoute('/refresh-session')({ export const Route = createFileRoute('/refresh-session')({
component: RouteComponent, component: RouteComponent,
}) })
// https://supertokens.com/docs/additional-verification/session-verification/ssr?uiType=custom
function RouteComponent() { function RouteComponent() {
const hasAttemptedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (hasAttemptedRef.current) return;
hasAttemptedRef.current = true;
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
resetRefreshFlag();
const refreshed = await attemptRefreshingSession() const refreshed = await attemptRefreshingSession()
if (refreshed) { if (refreshed) {
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
const redirect = urlParams.get('redirect') const redirect = urlParams.get('redirect')
const isServerFunction = redirect && ( if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) {
redirect.startsWith('_serverFn') ||
redirect.startsWith('api/') ||
redirect.includes('_serverFn')
);
if (redirect && !isServerFunction) {
window.location.href = decodeURIComponent(redirect) window.location.href = decodeURIComponent(redirect)
} else { } else {
const referrer = document.referrer; window.location.href = '/';
const referrerUrl = referrer && !referrer.includes('/_serverFn') && !referrer.includes('/api/')
? referrer
: '/';
window.location.href = referrerUrl;
} }
} else { } else {
window.location.href = '/login' window.location.href = '/login'
@@ -42,8 +37,7 @@ function RouteComponent() {
} }
} }
const timeout = setTimeout(handleRefresh, 100) setTimeout(handleRefresh, 100)
return () => clearTimeout(timeout)
}, []) }, [])
return <FullScreenLoader /> return <FullScreenLoader />

View File

@@ -2,7 +2,14 @@ import {
Avatar as MantineAvatar, Avatar as MantineAvatar,
AvatarProps as MantineAvatarProps, AvatarProps as MantineAvatarProps,
Paper, Paper,
Modal,
Image,
Group,
Text,
ActionIcon,
} from "@mantine/core"; } from "@mantine/core";
import { useState } from "react";
import { XIcon } from "@phosphor-icons/react";
interface AvatarProps interface AvatarProps
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> { extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
@@ -10,6 +17,8 @@ interface AvatarProps
size?: number; size?: number;
radius?: string | number; radius?: string | number;
withBorder?: boolean; withBorder?: boolean;
disableFullscreen?: boolean;
contain?: boolean;
} }
const Avatar = ({ const Avatar = ({
@@ -17,26 +26,122 @@ const Avatar = ({
size = 35, size = 35,
radius = "100%", radius = "100%",
withBorder = true, withBorder = true,
disableFullscreen = false,
contain = false,
...props ...props
}: AvatarProps) => { }: AvatarProps) => {
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const hasImage = Boolean(props.src);
const handleAvatarClick = () => {
if (hasImage && !disableFullscreen) {
setIsFullscreenOpen(true);
}
};
return ( return (
<Paper p={size / 20} radius={radius} withBorder={withBorder}> <>
<MantineAvatar <Paper
alt={name} p={size / 20}
key={name}
name={name}
color="initials"
size={size}
radius={radius} radius={radius}
w={size} withBorder={withBorder}
style={{
cursor: hasImage && !disableFullscreen ? 'pointer' : 'default',
transition: 'transform 0.15s ease',
}}
onMouseEnter={(e) => {
if (hasImage && !disableFullscreen) {
e.currentTarget.style.transform = 'scale(1.02)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)';
}}
onClick={handleAvatarClick}
>
<MantineAvatar
alt={name}
key={name}
name={name}
color="initials"
size={size}
radius={radius}
w={size}
styles={{
image: {
objectFit: contain ? 'contain' : 'cover',
},
}}
{...props}
/>
</Paper>
<Modal
opened={isFullscreenOpen}
onClose={() => setIsFullscreenOpen(false)}
size="auto"
centered
withCloseButton={false}
overlayProps={{
backgroundOpacity: 0.9,
blur: 2,
}}
styles={{ styles={{
image: { content: {
objectFit: "contain", background: 'transparent',
border: 'none',
},
body: {
padding: 0,
}, },
}} }}
{...props} >
/> <div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
</Paper> <ActionIcon
variant="filled"
color="dark"
size="lg"
radius="xl"
style={{
position: 'absolute',
top: -10,
right: -10,
zIndex: 1000,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
}}
onClick={() => setIsFullscreenOpen(false)}
>
<XIcon size={18} color="white" />
</ActionIcon>
<Image
src={props.src}
alt={name}
fit="contain"
style={{
borderRadius: 8,
maxWidth: '90vw',
maxHeight: '90vh',
}}
/>
<Group
justify="center"
mt="md"
style={{
position: 'absolute',
bottom: -50,
left: '50%',
transform: 'translateX(-50%)',
}}
>
<Text c="white" size="sm" fw={500}>
{name}
</Text>
</Group>
</div>
</Modal>
</>
); );
}; };

View File

@@ -62,6 +62,22 @@ const Drawer: React.FC<DrawerProps> = ({
useEffect(() => { useEffect(() => {
if (!opened || !contentRef.current) return; if (!opened || !contentRef.current) return;
const updateDrawerHeight = () => {
if (contentRef.current) {
const drawerContent = contentRef.current;
const visualViewport = window.visualViewport;
if (visualViewport) {
const availableHeight = visualViewport.height;
const maxDrawerHeight = Math.min(availableHeight * 0.75, window.innerHeight * 0.75);
drawerContent.style.maxHeight = `${maxDrawerHeight}px`;
} else {
drawerContent.style.maxHeight = '75vh';
}
}
};
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
if (contentRef.current) { if (contentRef.current) {
const drawerContent = contentRef.current.closest('[data-vaul-drawer-wrapper]'); const drawerContent = contentRef.current.closest('[data-vaul-drawer-wrapper]');
@@ -72,15 +88,24 @@ const Drawer: React.FC<DrawerProps> = ({
} }
}); });
updateDrawerHeight();
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateDrawerHeight);
}
resizeObserver.observe(contentRef.current); resizeObserver.observe(contentRef.current);
return () => { return () => {
resizeObserver.disconnect(); resizeObserver.disconnect();
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateDrawerHeight);
}
}; };
}, [opened, children]); }, [opened, children]);
return ( return (
<VaulDrawer.Root open={opened} onOpenChange={onChange}> <VaulDrawer.Root repositionInputs={false} open={opened} onOpenChange={onChange}>
<VaulDrawer.Portal> <VaulDrawer.Portal>
<VaulDrawer.Overlay className={styles.drawerOverlay} /> <VaulDrawer.Overlay className={styles.drawerOverlay} />
<VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer" ref={contentRef}> <VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer" ref={contentRef}>

View File

@@ -23,14 +23,14 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
onChange={onChange} onChange={onChange}
onClose={handleClose} onClose={handleClose}
> >
<ScrollArea <ScrollArea.Autosize
style={{ flex: 1 }} style={{ flex: 1, maxHeight: '75dvh' }}
scrollbarSize={8} scrollbarSize={8}
scrollbars="y" scrollbars="y"
type="scroll" type="scroll"
> >
{children} {children}
</ScrollArea> </ScrollArea.Autosize>
</SheetComponent> </SheetComponent>
); );
}; };

View File

@@ -13,11 +13,10 @@
margin-top: 24px; margin-top: 24px;
height: auto !important; height: auto !important;
min-height: fit-content; min-height: fit-content;
max-height: 100dvh;
position: fixed; position: fixed;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
outline: none; outline: none;
transition: height 0.2s ease-out; transition: height 0.2s ease-out, max-height 0.2s ease-out;
} }

View File

@@ -0,0 +1,175 @@
import { useState, useRef, useEffect, ReactNode } from "react";
import { TextInput, Loader, Paper, Stack, Box, Text } from "@mantine/core";
import { useDebouncedCallback } from "@mantine/hooks";
export interface TypeaheadOption<T = any> {
id: string;
data: T;
}
export interface TypeaheadProps<T> {
onSelect: (option: TypeaheadOption<T>) => void;
searchFn: (query: string) => Promise<TypeaheadOption<T>[]>;
renderOption: (option: TypeaheadOption<T>, isSelected?: boolean) => ReactNode;
format?: (option: TypeaheadOption<T>) => string;
placeholder?: string;
debounceMs?: number;
disabled?: boolean;
initialValue?: string;
}
const Typeahead = <T,>({
onSelect,
searchFn,
renderOption,
format,
placeholder = "Search...",
debounceMs = 300,
disabled = false,
initialValue = ""
}: TypeaheadProps<T>) => {
const [searchQuery, setSearchQuery] = useState(initialValue);
const [searchResults, setSearchResults] = useState<TypeaheadOption<T>[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const debouncedSearch = useDebouncedCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
setIsOpen(false);
return;
}
setIsLoading(true);
try {
const results = await searchFn(query);
setSearchResults(results);
setIsOpen(results.length > 0);
setSelectedIndex(-1);
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
setIsOpen(false);
} finally {
setIsLoading(false);
}
}, debounceMs);
const handleSearchChange = (value: string) => {
setSearchQuery(value);
debouncedSearch(value);
};
const handleOptionSelect = (option: TypeaheadOption<T>) => {
onSelect(option);
const displayValue = format ? format(option) : String(option.data);
setSearchQuery(displayValue);
setIsOpen(false);
setSelectedIndex(-1);
};
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen || searchResults.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev));
break;
case 'ArrowUp':
e.preventDefault();
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
break;
case 'Enter':
e.preventDefault();
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
handleOptionSelect(searchResults[selectedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setSelectedIndex(-1);
break;
}
};
return (
<Box ref={containerRef} pos="relative" w="100%">
<TextInput
ref={inputRef}
value={searchQuery}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
onKeyDown={handleKeyDown}
onFocus={() => {
if (searchResults.length > 0) setIsOpen(true);
}}
placeholder={placeholder}
rightSection={isLoading ? <Loader size="xs" /> : null}
disabled={disabled}
/>
{isOpen && (
<Paper
shadow="md"
p={0}
bd="1px solid var(--mantine-color-dimmed)"
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 9999,
maxHeight: '160px',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
touchAction: 'pan-y',
borderTop: 0,
borderTopLeftRadius: 0,
borderTopRightRadius: 0
}}
onTouchMove={(e) => e.stopPropagation()}
>
{searchResults.length > 0 ? (
<Stack gap={0}>
{searchResults.map((option, index) => (
<Box
key={option.id}
style={{
cursor: 'pointer',
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
}}
onClick={() => handleOptionSelect(option)}
onMouseEnter={() => setSelectedIndex(index)}
>
{renderOption(option, selectedIndex === index)}
</Box>
))}
</Stack>
) : (
<Box p="md">
<Text size="sm" c="dimmed" ta="center">
{searchQuery.trim() ? 'No results found' : 'Start typing to search...'}
</Text>
</Box>
)}
</Paper>
)}
</Box>
);
};
export default Typeahead;

View File

@@ -58,13 +58,13 @@ export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
const value = useMemo( const value = useMemo(
() => ({ () => ({
user: data?.user || defaultAuthData.user, user: data?.user,
metadata: data?.metadata || defaultAuthData.metadata, metadata: data?.metadata || { accentColor: "blue" as MantineColor, colorScheme: "dark" as MantineColorScheme },
roles: data?.roles || defaultAuthData.roles, roles: data?.roles || [],
phone: data?.phone || "", phone: data?.phone || "",
set, set,
}), }),
[data, defaultAuthData] [data, set]
); );
return <AuthContext value={value}>{children}</AuthContext>; return <AuthContext value={value}>{children}</AuthContext>;

View File

@@ -438,6 +438,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
activeDevice, activeDevice,
isLoading, isLoading,
error, error,
// Capture/Resume state
capturedState, capturedState,
isCaptureLoading, isCaptureLoading,
isResumeLoading, isResumeLoading,
@@ -452,9 +453,11 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
getDevices, getDevices,
setActiveDevice, setActiveDevice,
refreshPlaybackState, refreshPlaybackState,
// Capture/Resume methods
capturePlaybackState, capturePlaybackState,
resumePlaybackState, resumePlaybackState,
clearCapturedState, clearCapturedState,
// Search
searchTracks, searchTracks,
}; };

View File

@@ -9,7 +9,7 @@ import ListButton from "@/components/list-button";
const AdminPage = () => { const AdminPage = () => {
return ( return (
<List> <List p="0">
<ListLink <ListLink
label="Manage Tournaments" label="Manage Tournaments"
Icon={TrophyIcon} Icon={TrophyIcon}

View File

@@ -5,7 +5,7 @@ import ListLink from "@/components/list-link";
const ManageTournaments = () => { const ManageTournaments = () => {
const { data: tournaments } = useTournaments(); const { data: tournaments } = useTournaments();
return ( return (
<List> <List p="0">
{tournaments.map((t) => ( {tournaments.map((t) => (
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} /> <ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
))} ))}

View File

@@ -0,0 +1,4 @@
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
import { createServerFn } from "@tanstack/react-start";

View File

@@ -0,0 +1,8 @@
/*
pb.collection("team_stats_per_tournament").getFullList({
filter: `tournament_id = "${id}"`,
sort: "-wins,-total_cups_made"
})
*/

View File

@@ -1,6 +1,7 @@
import { Flex } from "@mantine/core"; import { Flex, Box } from "@mantine/core";
import { Match } from "@/features/matches/types"; import { Match } from "@/features/matches/types";
import { MatchCard } from "./match-card"; import { MatchCard } from "./match-card";
import { useEffect, useRef } from "react";
interface BracketProps { interface BracketProps {
rounds: Match[][]; rounds: Match[][];
@@ -13,33 +14,131 @@ export const Bracket: React.FC<BracketProps> = ({
orders, orders,
showControls, showControls,
}) => { }) => {
return ( const containerRef = useRef<HTMLDivElement>(null);
<Flex direction="row" gap={24} justify="left"> const svgRef = useRef<SVGSVGElement>(null);
{rounds.map((round, roundIndex) => (
<Flex useEffect(() => {
key={roundIndex} const updateConnectorLines = () => {
direction="column" if (!containerRef.current || !svgRef.current) return;
align="center"
pos="relative" const svg = svgRef.current;
gap={24} const container = containerRef.current;
justify="space-around" const flexContainer = container.querySelector('.bracket-flex-container') as HTMLElement;
p={24} if (!flexContainer) return;
>
{round.map((match) => svg.innerHTML = '';
match.bye ? (
<div key={match.lid}></div> const flexRect = flexContainer.getBoundingClientRect();
) : ( const containerRect = container.getBoundingClientRect();
<div key={match.lid}>
<MatchCard svg.style.width = `${flexContainer.scrollWidth}px`;
match={match} svg.style.height = `${flexContainer.scrollHeight}px`;
orders={orders}
showControls={showControls} rounds.forEach((round, roundIndex) => {
/> if (roundIndex === rounds.length - 1) return;
</div>
const nextRound = rounds[roundIndex + 1];
round.forEach((match, matchIndex) => {
if (match.bye) return;
const matchElement = container.querySelector(`[data-match-lid="${match.lid}"]`) as HTMLElement;
if (!matchElement) return;
const nextMatches = nextRound.filter(nextMatch =>
!nextMatch.bye && (
orders[nextMatch.home_from_lid] === match.order ||
orders[nextMatch.away_from_lid] === match.order
) )
)} );
</Flex>
))} nextMatches.forEach(nextMatch => {
</Flex> const nextMatchElement = container.querySelector(`[data-match-lid="${nextMatch.lid}"]`) as HTMLElement;
if (!nextMatchElement) return;
const matchRect = matchElement.getBoundingClientRect();
const nextMatchRect = nextMatchElement.getBoundingClientRect();
const startX = matchRect.right - flexRect.left;
const startY = matchRect.top + matchRect.height / 2 - flexRect.top;
const endX = nextMatchRect.left - flexRect.left;
const endY = nextMatchRect.top + nextMatchRect.height / 2 - flexRect.top;
const midX = startX + (endX - startX) * 0.5;
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
const pathData = `M ${startX} ${startY} L ${midX} ${startY} L ${midX} ${endY} L ${endX} ${endY}`;
path.setAttribute('d', pathData);
path.setAttribute('stroke', 'var(--mantine-color-default-border)');
path.setAttribute('stroke-width', '2');
path.setAttribute('fill', 'none');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
svg.appendChild(path);
});
});
});
};
updateConnectorLines();
const handleUpdate = () => {
requestAnimationFrame(updateConnectorLines);
};
const scrollContainer = containerRef.current?.closest('.mantine-ScrollArea-viewport');
scrollContainer?.addEventListener('scroll', handleUpdate);
window.addEventListener('resize', handleUpdate);
return () => {
scrollContainer?.removeEventListener('scroll', handleUpdate);
window.removeEventListener('resize', handleUpdate);
};
}, [rounds, orders]);
return (
<Box pos="relative" ref={containerRef}>
<svg
ref={svgRef}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: 0,
}}
/>
<Flex direction="row" gap={24} justify="left" pos="relative" style={{ zIndex: 1 }} className="bracket-flex-container">
{rounds.map((round, roundIndex) => (
<Flex
key={roundIndex}
direction="column"
align="center"
pos="relative"
gap={24}
justify="space-around"
p={24}
>
{round.map((match) =>
match.bye ? (
<div key={match.lid}></div>
) : (
<div key={match.lid}>
<MatchCard
match={match}
orders={orders}
showControls={showControls}
/>
</div>
)
)}
</Flex>
))}
</Flex>
</Box>
); );
}; };

View File

@@ -199,7 +199,15 @@ export const MatchCard: React.FC<MatchCardProps> = ({
return ( return (
<Flex direction="row" align="center" justify="end" gap={8}> <Flex direction="row" align="center" justify="end" gap={8}>
<Text c="dimmed" fw="bolder"> <Text
c="dimmed"
fw="bolder"
px={6}
py={2}
style={{
backgroundColor: 'var(--mantine-color-body)'
}}
>
{match.order} {match.order}
</Text> </Text>
<Flex align="stretch"> <Flex align="stretch">
@@ -214,7 +222,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
w={showToolbar || showEditButton ? 200 : 220} w={showToolbar || showEditButton ? 200 : 220}
withBorder withBorder
pos="relative" pos="relative"
style={{ overflow: "visible" }} style={{
overflow: "visible",
backgroundColor: 'var(--mantine-color-body)',
borderColor: 'var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
data-match-lid={match.lid} data-match-lid={match.lid}
> >
<Card.Section withBorder p={0}> <Card.Section withBorder p={0}>

View File

@@ -87,7 +87,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
{match.home?.name} Cups {match.home?.name} Cups
</Text> </Text>
{ {
match.home?.players.map(p => (<Text size='xs' c='dimmed'> match.home?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
{p.first_name} {p.last_name} {p.first_name} {p.last_name}
</Text>)) </Text>))
} }
@@ -110,7 +110,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
{match.away?.name} Cups {match.away?.name} Cups
</Text> </Text>
{ {
match.away?.players.map(p => (<Text size='xs' c='dimmed'> match.away?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
{p.first_name} {p.last_name} {p.first_name} {p.last_name}
</Text>)) </Text>))
} }

View File

@@ -21,16 +21,23 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
cups, cups,
isWinner isWinner
}) => ( }) => (
<Flex align="stretch"> <Flex
align="stretch"
style={{
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
borderRadius: 'var(--mantine-radius-sm)',
transition: 'background-color 200ms ease',
}}
>
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined} {(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
<Flex p="4px 8px" w='100%' align="center"> <Flex p="6px 10px" w='100%' align="center">
<Flex align="center" gap={4} flex={1}> <Flex align="center" gap={4} flex={1}>
{team ? ( {team ? (
<> <>
<Text <Text
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'} size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
truncate truncate
style={{ minWidth: 0, flex: 1 }} style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
> >
{team.name} {team.name}
</Text> </Text>

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()
.validator(z.number()) .inputValidator(z.number())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teams }) => .handler(async ({ data: teams }) =>
toServerResult(async () => { toServerResult(async () => {

View File

@@ -6,7 +6,10 @@ interface HeaderProps extends HeaderConfig {}
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => { const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
return ( return (
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}> <AppShell.Header
id='app-header'
display={collapsed ? 'none' : 'block'}
>
{ withBackButton && <BackButton /> } { withBackButton && <BackButton /> }
<Flex justify='center' align='center' h='100%' px='md'> <Flex justify='center' align='center' h='100%' px='md'>
<Title order={2}>{title}</Title> <Title order={2}>{title}</Title>

View File

@@ -31,11 +31,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
pos='relative' pos='relative'
h='100dvh' h='100dvh'
mah='100dvh' mah='100dvh'
style={{ // style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
top: 0,
minHeight: '100dvh',
maxHeight: '100dvh'
}}
> >
<Header {...header} /> <Header {...header} />
<AppShell.Main <AppShell.Main

View File

@@ -1,4 +1,4 @@
import { AppShell, ScrollArea, Stack, Group, Paper } from "@mantine/core"; import { AppShell, ScrollArea, Stack, Group, Paper, useMantineColorScheme } from "@mantine/core";
import { Link } from "@tanstack/react-router"; import { Link } from "@tanstack/react-router";
import { NavLink } from "./nav-link"; import { NavLink } from "./nav-link";
import { useIsMobile } from "@/hooks/use-is-mobile"; import { useIsMobile } from "@/hooks/use-is-mobile";
@@ -9,11 +9,17 @@ import { memo } from "react";
const Navbar = () => { const Navbar = () => {
const { user, roles } = useAuth() const { user, roles } = useAuth()
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const { colorScheme } = useMantineColorScheme();
const links = useLinks(user?.id, roles); const links = useLinks(user?.id, roles);
const isDark = colorScheme === 'dark';
const borderColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
const boxShadowColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
// boxShadow: `5px 5px ${boxShadowColor}`, borderColor
if (isMobile) return ( if (isMobile) return (
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 1rem)' shadow='sm' pos='fixed' m='0.5rem' bottom='0' style={{ zIndex: 10 }}> <Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1.5rem)' pos='fixed' m='0.75rem' bottom='0' style={{ zIndex: 10 }}>
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}> <Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
{links.map((link) => ( {links.map((link) => (
<NavLink key={link.href} {...link} /> <NavLink key={link.href} {...link} />
@@ -30,9 +36,6 @@ const Navbar = () => {
))} ))}
</Stack> </Stack>
</AppShell.Section> </AppShell.Section>
<AppShell.Section>
<Link to="/logout">Logout</Link>
</AppShell.Section>
</AppShell.Navbar> </AppShell.Navbar>
} }

View File

@@ -16,7 +16,7 @@ const orderedTeamsSchema = z.object({
}); });
export const generateTournamentBracket = createServerFn() export const generateTournamentBracket = createServerFn()
.validator(orderedTeamsSchema) .inputValidator(orderedTeamsSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: { tournamentId, orderedTeamIds } }) => .handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -137,7 +137,7 @@ export const generateTournamentBracket = createServerFn()
); );
export const startMatch = createServerFn() export const startMatch = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -170,7 +170,7 @@ const endMatchSchema = z.object({
ot_count: z.number(), ot_count: z.number(),
}); });
export const endMatch = createServerFn() export const endMatch = createServerFn()
.validator(endMatchSchema) .inputValidator(endMatchSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) => .handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -252,7 +252,7 @@ const toggleReactionSchema = z.object({
}); });
export const toggleMatchReaction = createServerFn() export const toggleMatchReaction = createServerFn()
.validator(toggleReactionSchema) .inputValidator(toggleReactionSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: { matchId, emoji }, context }) => .handler(async ({ data: { matchId, emoji }, context }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -312,7 +312,7 @@ export interface Reaction {
players: PlayerInfo[]; players: PlayerInfo[];
} }
export const getMatchReactions = createServerFn() export const getMatchReactions = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: matchId, context }) => .handler(async ({ data: matchId, context }) =>
toServerResult(async () => { toServerResult(async () => {

View File

@@ -25,7 +25,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
))} ))}
</List> </List>
return <List> return <List p="0">
{players?.map((player) => ( {players?.map((player) => (
<ListItem key={player.id} <ListItem key={player.id}
py='xs' py='xs'

View File

@@ -60,7 +60,6 @@ export const useMe = () => {
const errorData = error?.response?.data; const errorData = error?.response?.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") { if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
const currentUrl = window.location.pathname + window.location.search; const currentUrl = window.location.pathname + window.location.search;
console.log('redirecting 3')
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`; window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
return false; return false;
} }

View File

@@ -5,13 +5,13 @@ import { Match } from "@/features/matches/types";
import { pbAdmin } from "@/lib/pocketbase/client"; import { pbAdmin } from "@/lib/pocketbase/client";
import { z } from "zod"; import { z } from "zod";
import { logger } from "."; import { logger } from ".";
import { getWebRequest } 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";
export const fetchMe = createServerFn() export const fetchMe = createServerFn()
.handler(async () => .handler(async () =>
toServerResult(async () => { toServerResult(async () => {
const request = getWebRequest(); const request = getRequest();
try { try {
const context = await getSessionContext(request); const context = await getSessionContext(request);
@@ -25,7 +25,7 @@ export const fetchMe = createServerFn()
phone: context.phone phone: context.phone
}; };
} catch (error: any) { } catch (error: any) {
logger.info("FetchMe: Session error", error) // logger.info("FetchMe: Session error", error)
if (error?.response?.status === 401) { if (error?.response?.status === 401) {
const errorData = error?.response?.data; const errorData = error?.response?.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") { if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
@@ -38,14 +38,14 @@ export const fetchMe = createServerFn()
); );
export const getPlayer = createServerFn() export const getPlayer = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<Player>(async () => await pbAdmin.getPlayer(data)) toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
); );
export const updatePlayer = createServerFn() export const updatePlayer = createServerFn()
.validator(playerUpdateSchema) .inputValidator(playerUpdateSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -72,7 +72,7 @@ export const updatePlayer = createServerFn()
); );
export const createPlayer = createServerFn() export const createPlayer = createServerFn()
.validator(playerInputSchema) .inputValidator(playerInputSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -97,7 +97,7 @@ export const createPlayer = createServerFn()
); );
export const associatePlayer = createServerFn() export const associatePlayer = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -129,7 +129,7 @@ export const getUnassociatedPlayers = createServerFn()
); );
export const getPlayerStats = createServerFn() export const getPlayerStats = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data)) toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data))
@@ -142,14 +142,14 @@ export const getAllPlayerStats = createServerFn()
); );
export const getPlayerMatches = createServerFn() export const getPlayerMatches = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data)) toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
); );
export const getUnenrolledPlayers = createServerFn() export const getUnenrolledPlayers = createServerFn()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data: tournamentId }) => .handler(async ({ data: tournamentId }) =>
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId)) toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))

View File

@@ -1,7 +1,7 @@
import { Badge, FileInput, Group, Stack, Text, TextInput } from "@mantine/core"; import { FileInput, Stack, TextInput } from "@mantine/core";
import { useForm, UseFormInput } from "@mantine/form"; import { useForm, UseFormInput } from "@mantine/form";
import { LinkIcon } from "@phosphor-icons/react"; import { LinkIcon } from "@phosphor-icons/react";
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel"; import SlidePanel from "@/components/sheet/slide-panel";
import { isNotEmpty } from "@mantine/form"; import { isNotEmpty } from "@mantine/form";
import useCreateTeam from "../../hooks/use-create-team"; import useCreateTeam from "../../hooks/use-create-team";
import useUpdateTeam from "../../hooks/use-update-team"; import useUpdateTeam from "../../hooks/use-update-team";
@@ -13,8 +13,8 @@ import { useCallback } from "react";
import { TeamInput } from "../../types"; import { TeamInput } from "../../types";
import { teamKeys } from "../../queries"; import { teamKeys } from "../../queries";
import SongPicker from "./song-picker"; import SongPicker from "./song-picker";
import TeamColorPicker from "./color-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;
@@ -113,9 +113,32 @@ const TeamForm = ({
if (logo && team) { if (logo && team) {
try { try {
let processedLogo = logo;
if (logo.size > 500 * 1024) {
const compressionOptions = {
maxSizeMB: 0.5,
maxWidthOrHeight: 800,
useWebWorker: true,
fileType: logo.type,
};
try {
processedLogo = await imageCompression(logo, compressionOptions);
logger.info("image compressed", {
originalSize: logo.size,
compressedSize: processedLogo.size,
reduction: ((logo.size - processedLogo.size) / logo.size * 100).toFixed(1) + "%"
});
} catch (compressionError) {
logger.warn("compression failed, falling back", compressionError);
processedLogo = logo;
}
}
const formData = new FormData(); const formData = new FormData();
formData.append("teamId", team.id); formData.append("teamId", team.id);
formData.append("logo", logo); formData.append("logo", processedLogo);
const response = await fetch("/api/teams/upload-logo", { const response = await fetch("/api/teams/upload-logo", {
method: "POST", method: "POST",

View File

@@ -1,7 +1,6 @@
import { useState, useRef, useEffect } from "react"; import { Text, Group, Avatar, Box } from "@mantine/core";
import { Text, TextInput, Group, Avatar, Loader, Paper, Stack, Box } from "@mantine/core";
import { SpotifyTrack } from "@/lib/spotify/types"; import { SpotifyTrack } from "@/lib/spotify/types";
import { useDebouncedCallback } from "@mantine/hooks"; import Typeahead, { TypeaheadOption } from "@/components/typeahead";
interface SongSearchProps { interface SongSearchProps {
onChange: (track: SpotifyTrack) => void; onChange: (track: SpotifyTrack) => void;
@@ -9,15 +8,7 @@ interface SongSearchProps {
} }
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => { const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
const [searchQuery, setSearchQuery] = useState(""); const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
if (!query.trim()) return []; if (!query.trim()) return [];
try { try {
@@ -28,155 +19,62 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
} }
const data = await response.json(); const data = await response.json();
return data.tracks || []; const tracks = data.tracks || [];
return tracks.map((track: SpotifyTrack) => ({
id: track.id,
data: track
}));
} catch (error) { } catch (error) {
console.error('Failed to search tracks:', error); console.error('Failed to search tracks:', error);
return []; return [];
} }
}; };
const debouncedSearch = useDebouncedCallback(async (query: string) => { const handleSongSelect = (option: TypeaheadOption<SpotifyTrack>) => {
if (!query.trim()) { onChange(option.data);
setSearchResults([]);
setIsOpen(false);
return;
}
setIsLoading(true);
try {
const results = await searchSpotifyTracks(query);
setSearchResults(results);
setIsOpen(results.length > 0);
setSelectedIndex(-1);
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
setIsOpen(false);
} finally {
setIsLoading(false);
}
}, 300);
const handleSearchChange = (value: string) => {
setSearchQuery(value);
debouncedSearch(value);
}; };
const handleSongSelect = (track: SpotifyTrack) => { const formatTrack = (option: TypeaheadOption<SpotifyTrack>) => {
onChange(track); const track = option.data;
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`); return `${track.name} - ${track.artists.map(a => a.name).join(', ')}`;
setIsOpen(false);
setSelectedIndex(-1);
}; };
useEffect(() => { const renderOption = (option: TypeaheadOption<SpotifyTrack>) => {
const handleClickOutside = (event: MouseEvent) => { const track = option.data;
if (containerRef.current && !containerRef.current.contains(event.target as Node)) { return (
setIsOpen(false); <Box
} p="sm"
}; style={{
borderBottom: '1px solid var(--mantine-color-dimmed)'
document.addEventListener('mousedown', handleClickOutside); }}
return () => document.removeEventListener('mousedown', handleClickOutside); >
}, []); <Group gap="sm">
{track.album.images[2] && (
const handleKeyDown = (e: React.KeyboardEvent) => { <Avatar src={track.album.images[2].url} size={40} radius="sm" />
if (!isOpen || searchResults.length === 0) return; )}
<div>
switch (e.key) { <Text size="sm" fw={500}>
case 'ArrowDown': {track.name}
e.preventDefault(); </Text>
setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev)); <Text size="xs" c="dimmed">
break; {track.artists.map(a => a.name).join(', ')} {track.album.name}
case 'ArrowUp': </Text>
e.preventDefault(); </div>
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev)); </Group>
break; </Box>
case 'Enter': );
e.preventDefault();
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
handleSongSelect(searchResults[selectedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
setSelectedIndex(-1);
break;
}
}; };
return ( return (
<Box ref={containerRef} pos="relative" w="100%"> <Typeahead
<TextInput onSelect={handleSongSelect}
ref={inputRef} searchFn={searchSpotifyTracks}
value={searchQuery} renderOption={renderOption}
onChange={(event) => handleSearchChange(event.currentTarget.value)} format={formatTrack}
onKeyDown={handleKeyDown} placeholder={placeholder}
onFocus={() => { />
if (searchResults.length > 0) setIsOpen(true); )
}}
placeholder={placeholder}
rightSection={isLoading ? <Loader size="xs" /> : null}
/>
{isOpen && (
<Paper
shadow="md"
p={0}
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 9999,
maxHeight: '160px',
overflowY: 'auto',
WebkitOverflowScrolling: 'touch',
touchAction: 'pan-y'
}}
onTouchMove={(e) => e.stopPropagation()}
>
{searchResults.length > 0 ? (
<Stack gap={0}>
{searchResults.map((track, index) => (
<Box
key={track.id}
p="sm"
style={{
cursor: 'pointer',
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
borderBottom: index < searchResults.length - 1 ? '1px solid var(--mantine-color-gray-3)' : 'none'
}}
onClick={() => handleSongSelect(track)}
onMouseEnter={() => setSelectedIndex(index)}
>
<Group gap="sm">
{track.album.images[2] && (
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
)}
<div>
<Text size="sm" fw={500}>
{track.name}
</Text>
<Text size="xs" c="dimmed">
{track.artists.map(a => a.name).join(', ')} {track.album.name}
</Text>
</div>
</Group>
</Box>
))}
</Stack>
) : (
<Box p="md">
<Text size="sm" c="dimmed" ta="center">
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
</Text>
</Box>
)}
</Paper>
)}
</Box>
);
}; };
export default SongSearch; export default SongSearch;

View File

@@ -58,7 +58,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
if (loading) if (loading)
return ( return (
<List> <List p="0">
{Array.from({ length: 10 }).map((_, i) => ( {Array.from({ length: 10 }).map((_, i) => (
<ListItem <ListItem
key={`skeleton-${i}`} key={`skeleton-${i}`}
@@ -72,7 +72,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
); );
return ( return (
<List> <List p="0">
{teams?.map((team) => ( {teams?.map((team) => (
<div key={team.id}> <div key={team.id}>
<ListItem <ListItem

View File

@@ -15,21 +15,21 @@ export const listTeamInfos = createServerFn()
); );
export const getTeam = createServerFn() export const getTeam = createServerFn()
.validator(z.string()) .inputValidator(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()
.validator(z.string()) .inputValidator(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()
.validator(teamInputSchema) .inputValidator(teamInputSchema)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ data, context }) => .handler(async ({ data, context }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -46,7 +46,7 @@ export const createTeam = createServerFn()
); );
export const updateTeam = createServerFn() export const updateTeam = createServerFn()
.validator(z.object({ .inputValidator(z.object({
id: z.string(), id: z.string(),
updates: teamUpdateSchema updates: teamUpdateSchema
})) }))
@@ -72,14 +72,14 @@ export const updateTeam = createServerFn()
); );
export const getTeamStats = createServerFn() export const getTeamStats = createServerFn()
.validator(z.string()) .inputValidator(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()
.validator(z.string()) .inputValidator(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))

View File

@@ -1,11 +1,11 @@
import { import {
Autocomplete,
Stack, Stack,
ActionIcon, ActionIcon,
Text, Text,
Group, Group,
Loader, Loader,
} from "@mantine/core"; } from "@mantine/core";
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
import { TrashIcon } from "@phosphor-icons/react"; import { TrashIcon } from "@phosphor-icons/react";
import { useState, useCallback, useMemo, memo } from "react"; import { useState, useCallback, useMemo, memo } from "react";
import { useTournament, useUnenrolledTeams } from "../queries"; import { useTournament, useUnenrolledTeams } from "../queries";
@@ -68,8 +68,6 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
}); });
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => { const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const [search, setSearch] = useState("");
const { data: tournament, isLoading: tournamentLoading } = const { data: tournament, isLoading: tournamentLoading } =
useTournament(tournamentId); useTournament(tournamentId);
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } = const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
@@ -78,27 +76,24 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam(); const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam(); const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
const autocompleteData = useMemo( const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
() => if (!query.trim()) return [];
unenrolledTeams.map((team: Team) => ({
value: team.id, const filtered = unenrolledTeams.filter((team: Team) =>
label: team.name, team.name.toLowerCase().includes(query.toLowerCase())
})), );
[unenrolledTeams]
); return filtered.map((team: Team) => ({
id: team.id,
data: team
}));
};
const handleEnrollTeam = useCallback( const handleEnrollTeam = useCallback(
(teamId: string) => { (option: TypeaheadOption<Team>) => {
enrollTeam( enrollTeam({ tournamentId, teamId: option.data.id });
{ tournamentId, teamId },
{
onSuccess: () => {
setSearch("");
},
}
);
}, },
[enrollTeam, tournamentId, setSearch] [enrollTeam, tournamentId]
); );
const handleUnenrollTeam = useCallback( const handleUnenrollTeam = useCallback(
@@ -108,6 +103,31 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
[unenrollTeam, tournamentId] [unenrollTeam, tournamentId]
); );
const renderTeamOption = (option: TypeaheadOption<Team>) => {
const team = option.data;
return (
<Group py="xs" px="sm" gap="sm" align="center">
<Avatar
size={32}
radius="sm"
name={team.name}
src={
team.logo
? `/api/files/teams/${team.id}/${team.logo}`
: undefined
}
/>
<Text fw={500} truncate>
{team.name}
</Text>
</Group>
);
};
const formatTeam = (option: TypeaheadOption<Team>) => {
return option.data.name;
};
const isLoading = tournamentLoading || unenrolledLoading; const isLoading = tournamentLoading || unenrolledLoading;
const enrolledTeams = tournament?.teams || []; const enrolledTeams = tournament?.teams || [];
const hasEnrolledTeams = enrolledTeams.length > 0; const hasEnrolledTeams = enrolledTeams.length > 0;
@@ -118,16 +138,13 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
<Text fw={600} size="sm"> <Text fw={600} size="sm">
Add Team Add Team
</Text> </Text>
<Autocomplete <Typeahead
placeholder="Search for teams to enroll..." placeholder="Search for teams to enroll..."
data={autocompleteData} onSelect={handleEnrollTeam}
value={search} searchFn={searchTeams}
onChange={setSearch} renderOption={renderTeamOption}
onOptionSubmit={handleEnrollTeam} format={formatTeam}
disabled={isEnrolling || unenrolledLoading} disabled={isEnrolling || unenrolledLoading}
rightSection={isEnrolling ? <Loader size="xs" /> : null}
maxDropdownHeight={200}
limit={10}
/> />
</Stack> </Stack>

View File

@@ -45,7 +45,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
return ( return (
<> <>
<List> <List p="0">
<ListButton <ListButton
label="Edit Tournament" label="Edit Tournament"
Icon={HardDrivesIcon} Icon={HardDrivesIcon}

View File

@@ -11,7 +11,7 @@ const Header = ({ tournament }: HeaderProps) => {
return ( return (
<> <>
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'> <Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
<Avatar name={tournament.name} radius={0} withBorder={false} size={125} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} /> <Avatar contain name={tournament.name} radius={0} withBorder={false} size={150} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'> <Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' order={2}>{tournament.name}</Title> <Title ta='center' order={2}>{tournament.name}</Title>
</Flex> </Flex>

View File

@@ -18,6 +18,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
<Stack px="sm" align="center" gap={0}> <Stack px="sm" align="center" gap={0}>
<Avatar <Avatar
name={tournament.name} name={tournament.name}
contain
src={ src={
tournament.logo tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`

View File

@@ -60,6 +60,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
size={90} size={90}
radius="sm" radius="sm"
name={tournament.name} name={tournament.name}
contain
src={ src={
tournament.logo tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`

View File

@@ -63,7 +63,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
if (loading) { if (loading) {
return ( return (
<List> <List p="0">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<ListItem <ListItem
key={`skeleton-${i}`} key={`skeleton-${i}`}
@@ -97,7 +97,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
} }
return ( return (
<List> <List p="0">
{tournaments.map((tournament) => ( {tournaments.map((tournament) => (
<> <>
<ListItem <ListItem
@@ -108,6 +108,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
radius="sm" radius="sm"
size={40} size={40}
name={tournament.name} name={tournament.name}
contain
src={ src={
tournament.logo tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`

View File

@@ -281,5 +281,3 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
</Container> </Container>
); );
}); });
TournamentStats.displayName = 'TournamentStats';

View File

@@ -27,7 +27,7 @@ const EnrollTeam = ({ tournamentId, onSubmit }: EnrollTeamProps) => {
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null); const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
const { data: teamData } = useServerQuery({ const { data: teamData } = useServerQuery({
...teamQueries.details(selectedTeamId!), ...teamQueries.details(selectedTeamId || ''),
options: { enabled: !!selectedTeamId } options: { enabled: !!selectedTeamId }
}); });

View File

@@ -1,6 +1,7 @@
import { Stack, Button, Divider, Autocomplete, Group, ComboboxItem } from '@mantine/core'; import { Stack, Button, Divider, Group, ComboboxItem, Text } from '@mantine/core';
import { PlusIcon } from '@phosphor-icons/react'; import { PlusIcon } from '@phosphor-icons/react';
import React, { useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import Typeahead, { TypeaheadOption } from '@/components/typeahead';
interface TeamSelectionViewProps { interface TeamSelectionViewProps {
options: ComboboxItem[]; options: ComboboxItem[];
@@ -11,11 +12,39 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
options, options,
onSelect onSelect
}) => { }) => {
const [value, setValue] = useState<string>(''); const [selectedTeam, setSelectedTeam] = React.useState<ComboboxItem | null>(null);
const selectedOption = useMemo(() => options.find(option => option.label === value), [value, options])
const searchTeams = async (query: string): Promise<TypeaheadOption<ComboboxItem>[]> => {
if (!query.trim()) return [];
const filtered = options.filter(option =>
option.label.toLowerCase().includes(query.toLowerCase())
);
return filtered.map(option => ({
id: String(option.value),
data: option
}));
};
const handleTeamSelect = (option: TypeaheadOption<ComboboxItem>) => {
setSelectedTeam(option.data);
};
const renderTeamOption = (option: TypeaheadOption<ComboboxItem>) => {
return (
<Group py="xs" px="sm" gap="sm">
<Text fw={500}>{option.data.label}</Text>
</Group>
);
};
const formatTeam = (option: TypeaheadOption<ComboboxItem>) => {
return option.data.label;
};
const handleCreateNewTeamClicked = () => onSelect(undefined); const handleCreateNewTeamClicked = () => onSelect(undefined);
const handleSelectExistingTeam = () => onSelect(selectedOption?.value) const handleSelectExistingTeam = () => onSelect(selectedTeam?.value);
return ( return (
<Stack gap="md"> <Stack gap="md">
@@ -31,17 +60,17 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
<Divider my="sm" label="or" /> <Divider my="sm" label="or" />
<Stack gap="sm"> <Stack gap="sm">
<Autocomplete <Typeahead
placeholder="Select one of your existing teams" placeholder="Select one of your existing teams"
value={value} onSelect={handleTeamSelect}
onChange={setValue} searchFn={searchTeams}
data={options.map(option => option.label)} renderOption={renderTeamOption}
comboboxProps={{ withinPortal: false }} format={formatTeam}
/> />
<Button <Button
onClick={handleSelectExistingTeam} onClick={handleSelectExistingTeam}
disabled={!selectedOption} disabled={!selectedTeam}
fullWidth fullWidth
> >
Enroll Selected Team Enroll Selected Team

View File

@@ -19,6 +19,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
<Stack align="center" gap={0}> <Stack align="center" gap={0}>
<Avatar <Avatar
name={tournament.name} name={tournament.name}
contain
src={ src={
tournament.logo tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}` ? `/api/files/tournaments/${tournament.id}/${tournament.logo}`

View File

@@ -18,7 +18,6 @@ interface UpdateTeamProps {
const UpdateTeam = ({ tournamentId, teamId }: UpdateTeamProps) => { const UpdateTeam = ({ tournamentId, teamId }: UpdateTeamProps) => {
const { open, isOpen, toggle } = useSheet(); const { open, isOpen, toggle } = useSheet();
const { data: team } = useTeam(teamId); const { data: team } = useTeam(teamId);
const initialValues = useMemo(() => { const initialValues = useMemo(() => {

View File

@@ -13,14 +13,14 @@ export const listTournaments = createServerFn()
); );
export const createTournament = createServerFn() export const createTournament = createServerFn()
.validator(tournamentInputSchema) .inputValidator(tournamentInputSchema)
.middleware([superTokensAdminFunctionMiddleware]) .middleware([superTokensAdminFunctionMiddleware])
.handler(async ({ data }) => .handler(async ({ data }) =>
toServerResult(() => pbAdmin.createTournament(data)) toServerResult(() => pbAdmin.createTournament(data))
); );
export const updateTournament = createServerFn() export const updateTournament = createServerFn()
.validator(z.object({ .inputValidator(z.object({
id: z.string(), id: z.string(),
updates: tournamentInputSchema.partial() updates: tournamentInputSchema.partial()
})) }))
@@ -30,7 +30,7 @@ export const updateTournament = createServerFn()
); );
export const getTournament = createServerFn() export const getTournament = createServerFn()
.validator(z.string()) .inputValidator(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");
@@ -44,7 +44,7 @@ export const getCurrentTournament = createServerFn()
); );
export const enrollTeam = createServerFn() export const enrollTeam = createServerFn()
.validator(z.object({ .inputValidator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
teamId: z.string() teamId: z.string()
})) }))
@@ -70,7 +70,7 @@ export const enrollTeam = createServerFn()
); );
export const unenrollTeam = createServerFn() export const unenrollTeam = createServerFn()
.validator(z.object({ .inputValidator(z.object({
tournamentId: z.string(), tournamentId: z.string(),
teamId: z.string() teamId: z.string()
})) }))
@@ -80,21 +80,21 @@ export const unenrollTeam = createServerFn()
); );
export const getUnenrolledTeams = createServerFn() export const getUnenrolledTeams = createServerFn()
.validator(z.string()) .inputValidator(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()
.validator(z.string()) .inputValidator(z.string())
.middleware([superTokensAdminFunctionMiddleware]) .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()
.validator(z.object({ phone: z.string(), tournamentId: z.string() })) .inputValidator(z.object({ phone: z.string(), tournamentId: z.string() }))
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {
@@ -108,7 +108,7 @@ export const enrollFreeAgent = createServerFn()
); );
export const unenrollFreeAgent = createServerFn() export const unenrollFreeAgent = createServerFn()
.validator(z.object({ tournamentId: z.string() })) .inputValidator(z.object({ tournamentId: z.string() }))
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => .handler(async ({ context, data }) =>
toServerResult(async () => { toServerResult(async () => {

View File

@@ -37,6 +37,7 @@ export function useServerEvents() {
const timeoutRef = useRef<NodeJS.Timeout | null>(null); const timeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return;
if (!user?.id) return; if (!user?.id) return;
shouldConnectRef.current = true; shouldConnectRef.current = true;
@@ -111,5 +112,5 @@ export function useServerEvents() {
eventSource.close(); eventSource.close();
} }
}; };
}, [user?.id, queryClient]); }, [user?.id]);
} }

View File

@@ -46,7 +46,7 @@ class Logger {
constructor(context?: string, options: LoggerOptions = {}) { constructor(context?: string, options: LoggerOptions = {}) {
this.context = context; this.context = context;
this.options = { this.options = {
enabled: process.env.NODE_ENV !== "production", enabled: import.meta.env.NODE_ENV !== "production",
showTimestamp: true, showTimestamp: true,
collapsed: true, collapsed: true,
colors: true, colors: true,

View File

@@ -4,17 +4,14 @@ import { createTournamentsService } from "./services/tournaments";
import { createTeamsService } from "./services/teams"; import { createTeamsService } from "./services/teams";
import { createMatchesService } from "./services/matches"; import { createMatchesService } from "./services/matches";
import { createReactionsService } from "./services/reactions"; import { createReactionsService } from "./services/reactions";
import dotenv from 'dotenv';
dotenv.config();
class PocketBaseAdminClient { class PocketBaseAdminClient {
private pb: PocketBase; private pb: PocketBase;
public authPromise: Promise<void>; public authPromise: Promise<void>;
constructor() { constructor() {
console.log('Environment variables loaded:', {
POCKETBASE_URL: process.env.POCKETBASE_URL,
POCKETBASE_ADMIN_EMAIL: process.env.POCKETBASE_ADMIN_EMAIL,
POCKETBASE_ADMIN_PASSWORD: process.env.POCKETBASE_ADMIN_PASSWORD,
});
this.pb = new PocketBase(process.env.POCKETBASE_URL); this.pb = new PocketBase(process.env.POCKETBASE_URL);
this.pb.beforeSend = (url, options) => { this.pb.beforeSend = (url, options) => {

View File

@@ -1,4 +1,4 @@
import type { PKCEState, SpotifyTokenResponse } from './types'; import type { SpotifyTokenResponse } from './types';
const SPOTIFY_AUTH_BASE = 'https://accounts.spotify.com'; const SPOTIFY_AUTH_BASE = 'https://accounts.spotify.com';
const SPOTIFY_SCOPES = [ const SPOTIFY_SCOPES = [

View File

@@ -4,6 +4,32 @@ import Passwordless from "supertokens-web-js/recipe/passwordless";
import { appInfo } from "./config"; import { appInfo } from "./config";
import { logger } from "./"; import { logger } from "./";
let refreshAttemptCount = 0;
export const resetRefreshFlag = () => {
refreshAttemptCount = 0;
};
const setupFetchInterceptor = () => {
if (typeof window === 'undefined') return;
const originalFetch = window.fetch;
//@ts-ignore
window.fetch = async (resource: RequestInfo | URL, options?: RequestInit) => {
const url = typeof resource === 'string' ? resource :
resource instanceof URL ? resource.toString() : resource.url;
if (url.includes('/api/auth/session/refresh')) {
refreshAttemptCount++;
if (refreshAttemptCount > 1) {
throw new Error('Duplicate refresh attempt blocked');
}
}
return originalFetch.call(window, resource, options);
};
};
export const frontendConfig = () => { export const frontendConfig = () => {
return { return {
appInfo, appInfo,
@@ -12,7 +38,6 @@ export const frontendConfig = () => {
Session.init({ Session.init({
tokenTransferMethod: "cookie", tokenTransferMethod: "cookie",
sessionTokenBackendDomain: undefined, sessionTokenBackendDomain: undefined,
preAPIHook: async (context) => { preAPIHook: async (context) => {
context.requestInit.credentials = "include"; context.requestInit.credentials = "include";
return context; return context;
@@ -23,16 +48,14 @@ export const frontendConfig = () => {
}; };
let initialized = false; let initialized = false;
export function ensureSuperTokensFrontend() { export function ensureSuperTokensFrontend() {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if (!initialized) { if (!initialized) {
setupFetchInterceptor();
SuperTokens.init(frontendConfig()); SuperTokens.init(frontendConfig());
initialized = true; initialized = true;
logger.info("Initialized"); logger.info("SuperTokens initialized");
Session.doesSessionExist().then((exists) => {
logger.info(`Session does${exists ? "" : "NOT"} exist on load!`);
});
} }
} }

View File

@@ -6,6 +6,7 @@ import UserRoles from "supertokens-node/recipe/userroles";
import { appInfo } from "./config"; import { appInfo } from "./config";
import PasswordlessDevelopmentMode from "./recipes/passwordless-development-mode"; import PasswordlessDevelopmentMode from "./recipes/passwordless-development-mode";
import { logger } from "./"; import { logger } from "./";
import passwordlessTwilioVerify from "./recipes/passwordless-twilio-verify";
export const backendConfig = (): TypeInput => { export const backendConfig = (): TypeInput => {
return { return {
@@ -19,10 +20,10 @@ export const backendConfig = (): TypeInput => {
PasswordlessDevelopmentMode.init(), PasswordlessDevelopmentMode.init(),
Session.init({ Session.init({
cookieSameSite: "lax", cookieSameSite: "lax",
cookieSecure: process.env.NODE_ENV === "production", cookieSecure: import.meta.env.NODE_ENV === "production",
cookieDomain: cookieDomain:
process.env.NODE_ENV === "production" ? ".example.com" : undefined, import.meta.env.NODE_ENV === "production" ? ".example.com" : undefined,
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE", antiCsrf: import.meta.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
// Debug only // Debug only
exposeAccessTokenToFrontendInCookieBasedAuth: true, exposeAccessTokenToFrontendInCookieBasedAuth: true,
@@ -30,13 +31,13 @@ export const backendConfig = (): TypeInput => {
Dashboard.init(), Dashboard.init(),
UserRoles.init(), UserRoles.init(),
], ],
telemetry: process.env.NODE_ENV !== "production", telemetry: import.meta.env.NODE_ENV !== "production",
}; };
}; };
let initialized = false; let initialized = false;
export function ensureSuperTokensBackend() { export function ensureSuperTokensBackend() {
if (!initialized) { if (!initialized && typeof window === 'undefined') {
SuperTokens.init(backendConfig()); SuperTokens.init(backendConfig());
initialized = true; initialized = true;
logger.simple("Backend initialized"); logger.simple("Backend initialized");

View File

@@ -2,6 +2,8 @@ import { useMutation, UseMutationOptions } from "@tanstack/react-query";
import { ServerResult } from "../types"; import { ServerResult } from "../types";
import toast from '@/lib/sonner' import toast from '@/lib/sonner'
let isMutationRefreshingSession = false;
export function useServerMutation<TData, TVariables = unknown>( export function useServerMutation<TData, TVariables = unknown>(
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & { options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
mutationFn: (variables: TVariables) => Promise<ServerResult<TData>>; mutationFn: (variables: TVariables) => Promise<ServerResult<TData>>;
@@ -42,9 +44,14 @@ export function useServerMutation<TData, TVariables = unknown>(
: error.response.data; : error.response.data;
if (errorData?.error === "SESSION_REFRESH_REQUIRED") { if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
const currentUrl = window.location.pathname + window.location.search; if (!isMutationRefreshingSession) {
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`; isMutationRefreshingSession = true;
console.log('redirecting 2') const currentUrl = window.location.pathname + window.location.search;
setTimeout(() => {
isMutationRefreshingSession = false;
}, 1000);
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
}
throw new Error("SESSION_REFRESH_REQUIRED"); throw new Error("SESSION_REFRESH_REQUIRED");
} }
} catch (parseError) {} } catch (parseError) {}

View File

@@ -1,13 +1,23 @@
import twilio from "twilio"; import type { Twilio } from "twilio";
const accountSid = process.env.TWILIO_ACCOUNT_SID!; const accountSid = process.env.TWILIO_ACCOUNT_SID!;
const authToken = process.env.TWILIO_AUTH_TOKEN!; const authToken = process.env.TWILIO_AUTH_TOKEN!;
const serviceSid = process.env.TWILIO_SERVICE_SID!; const serviceSid = process.env.TWILIO_SERVICE_SID!;
const client = twilio(accountSid, authToken); let client: Twilio;
function getTwilioClient() {
if (!client) {
const twilio = require("twilio");
client = twilio(accountSid, authToken);
}
return client;
}
export async function sendVerifyCode(phoneNumber: string, code: string) { export async function sendVerifyCode(phoneNumber: string, code: string) {
const verification = await client.verify.v2 const twilioClient = getTwilioClient();
const verification = await twilioClient!.verify.v2
.services(serviceSid) .services(serviceSid)
.verifications.create({ .verifications.create({
channel: "sms", channel: "sms",
@@ -23,7 +33,9 @@ export async function sendVerifyCode(phoneNumber: string, code: string) {
} }
export async function updateVerify(sid: string) { export async function updateVerify(sid: string) {
const verification = await client.verify.v2 const twilioClient = getTwilioClient();
const verification = await twilioClient!.verify.v2
.services(serviceSid) .services(serviceSid)
.verifications(sid) .verifications(sid)
.update({ status: "approved" }); .update({ status: "approved" });
@@ -31,4 +43,4 @@ export async function updateVerify(sid: string) {
if (verification.status !== "approved") { if (verification.status !== "approved") {
throw new Error("Unknown error updating verification"); throw new Error("Unknown error updating verification");
} }
} }

View File

@@ -1,48 +1,25 @@
import { import {
createMiddleware, createMiddleware,
createServerFn, createServerFn,
ServerFnResponseType, createServerOnlyFn,
} from "@tanstack/react-start"; } from "@tanstack/react-start";
import { getWebRequest } from "@tanstack/react-start/server"; import { getRequest, setResponseHeader } from "@tanstack/react-start/server";
import { redirect as redirect } from "@tanstack/react-router"; import { redirect as redirect } from "@tanstack/react-router";
import UserRoles from "supertokens-node/recipe/userroles"; import UserRoles from "supertokens-node/recipe/userroles";
import UserMetadata from "supertokens-node/recipe/usermetadata"; import UserMetadata from "supertokens-node/recipe/usermetadata";
import { getSessionForStart } from "@/lib/supertokens/recipes/start-session"; 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 { refreshSession } from "supertokens-node/recipe/session";
const logger = new Logger("Middleware"); const logger = new Logger("Middleware");
export const verifySuperTokensSession = async ( const verifySuperTokensSession = async (
request: Request, request: Request
response?: ServerFnResponseType
) => { ) => {
let session = await getSessionForStart(request, { sessionRequired: false }); let session = await getSessionForStart(request, { sessionRequired: false });
if (session?.needsRefresh) { if (session?.needsRefresh) {
logger.info("Session needs refresh"); logger.info("Session needs refresh - redirecting to client");
return { context: { session: { tryRefresh: true } } };
try {
if (response) {
const refreshedSession = await refreshSession(request, response);
if (refreshedSession) {
session = await getSessionForStart(request, { sessionRequired: false });
}
}
if (session?.needsRefresh) {
return { context: { session: { tryRefresh: true } } };
}
} catch (error: any) {
logger.error("Session refresh error", error);
if (error.type === 'UNAUTHORISED' || error.type === 'TOKEN_THEFT_DETECTED') {
return { context: { userAuthId: null, roles: [] } };
}
return { context: { session: { tryRefresh: true } } };
}
} }
const userAuthId = session?.userId; const userAuthId = session?.userId;
@@ -69,7 +46,7 @@ export const verifySuperTokensSession = async (
}; };
}; };
export const getSessionContext = async (request: Request, options?: { isServerFunction?: boolean }) => { export const getSessionContext = createServerOnlyFn(async (request: Request, options?: { isServerFunction?: boolean }) => {
const session = await verifySuperTokensSession(request); const session = await verifySuperTokensSession(request);
if (session.context.session?.tryRefresh) { if (session.context.session?.tryRefresh) {
@@ -79,7 +56,6 @@ export const getSessionContext = async (request: Request, options?: { isServerFu
const url = new URL(request.url); const url = new URL(request.url);
const from = encodeURIComponent(url.pathname + url.search); const from = encodeURIComponent(url.pathname + url.search);
console.log('redirecting')
throw redirect({ throw redirect({
to: "/refresh-session", to: "/refresh-session",
search: { redirect: from } search: { redirect: from }
@@ -99,7 +75,7 @@ export const getSessionContext = async (request: Request, options?: { isServerFu
}; };
return context; return context;
}; });
export const superTokensRequestMiddleware = createMiddleware({ export const superTokensRequestMiddleware = createMiddleware({
type: "request", type: "request",
@@ -110,8 +86,8 @@ export const superTokensRequestMiddleware = createMiddleware({
export const superTokensFunctionMiddleware = createMiddleware({ export const superTokensFunctionMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async ({ next, response }) => { }).server(async ({ next }) => {
const request = getWebRequest(); const request = getRequest();
try { try {
const context = await getSessionContext(request, { isServerFunction: true }); const context = await getSessionContext(request, { isServerFunction: true });
@@ -136,7 +112,7 @@ export const superTokensFunctionMiddleware = createMiddleware({
export const superTokensAdminFunctionMiddleware = createMiddleware({ export const superTokensAdminFunctionMiddleware = createMiddleware({
type: "function", type: "function",
}).server(async ({ next }) => { }).server(async ({ next }) => {
const request = getWebRequest(); const request = getRequest();
try { try {
const context = await getSessionContext(request, { isServerFunction: true }); const context = await getSessionContext(request, { isServerFunction: true });
@@ -170,7 +146,7 @@ export const fetchUserRoles = async (userAuthId: string) => {
}; };
export const setUserMetadata = createServerFn({ method: "POST" }) export const setUserMetadata = createServerFn({ method: "POST" })
.validator( .inputValidator(
z z
.object({ .object({
first_name: z first_name: z
@@ -213,7 +189,7 @@ export const setUserMetadata = createServerFn({ method: "POST" })
}); });
export const updateUserColorScheme = createServerFn({ method: "POST" }) export const updateUserColorScheme = createServerFn({ method: "POST" })
.validator((data: string) => data) .inputValidator((data: string) => data)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => { .handler(async ({ context, data }) => {
const { userAuthId, metadata } = context; const { userAuthId, metadata } = context;
@@ -232,7 +208,7 @@ export const updateUserColorScheme = createServerFn({ method: "POST" })
}); });
export const updateUserAccentColor = createServerFn({ method: "POST" }) export const updateUserAccentColor = createServerFn({ method: "POST" })
.validator((data: string) => data) .inputValidator((data: string) => data)
.middleware([superTokensFunctionMiddleware]) .middleware([superTokensFunctionMiddleware])
.handler(async ({ context, data }) => { .handler(async ({ context, data }) => {
const { userAuthId, metadata } = context; const { userAuthId, metadata } = context;

View File

@@ -6,23 +6,15 @@ import react from '@vitejs/plugin-react';
export default defineConfig({ export default defineConfig({
server: { server: {
port: 3000, port: 3000,
allowedHosts: ["dev.flexxon.app"]
}, },
plugins: [ plugins: [
tsConfigPaths({ tsConfigPaths({
projects: ['./tsconfig.json'], projects: ['./tsconfig.json'],
}), }),
tanstackStart({ tanstackStart({
customViteReactPlugin: true, srcDirectory: 'src/app',
tsr: {
srcDirectory: 'src/app',
},
}), }),
react() react()
], ]
build: {
target: 'esnext',
},
ssr: {
target: 'node',
}
}) })