21 Commits

Author SHA1 Message Date
yohlo
732afaf623 changes to twilio 2025-09-29 12:51:33 -05:00
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
yohlo
94ea44c66e drawer fixes 2025-09-23 15:04:29 -05:00
yohlo
7441d1ac58 skeletons, tournament stats, polish, bug fixes 2025-09-23 14:48:04 -05:00
yohlo
7ff26229d9 dark mode default, basic tournament stats/podium 2025-09-22 19:33:58 -05:00
yohlo
b93ce38d48 play walkout songs 2025-09-22 17:57:29 -05:00
yohlo
ae934e77f4 manage team data 2025-09-22 17:24:45 -05:00
yohlo
cae5fa1c71 skeletons 2025-09-22 16:45:41 -05:00
yohlo
fc3f626313 minor cleanup 2025-09-21 11:38:10 -05:00
yohlo
1027b49258 free agents 2025-09-20 20:50:44 -05:00
135 changed files with 6239 additions and 2235 deletions

3
.gitignore vendored
View File

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

1245
bun.lock Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -32,17 +32,17 @@ services:
- app-network
restart: unless-stopped
redis:
image: redis:7-alpine
container_name: redis-cache
ports:
- "6379:6379"
command: redis-server --appendonly yes
volumes:
- redis-data:/data
networks:
- app-network
restart: unless-stopped
#redis:
# image: redis:7-alpine
# container_name: redis-cache
# ports:
# - "6379:6379"
# command: redis-server --appendonly yes
# volumes:
# - redis-data:/data
# networks:
# - app-network
# restart: unless-stopped
supertokens:
image: registry.supertokens.io/supertokens/supertokens-postgresql
@@ -51,6 +51,7 @@ services:
- postgres
environment:
POSTGRESQL_CONNECTION_URI: postgresql://supertokens:password@postgres:5432/supertokens
ACCESS_TOKEN_VALIDITY: 360000
ports:
- "3567:3567"
env_file:

View File

@@ -6,7 +6,8 @@
"scripts": {
"dev": "vite dev --host 0.0.0.0",
"build": "vite build && tsc --noEmit",
"start": "vite start"
"start": "bun run .output/server/index.mjs",
"start:node": "node .output/server/index.mjs"
},
"dependencies": {
"@hello-pangea/dnd": "^18.0.1",
@@ -24,12 +25,15 @@
"@tanstack/react-router": "^1.130.12",
"@tanstack/react-router-devtools": "^1.130.13",
"@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/react": "^3.4.3",
"@tiptap/starter-kit": "^3.4.3",
"@types/bun": "^1.2.22",
"@types/ioredis": "^4.28.10",
"browser-image-compression": "^2.0.2",
"dotenv": "^17.2.2",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.12",
"ioredis": "^5.7.0",
@@ -51,6 +55,8 @@
"zustand": "^5.0.7"
},
"devDependencies": {
"@tanstack/react-router-ssr-query": "^1.132.2",
"@tanstack/router-plugin": "^1.132.2",
"@types/node": "^22.5.4",
"@types/pg": "^8.15.5",
"@types/react": "^19.0.8",
@@ -63,7 +69,7 @@
"postcss-simple-vars": "^7.0.1",
"tsx": "^4.20.3",
"typescript": "^5.7.2",
"vite": "^6.3.5",
"vite": "^7.1.7",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

@@ -0,0 +1,85 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1579384326",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1843675174",
"max": 0,
"min": 0,
"name": "description",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_1340419796",
"indexes": [],
"listRule": null,
"name": "badges",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_1340419796");
return app.delete(collection);
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// add field
collection.fields.addAt(5, new Field({
"cascadeDelete": false,
"collectionId": "pbc_1340419796",
"hidden": false,
"id": "relation2029409178",
"maxSelect": 999,
"minSelect": 0,
"name": "badges",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// remove field
collection.fields.removeById("relation2029409178")
return app.save(collection)
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// add field
collection.fields.addAt(6, new Field({
"cascadeDelete": false,
"collectionId": "pbc_1340419796",
"hidden": false,
"id": "relation2813965191",
"maxSelect": 1,
"minSelect": 0,
"name": "featured_badge",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_3072146508")
// remove field
collection.fields.removeById("relation2813965191")
return app.save(collection)
})

View File

@@ -0,0 +1,84 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = new Collection({
"createRule": null,
"deleteRule": null,
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": false,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation2551806565",
"maxSelect": 1,
"minSelect": 0,
"name": "player",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "text1146066909",
"max": 0,
"min": 0,
"name": "phone",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"id": "pbc_2929550049",
"indexes": [],
"listRule": null,
"name": "free_agents",
"system": false,
"type": "base",
"updateRule": null,
"viewRule": null
});
return app.save(collection);
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049");
return app.delete(collection);
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// add field
collection.fields.addAt(3, new Field({
"cascadeDelete": false,
"collectionId": "pbc_340646327",
"hidden": false,
"id": "relation3177167065",
"maxSelect": 1,
"minSelect": 0,
"name": "tournament",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// remove field
collection.fields.removeById("relation3177167065")
return app.save(collection)
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// remove field
collection.fields.removeById("relation1584152981")
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_340646327")
// add field
collection.fields.addAt(11, new Field({
"cascadeDelete": false,
"collectionId": "pbc_3072146508",
"hidden": false,
"id": "relation1584152981",
"maxSelect": 999,
"minSelect": 0,
"name": "free_agents",
"presentable": false,
"required": false,
"system": false,
"type": "relation"
}))
return app.save(collection)
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// update collection data
unmarshal({
"createRule": "",
"deleteRule": "",
"listRule": "",
"updateRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// update collection data
unmarshal({
"createRule": null,
"deleteRule": null,
"listRule": null,
"updateRule": null,
"viewRule": null
}, collection)
return app.save(collection)
})

View File

@@ -0,0 +1,28 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// update collection data
unmarshal({
"createRule": null,
"deleteRule": null,
"listRule": null,
"updateRule": null,
"viewRule": null
}, collection)
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_2929550049")
// update collection data
unmarshal({
"createRule": "",
"deleteRule": "",
"listRule": "",
"updateRule": "",
"viewRule": ""
}, collection)
return app.save(collection)
})

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.
// 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 RefreshSessionRouteImport } from './routes/refresh-session'
import { Route as LogoutRouteImport } from './routes/logout'
@@ -21,27 +19,26 @@ import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/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 AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
import { Route as AuthedAdminTournamentsIdRouteImport } from './routes/_authed/admin/tournaments/$id'
import { Route as 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 { 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()
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
const RefreshSessionRoute = RefreshSessionRouteImport.update({
id: '/refresh-session',
@@ -92,6 +89,57 @@ const AuthedAdminIndexRoute = AuthedAdminIndexRouteImport.update({
path: '/',
getParentRoute: () => AuthedAdminRoute,
} 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 =
AuthedTournamentsTournamentIdRouteImport.update({
id: '/tournaments/$tournamentId',
@@ -125,77 +173,29 @@ const AuthedTournamentsIdBracketRoute =
path: '/tournaments/$id/bracket',
getParentRoute: () => AuthedRoute,
} as any)
const AuthedAdminTournamentsIdRoute =
AuthedAdminTournamentsIdRouteImport.update({
id: '/tournaments/$id',
path: '/tournaments/$id',
const AuthedAdminTournamentsIdIndexRoute =
AuthedAdminTournamentsIdIndexRouteImport.update({
id: '/tournaments/$id/',
path: '/tournaments/$id/',
getParentRoute: () => AuthedAdminRoute,
} as any)
const ApiFilesCollectionRecordIdFileRoute =
ApiFilesCollectionRecordIdFileRouteImport.update({
id: '/api/files/$collection/$recordId/$file',
path: '/api/files/$collection/$recordId/$file',
getParentRoute: () => rootRouteImport,
} as any)
const AuthedAdminTournamentsRunIdRoute =
AuthedAdminTournamentsRunIdRouteImport.update({
id: '/tournaments/run/$id',
path: '/tournaments/run/$id',
getParentRoute: () => AuthedAdminRoute,
} 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,
const AuthedAdminTournamentsIdTeamsRoute =
AuthedAdminTournamentsIdTeamsRouteImport.update({
id: '/tournaments/$id/teams',
path: '/tournaments/$id/teams',
getParentRoute: () => AuthedAdminRoute,
} as any)
export interface FileRoutesByFullPath {
@@ -210,12 +210,24 @@ export interface FileRoutesByFullPath {
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/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
'/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
}
export interface FileRoutesByTo {
'/login': typeof LoginRoute
@@ -228,12 +240,24 @@ export interface FileRoutesByTo {
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/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
'/tournaments': typeof AuthedTournamentsIndexRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
@@ -249,12 +273,24 @@ export interface FileRoutesById {
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
'/_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/tournaments/': typeof AuthedTournamentsIndexRoute
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -270,12 +306,24 @@ export interface FileRouteTypes {
| '/profile/$playerId'
| '/teams/$teamId'
| '/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/'
| '/tournaments'
| '/admin/tournaments/$id'
| '/tournaments/$id/bracket'
| '/admin/tournaments'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id'
fileRoutesByTo: FileRoutesByTo
to:
| '/login'
@@ -288,12 +336,24 @@ export interface FileRouteTypes {
| '/profile/$playerId'
| '/teams/$teamId'
| '/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'
| '/tournaments'
| '/admin/tournaments/$id'
| '/tournaments/$id/bracket'
| '/admin/tournaments'
| '/admin/tournaments/$id/teams'
| '/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/admin/tournaments/$id'
id:
| '__root__'
| '/_authed'
@@ -308,12 +368,24 @@ export interface FileRouteTypes {
| '/_authed/profile/$playerId'
| '/_authed/teams/$teamId'
| '/_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/tournaments/'
| '/_authed/admin/tournaments/$id'
| '/_authed/tournaments/$id/bracket'
| '/_authed/admin/tournaments/'
| '/_authed/admin/tournaments/$id/teams'
| '/_authed/admin/tournaments/run/$id'
| '/api/files/$collection/$recordId/$file'
| '/_authed/admin/tournaments/$id/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -321,101 +393,17 @@ export interface RootRouteChildren {
LoginRoute: typeof LoginRoute
LogoutRoute: typeof LogoutRoute
RefreshSessionRoute: typeof RefreshSessionRoute
}
export interface FileServerRoutesByFullPath {
'/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 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
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
ApiSpotifyResumeRoute: typeof ApiSpotifyResumeRoute
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
}
declare module '@tanstack/react-router' {
@@ -490,6 +478,76 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminIndexRouteImport
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': {
id: '/_authed/tournaments/$tournamentId'
path: '/tournaments/$tournamentId'
@@ -532,13 +590,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
parentRoute: typeof AuthedRoute
}
'/_authed/admin/tournaments/$id': {
id: '/_authed/admin/tournaments/$id'
'/_authed/admin/tournaments/$id/': {
id: '/_authed/admin/tournaments/$id/'
path: '/tournaments/$id'
fullPath: '/admin/tournaments/$id'
preLoaderRoute: typeof AuthedAdminTournamentsIdRouteImport
preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport
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': {
id: '/_authed/admin/tournaments/run/$id'
path: '/tournaments/run/$id'
@@ -546,86 +611,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminTournamentsRunIdRouteImport
parentRoute: typeof AuthedAdminRoute
}
}
}
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
'/_authed/admin/tournaments/$id/teams': {
id: '/_authed/admin/tournaments/$id/teams'
path: '/tournaments/$id/teams'
fullPath: '/admin/tournaments/$id/teams'
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
parentRoute: typeof AuthedAdminRoute
}
}
}
@@ -633,17 +624,19 @@ declare module '@tanstack/react-start/server' {
interface AuthedAdminRouteChildren {
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
}
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute,
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
}
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
@@ -682,24 +675,27 @@ const rootRouteChildren: RootRouteChildren = {
LoginRoute: LoginRoute,
LogoutRoute: LogoutRoute,
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
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
const rootServerRouteChildren: RootServerRouteChildren = {
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute,
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute,
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
ApiSpotifySearchServerRoute: ApiSpotifySearchServerRoute,
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute,
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
ApiFilesCollectionRecordIdFileServerRoute:
ApiFilesCollectionRecordIdFileServerRoute,
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}
export const serverRouteTree = rootServerRouteImport
._addFileChildren(rootServerRouteChildren)
._addFileTypes<FileServerRouteTypes>()

View File

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

View File

@@ -1,7 +1,3 @@
import "@mantine/core/styles.css";
import "@mantine/dates/styles.css";
import "@mantine/carousel/styles.css";
import '@mantine/tiptap/styles.css';
import {
HeadContent,
Navigate,
@@ -18,9 +14,12 @@ import Providers from "@/features/core/components/providers";
import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core";
import { HeaderConfig } from "@/features/core/types/header-config";
import { playerQueries } from "@/features/players/queries";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import FullScreenLoader from "@/components/full-screen-loader";
import mantineCssUrl from '@mantine/core/styles.css?url'
import mantineDatesCssUrl from '@mantine/dates/styles.css?url'
import mantineCarouselCssUrl from '@mantine/carousel/styles.css?url'
import mantineTiptapCssUrl from '@mantine/tiptap/styles.css?url'
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
@@ -61,6 +60,10 @@ export const Route = createRootRouteWithContext<{
},
{ rel: "manifest", href: "/site.webmanifest" },
{ rel: "icon", href: "/favicon.ico" },
{ rel: 'stylesheet', href: mantineCssUrl },
{ rel: 'stylesheet', href: mantineCarouselCssUrl },
{ rel: 'stylesheet', href: mantineDatesCssUrl },
{ rel: 'stylesheet', href: mantineTiptapCssUrl }
],
}),
errorComponent: (props) => {
@@ -80,12 +83,20 @@ export const Route = createRootRouteWithContext<{
return {};
}
// https://github.com/TanStack/router/discussions/3531
const auth = await ensureServerQueryData(
context.queryClient,
playerQueries.auth()
);
return { auth };
if (location.pathname === '/login' || location.pathname === '/logout') {
return {};
}
try {
// https://github.com/TanStack/router/discussions/3531
const auth = await ensureServerQueryData(
context.queryClient,
playerQueries.auth()
);
return { auth };
} catch (error) {
return {};
}
},
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
});
@@ -131,7 +142,6 @@ function RootDocument({ children }: { children: React.ReactNode }) {
>
<div className="app">{children}</div>
<Scripts />
<ReactQueryDevtools />
</body>
</html>
);

View File

@@ -3,7 +3,7 @@ import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTournament from "@/features/tournaments/components/manage-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
export const Route = createFileRoute("/_authed/admin/tournaments/$id")({
export const Route = createFileRoute("/_authed/admin/tournaments/$id/")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(

View File

@@ -0,0 +1,32 @@
import { createFileRoute, redirect } from "@tanstack/react-router";
import { tournamentQueries } from "@/features/tournaments/queries";
import ManageTeams from "@/features/teams/components/manage-teams";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
export const Route = createFileRoute("/_authed/admin/tournaments/$id/teams")({
beforeLoad: async ({ context, params }) => {
const { queryClient } = context;
const tournament = await ensureServerQueryData(
queryClient,
tournamentQueries.details(params.id)
);
if (!tournament) throw redirect({ to: "/admin/tournaments" });
return {
tournament,
};
},
loader: ({ context }) => ({
header: {
withBackButton: true,
title: `${context.tournament.name} Teams`,
},
withPadding: false,
}),
component: RouteComponent,
});
function RouteComponent() {
const { id } = Route.useParams();
const { tournament } = Route.useRouteContext();
return <ManageTeams tournament={tournament} />;
}

View File

@@ -3,9 +3,13 @@ import { tournamentQueries, useCurrentTournament } from "@/features/tournaments/
import UpcomingTournament from "@/features/tournaments/components/upcoming-tournament";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import StartedTournament from "@/features/tournaments/components/started-tournament";
import { Suspense } from "react";
import UpcomingTournamentSkeleton from "@/features/tournaments/components/upcoming-tournament/skeleton";
export const Route = createFileRoute("/_authed/")({
component: Home,
component: () => <Suspense fallback={<UpcomingTournamentSkeleton />}>
<Home />
</Suspense>,
beforeLoad: async ({ context }) => {
const queryClient = context.queryClient;
const tournament = await ensureServerQueryData(queryClient, tournamentQueries.current())
@@ -18,11 +22,11 @@ export const Route = createFileRoute("/_authed/")({
title: context.tournament.name || "FLXN"
}
}),
pendingComponent: () => <UpcomingTournamentSkeleton />
});
function Home() {
const { data: tournament } = useCurrentTournament();
if (!tournament.matches || tournament.matches.length === 0) {
return <UpcomingTournament tournament={tournament} />;
}

View File

@@ -1,13 +1,15 @@
import { createFileRoute } from "@tanstack/react-router";
import { playerQueries, useAllPlayerStats } from "@/features/players/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { playerQueries } from "@/features/players/queries";
import PlayerStatsTable from "@/features/players/components/player-stats-table";
import { Suspense } from "react";
import PlayerStatsTableSkeleton from "@/features/players/components/player-stats-table-skeleton";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
export const Route = createFileRoute("/_authed/stats")({
component: Stats,
beforeLoad: async ({ context }) => {
beforeLoad: ({ context }) => {
const queryClient = context.queryClient;
await ensureServerQueryData(queryClient, playerQueries.allStats());
prefetchServerQuery(queryClient, playerQueries.allStats());
},
loader: () => ({
withPadding: false,
@@ -20,7 +22,7 @@ export const Route = createFileRoute("/_authed/stats")({
});
function Stats() {
const { data: playerStats } = useAllPlayerStats();
return <PlayerStatsTable playerStats={playerStats} />;
}
return <Suspense fallback={<PlayerStatsTableSkeleton />}>
<PlayerStatsTable />
</Suspense>;
}

View File

@@ -1,8 +1,9 @@
import TeamProfile from "@/features/teams/components/team-profile";
import ProfileSkeleton from "@/features/teams/components/team-profile/skeleton";
import { teamKeys, teamQueries } from "@/features/teams/queries";
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
import { redirect, createFileRoute } from "@tanstack/react-router";
import { createFileRoute } from "@tanstack/react-router";
import { Suspense } from "react";
import { z } from "zod";
const searchSchema = z.object({
@@ -25,6 +26,8 @@ export const Route = createFileRoute("/_authed/teams/$teamId")({
}),
component: () => {
const { teamId } = Route.useParams();
return <TeamProfile id={teamId} />;
return <Suspense fallback={<ProfileSkeleton />}>
<TeamProfile id={teamId} />
</Suspense>;
},
});

View File

@@ -3,6 +3,8 @@ import { tournamentQueries } from '@/features/tournaments/queries';
import Profile from '@/features/tournaments/components/profile';
import { z } from "zod";
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
import { Suspense } from 'react';
import ProfileSkeleton from '@/features/tournaments/components/profile/skeleton';
const searchSchema = z.object({
tab: z.string().optional(),
@@ -10,9 +12,9 @@ const searchSchema = z.object({
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
validateSearch: searchSchema,
beforeLoad: async ({ context, params }) => {
beforeLoad: ({ context, params }) => {
const { queryClient } = context;
await prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
},
loader: ({ params, context }) => ({
header: {
@@ -28,5 +30,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
function RouteComponent() {
const tournamentId = Route.useParams().tournamentId;
return <Profile id={tournamentId} />
return <Suspense fallback={<ProfileSkeleton />}>
<Profile id={tournamentId} />
</Suspense>
}

View File

@@ -1,20 +1,14 @@
import Page from '@/components/page'
import { Stack } from '@mantine/core'
import { createFileRoute } from '@tanstack/react-router'
import { TournamentCard } from '@/features/tournaments/components/tournament-card'
import { tournamentQueries, useTournaments } from '@/features/tournaments/queries'
import { useAuth } from '@/contexts/auth-context'
import { useSheet } from '@/hooks/use-sheet'
import Sheet from '@/components/sheet/sheet'
import TournamentForm from '@/features/tournaments/components/tournament-form'
import { PlusIcon } from '@phosphor-icons/react'
import Button from '@/components/button'
import { tournamentQueries } from '@/features/tournaments/queries'
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'
import { Suspense } from 'react'
import TournamentCardList from '@/features/tournaments/components/tournament-card-list'
import { Skeleton, Stack } from '@mantine/core'
export const Route = createFileRoute('/_authed/tournaments/')({
beforeLoad: async ({ context }) => {
const { queryClient } = context;
await prefetchServerQuery(queryClient, tournamentQueries.list())
prefetchServerQuery(queryClient, tournamentQueries.list())
},
loader: () => ({
header: {
@@ -27,27 +21,11 @@ export const Route = createFileRoute('/_authed/tournaments/')({
})
function RouteComponent() {
const { data: tournaments } = useTournaments();
const { roles } = useAuth();
const sheet = useSheet();
return (
<Stack>
{
roles?.includes("Admin") ? (
<>
<Button leftSection={<PlusIcon />} variant='subtle' onClick={sheet.open}>Create Tournament</Button>
<Sheet {...sheet.props} title='Create Tournament'>
<TournamentForm close={sheet.close} />
</Sheet>
</>
) : null
}
{
tournaments?.map((tournament: any) => (
<TournamentCard key={tournament.id} tournament={tournament} />
))
}
</Stack>
)
return <Suspense fallback={<Stack gap="md">
{Array(10).fill(null).map((_, index) => (
<Skeleton height="120px" w="100%" />
))}
</Stack>}>
<TournamentCardList />
</Suspense>
}

View File

@@ -1,5 +1,5 @@
// 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 { ensureSuperTokensBackend } from '@/lib/supertokens/server'
@@ -12,12 +12,16 @@ const handleRequest = async ({ request }: {request: Request}) => {
console.log("Handling auth request:", request.method, request.url);
return superTokensHandler(request);
};
export const ServerRoute = createServerFileRoute('/api/auth/$').methods({
GET: handleRequest,
POST: handleRequest,
PUT: handleRequest,
DELETE: handleRequest,
PATCH: handleRequest,
OPTIONS: handleRequest,
HEAD: handleRequest,
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: handleRequest,
POST: handleRequest,
PUT: handleRequest,
DELETE: 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 { logger } from "@/lib/logger";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({
GET: ({ request, context }) => {
logger.info('ServerEvents | New connection', context?.userAuthId);
const stream = new ReadableStream({
start(controller) {
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
controller.enqueue(new TextEncoder().encode(connectMessage));
export const Route = createFileRoute("/api/events/$")({
server: {
middleware: [superTokensRequestMiddleware],
handlers: {
GET: ({ request, context }) => {
const stream = new ReadableStream({
start(controller) {
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
controller.enqueue(new TextEncoder().encode(connectMessage));
const handleEvent = (event: ServerEvent) => {
logger.info('ServerEvents | Event received', event);
const message = `data: ${JSON.stringify(event)}\n\n`;
try {
controller.enqueue(new TextEncoder().encode(message));
} catch (error) {
logger.error("ServerEvents | Error sending SSE message", error);
}
};
const handleEvent = (event: ServerEvent) => {
logger.info("ServerEvents | Event received", event);
const message = `data: ${JSON.stringify(event)}\n\n`;
try {
controller.enqueue(new TextEncoder().encode(message));
} catch (error) {
logger.error("ServerEvents | Error sending SSE message", error);
}
};
serverEvents.on("test", handleEvent);
serverEvents.on("match", handleEvent);
serverEvents.on("reaction", handleEvent);
serverEvents.on("test", handleEvent);
serverEvents.on("match", handleEvent);
serverEvents.on("reaction", handleEvent);
const pingInterval = setInterval(() => {
try {
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
controller.enqueue(new TextEncoder().encode(pingMessage));
} catch (e) {
clearInterval(pingInterval);
controller.close();
}
}, 30000);
const pingInterval = setInterval(() => {
try {
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
controller.enqueue(new TextEncoder().encode(pingMessage));
} catch (e) {
clearInterval(pingInterval);
}
}, 30000);
const cleanup = () => {
serverEvents.off("test", handleEvent);
clearInterval(pingInterval);
try {
logger.info('ServerEvents | Closing connection', context?.userAuthId);
controller.close();
} catch (e) {
logger.error('ServerEvents | Error closing controller', e);
}
};
const cleanup = () => {
serverEvents.off("test", handleEvent);
clearInterval(pingInterval);
};
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";
export const ServerRoute = createServerFileRoute("/api/files/$collection/$recordId/$file").methods({
GET: async ({ params, request }) => {
try {
const { collection, recordId, file } = params;
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://127.0.0.1:8090';
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
logger.info('File proxy', {
collection,
recordId,
file,
targetUrl: fileUrl
});
export const Route = createFileRoute(
"/api/files/$collection/$recordId/$file"
)({
server: {
handlers: {
GET: async ({ params, request }) => {
try {
const { collection, recordId, file } = params;
const pocketbaseUrl =
process.env.POCKETBASE_URL || "http://127.0.0.1:8090";
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
const response = await fetch(fileUrl, {
method: 'GET',
headers: {
...(request.headers.get('range') && { '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')! }),
},
});
logger.info("File proxy", {
collection,
recordId,
file,
targetUrl: fileUrl,
});
if (!response.ok) {
logger.error('PocketBase file request failed', {
status: response.status,
statusText: response.statusText,
url: fileUrl
});
const response = await fetch(fileUrl, {
method: "GET",
headers: {
...(request.headers.get("range") && {
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) {
return new Response('File not found', { status: 404 });
if (!response.ok) {
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}`, {
status: response.status
OPTIONS: () => {
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,145 @@
import { createServerFileRoute } from '@tanstack/react-start/server'
import { SpotifyAuth } from '@/lib/spotify/auth'
import { createFileRoute } from "@tanstack/react-router";
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!
const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
const SPOTIFY_REDIRECT_URI = process.env.VITE_SPOTIFY_REDIRECT_URI!;
export const ServerRoute = createServerFileRoute('/api/spotify/callback').methods({
GET: async ({ request }: { request: Request }) => {
const getReturnPath = (state: string | null): string => {
if (!state) return '/';
try {
const decodedState = JSON.parse(atob(state));
return decodedState.returnPath || '/';
} catch {
return '/';
}
};
export const Route = createFileRoute("/api/spotify/callback")({
server: {
handlers: {
GET: async ({ request }: { request: Request }) => {
const getReturnPath = (state: string | null): string => {
if (!state) return "/";
try {
const decodedState = JSON.parse(atob(state));
return decodedState.returnPath || "/";
} catch {
return "/";
}
};
try {
const url = new URL(request.url)
const code = url.searchParams.get('code')
const state = url.searchParams.get('state')
const error = url.searchParams.get('error')
try {
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
const returnPath = getReturnPath(state);
const returnPath = getReturnPath(state);
if (error) {
console.error('Spotify OAuth error:', error)
return new Response(null, {
status: 302,
headers: {
'Location': returnPath + '?spotify_error=' + encodeURIComponent(error),
},
})
}
if (error) {
console.error("Spotify OAuth error:", error);
return new Response(null, {
status: 302,
headers: {
Location:
returnPath + "?spotify_error=" + encodeURIComponent(error),
},
});
}
if (!code || !state) {
console.error('Missing code or state:', { code: !!code, state: !!state })
return new Response(null, {
status: 302,
headers: {
'Location': returnPath + '?spotify_error=missing_code_or_state',
},
})
}
if (!code || !state) {
console.error("Missing code or state:", {
code: !!code,
state: !!state,
});
return new Response(null, {
status: 302,
headers: {
Location: returnPath + "?spotify_error=missing_code_or_state",
},
});
}
console.log('Token exchange attempt:', {
client_id: SPOTIFY_CLIENT_ID,
redirect_uri: SPOTIFY_REDIRECT_URI,
has_code: !!code,
has_state: !!state,
})
console.log("Token exchange attempt:", {
client_id: SPOTIFY_CLIENT_ID,
redirect_uri: SPOTIFY_REDIRECT_URI,
has_code: !!code,
has_state: !!state,
});
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: SPOTIFY_REDIRECT_URI,
}),
})
const tokenResponse = await fetch(
"https://accounts.spotify.com/api/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: SPOTIFY_REDIRECT_URI,
}),
}
);
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text()
console.error('Token exchange error:', {
status: tokenResponse.status,
statusText: tokenResponse.statusText,
body: errorText,
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}`,
},
})
}
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
console.error("Token exchange error:", {
status: tokenResponse.status,
statusText: tokenResponse.statusText,
body: errorText,
redirect_uri: SPOTIFY_REDIRECT_URI,
});
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:', {
has_access_token: !!tokens.access_token,
has_refresh_token: !!tokens.refresh_token,
expires_in: tokens.expires_in,
})
const tokens = await tokenResponse.json();
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, {
status: 302,
headers: {
'Location': returnPath + '?spotify_auth=success',
},
})
console.log("Decoded return path:", returnPath);
const isSecure = process.env.NODE_ENV === 'production'
const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}`
const response = new Response(null, {
status: 302,
headers: {
Location: returnPath + "?spotify_auth=success",
},
});
response.headers.append('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`)
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}`)
}
const isSecure = import.meta.env.NODE_ENV === "production";
const cookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokens.expires_in}`;
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',
},
})
}
response.headers.append(
"Set-Cookie",
`spotify_access_token=${tokens.access_token}; ${cookieOptions}`
);
if (tokens.refresh_token) {
const refreshCookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 30}`; // 30 days
response.headers.append(
"Set-Cookie",
`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 { SpotifyWebApiClient } from '@/lib/spotify/client'
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types'
import { createFileRoute } from "@tanstack/react-router";
import { SpotifyWebApiClient } from "@/lib/spotify/client";
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
export const ServerRoute = createServerFileRoute('/api/spotify/capture').methods({
POST: async ({ request }: { request: Request }) => {
try {
// Get access token from cookies
const cookies = request.headers.get('Cookie') || ''
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/)
if (!accessTokenMatch) {
return new Response(
JSON.stringify({ error: 'No access token found' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' }
export const Route = createFileRoute("/api/spotify/capture")({
server: {
handlers: {
POST: async ({ request }: { request: Request }) => {
try {
const cookies = request.headers.get("Cookie") || "";
const accessTokenMatch = cookies.match(
/spotify_access_token=([^;]+)/
);
if (!accessTokenMatch) {
return new Response(
JSON.stringify({ error: "No access token found" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
}
)
}
const accessToken = decodeURIComponent(accessTokenMatch[1])
const spotifyClient = new SpotifyWebApiClient(accessToken)
const accessToken = decodeURIComponent(accessTokenMatch[1]);
const spotifyClient = new SpotifyWebApiClient(accessToken);
// Create a snapshot of the current playback state
const snapshot = await spotifyClient.createPlaybackSnapshot()
if (!snapshot) {
return new Response(
JSON.stringify({ error: 'No active playback to capture' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
const snapshot = await spotifyClient.createPlaybackSnapshot();
if (!snapshot) {
return new Response(
JSON.stringify({ error: "No active playback to capture" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
)
}
return new Response(
JSON.stringify({ snapshot }),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
return new Response(JSON.stringify({ snapshot }), {
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,195 +1,203 @@
import { createServerFileRoute } from '@tanstack/react-start/server'
import { SpotifyWebApiClient } from '@/lib/spotify/client'
import { createFileRoute } from "@tanstack/react-router";
import { SpotifyWebApiClient } from "@/lib/spotify/client";
// Helper function to get access token from cookies
function getAccessTokenFromCookies(request: Request): string | null {
const cookieHeader = request.headers.get('cookie')
if (!cookieHeader) return null
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) return null;
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({
POST: 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 body = await request.json()
const { action, deviceId, volumePercent } = body
const spotifyClient = new SpotifyWebApiClient(accessToken)
switch (action) {
case 'play':
await spotifyClient.play(deviceId)
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') {
export const Route = createFileRoute("/api/spotify/playback")({
server: {
handlers: {
POST: async ({ request }: { request: Request }) => {
try {
const accessToken = getAccessTokenFromCookies(request);
if (!accessToken) {
return new Response(
JSON.stringify({ error: 'volumePercent must be a number' }),
JSON.stringify({ error: "No access token found" }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
status: 401,
headers: { "Content-Type": "application/json" },
}
)
);
}
await spotifyClient.setVolume(volumePercent)
break
case 'transfer':
if (!deviceId) {
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(
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(
JSON.stringify({ error: 'deviceId is required for transfer action' }),
JSON.stringify({ error: "No access token found" }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
status: 401,
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)
// Handle specific Spotify API errors
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' },
}
)
}
const url = new URL(request.url);
const type = url.searchParams.get("type");
// Log the full error details for debugging
console.error('Full error details:', {
message: error.message,
stack: error.stack,
name: error.name,
})
}
const spotifyClient = new SpotifyWebApiClient(accessToken);
return new Response(
JSON.stringify({ error: 'Playback control failed', details: error instanceof Error ? error.message : 'Unknown error' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
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" },
}
);
}
)
}
},
},
},
// GET endpoint for retrieving current playback state and devices
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') // 'state' or 'devices'
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 {
// Return both by default
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 { SpotifyWebApiClient } from '@/lib/spotify/client'
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types'
import { createFileRoute } from "@tanstack/react-router";
import { SpotifyWebApiClient } from "@/lib/spotify/client";
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
export const ServerRoute = createServerFileRoute('/api/spotify/resume').methods({
POST: async ({ request }: { request: Request }) => {
try {
// Get access token from cookies
const cookies = request.headers.get('Cookie') || ''
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/)
if (!accessTokenMatch) {
return new Response(
JSON.stringify({ error: 'No access token found' }),
{
status: 401,
headers: { 'Content-Type': 'application/json' }
export const Route = createFileRoute("/api/spotify/resume")({
server: {
handlers: {
POST: async ({ request }: { request: Request }) => {
try {
const cookies = request.headers.get("Cookie") || "";
const accessTokenMatch = cookies.match(
/spotify_access_token=([^;]+)/
);
if (!accessTokenMatch) {
return new Response(
JSON.stringify({ error: "No access token found" }),
{
status: 401,
headers: { "Content-Type": "application/json" },
}
);
}
)
}
const accessToken = decodeURIComponent(accessTokenMatch[1])
const spotifyClient = new SpotifyWebApiClient(accessToken)
const accessToken = decodeURIComponent(accessTokenMatch[1]);
const spotifyClient = new SpotifyWebApiClient(accessToken);
// Parse the request body to get the snapshot
const body = await request.json()
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot }
if (!snapshot) {
return new Response(
JSON.stringify({ error: 'No snapshot provided' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' }
const body = await request.json();
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot };
if (!snapshot) {
return new Response(
JSON.stringify({ error: "No snapshot provided" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
)
}
// Restore the playback state from the snapshot
await spotifyClient.restorePlaybackSnapshot(snapshot)
await spotifyClient.restorePlaybackSnapshot(snapshot);
return new Response(
JSON.stringify({ success: true }),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
return new Response(JSON.stringify({ success: true }), {
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> {
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
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', {
method: 'POST',
const response = await fetch("https://accounts.spotify.com/api/token", {
method: "POST",
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
},
body: 'grant_type=client_credentials',
})
body: "grant_type=client_credentials",
});
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()
return data.access_token
const data = await response.json();
return data.access_token;
}
export const ServerRoute = createServerFileRoute('/api/spotify/search').methods({
GET: async ({ request }: { request: Request }) => {
try {
const url = new URL(request.url)
const query = url.searchParams.get('q')
export const Route = createFileRoute("/api/spotify/search")({
server: {
handlers: {
GET: async ({ request }: { request: Request }) => {
try {
const url = new URL(request.url);
const query = url.searchParams.get("q");
if (!query) {
return new Response(
JSON.stringify({ error: 'Query parameter q is required' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
if (!query) {
return new Response(
JSON.stringify({ error: "Query parameter q is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
)
}
// Get client credentials access token
const accessToken = await getClientCredentialsToken()
// Get client credentials access token
const accessToken = await getClientCredentialsToken();
// Search using Spotify API directly
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`
// Search using Spotify API directly
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`;
const searchResponse = await fetch(searchUrl, {
headers: {
'Authorization': `Bearer ${accessToken}`,
},
})
const searchResponse = await fetch(searchUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!searchResponse.ok) {
throw new Error('Spotify search request failed')
}
if (!searchResponse.ok) {
throw new Error("Spotify search request failed");
}
const searchResult = await searchResponse.json()
const searchResult = await searchResponse.json();
return new Response(
JSON.stringify({ tracks: searchResult.tracks.items }),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
return new Response(
JSON.stringify({ tracks: searchResult.tracks.items }),
{
status: 200,
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,127 +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_SECRET = process.env.SPOTIFY_CLIENT_SECRET!
const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
POST: async ({ request }: { request: Request }) => {
try {
const body = await request.json()
const { refresh_token } = body
export const Route = createFileRoute("/api/spotify/token")({
server: {
handlers: {
POST: async ({ request }: { request: Request }) => {
try {
const body = await request.json();
const { refresh_token } = body;
if (!refresh_token) {
return new Response(
JSON.stringify({ error: 'refresh_token is required' }),
{
status: 400,
headers: { 'Content-Type': 'application/json' },
if (!refresh_token) {
return new Response(
JSON.stringify({ error: "refresh_token is required" }),
{
status: 400,
headers: { "Content-Type": "application/json" },
}
);
}
)
}
// Refresh access token
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token,
}),
})
const tokenResponse = await fetch(
"https://accounts.spotify.com/api/token",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
},
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token,
}),
}
);
if (!tokenResponse.ok) {
const error = await tokenResponse.json()
console.error('Token refresh error:', error)
return new Response(
JSON.stringify({ error: 'Failed to refresh token', details: error }),
{
status: tokenResponse.status,
headers: { 'Content-Type': 'application/json' },
if (!tokenResponse.ok) {
const error = await tokenResponse.json();
console.error("Token refresh error:", error);
return new Response(
JSON.stringify({
error: "Failed to refresh token",
details: error,
}),
{
status: tokenResponse.status,
headers: { "Content-Type": "application/json" },
}
);
}
)
}
const tokens = await tokenResponse.json()
const tokens = await tokenResponse.json();
// Return new tokens
return new Response(
JSON.stringify({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
scope: tokens.scope,
token_type: tokens.token_type,
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
return new Response(
JSON.stringify({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
scope: tokens.scope,
token_type: tokens.token_type,
}),
{
status: 200,
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)
return new Response(
JSON.stringify({ error: 'Internal server error' }),
{
status: 500,
headers: { 'Content-Type': 'application/json' },
},
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" },
}
);
}
)
}
},
},
},
// 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,116 +1,148 @@
import { createServerFileRoute } from '@tanstack/react-start/server';
import { superTokensRequestMiddleware } from '@/utils/supertokens';
import { pbAdmin } from '@/lib/pocketbase/client';
import { logger } from '@/lib/logger';
import { z } from 'zod';
import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger";
import { z } from "zod";
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')
.middleware([superTokensRequestMiddleware])
.methods({
POST: async ({ request, context }) => {
try {
const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
export const Route = createFileRoute("/api/teams/upload-logo")({
server: {
middleware: [superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
try {
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 teamId = formData.get('teamId') as string;
const logoFile = formData.get('logo') as File;
const formData = await request.formData();
const teamId = formData.get("teamId") as string;
const logoFile = formData.get("logo") as File;
const validationResult = uploadSchema.safeParse({ teamId });
if (!validationResult.success) {
return new Response(JSON.stringify({
error: 'Invalid input',
details: validationResult.error.issues
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
const validationResult = uploadSchema.safeParse({ teamId });
if (!validationResult.success) {
return new Response(
JSON.stringify({
error: "Invalid input",
details: validationResult.error.issues,
}),
{
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) {
return new Response(JSON.stringify({
error: 'Logo file is required'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
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" },
}
);
}
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' }
});
}
if (!team.players.map(p => p.id).includes(context.userId) && !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 { superTokensRequestMiddleware } from '@/utils/supertokens';
import { pbAdmin } from '@/lib/pocketbase/client';
import { logger } from '@/lib/logger';
import { z } from 'zod';
import { createFileRoute } from "@tanstack/react-router";
import { superTokensRequestMiddleware } from "@/utils/supertokens";
import { pbAdmin } from "@/lib/pocketbase/client";
import { logger } from "@/lib/logger";
import { z } from "zod";
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')
.middleware([superTokensRequestMiddleware])
.methods({
POST: async ({ request, context }) => {
try {
const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
export const Route = createFileRoute("/api/tournaments/upload-logo")({
server: {
middleware: [superTokensRequestMiddleware],
handlers: {
POST: async ({ request, context }) => {
try {
const userId = context.userAuthId;
const isAdmin = context.roles.includes("Admin");
if (!userId) return new Response('Unauthenticated', { status: 401 });
if (!isAdmin) return new Response('Unauthorized', { status: 403 });
if (!userId) return new Response("Unauthenticated", { status: 401 });
if (!isAdmin) return new Response("Unauthorized", { status: 403 });
const formData = await request.formData();
const tournamentId = formData.get('tournamentId') as string;
const logoFile = formData.get('logo') as File;
const formData = await request.formData();
const tournamentId = formData.get("tournamentId") as string;
const logoFile = formData.get("logo") as File;
const validationResult = uploadSchema.safeParse({ tournamentId });
if (!validationResult.success) {
return new Response(JSON.stringify({
error: 'Invalid input',
details: validationResult.error.issues
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
const validationResult = uploadSchema.safeParse({ tournamentId });
if (!validationResult.success) {
return new Response(
JSON.stringify({
error: "Invalid input",
details: validationResult.error.issues,
}),
{
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) {
return new Response(JSON.stringify({
error: 'Logo file is required'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
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" },
}
);
}
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 { useEffect } from 'react'
import { useEffect, useRef } from 'react'
import FullScreenLoader from '@/components/full-screen-loader'
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
import { resetRefreshFlag } from '@/lib/supertokens/client'
export const Route = createFileRoute('/refresh-session')({
component: RouteComponent,
})
// https://supertokens.com/docs/additional-verification/session-verification/ssr?uiType=custom
function RouteComponent() {
const hasAttemptedRef = useRef(false);
useEffect(() => {
if (hasAttemptedRef.current) return;
hasAttemptedRef.current = true;
const handleRefresh = async () => {
try {
try {
resetRefreshFlag();
const refreshed = await attemptRefreshingSession()
if (refreshed) {
const urlParams = new URLSearchParams(window.location.search)
const redirect = urlParams.get('redirect')
const isServerFunction = redirect && (
redirect.startsWith('_serverFn') ||
redirect.startsWith('api/') ||
redirect.includes('_serverFn')
);
if (redirect && !isServerFunction) {
if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) {
window.location.href = decodeURIComponent(redirect)
} else {
const referrer = document.referrer;
const referrerUrl = referrer && !referrer.includes('/_serverFn') && !referrer.includes('/api/')
? referrer
: '/';
window.location.href = referrerUrl;
window.location.href = '/';
}
} else {
window.location.href = '/login'
@@ -42,8 +37,7 @@ function RouteComponent() {
}
}
const timeout = setTimeout(handleRefresh, 100)
return () => clearTimeout(timeout)
setTimeout(handleRefresh, 100)
}, [])
return <FullScreenLoader />

View File

@@ -7,23 +7,22 @@ import {
redirect,
} from '@tanstack/react-router'
import type { ErrorComponentProps } from '@tanstack/react-router'
import {
Box,
import {
Box,
Button as MantineButton,
Text,
Title,
Stack,
Group,
Alert,
Text,
Stack,
Group,
Collapse,
Code,
ThemeIcon
Container,
Center
} from '@mantine/core'
import { useDisclosure } from '@mantine/hooks'
import { useEffect } from 'react'
import toast from '@/lib/sonner'
import { logger } from '@/lib/logger'
import { ExclamationMarkIcon, XCircleIcon } from '@phosphor-icons/react'
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
import Button from './button'
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
@@ -50,112 +49,90 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
if (errorMessage.toLowerCase().includes('unauthorized')) {
return (
<Box
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '50vh',
padding: 'var(--mantine-spacing-xl)',
}}
>
<Stack align="center" gap="lg">
<ThemeIcon color="red" size={80} radius="xl">
<XCircleIcon size={48} />
</ThemeIcon>
<Title order={2} ta="center">Access Denied</Title>
<Text size="lg" c="dimmed" ta="center">
You don't have permission to access this.
</Text>
<Group>
<Button
variant="light"
onClick={() => window.history.back()}
>
Go Back
</Button>
<MantineButton
component={Link}
to="/"
variant="filled"
>
Home
</MantineButton>
</Group>
</Stack>
</Box>
<Container size="sm" py="xl">
<Center>
<Stack align="center" gap="md">
<XCircleIcon size={64} color="var(--mantine-color-red-6)" />
<Text size="xl" fw={600}>Access Denied</Text>
<Text c="dimmed" ta="center">
You don't have permission to access this page.
</Text>
<Group gap="sm" mt="md">
<Button
variant="light"
onClick={() => window.history.back()}
>
Go Back
</Button>
<MantineButton
component={Link}
to="/"
variant="filled"
>
Home
</MantineButton>
</Group>
</Stack>
</Center>
</Container>
)
}
return (
<Box
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '50vh',
padding: 'var(--mantine-spacing-xl)',
}}
>
<Stack align="center" gap="lg" maw={600}>
<ThemeIcon color="red" size={80} radius="xl">
<ExclamationMarkIcon size={48} />
</ThemeIcon>
<Title order={2} ta="center">Something went wrong</Title>
<Text size="lg" c="dimmed" ta="center">
There was an unexpected error. Please try again later.
</Text>
<Container size="sm" py="xl">
<Center>
<Stack align="center" gap="md" w="100%">
<WarningIcon size={64} color="var(--mantine-color-red-6)" />
<Alert
variant="light"
color="red"
title="Error Details"
w="100%"
>
<Text mb="sm">{errorMessage}</Text>
<Button
variant="subtle"
size="compact-sm"
onClick={toggleDetails}
>
{detailsOpened ? 'Hide' : 'Show'} stack trace
</Button>
<Collapse in={detailsOpened}>
<Code block mt="md" p="md">
{errorStack}
</Code>
</Collapse>
</Alert>
<Text size="xl" fw={600}>Something went wrong</Text>
<Group>
<Button
variant="light"
onClick={() => router.invalidate()}
>
Try Again
</Button>
{isRoot ? (
<MantineButton
component={Link}
to="/"
variant="filled"
>
Home
</MantineButton>
) : (
<Text c="dimmed" ta="center">
An error occurred while loading this page.
</Text>
<Box w="100%" mt="md">
<Text size="sm" c="dimmed" mb="xs">Error: {errorMessage}</Text>
<Button
variant="filled"
onClick={() => window.history.back()}
variant="subtle"
size="compact-sm"
onClick={toggleDetails}
fullWidth
>
Go Back
{detailsOpened ? 'Hide' : 'Show'} details
</Button>
)}
</Group>
</Stack>
</Box>
<Collapse in={detailsOpened}>
<Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
{errorStack}
</Code>
</Collapse>
</Box>
<Group gap="sm" mt="lg">
<Button
variant="light"
onClick={() => router.invalidate()}
>
Retry
</Button>
{isRoot ? (
<MantineButton
component={Link}
to="/"
variant="filled"
>
Home
</MantineButton>
) : (
<Button
variant="filled"
onClick={() => window.history.back()}
>
Go Back
</Button>
)}
</Group>
</Stack>
</Center>
</Container>
)
}

View File

@@ -2,7 +2,14 @@ import {
Avatar as MantineAvatar,
AvatarProps as MantineAvatarProps,
Paper,
Modal,
Image,
Group,
Text,
ActionIcon,
} from "@mantine/core";
import { useState } from "react";
import { XIcon } from "@phosphor-icons/react";
interface AvatarProps
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
@@ -10,6 +17,8 @@ interface AvatarProps
size?: number;
radius?: string | number;
withBorder?: boolean;
disableFullscreen?: boolean;
contain?: boolean;
}
const Avatar = ({
@@ -17,26 +26,122 @@ const Avatar = ({
size = 35,
radius = "100%",
withBorder = true,
disableFullscreen = false,
contain = false,
...props
}: AvatarProps) => {
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
const hasImage = Boolean(props.src);
const handleAvatarClick = () => {
if (hasImage && !disableFullscreen) {
setIsFullscreenOpen(true);
}
};
return (
<Paper p={size / 20} radius={radius} withBorder={withBorder}>
<MantineAvatar
alt={name}
key={name}
name={name}
color="initials"
size={size}
<>
<Paper
p={size / 20}
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={{
image: {
objectFit: "contain",
content: {
background: 'transparent',
border: 'none',
},
body: {
padding: 0,
},
}}
{...props}
/>
</Paper>
>
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
<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

@@ -6,14 +6,16 @@ interface ListLinkProps {
label: string;
to: string;
Icon?: Icon;
disabled?: boolean
}
const ListLink = ({ label, to, Icon }: ListLinkProps) => {
const ListLink = ({ label, to, Icon, disabled=false }: ListLinkProps) => {
const navigate = useNavigate();
return (
<>
<NavLink
disabled={disabled}
w="100%"
p="md"
component={"button"}

View File

@@ -14,6 +14,7 @@ export function RichTextEditor({
const editor = useEditor({
extensions: [StarterKit],
content: value,
immediatelyRender: false,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
},

View File

@@ -1,8 +1,7 @@
import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core";
import { PropsWithChildren, Suspense, useEffect } from "react";
import { PropsWithChildren, Suspense, useEffect, useRef } from "react";
import { Drawer as VaulDrawer } from "vaul";
import styles from "./styles.module.css";
import FullScreenLoader from "../full-screen-loader";
interface DrawerProps extends PropsWithChildren {
title?: string;
@@ -17,6 +16,7 @@ const Drawer: React.FC<DrawerProps> = ({
onChange,
}) => {
const colorScheme = useComputedColorScheme("light");
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const appElement = document.querySelector(".app") as HTMLElement;
@@ -59,11 +59,56 @@ const Drawer: React.FC<DrawerProps> = ({
};
}, [opened, colorScheme]);
useEffect(() => {
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(() => {
if (contentRef.current) {
const drawerContent = contentRef.current.closest('[data-vaul-drawer-wrapper]');
if (drawerContent) {
(drawerContent as HTMLElement).style.height = 'auto';
(drawerContent as HTMLElement).offsetHeight;
}
}
});
updateDrawerHeight();
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateDrawerHeight);
}
resizeObserver.observe(contentRef.current);
return () => {
resizeObserver.disconnect();
if (window.visualViewport) {
window.visualViewport.removeEventListener('resize', updateDrawerHeight);
}
};
}, [opened, children]);
return (
<VaulDrawer.Root open={opened} onOpenChange={onChange}>
<VaulDrawer.Root repositionInputs={false} open={opened} onOpenChange={onChange}>
<VaulDrawer.Portal>
<VaulDrawer.Overlay className={styles.drawerOverlay} />
<VaulDrawer.Content className={styles.drawerContent}>
<VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer" ref={contentRef}>
<Container flex={1} p="md">
<Box
mb="sm"
@@ -74,7 +119,7 @@ const Drawer: React.FC<DrawerProps> = ({
mr="auto"
style={{ borderRadius: "9999px" }}
/>
<Container mah="fit-content" mx="auto" maw="28rem" px={0}>
<Container mx="auto" maw="28rem" px={0}>
<VaulDrawer.Title>{title}</VaulDrawer.Title>
<Suspense fallback={
<Flex justify='center' align='center' w='100%' h={400}>

View File

@@ -2,7 +2,7 @@ import { PropsWithChildren, useCallback } from "react";
import { useIsMobile } from "@/hooks/use-is-mobile";
import Drawer from "./drawer";
import Modal from "./modal";
import { Box, ScrollArea } from "@mantine/core";
import { ScrollArea } from "@mantine/core";
interface SheetProps extends PropsWithChildren {
title?: string;
@@ -23,14 +23,14 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
onChange={onChange}
onClose={handleClose}
>
<ScrollArea
style={{ flex: 1 }}
<ScrollArea.Autosize
style={{ flex: 1, maxHeight: '75dvh' }}
scrollbarSize={8}
scrollbars="y"
type="scroll"
>
<Box mah="70vh">{children}</Box>
</ScrollArea>
{children}
</ScrollArea.Autosize>
</SheetComponent>
);
};

View File

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

View File

@@ -19,7 +19,7 @@ import {
ArrowUpIcon,
ArrowDownIcon,
} from "@phosphor-icons/react";
import { BaseStats } from "@/shared/types/stats";
import { BaseStats } from "@/types/stats";
interface StatsOverviewProps {
statsData: BaseStats | null;
@@ -50,18 +50,18 @@ const StatItem = ({
{label}
</Text>
</Group>
<Text size="sm" fw={700} c="dimmed">
{value !== null ? `${value}${suffix}` : "—"}
</Text>
{value !== null ? (
<Text size="sm" fw={700} c="dimmed">
{`${value}${suffix}`}
</Text>
) : (
<Skeleton width={20} height={20} />
)}
</Group>
);
};
const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) => {
if (isLoading || (!statsData && isLoading)) {
return <StatsSkeleton />
}
if (!statsData && !isLoading) {
return (
<Box p="sm" h="auto" mih={200}>
@@ -126,7 +126,7 @@ const StatsOverview = ({ statsData, isLoading = false }: StatsOverviewProps) =>
);
};
const StatsSkeleton = () => {
export const StatsSkeleton = () => {
const skeletonStats = [
{ label: "Matches Played", Icon: BoxingGloveIcon },
{ label: "Wins", Icon: CrownIcon },

View File

@@ -101,20 +101,23 @@ function SwipeableTabs({
useEffect(() => {
const timeoutId = setTimeout(updateHeight, 0);
return () => clearTimeout(timeoutId);
});
}, [updateHeight]);
useEffect(() => {
const activeSlideRef = slideRefs.current[activeTab];
if (!activeSlideRef) return;
let timeoutId: any;
const resizeObserver = new ResizeObserver(() => {
updateHeight();
clearTimeout(timeoutId);
timeoutId = setTimeout(updateHeight, 16);
});
resizeObserver.observe(activeSlideRef);
return () => {
resizeObserver.disconnect();
clearTimeout(timeoutId);
};
}, [activeTab, updateHeight]);

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

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

View File

@@ -165,16 +165,16 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
const play = useCallback(async (deviceId?: string) => {
if (!authState.isAuthenticated) return;
setIsLoading(true);
setError(null);
try {
await makeSpotifyRequest('playback', {
method: 'POST',
body: JSON.stringify({ action: 'play', deviceId }),
});
setTimeout(refreshPlaybackState, 500);
} catch (error) {
if (error instanceof Error && !error.message.includes('JSON')) {
@@ -186,6 +186,29 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
}
}, [authState.isAuthenticated]);
const playTrack = useCallback(async (trackId: string, deviceId?: string, positionMs?: number) => {
if (!authState.isAuthenticated) return;
setIsLoading(true);
setError(null);
try {
await makeSpotifyRequest('playback', {
method: 'POST',
body: JSON.stringify({ action: 'playTrack', trackId, deviceId, positionMs }),
});
setTimeout(refreshPlaybackState, 500);
} catch (error) {
if (error instanceof Error && !error.message.includes('JSON')) {
setError(error.message);
}
console.warn('Track playback action completed with warning:', error);
} finally {
setIsLoading(false);
}
}, [authState.isAuthenticated]);
const pause = useCallback(async () => {
if (!authState.isAuthenticated) return;
@@ -422,6 +445,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
login,
logout,
play,
playTrack,
pause,
skipNext,
skipPrevious,

View File

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

View File

@@ -5,7 +5,7 @@ import ListLink from "@/components/list-link";
const ManageTournaments = () => {
const { data: tournaments } = useTournaments();
return (
<List>
<List p="0">
{tournaments.map((t) => (
<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 { MatchCard } from "./match-card";
import { useEffect, useRef } from "react";
interface BracketProps {
rounds: Match[][];
@@ -13,33 +14,131 @@ export const Bracket: React.FC<BracketProps> = ({
orders,
showControls,
}) => {
return (
<Flex direction="row" gap={24} justify="left">
{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>
const containerRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
useEffect(() => {
const updateConnectorLines = () => {
if (!containerRef.current || !svgRef.current) return;
const svg = svgRef.current;
const container = containerRef.current;
const flexContainer = container.querySelector('.bracket-flex-container') as HTMLElement;
if (!flexContainer) return;
svg.innerHTML = '';
const flexRect = flexContainer.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
svg.style.width = `${flexContainer.scrollWidth}px`;
svg.style.height = `${flexContainer.scrollHeight}px`;
rounds.forEach((round, roundIndex) => {
if (roundIndex === rounds.length - 1) return;
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>
))}
</Flex>
);
nextMatches.forEach(nextMatch => {
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

@@ -1,8 +1,9 @@
import { ActionIcon, Card, Flex, Text, Stack, Indicator } from "@mantine/core";
import { ActionIcon, Card, Flex, Text, Indicator } from "@mantine/core";
import { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
import React, { useCallback, useMemo } from "react";
import { MatchSlot } from "./match-slot";
import { Match } from "@/features/matches/types";
import { Team } from "@/features/teams/types";
import { useSheet } from "@/hooks/use-sheet";
import { MatchForm } from "./match-form";
import Sheet from "@/components/sheet/sheet";
@@ -10,6 +11,7 @@ import { useServerMutation } from "@/lib/tanstack-query/hooks";
import { endMatch, startMatch } from "@/features/matches/server";
import { tournamentKeys } from "@/features/tournaments/queries";
import { useQueryClient } from "@tanstack/react-query";
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
interface MatchCardProps {
match: Match;
@@ -24,6 +26,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
}) => {
const queryClient = useQueryClient();
const editSheet = useSheet();
const { playTrack, pause } = useSpotifyPlayback();
const homeSlot = useMemo(
() => ({
from: orders[match.home_from_lid],
@@ -65,6 +68,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
[showControls, match.status]
);
const hasWalkoutData = showControls && match.home && match.away && 'song_id' in match.home && 'song_id' in match.away;
const start = useServerMutation({
mutationFn: startMatch,
successMessage: "Match started!",
@@ -84,19 +89,13 @@ export const MatchCard: React.FC<MatchCardProps> = ({
},
});
const handleStart = useCallback(async () => {
await start.mutate({
data: match.id,
});
}, [match]);
const handleFormSubmit = useCallback(
async (data: {
home_cups: number;
away_cups: number;
ot_count: number;
}) => {
await end.mutate({
end.mutate({
data: {
...data,
matchId: match.id,
@@ -107,12 +106,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
[match.id, editSheet]
);
const handleSpeakerClick = useCallback(() => {
if ("speechSynthesis" in window && match.home?.name && match.away?.name) {
const utterance = new SpeechSynthesisUtterance(
`${match.home.name} vs. ${match.away.name}`
);
const speak = useCallback((text: string): Promise<void> => {
return new Promise((resolve) => {
if (!("speechSynthesis" in window)) {
resolve();
return;
}
const utterance = new SpeechSynthesisUtterance(text);
const voices = window.speechSynthesis.getVoices();
const preferredVoice =
@@ -130,13 +131,83 @@ export const MatchCard: React.FC<MatchCardProps> = ({
utterance.volume = 0.8;
utterance.pitch = 1.0;
utterance.onend = () => resolve();
utterance.onerror = () => resolve();
window.speechSynthesis.speak(utterance);
});
}, []);
const playTeamWalkout = useCallback((team: Team): Promise<void> => {
return new Promise((resolve) => {
const songDuration = (team.song_end - team.song_start) * 1000;
playTrack(team.song_id, undefined, team.song_start * 1000);
setTimeout(async () => {
await pause();
resolve();
}, songDuration);
});
}, [playTrack, pause]);
const handleSpeakerClick = useCallback(async () => {
if (!hasWalkoutData || !match.home?.name || !match.away?.name) {
await speak(`${match.home?.name || "Home"} vs. ${match.away?.name || "Away"}`);
return;
}
}, [match.home?.name, match.away?.name]);
try {
const homeTeam = match.home as Team;
const awayTeam = match.away as Team;
await playTeamWalkout(homeTeam);
await speak(homeTeam.name);
await speak("versus");
await playTeamWalkout(awayTeam);
await speak(awayTeam.name);
await speak("have fun, good luck!");
} catch (error) {
console.warn('Walkout sequence error:', error);
await speak(`${match.home.name} vs. ${match.away.name}`);
}
}, [hasWalkoutData, match.home, match.away, speak, playTeamWalkout]);
const handleStart = useCallback(async () => {
start.mutate({
data: match.id,
});
// Play walkout sequence after starting the match
if (hasWalkoutData && match.home?.name && match.away?.name) {
try {
const homeTeam = match.home as Team;
const awayTeam = match.away as Team;
await playTeamWalkout(homeTeam);
await speak(homeTeam.name);
await speak("versus");
await playTeamWalkout(awayTeam);
await speak(awayTeam.name);
await speak("have fun, good luck!");
} catch (error) {
console.warn('Auto-walkout sequence error:', error);
}
}
}, [match, start, hasWalkoutData, playTeamWalkout, speak]);
return (
<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}
</Text>
<Flex align="stretch">
@@ -151,7 +222,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
w={showToolbar || showEditButton ? 200 : 220}
withBorder
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}
>
<Card.Section withBorder p={0}>
@@ -175,7 +251,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Text>
)}
{showControls && (
{showControls && match.status !== "tbd" && (
<ActionIcon
pos="absolute"
bottom={-2}
@@ -210,6 +286,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
</Flex>
)}
{showEditButton && (
<Flex direction="column" justify="center" align="center">
<ActionIcon

View File

@@ -87,7 +87,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
{match.home?.name} Cups
</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}
</Text>))
}
@@ -110,7 +110,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
{match.away?.name} Cups
</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}
</Text>))
}

View File

@@ -21,16 +21,23 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
cups,
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}
<Flex p="4px 8px" w='100%' align="center">
<Flex p="6px 10px" w='100%' align="center">
<Flex align="center" gap={4} flex={1}>
{team ? (
<>
<Text
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
truncate
style={{ minWidth: 0, flex: 1 }}
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
>
{team.name}
</Text>

View File

@@ -9,7 +9,7 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
const logger = new Logger("Bracket Generation");
export const previewBracket = createServerFn()
.validator(z.number())
.inputValidator(z.number())
.middleware([superTokensFunctionMiddleware])
.handler(async ({ data: teams }) =>
toServerResult(async () => {

View File

@@ -1,16 +1,16 @@
import { Title, AppShell, Flex } from "@mantine/core";
import { Title, AppShell, Flex, Box, Paper } from "@mantine/core";
import { HeaderConfig } from "../types/header-config";
import useRouterConfig from "../hooks/use-router-config";
import BackButton from "./back-button";
interface HeaderProps extends HeaderConfig {}
const Header = ({ collapsed, title }: HeaderProps) => {
const { header } = useRouterConfig();
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
return (
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
{ header.withBackButton && <BackButton /> }
<AppShell.Header
id='app-header'
display={collapsed ? 'none' : 'block'}
>
{ withBackButton && <BackButton /> }
<Flex justify='center' align='center' h='100%' px='md'>
<Title order={2}>{title}</Title>
</Flex>

View File

@@ -31,14 +31,14 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
pos='relative'
h='100dvh'
mah='100dvh'
style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
// style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
>
<Header {...header} />
<AppShell.Main
pos='relative'
h='100%'
mah='100%'
pb={{ base: 70, md: 0 }}
pb={{ base: 65, md: 0 }}
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
maw='100dvw'
style={{ transition: 'none', overflow: 'hidden' }}

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 { NavLink } from "./nav-link";
import { useIsMobile } from "@/hooks/use-is-mobile";
@@ -9,11 +9,17 @@ import { memo } from "react";
const Navbar = () => {
const { user, roles } = useAuth()
const isMobile = useIsMobile();
const { colorScheme } = useMantineColorScheme();
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 (
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 2rem)' shadow='sm' pos='fixed' m='1rem' 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 }}>
{links.map((link) => (
<NavLink key={link.href} {...link} />
@@ -30,9 +36,6 @@ const Navbar = () => {
))}
</Stack>
</AppShell.Section>
<AppShell.Section>
<Link to="/logout">Logout</Link>
</AppShell.Section>
</AppShell.Navbar>
}

View File

@@ -4,6 +4,7 @@ import useAppShellHeight from "@/hooks/use-appshell-height";
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import useRouterConfig from "../hooks/use-router-config";
import { useLocation } from "@tanstack/react-router";
const THRESHOLD = 80;
@@ -21,6 +22,8 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
const [scrolling, setScrolling] = useState(false);
const { refresh } = useRouterConfig();
const queryClient = useQueryClient();
const location = useLocation();
const scrollAreaRef = useRef<HTMLDivElement>(null);
const scrollY = useMemo(() => scrollPosition.y < 0 && scrolling ? Math.abs(scrollPosition.y) : 0, [scrollPosition.y, scrolling]);
@@ -79,6 +82,21 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
return () => void ac.abort();
}, []);
useEffect(() => {
const timeoutId = setTimeout(() => {
if (scrollAreaRef.current) {
const viewport = scrollAreaRef.current.querySelector('.mantine-ScrollArea-viewport') as HTMLElement;
if (viewport) {
viewport.scrollTop = 0;
viewport.scrollLeft = 0;
}
}
onScrollPositionChange({ x: 0, y: 0 });
}, 10);
return () => clearTimeout(timeoutId);
}, [location.pathname, onScrollPositionChange]);
return (
<>
@@ -103,6 +121,7 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
/>
</Flex>
<ScrollArea
ref={scrollAreaRef}
id='scroll-wrapper'
onScrollPositionChange={onScrollPositionChange}
type='never' mah='100%' h='100%'

View File

@@ -1,23 +0,0 @@
import { Alert } from "@mantine/core";
import { Info } from "@phosphor-icons/react";
import { Transition } from "@mantine/core";
import { useMemo } from "react";
const Error = ({ error }: { error?: string }) => {
const show = useMemo(() => (error ? error.length > 0 : false), [error]);
return (
<Transition
mounted={show}
transition="slide-up"
duration={400}
timingFunction="ease"
>
{(styles) => (
<Alert w='95%' color="red" icon={<Info />} style={styles}>{error}</Alert>
)}
</Transition>
)
}
export default Error;

View File

@@ -1,9 +1,10 @@
import { Text, Group, Stack, Paper, Indicator, Box } from "@mantine/core";
import { Text, Group, Stack, Paper, Indicator, Box, Tooltip } from "@mantine/core";
import { CrownIcon } from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import { Match } from "../types";
import Avatar from "@/components/avatar";
import EmojiBar from "@/features/reactions/components/emoji-bar";
import { Suspense } from "react";
interface MatchCardProps {
match: Match;
@@ -88,15 +89,28 @@ const MatchCard = ({ match }: MatchCardProps) => {
</Box>
)}
</Box>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1 }}
<Tooltip
label={match.home?.name!}
disabled={!match.home?.name}
events={{ hover: true, focus: true, touch: true }}
>
{match.home?.name!}
</Text>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
>
{match.home?.name!}
</Text>
</Tooltip>
</Group>
<Stack gap={1}>
{match.home?.players.map((p) => (
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}
</Stack>
<Text
size="xl"
fw={700}
@@ -105,13 +119,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
>
{match.home_cups}
</Text>
<Stack gap={1}>
{match.home?.players.map((p) => (
<Text size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}
</Stack>
</Group>
<Group justify="space-between" align="center">
@@ -144,15 +151,28 @@ const MatchCard = ({ match }: MatchCardProps) => {
</Box>
)}
</Box>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1 }}
<Tooltip
label={match.away?.name}
disabled={!match.away?.name}
events={{ hover: true, focus: true, touch: true }}
>
{match.away?.name}
</Text>
<Text
size="sm"
fw={600}
lineClamp={1}
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
>
{match.away?.name}
</Text>
</Tooltip>
</Group>
<Stack gap={1}>
{match.away?.players.map((p) => (
<Text key={`match-card-p-${p.id}`} size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}
</Stack>
<Text
size="xl"
fw={700}
@@ -161,13 +181,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
>
{match.away_cups}
</Text>
<Stack gap={1}>
{match.away?.players.map((p) => (
<Text size="xs" fw={600} c="dimmed" ta="right">
{p.first_name} {p.last_name}
</Text>
))}
</Stack>
</Group>
</Stack>
</Paper>
@@ -187,7 +200,9 @@ const MatchCard = ({ match }: MatchCardProps) => {
border: "1px solid var(--mantine-color-default-border)",
}}
>
<EmojiBar matchId={match.id} />
<Suspense>
<EmojiBar matchId={match.id} />
</Suspense>
</Paper>
</Box>
</Indicator>

View File

@@ -1,5 +1,4 @@
import { Stack } from "@mantine/core";
import { motion, AnimatePresence } from "framer-motion";
import { Match } from "../types";
import MatchCard from "./match-card";
@@ -18,19 +17,13 @@ const MatchList = ({ matches }: MatchListProps) => {
return (
<Stack p="md" gap="sm">
<AnimatePresence>
{filteredMatches.map((match, index) => (
<motion.div
key={`match-${match.id}-${index}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.2, delay: index * 0.01 }}
>
<MatchCard match={match} />
</motion.div>
))}
</AnimatePresence>
{filteredMatches.map((match, index) => (
<div
key={`match-${match.id}-${index}`}
>
<MatchCard match={match} />
</div>
))}
</Stack>
);
};

View File

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

View File

@@ -1,5 +1,5 @@
import { z } from "zod";
import { TeamInfo } from "../teams/types";
import { TeamInfo, Team } from "../teams/types";
import { TournamentInfo } from "../tournaments/types";
export type MatchStatus = "tbd" | "ready" | "started" | "ended";
@@ -23,8 +23,8 @@ export interface Match {
is_losers_bracket: boolean;
status: MatchStatus;
tournament: TournamentInfo;
home?: TeamInfo;
away?: TeamInfo;
home?: TeamInfo | Team;
away?: TeamInfo | Team;
created: string;
updated: string;
home_seed?: number;

View File

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

View File

@@ -0,0 +1,87 @@
import {
Stack,
Group,
Box,
Container,
Divider,
Skeleton,
} from "@mantine/core";
const PlayerListItemSkeleton = () => {
return (
<Box p="md">
<Group justify="space-between" align="center" w="100%">
<Group gap="sm" align="center">
<Skeleton height={45} circle />
<Stack gap={2}>
<Group gap='xs'>
<Skeleton height={16} width={120} />
<Skeleton height={12} width={60} />
<Skeleton height={12} width={80} />
</Group>
<Group gap="md" ta="center">
<Stack gap={0}>
<Skeleton height={10} width={25} />
<Skeleton height={10} width={30} />
</Stack>
<Stack gap={0}>
<Skeleton height={10} width={10} />
<Skeleton height={10} width={15} />
</Stack>
<Stack gap={0}>
<Skeleton height={10} width={10} />
<Skeleton height={10} width={15} />
</Stack>
<Stack gap={0}>
<Skeleton height={10} width={20} />
<Skeleton height={10} width={25} />
</Stack>
<Stack gap={0}>
<Skeleton height={10} width={25} />
<Skeleton height={10} width={20} />
</Stack>
<Stack gap={0}>
<Skeleton height={10} width={15} />
<Skeleton height={10} width={25} />
</Stack>
<Stack gap={0}>
<Skeleton height={10} width={15} />
<Skeleton height={10} width={25} />
</Stack>
</Group>
</Stack>
</Group>
</Group>
</Box>
);
};
const PlayerStatsTableSkeleton = () => {
return (
<Container size="100%" px={0}>
<Stack gap="xs">
<Box px="md" pb="xs">
<Skeleton height={40} />
</Box>
<Group px="md" justify="space-between" align="center">
<Skeleton height={12} width={100} />
<Group gap="xs">
<Skeleton height={12} width={200} />
</Group>
</Group>
<Stack>
{Array(10).fill(null).map((_, index) => (
<Box key={index}>
<PlayerListItemSkeleton />
{index < 9 && <Divider />}
</Box>
))}
</Stack>
</Stack>
</Container>
);
};
export default PlayerStatsTableSkeleton;

View File

@@ -1,4 +1,4 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useCallback, memo } from "react";
import {
Text,
TextInput,
@@ -12,7 +12,6 @@ import {
UnstyledButton,
Popover,
ActionIcon,
Skeleton,
} from "@mantine/core";
import {
MagnifyingGlassIcon,
@@ -24,10 +23,7 @@ import {
import { PlayerStats } from "../types";
import Avatar from "@/components/avatar";
import { useNavigate } from "@tanstack/react-router";
interface PlayerStatsTableProps {
playerStats: PlayerStats[];
}
import { useAllPlayerStats } from "../queries";
type SortKey = keyof PlayerStats | "mmr";
type SortDirection = "asc" | "desc";
@@ -39,33 +35,11 @@ interface SortConfig {
interface PlayerListItemProps {
stat: PlayerStats;
index: number;
onPlayerClick: (playerId: string) => void;
mmr: number;
}
const PlayerListItem = ({ stat, index, onPlayerClick }: PlayerListItemProps) => {
const calculateMMR = (stat: PlayerStats): number => {
if (stat.matches === 0) return 0;
const winScore = stat.win_percentage;
const matchConfidence = Math.min(stat.matches / 15, 1);
const avgCupsScore = Math.min(stat.avg_cups_per_match * 10, 100);
const marginScore = stat.margin_of_victory
? Math.min(stat.margin_of_victory * 20, 50)
: 0;
const volumeBonus = Math.min(stat.matches * 0.5, 10);
const baseMMR =
winScore * 0.5 +
avgCupsScore * 0.25 +
marginScore * 0.15 +
volumeBonus * 0.1;
const finalMMR = baseMMR * matchConfidence;
return Math.round(finalMMR * 10) / 10;
};
const mmr = calculateMMR(stat);
const PlayerListItem = memo(({ stat, onPlayerClick, mmr }: PlayerListItemProps) => {
return (
<>
@@ -165,9 +139,12 @@ const PlayerListItem = ({ stat, index, onPlayerClick }: PlayerListItemProps) =>
</UnstyledButton>
</>
);
};
});
const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
PlayerListItem.displayName = 'PlayerListItem';
const PlayerStatsTable = () => {
const { data: playerStats } = useAllPlayerStats();
const navigate = useNavigate();
const [search, setSearch] = useState("");
const [sortConfig, setSortConfig] = useState<SortConfig>({
@@ -196,8 +173,15 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
return Math.round(finalMMR * 10) / 10;
};
const statsWithMMR = useMemo(() => {
return playerStats.map((stat) => ({
...stat,
mmr: calculateMMR(stat),
}));
}, [playerStats]);
const filteredAndSortedStats = useMemo(() => {
let filtered = playerStats.filter((stat) =>
let filtered = statsWithMMR.filter((stat) =>
stat.player_name.toLowerCase().includes(search.toLowerCase())
);
@@ -206,8 +190,8 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
let bValue: number | string;
if (sortConfig.key === "mmr") {
aValue = calculateMMR(a);
bValue = calculateMMR(b);
aValue = a.mmr;
bValue = b.mmr;
} else {
aValue = a[sortConfig.key];
bValue = b[sortConfig.key];
@@ -227,11 +211,11 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
return 0;
});
}, [playerStats, search, sortConfig]);
}, [statsWithMMR, search, sortConfig]);
const handlePlayerClick = (playerId: string) => {
const handlePlayerClick = useCallback((playerId: string) => {
navigate({ to: `/profile/${playerId}` });
};
}, [navigate]);
const handleSort = (key: SortKey) => {
setSortConfig((prev) => ({
@@ -351,8 +335,8 @@ const PlayerStatsTable = ({ playerStats }: PlayerStatsTableProps) => {
<Box key={stat.id}>
<PlayerListItem
stat={stat}
index={index}
onPlayerClick={handlePlayerClick}
mmr={stat.mmr}
/>
{index < filteredAndSortedStats.length - 1 && <Divider />}
</Box>

View File

@@ -2,7 +2,7 @@ import { Flex, Skeleton } from "@mantine/core";
const HeaderSkeleton = () => {
return (
<Flex h="10vh" px='xl' w='100%' align='self-end' gap='md'>
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
<Skeleton opacity={0} height={100} width={100} radius="50%" />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Skeleton height={24} width={200} />

View File

@@ -33,7 +33,7 @@ const Header = ({ player }: HeaderProps) => {
return (
<>
<Flex h="10vh" px='xl' w='100%' align='self-end' gap='md'>
<Flex h="15dvh" px='xl' w='100%' align='self-end' gap='md'>
<Avatar name={name} size={100} />
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>

View File

@@ -3,7 +3,7 @@ import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "@/shared/components/stats-overview";
import StatsOverview from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list";
interface ProfileProps {

View File

@@ -3,7 +3,7 @@ import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs";
import { usePlayer, usePlayerMatches, usePlayerStats } from "../../queries";
import TeamList from "@/features/teams/components/team-list";
import StatsOverview from "@/shared/components/stats-overview";
import StatsOverview, { StatsSkeleton } from "@/components/stats-overview";
import MatchList from "@/features/matches/components/match-list";
import HeaderSkeleton from "./header-skeleton";
@@ -17,7 +17,7 @@ const ProfileSkeleton = () => {
const tabs = [
{
label: "Overview",
content: <SkeletonLoader />,
content: <StatsSkeleton />,
},
{
label: "Matches",

View File

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

View File

@@ -58,15 +58,12 @@ const EmojiBar = ({
return reaction.players.map(p => p.id).includes(user?.id || "");
}, [user?.id]);
// Get emojis the current user has reacted to
const userReactions = reactions?.filter(r => hasReacted(r)).map(r => r.emoji) || [];
if (!reactions) return;
// Sort reactions by count (descending)
const sortedReactions = [...reactions].sort((a, b) => b.count - a.count);
// Group reactions: show first 3, group the rest
const visibleReactions = sortedReactions.slice(0, 3);
const groupedReactions = sortedReactions.slice(3);
@@ -81,8 +78,7 @@ const EmojiBar = ({
{visibleReactions.map((reaction) => (
<Button
key={reaction.emoji}
variant={hasReacted(reaction) ? "filled" : "light"}
color="gray"
variant={"light"}
bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs"
radius="xl"
@@ -112,8 +108,7 @@ const EmojiBar = ({
{hasGrouped && (
<Button
variant={userHasReactedToGrouped ? "filled" : "light"}
color="gray"
variant={"light"}
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
size="compact-xs"
radius="xl"

View File

@@ -1,4 +1,4 @@
import { useServerQuery, useServerMutation } from "@/lib/tanstack-query/hooks";
import { useServerMutation, useServerSuspenseQuery } from "@/lib/tanstack-query/hooks";
import { getMatchReactions, toggleMatchReaction } from "@/features/matches/server";
import { useQueryClient } from "@tanstack/react-query";
@@ -14,7 +14,7 @@ export const reactionQueries = {
};
export const useMatchReactions = (matchId: string) =>
useServerQuery(reactionQueries.match(matchId));
useServerSuspenseQuery(reactionQueries.match(matchId));
export const useToggleMatchReaction = (matchId: string) => {
const queryClient = useQueryClient();
@@ -27,4 +27,4 @@ export const useToggleMatchReaction = (matchId: string) => {
});
},
});
};
};

View File

@@ -0,0 +1,165 @@
import { useState, useMemo } from "react";
import {
Text,
TextInput,
Stack,
Container,
Box,
ThemeIcon,
Title,
} from "@mantine/core";
import {
MagnifyingGlassIcon,
UsersIcon,
} from "@phosphor-icons/react";
import { Tournament } from "@/features/tournaments/types";
import TeamList from "./team-list";
import Sheet from "@/components/sheet/sheet";
import TeamForm from "./team-form";
import { useSheet } from "@/hooks/use-sheet";
import { useTeam } from "../queries";
interface TeamEditSheetProps {
teamId: string;
isOpen: boolean;
onClose: () => void;
}
const TeamEditSheet = ({ teamId, isOpen, onClose }: TeamEditSheetProps) => {
const { data: team } = useTeam(teamId);
return (
<Sheet
title={team ? `Edit ${team.name}` : "Edit Team"}
opened={isOpen}
onChange={onClose}
>
{team && (
<TeamForm
teamId={team.id}
initialValues={{
...team,
players: team.players ? team.players.map((p) => p.id) : [],
logo: typeof team.logo === "string" ? undefined : team.logo,
}}
close={onClose}
/>
)}
</Sheet>
);
};
interface ManageTeamsProps {
tournament: Tournament;
}
const ManageTeams = ({ tournament }: ManageTeamsProps) => {
const [search, setSearch] = useState("");
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const {
isOpen: editTeamOpened,
open: openEditTeam,
close: closeEditTeam,
} = useSheet();
const teams = tournament.teams || [];
const filteredTeams = useMemo(() => {
if (!search.trim()) return teams;
const searchLower = search.toLowerCase();
return teams.filter((team) => {
if (team.name.toLowerCase().includes(searchLower)) {
return true;
}
if (team.players) {
return team.players.some((player) => {
const firstName = player.first_name?.toLowerCase() || "";
const lastName = player.last_name?.toLowerCase() || "";
const fullName = `${firstName} ${lastName}`.toLowerCase();
return fullName.includes(searchLower) ||
firstName.includes(searchLower) ||
lastName.includes(searchLower);
});
}
return false;
});
}, [teams, search]);
const handleTeamClick = (teamId: string) => {
setSelectedTeamId(teamId);
openEditTeam();
};
const handleCloseEditTeam = () => {
setSelectedTeamId(null);
closeEditTeam();
};
if (!teams.length) {
return (
<Container px={0} size="md">
<Stack align="center" gap="md" py="xl">
<ThemeIcon size="xl" variant="light" radius="md">
<UsersIcon size={32} />
</ThemeIcon>
<Title order={3} c="dimmed">
No Teams Enrolled
</Title>
<Text c="dimmed" ta="center">
This tournament has no enrolled teams yet.
</Text>
</Stack>
</Container>
);
}
return (
<>
<Container size="100%" px={0}>
<Stack gap="xs">
<TextInput
placeholder="Search teams by name or player..."
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
leftSection={<MagnifyingGlassIcon size={16} />}
size="md"
px="md"
/>
<Box px="md">
<Text size="xs" c="dimmed">
{filteredTeams.length} of {teams.length} teams
</Text>
</Box>
<TeamList
teams={filteredTeams}
onTeamClick={handleTeamClick}
/>
{filteredTeams.length === 0 && search && (
<Text ta="center" c="dimmed" py="xl">
No teams found matching "{search}"
</Text>
)}
</Stack>
</Container>
{selectedTeamId && (
<TeamEditSheet
teamId={selectedTeamId}
isOpen={editTeamOpened}
onClose={handleCloseEditTeam}
/>
)}
</>
);
};
export default ManageTeams;

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from "react";
import { Stack, Text, Group, RangeSlider, Divider } from "@mantine/core";
import { Stack, Text, Group, TextInput, Button } from "@mantine/core";
interface DurationPickerProps {
songDurationMs: number;
@@ -9,6 +9,41 @@ interface DurationPickerProps {
disabled?: boolean;
}
interface IncrementButtonsProps {
onAdjust: (seconds: number) => void;
disabled: boolean;
isPositive?: boolean;
}
const IncrementButtons = ({ onAdjust, disabled, isPositive = true }: IncrementButtonsProps) => {
const increments = [1, 5, 30, 60];
const labels = ["1s", "5s", "30s", "1m"];
return (
<Group gap={3} wrap="nowrap" flex={1}>
{increments.map((increment, index) => (
<Button
key={increment}
variant={isPositive ? "light" : "outline"}
color={isPositive ? "blue" : "gray"}
size="xs"
disabled={disabled}
onClick={() => onAdjust(isPositive ? increment : -increment)}
flex={1}
h={24}
style={{
fontSize: '10px',
fontWeight: 500,
minWidth: 0
}}
>
{isPositive ? '+' : '-'}{labels[index]}
</Button>
))}
</Group>
);
};
const DurationPicker = ({
songDurationMs,
initialStart = 0,
@@ -17,11 +52,6 @@ const DurationPicker = ({
disabled = false,
}: DurationPickerProps) => {
const songDurationSeconds = Math.floor(songDurationMs / 1000);
const [range, setRange] = useState<[number, number]>([
initialStart,
initialEnd,
]);
const [isValid, setIsValid] = useState(true);
const formatTime = useCallback((seconds: number) => {
const minutes = Math.floor(seconds / 60);
@@ -29,7 +59,26 @@ const DurationPicker = ({
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
}, []);
const validateRange = useCallback(
const [startTime, setStartTime] = useState(initialStart);
const [endTime, setEndTime] = useState(initialEnd);
const [isValid, setIsValid] = useState(true);
const [startInputValue, setStartInputValue] = useState(formatTime(initialStart));
const [endInputValue, setEndInputValue] = useState(formatTime(initialEnd));
const parseTimeInput = useCallback((input: string): number | null => {
if (input.includes(':')) {
const parts = input.split(':');
if (parts.length === 2) {
const minutes = parseInt(parts[0]) || 0;
const seconds = parseInt(parts[1]) || 0;
return minutes * 60 + seconds;
}
}
const parsed = parseInt(input);
return isNaN(parsed) ? null : parsed;
}, []);
const validateTimes = useCallback(
(start: number, end: number) => {
const duration = end - start;
const withinBounds = start >= 0 && end <= songDurationSeconds;
@@ -53,146 +102,150 @@ const DurationPicker = ({
return null;
}, [songDurationSeconds]);
const handleRangeChange = useCallback(
(newRange: [number, number]) => {
setRange(newRange);
const [start, end] = newRange;
const valid = validateRange(start, end);
setIsValid(valid);
const updateTimes = useCallback((newStart: number, newEnd: number) => {
const clampedStart = Math.max(0, Math.min(newStart, songDurationSeconds - 10));
const clampedEnd = Math.min(songDurationSeconds, Math.max(newEnd, clampedStart + 10));
if (valid) {
onChange(start, end);
}
},
[onChange, validateRange]
);
setStartTime(clampedStart);
setEndTime(clampedEnd);
setStartInputValue(formatTime(clampedStart));
setEndInputValue(formatTime(clampedEnd));
const handleRangeChangeEnd = useCallback(
(newRange: [number, number]) => {
let [start, end] = newRange;
let duration = end - start;
const valid = validateTimes(clampedStart, clampedEnd);
setIsValid(valid);
if (duration < 10) {
if (start < songDurationSeconds / 2) {
end = Math.min(start + 10, songDurationSeconds);
} else {
start = Math.max(end - 10, 0);
}
duration = end - start;
}
if (valid) {
onChange(clampedStart, clampedEnd);
}
}, [songDurationSeconds, validateTimes, onChange, formatTime]);
if (duration > 15) {
const startDiff = Math.abs(start - range[0]);
const endDiff = Math.abs(end - range[1]);
const handleStartInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setStartInputValue(event.target.value);
}, []);
if (startDiff > endDiff) {
end = start + 15;
if (end > songDurationSeconds) {
end = songDurationSeconds;
start = end - 15;
}
} else {
start = end - 15;
if (start < 0) {
start = 0;
end = start + 15;
}
}
}
const handleEndInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setEndInputValue(event.target.value);
}, []);
start = Math.max(0, start);
end = Math.min(songDurationSeconds, end);
const handleStartBlur = useCallback(() => {
const parsed = parseTimeInput(startInputValue);
if (parsed !== null) {
updateTimes(parsed, endTime);
} else {
setStartInputValue(formatTime(startTime));
}
}, [startInputValue, endTime, updateTimes, parseTimeInput, formatTime, startTime]);
const finalRange: [number, number] = [start, end];
setRange(finalRange);
setIsValid(validateRange(start, end));
onChange(start, end);
},
[range, songDurationSeconds, onChange, validateRange]
);
const handleEndBlur = useCallback(() => {
const parsed = parseTimeInput(endInputValue);
if (parsed !== null) {
updateTimes(startTime, parsed);
} else {
setEndInputValue(formatTime(endTime));
}
}, [endInputValue, startTime, updateTimes, parseTimeInput, formatTime, endTime]);
const adjustStartTime = useCallback((seconds: number) => {
updateTimes(startTime + seconds, endTime);
}, [startTime, endTime, updateTimes]);
const adjustEndTime = useCallback((seconds: number) => {
updateTimes(startTime, endTime + seconds);
}, [startTime, endTime, updateTimes]);
useEffect(() => {
if (!validateRange(initialStart, initialEnd)) {
if (!validateTimes(initialStart, initialEnd)) {
const defaultStart = Math.min(30, Math.max(0, songDurationSeconds - 15));
const defaultEnd = Math.min(defaultStart + 15, songDurationSeconds);
const defaultRange: [number, number] = [defaultStart, defaultEnd];
setRange(defaultRange);
onChange(defaultStart, defaultEnd);
updateTimes(defaultStart, defaultEnd);
}
}, [initialStart, initialEnd, songDurationSeconds, validateRange, onChange]);
}, [initialStart, initialEnd, songDurationSeconds, validateTimes, updateTimes]);
const segmentDuration = range[1] - range[0];
const segmentDuration = endTime - startTime;
return (
<Stack gap="md" opacity={disabled ? 0.5 : 1}>
<div>
<Text size="sm" fw={500} mb="xs" c={disabled ? "dimmed" : undefined}>
Start and End
</Text>
<Text size="xs" c="dimmed" mb="md">
{disabled ? "Select a song to choose segment timing" : "Choose a 10-15 second segment for your walkout song"}
</Text>
</div>
<Stack gap="sm" opacity={disabled ? 0.5 : 1}>
<Text size="sm" fw={500} c={disabled ? "dimmed" : undefined} ta="center">
Walkout Segment ({segmentDuration}s)
</Text>
<RangeSlider
min={0}
max={songDurationSeconds}
step={1}
value={range}
onChange={disabled ? undefined : handleRangeChange}
onChangeEnd={disabled ? undefined : handleRangeChangeEnd}
marks={[
{ value: 0, label: "0:00" },
{
value: songDurationSeconds,
label: formatTime(songDurationSeconds),
},
]}
size="lg"
m='xs'
color={disabled ? "gray" : (isValid ? "blue" : "red")}
thumbSize={20}
label={disabled ? undefined : (value) => formatTime(value)}
disabled={disabled}
styles={{
track: { height: 8 },
}}
/>
<Divider />
<Group justify="space-between" align="center">
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
Start
</Text>
<Text size="sm" fw={500}>
{formatTime(range[0])}
</Text>
<Stack gap="sm">
<Stack gap={4}>
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
Start
</Text>
<TextInput
value={startInputValue}
onChange={handleStartInputChange}
onBlur={handleStartBlur}
disabled={disabled}
size="xs"
w={70}
placeholder="0:00"
ta="center"
styles={{
input: {
fontWeight: 600,
fontSize: '12px'
}
}}
/>
</Group>
<Group gap={4}>
<IncrementButtons
onAdjust={adjustStartTime}
disabled={disabled || startTime <= 0}
isPositive={false}
/>
<IncrementButtons
onAdjust={adjustStartTime}
disabled={disabled || startTime >= songDurationSeconds - 10}
isPositive={true}
/>
</Group>
</Stack>
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
Duration
</Text>
<Text size="sm" fw={500} c={isValid ? undefined : "red"}>
{segmentDuration}s
</Text>
<Stack gap={4}>
<Group justify="space-between" align="center">
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
End
</Text>
<TextInput
value={endInputValue}
onChange={handleEndInputChange}
onBlur={handleEndBlur}
disabled={disabled}
size="xs"
w={70}
placeholder="0:15"
ta="center"
styles={{
input: {
fontWeight: 600,
fontSize: '12px'
}
}}
/>
</Group>
<Group gap={4}>
<IncrementButtons
onAdjust={adjustEndTime}
disabled={disabled || endTime <= startTime + 10}
isPositive={false}
/>
<IncrementButtons
onAdjust={adjustEndTime}
disabled={disabled || endTime >= songDurationSeconds}
isPositive={true}
/>
</Group>
</Stack>
<Stack gap={2} align="center">
<Text size="xs" c="dimmed">
End
</Text>
<Text size="sm" fw={500}>
{formatTime(range[1])}
</Text>
</Stack>
</Group>
</Stack>
{!isValid && (
<Text size="xs" c="red" ta="center">
{getValidationMessage(range[0], range[1])}
{getValidationMessage(startTime, endTime)}
</Text>
)}
</Stack>

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 { 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 useCreateTeam from "../../hooks/use-create-team";
import useUpdateTeam from "../../hooks/use-update-team";
@@ -13,8 +13,8 @@ import { useCallback } from "react";
import { TeamInput } from "../../types";
import { teamKeys } from "../../queries";
import SongPicker from "./song-picker";
import TeamColorPicker from "./color-picker";
import PlayersPicker from "./players-picker";
import imageCompression from "browser-image-compression";
interface TeamFormProps {
close: () => void;
@@ -113,9 +113,32 @@ const TeamForm = ({
if (logo && team) {
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();
formData.append("teamId", team.id);
formData.append("logo", logo);
formData.append("logo", processedLogo);
const response = await fetch("/api/teams/upload-logo", {
method: "POST",

View File

@@ -8,6 +8,7 @@ import SongSearch from "./song-search";
import DurationPicker from "./duration-picker";
import SongSummary from "./song-summary";
import { MusicNote } from "@phosphor-icons/react/dist/ssr";
import { MusicNoteIcon } from "@phosphor-icons/react";
interface Song {
song_id: string;
@@ -17,6 +18,7 @@ interface Song {
song_start?: number;
song_end?: number;
song_image_url: string;
duration_ms?: number;
}
interface SongPickerProps {
@@ -61,7 +63,7 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
}}
error={error}
Component={SongPickerComponent}
componentProps={{ formValues: form.getValues() }}
componentProps={{}}
title={"Select Song"}
label={"Walkout Song"}
placeholder={"Select your walkout song"}
@@ -72,10 +74,9 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
interface SongPickerComponentProps {
value: Song | undefined;
onChange: (song: Song) => void;
formValues: any;
}
const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerComponentProps) => {
const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps) => {
const handleSongSelect = (track: SpotifyTrack) => {
const defaultStart = 0;
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
@@ -88,6 +89,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
song_image_url: track.album.images[0]?.url || '',
song_start: defaultStart,
song_end: defaultEnd,
duration_ms: track.duration_ms,
};
onChange(newSong);
@@ -117,7 +119,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
radius="md"
bg="transparent"
>
{!song?.song_image_url && <MusicNote size={24} color="var(--mantine-color-dimmed)" />}
{!song?.song_image_url && <MusicNoteIcon size={24} color="var(--mantine-color-dimmed)" />}
</Avatar>
<div>
<Text size="sm" fw={500} c={song?.song_name ? undefined : "dimmed"}>
@@ -134,7 +136,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
<Stack gap="xs">
<DurationPicker
songDurationMs={180000}
songDurationMs={song?.duration_ms || 180000}
initialStart={song?.song_start || 0}
initialEnd={song?.song_end || 15}
onChange={handleDurationChange}

View File

@@ -1,7 +1,6 @@
import { useState } from "react";
import { Text, Combobox, InputBase, useCombobox, Group, Avatar, Loader } from "@mantine/core";
import { Text, Group, Avatar, Box } from "@mantine/core";
import { SpotifyTrack } from "@/lib/spotify/types";
import { useDebouncedCallback } from "@mantine/hooks";
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
interface SongSearchProps {
onChange: (track: SpotifyTrack) => void;
@@ -9,14 +8,7 @@ interface SongSearchProps {
}
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
const [isLoading, setIsLoading] = useState(false);
const combobox = useCombobox();
// Standalone search function that doesn't require Spotify context
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
if (!query.trim()) return [];
try {
@@ -27,95 +19,62 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
}
const data = await response.json();
return data.tracks || [];
const tracks = data.tracks || [];
return tracks.map((track: SpotifyTrack) => ({
id: track.id,
data: track
}));
} catch (error) {
console.error('Failed to search tracks:', error);
return [];
}
};
const debouncedSearch = useDebouncedCallback(async (query: string) => {
if (!query.trim()) {
setSearchResults([]);
return;
}
setIsLoading(true);
try {
const results = await searchSpotifyTracks(query);
setSearchResults(results);
combobox.openDropdown();
} catch (error) {
console.error('Search failed:', error);
setSearchResults([]);
} finally {
setIsLoading(false);
}
}, 300);
const handleSearchChange = (value: string) => {
setSearchQuery(value);
debouncedSearch(value);
const handleSongSelect = (option: TypeaheadOption<SpotifyTrack>) => {
onChange(option.data);
};
const handleSongSelect = (track: SpotifyTrack) => {
onChange(track);
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
combobox.closeDropdown();
const formatTrack = (option: TypeaheadOption<SpotifyTrack>) => {
const track = option.data;
return `${track.name} - ${track.artists.map(a => a.name).join(', ')}`;
};
const options = searchResults.map((track) => (
<Combobox.Option value={track.id} key={track.id}>
<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>
</Combobox.Option>
));
const renderOption = (option: TypeaheadOption<SpotifyTrack>) => {
const track = option.data;
return (
<Box
p="sm"
style={{
borderBottom: '1px solid var(--mantine-color-dimmed)'
}}
>
<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>
);
};
return (
<Combobox
store={combobox}
onOptionSubmit={(value) => {
const track = searchResults.find(t => t.id === value);
if (track) handleSongSelect(track);
}}
width='100%'
zIndex={9999}
withinPortal={false}
>
<Combobox.Target>
<InputBase
rightSection={isLoading ? <Loader size="xs" /> : <Combobox.Chevron />}
value={searchQuery}
onChange={(event) => handleSearchChange(event.currentTarget.value)}
onClick={() => combobox.openDropdown()}
onFocus={() => combobox.openDropdown()}
onBlur={() => combobox.closeDropdown()}
placeholder={placeholder}
/>
</Combobox.Target>
<Combobox.Dropdown>
<Combobox.Options>
{options.length > 0 ? options :
<Combobox.Empty>
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
</Combobox.Empty>
}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
<Typeahead
onSelect={handleSongSelect}
searchFn={searchSpotifyTracks}
renderOption={renderOption}
format={formatTrack}
placeholder={placeholder}
/>
)
};
export default SongSearch;

View File

@@ -39,19 +39,26 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
interface TeamListProps {
teams: TeamInfo[];
loading?: boolean;
onTeamClick?: (teamId: string) => void;
}
const TeamList = ({ teams, loading = false }: TeamListProps) => {
const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
const navigate = useNavigate();
const handleClick = useCallback(
(teamId: string) => navigate({ to: `/teams/${teamId}` }),
[navigate]
(teamId: string) => {
if (onTeamClick) {
onTeamClick(teamId);
} else {
navigate({ to: `/teams/${teamId}` });
}
},
[navigate, onTeamClick]
);
if (loading)
return (
<List>
<List p="0">
{Array.from({ length: 10 }).map((_, i) => (
<ListItem
key={`skeleton-${i}`}
@@ -65,10 +72,11 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => {
);
return (
<List>
<List p="0">
{teams?.map((team) => (
<div key={team.id}>
<ListItem
key={`team-list-${team.id}`}
p="xs"
icon={
<Avatar

View File

@@ -0,0 +1,14 @@
import { Flex, Skeleton } from "@mantine/core";
const HeaderSkeleton = () => {
return (
<Flex h="20dvh" px='xl' w='100%' align='flex-end' gap='md'>
<Skeleton opacity={0} height={200} width={150} />
<Flex align='center' justify='center' gap={4} w='100%'>
<Skeleton height={36} width={200} />
</Flex>
</Flex>
);
};
export default HeaderSkeleton;

View File

@@ -11,7 +11,7 @@ interface HeaderProps {
const Header = ({ name, logo, id }: HeaderProps) => {
return (
<>
<Flex px="xl" w="100%" align="self-end" gap="md">
<Flex h="20dvh" px="xl" w="100%" align="self-end" gap="md">
<Avatar
radius="sm"
name={name}

View File

@@ -2,7 +2,7 @@ import { Box, Divider, Text, Stack } from "@mantine/core";
import Header from "./header";
import SwipeableTabs from "@/components/swipeable-tabs";
import TournamentList from "@/features/tournaments/components/tournament-list";
import StatsOverview from "@/shared/components/stats-overview";
import StatsOverview from "@/components/stats-overview";
import { useTeam, useTeamMatches, useTeamStats } from "../../queries";
import MatchList from "@/features/matches/components/match-list";
import PlayerList from "@/features/players/components/player-list";

View File

@@ -0,0 +1,37 @@
import { Box, Flex, Loader } from "@mantine/core";
import SwipeableTabs from "@/components/swipeable-tabs";
import HeaderSkeleton from "./header-skeleton";
const SkeletonLoader = () => (
<Flex h="30vh" w="100%" align="center" justify="center">
<Loader />
</Flex>
)
const ProfileSkeleton = () => {
const tabs = [
{
label: "Overview",
content: <SkeletonLoader />,
},
{
label: "Matches",
content: <SkeletonLoader />,
},
{
label: "Tournaments",
content: <SkeletonLoader />,
},
];
return (
<>
<HeaderSkeleton />
<Box mt="lg">
<SwipeableTabs tabs={tabs} />
</Box>
</>
);
};
export default ProfileSkeleton;

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import {
PencilLineIcon,
TreeStructureIcon,
UsersThreeIcon,
UsersIcon,
} from "@phosphor-icons/react";
import { useSheet } from "@/hooks/use-sheet";
import EditEnrolledTeams from "./edit-enrolled-teams";
@@ -44,7 +45,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
return (
<>
<List>
<List p="0">
<ListButton
label="Edit Tournament"
Icon={HardDrivesIcon}
@@ -56,10 +57,15 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
onClick={openEditRules}
/>
<ListButton
label="Edit Enrolled Teams"
label="Edit Enrollments"
Icon={UsersThreeIcon}
onClick={openEditTeams}
/>
<ListLink
label="Manage Team Songs/Logos"
Icon={UsersIcon}
to={`/admin/tournaments/${tournamentId}/teams`}
/>
<ListLink
label="Run Tournament"
Icon={TreeStructureIcon}

View File

@@ -0,0 +1,14 @@
import { Flex, Skeleton } from "@mantine/core";
const HeaderSkeleton = () => {
return (
<Flex h="20dvh" px='xl' w='100%' align='flex-end' gap='md'>
<Skeleton opacity={0} height={150} width={150} />
<Flex align='center' justify='center' gap={4} w='100%'>
<Skeleton height={36} width={200} />
</Flex>
</Flex>
);
};
export default HeaderSkeleton;

View File

@@ -10,8 +10,8 @@ const Header = ({ tournament }: HeaderProps) => {
return (
<>
<Flex 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}`} />
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
<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%'>
<Title ta='center' order={2}>{tournament.name}</Title>
</Flex>

View File

@@ -1,9 +1,11 @@
import { Box, Text } from "@mantine/core";
import { useMemo } from "react";
import { Box } from "@mantine/core";
import Header from "./header";
import TeamList from "@/features/teams/components/team-list";
import SwipeableTabs from "@/components/swipeable-tabs";
import { useTournament } from "../../queries";
import MatchList from "@/features/matches/components/match-list";
import { TournamentStats } from "../tournament-stats";
interface ProfileProps {
id: string;
@@ -13,22 +15,22 @@ const Profile = ({ id }: ProfileProps) => {
const { data: tournament } = useTournament(id);
if (!tournament) return null;
const tabs = [
const tabs = useMemo(() => [
{
label: "Overview",
content: <Text p="md">Stats/Badges will go here, bracket link</Text>
content: <TournamentStats tournament={tournament} />
},
{
label: "Matches",
content: <MatchList matches={tournament.matches?.sort((a, b) => b.order - a.order) || []} />
},
{
label: "Teams",
label: "Teams",
content: <>
<TeamList teams={tournament.teams || []} />
</>
}
];
], [tournament]);
return <>
<Header tournament={tournament} />

View File

@@ -0,0 +1,37 @@
import { Box, Flex, Loader } from "@mantine/core";
import SwipeableTabs from "@/components/swipeable-tabs";
import HeaderSkeleton from "./header-skeleton";
const SkeletonLoader = () => (
<Flex h="30vh" w="100%" align="center" justify="center">
<Loader />
</Flex>
)
const ProfileSkeleton = () => {
const tabs = [
{
label: "Overview",
content: <SkeletonLoader />,
},
{
label: "Matches",
content: <SkeletonLoader />,
},
{
label: "Teams",
content: <SkeletonLoader />,
},
];
return (
<>
<HeaderSkeleton />
<Box mt="lg">
<SwipeableTabs tabs={tabs} />
</Box>
</>
);
};
export default ProfileSkeleton;

View File

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

View File

@@ -35,7 +35,7 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
>
{startedMatches.map((match, index) => (
<Carousel.Slide key={match.id}>
<Box pl={index === 0 ? "xl" : undefined } pr={index === startedMatches.length - 1 ? "xl" : undefined}>
<Box pl={index === 0 ? "md" : undefined } pr={index === startedMatches.length - 1 ? "md" : undefined}>
<MatchCard match={match} />
</Box>
</Carousel.Slide>
@@ -69,8 +69,8 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
/>
<RulesListButton tournamentId={tournament.id} />
<TeamListButton teams={tournament.teams || []} />
<RulesListButton tournamentId={tournament.id} />
</Box>
</Stack>
);

View File

@@ -0,0 +1,75 @@
import { Box, Card, Center, Divider, Group, Skeleton, Stack } from "@mantine/core";
const StartedTournamentSkeleton = () => {
return (
<Stack gap="lg">
{/* Header skeleton */}
<Stack px="md">
<Group justify="space-between" align="flex-start">
<Box style={{ flex: 1 }}>
<Skeleton height={32} width="60%" mb="xs" />
<Skeleton height={16} width="40%" />
</Box>
<Skeleton height={60} width={60} radius="md" />
</Group>
</Stack>
{/* Match carousel skeleton */}
<Box>
<Group gap="xs" px="xl">
{Array.from({ length: 2 }).map((_, index) => (
<Card
key={index}
withBorder
radius="lg"
p="lg"
style={{ minWidth: "95%", flex: "0 0 auto" }}
>
<Stack gap="md">
{/* Match header */}
<Group justify="space-between">
<Skeleton height={14} width="30%" />
<Skeleton height={20} width={60} radius="xl" />
</Group>
{/* Teams */}
<Stack gap="sm">
<Group>
<Skeleton height={32} width={32} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
<Center>
<Skeleton height={14} width={20} />
</Center>
<Group>
<Skeleton height={32} width={32} radius="sm" />
<Skeleton height={16} width="40%" />
<Box ml="auto">
<Skeleton height={24} width={30} />
</Box>
</Group>
</Stack>
</Stack>
</Card>
))}
</Group>
</Box>
{/* Actions section skeleton */}
<Box>
<Divider />
<Stack gap={0}>
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
<Skeleton height={48} width="100%" />
</Stack>
</Box>
</Stack>
);
};
export default StartedTournamentSkeleton;

View File

@@ -0,0 +1,37 @@
import { useAuth } from "@/contexts/auth-context";
import { useTournaments } from "../queries";
import { useSheet } from "@/hooks/use-sheet";
import { Button, Stack } from "@mantine/core";
import { PlusIcon } from "@phosphor-icons/react";
import Sheet from "@/components/sheet/sheet";
import TournamentForm from "./tournament-form";
import { TournamentCard } from "./tournament-card";
const TournamentCardList = () => {
const { data: tournaments } = useTournaments();
const { roles } = useAuth();
const sheet = useSheet();
return (
<Stack>
{roles?.includes("Admin") ? (
<>
<Button
leftSection={<PlusIcon />}
variant="subtle"
onClick={sheet.open}
>
Create Tournament
</Button>
<Sheet {...sheet.props} title="Create Tournament">
<TournamentForm close={sheet.close} />
</Sheet>
</>
) : null}
{tournaments?.map((tournament: any) => (
<TournamentCard key={tournament.id} tournament={tournament} />
))}
</Stack>
);
};
export default TournamentCardList;

View File

@@ -1,48 +1,27 @@
import {
Badge,
Card,
Text,
Stack,
Group,
Box,
ThemeIcon,
UnstyledButton,
Badge,
} from "@mantine/core";
import { Tournament } from "@/features/tournaments/types";
import { useMemo } from "react";
import { TournamentInfo } from "@/features/tournaments/types";
import {
TrophyIcon,
CalendarIcon,
MapPinIcon,
UsersIcon,
CrownIcon,
MedalIcon,
} from "@phosphor-icons/react";
import { useNavigate } from "@tanstack/react-router";
import Avatar from "@/components/avatar";
interface TournamentCardProps {
tournament: Tournament;
tournament: TournamentInfo;
}
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
const navigate = useNavigate();
const displayDate = useMemo(() => {
if (!tournament.start_time) return null;
const date = new Date(tournament.start_time);
if (isNaN(date.getTime())) return null;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
}, [tournament.start_time]);
const enrollmentDeadline = tournament.enroll_time
? new Date(tournament.enroll_time)
: new Date(tournament.start_time);
const isEnrollmentOpen = enrollmentDeadline > new Date();
const enrolledTeamsCount = tournament.teams?.length || 0;
return (
<UnstyledButton
w="100%"
@@ -78,9 +57,10 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
<Group justify="space-between" align="center">
<Group gap="md" align="center">
<Avatar
size={120}
size={90}
radius="sm"
name={tournament.name}
contain
src={
tournament.logo
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
@@ -93,31 +73,62 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
<Text fw={600} size="lg" lineClamp={2}>
{tournament.name}
</Text>
{displayDate && (
<Group gap="xs">
<ThemeIcon
size="sm"
variant="light"
radius="sm"
color="gray"
>
<CalendarIcon size={12} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{displayDate}
</Text>
</Group>
{(tournament.first_place || tournament.second_place || tournament.third_place) && (
<Stack gap={6} >
{tournament.first_place && (
<Badge
size="md"
radius="md"
variant="filled"
color="yellow"
leftSection={
<CrownIcon size={16} />
}
style={{
textTransform: 'none',
fontWeight: 600,
color: 'white',
}}
>
{tournament.first_place.name}
</Badge>
)}
{tournament.second_place && (
<Badge
size="md"
radius="md"
color="gray"
variant="filled"
leftSection={
<MedalIcon size={16} />
}
style={{
textTransform: 'none',
fontWeight: 500,
}}
>
{tournament.second_place.name}
</Badge>
)}
{tournament.third_place && (
<Badge
size="md"
radius="md"
color="orange"
variant="filled"
leftSection={
<MedalIcon size={16} />
}
style={{
textTransform: 'none',
fontWeight: 500,
}}
>
{tournament.third_place.name}
</Badge>
)}
</Stack>
)}
<Group gap="xs">
<ThemeIcon size="sm" variant="light" radius="sm" color="gray">
<UsersIcon size={12} />
</ThemeIcon>
<Text size="sm" c="dimmed">
{enrolledTeamsCount} team
{enrolledTeamsCount !== 1 ? "s" : ""}
</Text>
</Group>
</Stack>
</Group>
</Group>

View File

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

View File

@@ -0,0 +1,283 @@
import { useMemo, memo } from "react";
import {
Stack,
Text,
Group,
UnstyledButton,
Container,
Box,
Center,
ThemeIcon,
Divider,
} from "@mantine/core";
import { Tournament } from "@/features/tournaments/types";
import { CrownIcon, MedalIcon, TreeStructureIcon } from "@phosphor-icons/react";
import Avatar from "@/components/avatar";
import ListLink from "@/components/list-link";
interface TournamentStatsProps {
tournament: Tournament;
}
export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
const matches = tournament.matches || [];
const nonByeMatches = useMemo(() =>
matches.filter((match) => !(match.status === 'tbd' && match.bye === true)),
[matches]
);
const isComplete = useMemo(() =>
nonByeMatches.length > 0 && nonByeMatches.every((match) => match.status === 'ended'),
[nonByeMatches]
);
const sortedTeamStats = useMemo(() => {
return [...(tournament.team_stats || [])].sort((a, b) => {
if (b.wins !== a.wins) {
return b.wins - a.wins;
}
return b.total_cups_made - a.total_cups_made;
});
}, [tournament.team_stats]);
const renderPodium = () => {
if (!isComplete || !tournament.first_place) {
return (
<Box p="md">
<Center>
<Text c="dimmed" size="sm">
Podium will appear here when the tournament is over
</Text>
</Center>
</Box>
);
}
return (
<Stack gap="xs" px="md">
{tournament.first_place && (
<Group
gap="md"
p="md"
style={{
backgroundColor: 'var(--mantine-color-yellow-light)',
borderRadius: 'var(--mantine-radius-md)',
border: '3px solid var(--mantine-color-yellow-outline)',
boxShadow: 'var(--mantine-shadow-md)',
}}
>
<ThemeIcon size="xl" color="yellow" variant="light" radius="xl">
<CrownIcon size={24} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="md" fw={600}>
{tournament.first_place.name}
</Text>
<Group gap="xs">
{tournament.first_place.players?.map((player) => (
<Text key={player.id} size="sm" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
{tournament.second_place && (
<Group
gap="md"
p="xs"
style={{
backgroundColor: 'var(--mantine-color-default)',
borderRadius: 'var(--mantine-radius-md)',
border: '2px solid var(--mantine-color-default-border)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<ThemeIcon size="lg" color="gray" variant="light" radius="xl">
<MedalIcon size={20} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{tournament.second_place.name}
</Text>
<Group gap="xs">
{tournament.second_place.players?.map((player) => (
<Text key={player.id} size="xs" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
{tournament.third_place && (
<Group
gap="md"
p="xs"
style={{
backgroundColor: 'var(--mantine-color-orange-light)',
borderRadius: 'var(--mantine-radius-md)',
border: '2px solid var(--mantine-color-orange-outline)',
boxShadow: 'var(--mantine-shadow-sm)',
}}
>
<ThemeIcon size="lg" color="orange" variant="light" radius="xl">
<MedalIcon size={18} />
</ThemeIcon>
<Stack gap={4} style={{ flex: 1 }}>
<Text size="sm" fw={600}>
{tournament.third_place.name}
</Text>
<Group gap="xs">
{tournament.third_place.players?.map((player) => (
<Text key={player.id} size="xs" c="dimmed">
{player.first_name} {player.last_name}
</Text>
))}
</Group>
</Stack>
</Group>
)}
</Stack>
);
};
const teamStatsWithCalculations = useMemo(() => {
return sortedTeamStats.map((stat, index) => ({
...stat,
index,
winPercentage: stat.matches > 0 ? (stat.wins / stat.matches) * 100 : 0,
avgCupsPerMatch: stat.matches > 0 ? stat.total_cups_made / stat.matches : 0,
}));
}, [sortedTeamStats]);
const renderTeamStatsTable = () => {
if (!teamStatsWithCalculations.length) {
return (
<Box p="md">
<Center>
<Text c="dimmed" size="sm">
No stats available yet
</Text>
</Center>
</Box>
);
}
return (
<Stack gap={0}>
<Text px="md" size="lg" fw={600}>Results</Text>
{teamStatsWithCalculations.map((stat) => {
return (
<Box key={stat.id}>
<UnstyledButton
w="100%"
p="md"
style={{
borderRadius: 0,
transition: "background-color 0.15s ease",
}}
styles={{
root: {
'&:hover': {
backgroundColor: 'var(--mantine-color-gray-0)',
},
},
}}
>
<Group justify="space-between" align="center" w="100%">
<Group gap="sm" align="center">
<Avatar name={stat.team_name} size={40} radius="sm" />
<Stack gap={2}>
<Group gap='xs'>
<Text size="xs" c="dimmed">
#{stat.index + 1}
</Text>
<Text size="sm" fw={600}>
{stat.team_name}
</Text>
{stat.index === 0 && isComplete && (
<ThemeIcon size="xs" color="yellow" variant="light" radius="xl">
<CrownIcon size={12} />
</ThemeIcon>
)}
</Group>
<Group gap="md" ta="center">
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
W
</Text>
<Text size="xs" c="dimmed">
{stat.wins}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
L
</Text>
<Text size="xs" c="dimmed">
{stat.losses}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
W%
</Text>
<Text size="xs" c="dimmed">
{stat.winPercentage.toFixed(1)}%
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
AVG
</Text>
<Text size="xs" c="dimmed">
{stat.avgCupsPerMatch.toFixed(1)}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
CF
</Text>
<Text size="xs" c="dimmed">
{stat.total_cups_made}
</Text>
</Stack>
<Stack gap={0}>
<Text size="xs" c="dimmed" fw={700}>
CA
</Text>
<Text size="xs" c="dimmed">
{stat.total_cups_against}
</Text>
</Stack>
</Group>
</Stack>
</Group>
</Group>
</UnstyledButton>
{stat.index < teamStatsWithCalculations.length - 1 && <Divider />}
</Box>
);
})}
</Stack>
);
};
return (
<Container size="100%" px={0}>
<Stack gap="md">
{renderPodium()}
<ListLink
label={`View Bracket`}
to={`/tournaments/${tournament.id}/bracket`}
Icon={TreeStructureIcon}
/>
{renderTeamStatsTable()}
</Stack>
</Container>
);
});

View File

@@ -2,11 +2,23 @@ import Button from "@/components/button";
import Sheet from "@/components/sheet/sheet";
import { useAuth } from "@/contexts/auth-context";
import { useSheet } from "@/hooks/use-sheet";
import { Text } from "@mantine/core";
import { Stack, Text } from "@mantine/core";
import useEnrollFreeAgent from "../../hooks/use-enroll-free-agent";
const EnrollFreeAgent = () => {
const EnrollFreeAgent = ({ tournamentId }: {tournamentId: string} ) => {
const { open, isOpen, toggle } = useSheet();
const { user } = useAuth();
const { user, phone } = useAuth();
const { mutate: enrollFreeAgent, isPending: isEnrolling } = useEnrollFreeAgent();
const handleEnroll = () => {
console.log('enrolling...')
enrollFreeAgent({ playerId: user!.id, tournamentId, phone }, {
onSuccess: () => {
toggle();
}
});
}
return (
<>
<Button variant="subtle" size="sm" onClick={open}>
@@ -14,13 +26,19 @@ const EnrollFreeAgent = () => {
</Button>
<Sheet title="Free Agent Enrollment" opened={isOpen} onChange={toggle}>
<Text size="md" mb="md">
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
</Text>
<Text size="sm" mb="md" c='dimmed'>
You will be automatically paired with a partner before the tournament starts, and you will be able to see your new team and set a walkout song in the app.
</Text>
<Button onClick={console.log}>Confirm</Button>
<Stack gap="xs">
<Text size="md">
Enrolling as a free agent will enter you in a pool of players wanting to play but don't have a teammate yet.
</Text>
<Text size="sm" c='dimmed'>
You will be able to see a list of other enrolled free agents, as well as their contact information for organizing your team and walkout song. By enrolling, your phone number will be visible to other free agents.
</Text>
<Text size="xs" c="dimmed">
Note: this does not guarantee you a spot in the tournament. One person from your team must enroll in the app and choose a walkout song in order to secure a spot.
</Text>
<Button onClick={handleEnroll}>Confirm</Button>
<Button variant="subtle" color="red" onClick={toggle}>Cancel</Button>
</Stack>
</Sheet>
</>
);

Some files were not shown because too many files have changed in this diff Show More