Compare commits
18 Commits
cae5fa1c71
...
upgrade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
732afaf623 | ||
|
|
48aeaabeea | ||
|
|
a4b9fe9065 | ||
|
|
31e50af593 | ||
|
|
39053cadaa | ||
|
|
ea6656aa33 | ||
|
|
92c4987372 | ||
|
|
b3ebf46afa | ||
|
|
c0ef535001 | ||
|
|
81329e4354 | ||
|
|
36f3bb77d4 | ||
|
|
6760ea46f9 | ||
|
|
e4164cbc71 | ||
|
|
94ea44c66e | ||
|
|
7441d1ac58 | ||
|
|
7ff26229d9 | ||
|
|
b93ce38d48 | ||
|
|
ae934e77f4 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ yarn.lock
|
|||||||
/scripts/
|
/scripts/
|
||||||
/pb_data/
|
/pb_data/
|
||||||
/.tanstack/
|
/.tanstack/
|
||||||
|
/dist/
|
||||||
@@ -32,17 +32,17 @@ services:
|
|||||||
- app-network
|
- app-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
redis:
|
#redis:
|
||||||
image: redis:7-alpine
|
# image: redis:7-alpine
|
||||||
container_name: redis-cache
|
# container_name: redis-cache
|
||||||
ports:
|
# ports:
|
||||||
- "6379:6379"
|
# - "6379:6379"
|
||||||
command: redis-server --appendonly yes
|
# command: redis-server --appendonly yes
|
||||||
volumes:
|
# volumes:
|
||||||
- redis-data:/data
|
# - redis-data:/data
|
||||||
networks:
|
# networks:
|
||||||
- app-network
|
# - app-network
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
|
|
||||||
supertokens:
|
supertokens:
|
||||||
image: registry.supertokens.io/supertokens/supertokens-postgresql
|
image: registry.supertokens.io/supertokens/supertokens-postgresql
|
||||||
|
|||||||
12
package.json
12
package.json
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host 0.0.0.0",
|
"dev": "vite dev --host 0.0.0.0",
|
||||||
"build": "vite build && tsc --noEmit",
|
"build": "vite build && tsc --noEmit",
|
||||||
"start": "vite start"
|
"start": "bun run .output/server/index.mjs",
|
||||||
|
"start:node": "node .output/server/index.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
@@ -24,12 +25,15 @@
|
|||||||
"@tanstack/react-router": "^1.130.12",
|
"@tanstack/react-router": "^1.130.12",
|
||||||
"@tanstack/react-router-devtools": "^1.130.13",
|
"@tanstack/react-router-devtools": "^1.130.13",
|
||||||
"@tanstack/react-router-with-query": "^1.130.12",
|
"@tanstack/react-router-with-query": "^1.130.12",
|
||||||
"@tanstack/react-start": "^1.130.15",
|
"@tanstack/react-start": "^1.132.2",
|
||||||
"@tanstack/react-virtual": "^3.13.12",
|
"@tanstack/react-virtual": "^3.13.12",
|
||||||
"@tiptap/pm": "^3.4.3",
|
"@tiptap/pm": "^3.4.3",
|
||||||
"@tiptap/react": "^3.4.3",
|
"@tiptap/react": "^3.4.3",
|
||||||
"@tiptap/starter-kit": "^3.4.3",
|
"@tiptap/starter-kit": "^3.4.3",
|
||||||
|
"@types/bun": "^1.2.22",
|
||||||
"@types/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
|
"dotenv": "^17.2.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
"ioredis": "^5.7.0",
|
"ioredis": "^5.7.0",
|
||||||
@@ -51,6 +55,8 @@
|
|||||||
"zustand": "^5.0.7"
|
"zustand": "^5.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tanstack/react-router-ssr-query": "^1.132.2",
|
||||||
|
"@tanstack/router-plugin": "^1.132.2",
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
"@types/react": "^19.0.8",
|
"@types/react": "^19.0.8",
|
||||||
@@ -63,7 +69,7 @@
|
|||||||
"postcss-simple-vars": "^7.0.1",
|
"postcss-simple-vars": "^7.0.1",
|
||||||
"tsx": "^4.20.3",
|
"tsx": "^4.20.3",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^6.3.5",
|
"vite": "^7.1.7",
|
||||||
"vite-tsconfig-paths": "^5.1.4"
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
364
server.ts
Normal file
364
server.ts
Normal 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)
|
||||||
|
})
|
||||||
@@ -8,8 +8,6 @@
|
|||||||
// You should NOT make any changes in this file as it will be overwritten.
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
import { createServerRootRoute } from '@tanstack/react-start/server'
|
|
||||||
|
|
||||||
import { Route as rootRouteImport } from './routes/__root'
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
import { Route as RefreshSessionRouteImport } from './routes/refresh-session'
|
import { Route as RefreshSessionRouteImport } from './routes/refresh-session'
|
||||||
import { Route as LogoutRouteImport } from './routes/logout'
|
import { Route as LogoutRouteImport } from './routes/logout'
|
||||||
@@ -21,27 +19,26 @@ import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
|
|||||||
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
|
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
|
||||||
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
|
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
|
||||||
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
|
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
|
||||||
|
import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo'
|
||||||
|
import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo'
|
||||||
|
import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
|
||||||
|
import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search'
|
||||||
|
import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resume'
|
||||||
|
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
|
||||||
|
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
|
||||||
|
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
|
||||||
|
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
|
||||||
|
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||||
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
||||||
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
||||||
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
||||||
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/preview'
|
||||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
||||||
import { Route as 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 { Route as AuthedAdminTournamentsRunIdRouteImport } from './routes/_authed/admin/tournaments/run.$id'
|
||||||
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
import { Route as AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
||||||
import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
|
|
||||||
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
|
|
||||||
import { ServerRoute as ApiSpotifySearchServerRouteImport } from './routes/api/spotify/search'
|
|
||||||
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
|
|
||||||
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
|
|
||||||
import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture'
|
|
||||||
import { ServerRoute as ApiSpotifyCallbackServerRouteImport } from './routes/api/spotify/callback'
|
|
||||||
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
|
|
||||||
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
|
|
||||||
import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
|
||||||
|
|
||||||
const rootServerRouteImport = createServerRootRoute()
|
|
||||||
|
|
||||||
const RefreshSessionRoute = RefreshSessionRouteImport.update({
|
const RefreshSessionRoute = RefreshSessionRouteImport.update({
|
||||||
id: '/refresh-session',
|
id: '/refresh-session',
|
||||||
@@ -92,6 +89,57 @@ const AuthedAdminIndexRoute = AuthedAdminIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiTournamentsUploadLogoRoute =
|
||||||
|
ApiTournamentsUploadLogoRouteImport.update({
|
||||||
|
id: '/api/tournaments/upload-logo',
|
||||||
|
path: '/api/tournaments/upload-logo',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
|
||||||
|
id: '/api/teams/upload-logo',
|
||||||
|
path: '/api/teams/upload-logo',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyTokenRoute = ApiSpotifyTokenRouteImport.update({
|
||||||
|
id: '/api/spotify/token',
|
||||||
|
path: '/api/spotify/token',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifySearchRoute = ApiSpotifySearchRouteImport.update({
|
||||||
|
id: '/api/spotify/search',
|
||||||
|
path: '/api/spotify/search',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyResumeRoute = ApiSpotifyResumeRouteImport.update({
|
||||||
|
id: '/api/spotify/resume',
|
||||||
|
path: '/api/spotify/resume',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyPlaybackRoute = ApiSpotifyPlaybackRouteImport.update({
|
||||||
|
id: '/api/spotify/playback',
|
||||||
|
path: '/api/spotify/playback',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyCaptureRoute = ApiSpotifyCaptureRouteImport.update({
|
||||||
|
id: '/api/spotify/capture',
|
||||||
|
path: '/api/spotify/capture',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
|
||||||
|
id: '/api/spotify/callback',
|
||||||
|
path: '/api/spotify/callback',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
|
||||||
|
id: '/api/events/$',
|
||||||
|
path: '/api/events/$',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||||
|
id: '/api/auth/$',
|
||||||
|
path: '/api/auth/$',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthedTournamentsTournamentIdRoute =
|
const AuthedTournamentsTournamentIdRoute =
|
||||||
AuthedTournamentsTournamentIdRouteImport.update({
|
AuthedTournamentsTournamentIdRouteImport.update({
|
||||||
id: '/tournaments/$tournamentId',
|
id: '/tournaments/$tournamentId',
|
||||||
@@ -125,77 +173,29 @@ const AuthedTournamentsIdBracketRoute =
|
|||||||
path: '/tournaments/$id/bracket',
|
path: '/tournaments/$id/bracket',
|
||||||
getParentRoute: () => AuthedRoute,
|
getParentRoute: () => AuthedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const AuthedAdminTournamentsIdRoute =
|
const AuthedAdminTournamentsIdIndexRoute =
|
||||||
AuthedAdminTournamentsIdRouteImport.update({
|
AuthedAdminTournamentsIdIndexRouteImport.update({
|
||||||
id: '/tournaments/$id',
|
id: '/tournaments/$id/',
|
||||||
path: '/tournaments/$id',
|
path: '/tournaments/$id/',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const ApiFilesCollectionRecordIdFileRoute =
|
||||||
|
ApiFilesCollectionRecordIdFileRouteImport.update({
|
||||||
|
id: '/api/files/$collection/$recordId/$file',
|
||||||
|
path: '/api/files/$collection/$recordId/$file',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
const AuthedAdminTournamentsRunIdRoute =
|
const AuthedAdminTournamentsRunIdRoute =
|
||||||
AuthedAdminTournamentsRunIdRouteImport.update({
|
AuthedAdminTournamentsRunIdRouteImport.update({
|
||||||
id: '/tournaments/run/$id',
|
id: '/tournaments/run/$id',
|
||||||
path: '/tournaments/run/$id',
|
path: '/tournaments/run/$id',
|
||||||
getParentRoute: () => AuthedAdminRoute,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
} as any)
|
||||||
const ApiTournamentsUploadLogoServerRoute =
|
const AuthedAdminTournamentsIdTeamsRoute =
|
||||||
ApiTournamentsUploadLogoServerRouteImport.update({
|
AuthedAdminTournamentsIdTeamsRouteImport.update({
|
||||||
id: '/api/tournaments/upload-logo',
|
id: '/tournaments/$id/teams',
|
||||||
path: '/api/tournaments/upload-logo',
|
path: '/tournaments/$id/teams',
|
||||||
getParentRoute: () => rootServerRouteImport,
|
getParentRoute: () => AuthedAdminRoute,
|
||||||
} as any)
|
|
||||||
const ApiTeamsUploadLogoServerRoute =
|
|
||||||
ApiTeamsUploadLogoServerRouteImport.update({
|
|
||||||
id: '/api/teams/upload-logo',
|
|
||||||
path: '/api/teams/upload-logo',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
|
|
||||||
id: '/api/spotify/token',
|
|
||||||
path: '/api/spotify/token',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiSpotifySearchServerRoute = ApiSpotifySearchServerRouteImport.update({
|
|
||||||
id: '/api/spotify/search',
|
|
||||||
path: '/api/spotify/search',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({
|
|
||||||
id: '/api/spotify/resume',
|
|
||||||
path: '/api/spotify/resume',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiSpotifyPlaybackServerRoute =
|
|
||||||
ApiSpotifyPlaybackServerRouteImport.update({
|
|
||||||
id: '/api/spotify/playback',
|
|
||||||
path: '/api/spotify/playback',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiSpotifyCaptureServerRoute = ApiSpotifyCaptureServerRouteImport.update({
|
|
||||||
id: '/api/spotify/capture',
|
|
||||||
path: '/api/spotify/capture',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiSpotifyCallbackServerRoute =
|
|
||||||
ApiSpotifyCallbackServerRouteImport.update({
|
|
||||||
id: '/api/spotify/callback',
|
|
||||||
path: '/api/spotify/callback',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({
|
|
||||||
id: '/api/events/$',
|
|
||||||
path: '/api/events/$',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiAuthSplatServerRoute = ApiAuthSplatServerRouteImport.update({
|
|
||||||
id: '/api/auth/$',
|
|
||||||
path: '/api/auth/$',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
|
||||||
const ApiFilesCollectionRecordIdFileServerRoute =
|
|
||||||
ApiFilesCollectionRecordIdFileServerRouteImport.update({
|
|
||||||
id: '/api/files/$collection/$recordId/$file',
|
|
||||||
path: '/api/files/$collection/$recordId/$file',
|
|
||||||
getParentRoute: () => rootServerRouteImport,
|
|
||||||
} as any)
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
@@ -210,12 +210,24 @@ export interface FileRoutesByFullPath {
|
|||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/api/events/$': typeof ApiEventsSplatRoute
|
||||||
|
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||||
|
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||||
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||||
|
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
|
||||||
|
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||||
|
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||||
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||||
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||||
'/admin/': typeof AuthedAdminIndexRoute
|
'/admin/': typeof AuthedAdminIndexRoute
|
||||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
|
||||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||||
|
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
|
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
@@ -228,12 +240,24 @@ export interface FileRoutesByTo {
|
|||||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/api/events/$': typeof ApiEventsSplatRoute
|
||||||
|
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||||
|
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||||
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||||
|
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
|
||||||
|
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||||
|
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||||
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||||
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||||
'/admin': typeof AuthedAdminIndexRoute
|
'/admin': typeof AuthedAdminIndexRoute
|
||||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
|
||||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||||
|
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
|
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
@@ -249,12 +273,24 @@ export interface FileRoutesById {
|
|||||||
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||||
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||||
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||||
|
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||||
|
'/api/events/$': typeof ApiEventsSplatRoute
|
||||||
|
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||||
|
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||||
|
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||||
|
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
|
||||||
|
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||||
|
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||||
|
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||||
|
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||||
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
||||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||||
'/_authed/admin/tournaments/$id': typeof AuthedAdminTournamentsIdRoute
|
|
||||||
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||||
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||||
|
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||||
|
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
|
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
@@ -270,12 +306,24 @@ export interface FileRouteTypes {
|
|||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
| '/teams/$teamId'
|
| '/teams/$teamId'
|
||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
|
| '/api/auth/$'
|
||||||
|
| '/api/events/$'
|
||||||
|
| '/api/spotify/callback'
|
||||||
|
| '/api/spotify/capture'
|
||||||
|
| '/api/spotify/playback'
|
||||||
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/search'
|
||||||
|
| '/api/spotify/token'
|
||||||
|
| '/api/teams/upload-logo'
|
||||||
|
| '/api/tournaments/upload-logo'
|
||||||
| '/admin/'
|
| '/admin/'
|
||||||
| '/tournaments'
|
| '/tournaments'
|
||||||
| '/admin/tournaments/$id'
|
|
||||||
| '/tournaments/$id/bracket'
|
| '/tournaments/$id/bracket'
|
||||||
| '/admin/tournaments'
|
| '/admin/tournaments'
|
||||||
|
| '/admin/tournaments/$id/teams'
|
||||||
| '/admin/tournaments/run/$id'
|
| '/admin/tournaments/run/$id'
|
||||||
|
| '/api/files/$collection/$recordId/$file'
|
||||||
|
| '/admin/tournaments/$id'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/login'
|
| '/login'
|
||||||
@@ -288,12 +336,24 @@ export interface FileRouteTypes {
|
|||||||
| '/profile/$playerId'
|
| '/profile/$playerId'
|
||||||
| '/teams/$teamId'
|
| '/teams/$teamId'
|
||||||
| '/tournaments/$tournamentId'
|
| '/tournaments/$tournamentId'
|
||||||
|
| '/api/auth/$'
|
||||||
|
| '/api/events/$'
|
||||||
|
| '/api/spotify/callback'
|
||||||
|
| '/api/spotify/capture'
|
||||||
|
| '/api/spotify/playback'
|
||||||
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/search'
|
||||||
|
| '/api/spotify/token'
|
||||||
|
| '/api/teams/upload-logo'
|
||||||
|
| '/api/tournaments/upload-logo'
|
||||||
| '/admin'
|
| '/admin'
|
||||||
| '/tournaments'
|
| '/tournaments'
|
||||||
| '/admin/tournaments/$id'
|
|
||||||
| '/tournaments/$id/bracket'
|
| '/tournaments/$id/bracket'
|
||||||
| '/admin/tournaments'
|
| '/admin/tournaments'
|
||||||
|
| '/admin/tournaments/$id/teams'
|
||||||
| '/admin/tournaments/run/$id'
|
| '/admin/tournaments/run/$id'
|
||||||
|
| '/api/files/$collection/$recordId/$file'
|
||||||
|
| '/admin/tournaments/$id'
|
||||||
id:
|
id:
|
||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_authed'
|
| '/_authed'
|
||||||
@@ -308,12 +368,24 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed/profile/$playerId'
|
| '/_authed/profile/$playerId'
|
||||||
| '/_authed/teams/$teamId'
|
| '/_authed/teams/$teamId'
|
||||||
| '/_authed/tournaments/$tournamentId'
|
| '/_authed/tournaments/$tournamentId'
|
||||||
|
| '/api/auth/$'
|
||||||
|
| '/api/events/$'
|
||||||
|
| '/api/spotify/callback'
|
||||||
|
| '/api/spotify/capture'
|
||||||
|
| '/api/spotify/playback'
|
||||||
|
| '/api/spotify/resume'
|
||||||
|
| '/api/spotify/search'
|
||||||
|
| '/api/spotify/token'
|
||||||
|
| '/api/teams/upload-logo'
|
||||||
|
| '/api/tournaments/upload-logo'
|
||||||
| '/_authed/admin/'
|
| '/_authed/admin/'
|
||||||
| '/_authed/tournaments/'
|
| '/_authed/tournaments/'
|
||||||
| '/_authed/admin/tournaments/$id'
|
|
||||||
| '/_authed/tournaments/$id/bracket'
|
| '/_authed/tournaments/$id/bracket'
|
||||||
| '/_authed/admin/tournaments/'
|
| '/_authed/admin/tournaments/'
|
||||||
|
| '/_authed/admin/tournaments/$id/teams'
|
||||||
| '/_authed/admin/tournaments/run/$id'
|
| '/_authed/admin/tournaments/run/$id'
|
||||||
|
| '/api/files/$collection/$recordId/$file'
|
||||||
|
| '/_authed/admin/tournaments/$id/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
@@ -321,101 +393,17 @@ export interface RootRouteChildren {
|
|||||||
LoginRoute: typeof LoginRoute
|
LoginRoute: typeof LoginRoute
|
||||||
LogoutRoute: typeof LogoutRoute
|
LogoutRoute: typeof LogoutRoute
|
||||||
RefreshSessionRoute: typeof RefreshSessionRoute
|
RefreshSessionRoute: typeof RefreshSessionRoute
|
||||||
}
|
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||||
export interface FileServerRoutesByFullPath {
|
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
|
||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
|
||||||
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
|
||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
ApiSpotifyResumeRoute: typeof ApiSpotifyResumeRoute
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
|
||||||
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
|
||||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
|
||||||
}
|
|
||||||
export interface FileServerRoutesByTo {
|
|
||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
|
||||||
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
|
||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
|
||||||
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
|
||||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
|
||||||
}
|
|
||||||
export interface FileServerRoutesById {
|
|
||||||
__root__: typeof rootServerRouteImport
|
|
||||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
|
||||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
|
||||||
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
|
||||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
|
||||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
|
||||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
|
||||||
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
|
||||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
|
||||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
|
||||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
|
||||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
|
||||||
}
|
|
||||||
export interface FileServerRouteTypes {
|
|
||||||
fileServerRoutesByFullPath: FileServerRoutesByFullPath
|
|
||||||
fullPaths:
|
|
||||||
| '/api/auth/$'
|
|
||||||
| '/api/events/$'
|
|
||||||
| '/api/spotify/callback'
|
|
||||||
| '/api/spotify/capture'
|
|
||||||
| '/api/spotify/playback'
|
|
||||||
| '/api/spotify/resume'
|
|
||||||
| '/api/spotify/search'
|
|
||||||
| '/api/spotify/token'
|
|
||||||
| '/api/teams/upload-logo'
|
|
||||||
| '/api/tournaments/upload-logo'
|
|
||||||
| '/api/files/$collection/$recordId/$file'
|
|
||||||
fileServerRoutesByTo: FileServerRoutesByTo
|
|
||||||
to:
|
|
||||||
| '/api/auth/$'
|
|
||||||
| '/api/events/$'
|
|
||||||
| '/api/spotify/callback'
|
|
||||||
| '/api/spotify/capture'
|
|
||||||
| '/api/spotify/playback'
|
|
||||||
| '/api/spotify/resume'
|
|
||||||
| '/api/spotify/search'
|
|
||||||
| '/api/spotify/token'
|
|
||||||
| '/api/teams/upload-logo'
|
|
||||||
| '/api/tournaments/upload-logo'
|
|
||||||
| '/api/files/$collection/$recordId/$file'
|
|
||||||
id:
|
|
||||||
| '__root__'
|
|
||||||
| '/api/auth/$'
|
|
||||||
| '/api/events/$'
|
|
||||||
| '/api/spotify/callback'
|
|
||||||
| '/api/spotify/capture'
|
|
||||||
| '/api/spotify/playback'
|
|
||||||
| '/api/spotify/resume'
|
|
||||||
| '/api/spotify/search'
|
|
||||||
| '/api/spotify/token'
|
|
||||||
| '/api/teams/upload-logo'
|
|
||||||
| '/api/tournaments/upload-logo'
|
|
||||||
| '/api/files/$collection/$recordId/$file'
|
|
||||||
fileServerRoutesById: FileServerRoutesById
|
|
||||||
}
|
|
||||||
export interface RootServerRouteChildren {
|
|
||||||
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
|
|
||||||
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
|
|
||||||
ApiSpotifyCallbackServerRoute: typeof ApiSpotifyCallbackServerRoute
|
|
||||||
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
|
|
||||||
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
|
||||||
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
|
||||||
ApiSpotifySearchServerRoute: typeof ApiSpotifySearchServerRoute
|
|
||||||
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
|
||||||
ApiTeamsUploadLogoServerRoute: typeof ApiTeamsUploadLogoServerRoute
|
|
||||||
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
|
||||||
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module '@tanstack/react-router' {
|
declare module '@tanstack/react-router' {
|
||||||
@@ -490,6 +478,76 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminIndexRouteImport
|
preLoaderRoute: typeof AuthedAdminIndexRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
|
'/api/tournaments/upload-logo': {
|
||||||
|
id: '/api/tournaments/upload-logo'
|
||||||
|
path: '/api/tournaments/upload-logo'
|
||||||
|
fullPath: '/api/tournaments/upload-logo'
|
||||||
|
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/teams/upload-logo': {
|
||||||
|
id: '/api/teams/upload-logo'
|
||||||
|
path: '/api/teams/upload-logo'
|
||||||
|
fullPath: '/api/teams/upload-logo'
|
||||||
|
preLoaderRoute: typeof ApiTeamsUploadLogoRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/token': {
|
||||||
|
id: '/api/spotify/token'
|
||||||
|
path: '/api/spotify/token'
|
||||||
|
fullPath: '/api/spotify/token'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyTokenRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/search': {
|
||||||
|
id: '/api/spotify/search'
|
||||||
|
path: '/api/spotify/search'
|
||||||
|
fullPath: '/api/spotify/search'
|
||||||
|
preLoaderRoute: typeof ApiSpotifySearchRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/resume': {
|
||||||
|
id: '/api/spotify/resume'
|
||||||
|
path: '/api/spotify/resume'
|
||||||
|
fullPath: '/api/spotify/resume'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyResumeRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/playback': {
|
||||||
|
id: '/api/spotify/playback'
|
||||||
|
path: '/api/spotify/playback'
|
||||||
|
fullPath: '/api/spotify/playback'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyPlaybackRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/capture': {
|
||||||
|
id: '/api/spotify/capture'
|
||||||
|
path: '/api/spotify/capture'
|
||||||
|
fullPath: '/api/spotify/capture'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyCaptureRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/spotify/callback': {
|
||||||
|
id: '/api/spotify/callback'
|
||||||
|
path: '/api/spotify/callback'
|
||||||
|
fullPath: '/api/spotify/callback'
|
||||||
|
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/events/$': {
|
||||||
|
id: '/api/events/$'
|
||||||
|
path: '/api/events/$'
|
||||||
|
fullPath: '/api/events/$'
|
||||||
|
preLoaderRoute: typeof ApiEventsSplatRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/api/auth/$': {
|
||||||
|
id: '/api/auth/$'
|
||||||
|
path: '/api/auth/$'
|
||||||
|
fullPath: '/api/auth/$'
|
||||||
|
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_authed/tournaments/$tournamentId': {
|
'/_authed/tournaments/$tournamentId': {
|
||||||
id: '/_authed/tournaments/$tournamentId'
|
id: '/_authed/tournaments/$tournamentId'
|
||||||
path: '/tournaments/$tournamentId'
|
path: '/tournaments/$tournamentId'
|
||||||
@@ -532,13 +590,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
|
preLoaderRoute: typeof AuthedTournamentsIdBracketRouteImport
|
||||||
parentRoute: typeof AuthedRoute
|
parentRoute: typeof AuthedRoute
|
||||||
}
|
}
|
||||||
'/_authed/admin/tournaments/$id': {
|
'/_authed/admin/tournaments/$id/': {
|
||||||
id: '/_authed/admin/tournaments/$id'
|
id: '/_authed/admin/tournaments/$id/'
|
||||||
path: '/tournaments/$id'
|
path: '/tournaments/$id'
|
||||||
fullPath: '/admin/tournaments/$id'
|
fullPath: '/admin/tournaments/$id'
|
||||||
preLoaderRoute: typeof AuthedAdminTournamentsIdRouteImport
|
preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
|
'/api/files/$collection/$recordId/$file': {
|
||||||
|
id: '/api/files/$collection/$recordId/$file'
|
||||||
|
path: '/api/files/$collection/$recordId/$file'
|
||||||
|
fullPath: '/api/files/$collection/$recordId/$file'
|
||||||
|
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
'/_authed/admin/tournaments/run/$id': {
|
'/_authed/admin/tournaments/run/$id': {
|
||||||
id: '/_authed/admin/tournaments/run/$id'
|
id: '/_authed/admin/tournaments/run/$id'
|
||||||
path: '/tournaments/run/$id'
|
path: '/tournaments/run/$id'
|
||||||
@@ -546,86 +611,12 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedAdminTournamentsRunIdRouteImport
|
preLoaderRoute: typeof AuthedAdminTournamentsRunIdRouteImport
|
||||||
parentRoute: typeof AuthedAdminRoute
|
parentRoute: typeof AuthedAdminRoute
|
||||||
}
|
}
|
||||||
}
|
'/_authed/admin/tournaments/$id/teams': {
|
||||||
}
|
id: '/_authed/admin/tournaments/$id/teams'
|
||||||
declare module '@tanstack/react-start/server' {
|
path: '/tournaments/$id/teams'
|
||||||
interface ServerFileRoutesByPath {
|
fullPath: '/admin/tournaments/$id/teams'
|
||||||
'/api/tournaments/upload-logo': {
|
preLoaderRoute: typeof AuthedAdminTournamentsIdTeamsRouteImport
|
||||||
id: '/api/tournaments/upload-logo'
|
parentRoute: typeof AuthedAdminRoute
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -633,17 +624,19 @@ declare module '@tanstack/react-start/server' {
|
|||||||
interface AuthedAdminRouteChildren {
|
interface AuthedAdminRouteChildren {
|
||||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||||
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute
|
||||||
AuthedAdminTournamentsIdRoute: typeof AuthedAdminTournamentsIdRoute
|
|
||||||
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
AuthedAdminTournamentsIndexRoute: typeof AuthedAdminTournamentsIndexRoute
|
||||||
|
AuthedAdminTournamentsIdTeamsRoute: typeof AuthedAdminTournamentsIdTeamsRoute
|
||||||
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
|
AuthedAdminTournamentsRunIdRoute: typeof AuthedAdminTournamentsRunIdRoute
|
||||||
|
AuthedAdminTournamentsIdIndexRoute: typeof AuthedAdminTournamentsIdIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
const AuthedAdminRouteChildren: AuthedAdminRouteChildren = {
|
||||||
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
AuthedAdminPreviewRoute: AuthedAdminPreviewRoute,
|
||||||
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
AuthedAdminIndexRoute: AuthedAdminIndexRoute,
|
||||||
AuthedAdminTournamentsIdRoute: AuthedAdminTournamentsIdRoute,
|
|
||||||
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
AuthedAdminTournamentsIndexRoute: AuthedAdminTournamentsIndexRoute,
|
||||||
|
AuthedAdminTournamentsIdTeamsRoute: AuthedAdminTournamentsIdTeamsRoute,
|
||||||
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
|
AuthedAdminTournamentsRunIdRoute: AuthedAdminTournamentsRunIdRoute,
|
||||||
|
AuthedAdminTournamentsIdIndexRoute: AuthedAdminTournamentsIdIndexRoute,
|
||||||
}
|
}
|
||||||
|
|
||||||
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
|
const AuthedAdminRouteWithChildren = AuthedAdminRoute._addFileChildren(
|
||||||
@@ -682,24 +675,27 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
LoginRoute: LoginRoute,
|
LoginRoute: LoginRoute,
|
||||||
LogoutRoute: LogoutRoute,
|
LogoutRoute: LogoutRoute,
|
||||||
RefreshSessionRoute: RefreshSessionRoute,
|
RefreshSessionRoute: RefreshSessionRoute,
|
||||||
|
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||||
|
ApiEventsSplatRoute: ApiEventsSplatRoute,
|
||||||
|
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
|
||||||
|
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
|
||||||
|
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
|
||||||
|
ApiSpotifyResumeRoute: ApiSpotifyResumeRoute,
|
||||||
|
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
|
||||||
|
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
|
||||||
|
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
|
||||||
|
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
|
||||||
|
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
._addFileChildren(rootRouteChildren)
|
._addFileChildren(rootRouteChildren)
|
||||||
._addFileTypes<FileRouteTypes>()
|
._addFileTypes<FileRouteTypes>()
|
||||||
const rootServerRouteChildren: RootServerRouteChildren = {
|
|
||||||
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
|
import type { getRouter } from './router.tsx'
|
||||||
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
|
import type { createStart } from '@tanstack/react-start'
|
||||||
ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute,
|
declare module '@tanstack/react-start' {
|
||||||
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute,
|
interface Register {
|
||||||
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
ssr: true
|
||||||
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
router: Awaited<ReturnType<typeof getRouter>>
|
||||||
ApiSpotifySearchServerRoute: ApiSpotifySearchServerRoute,
|
}
|
||||||
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
|
||||||
ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute,
|
|
||||||
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
|
||||||
ApiFilesCollectionRecordIdFileServerRoute:
|
|
||||||
ApiFilesCollectionRecordIdFileServerRoute,
|
|
||||||
}
|
}
|
||||||
export const serverRouteTree = rootServerRouteImport
|
|
||||||
._addFileChildren(rootServerRouteChildren)
|
|
||||||
._addFileTypes<FileServerRouteTypes>()
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||||
import { routerWithQueryClient } from "@tanstack/react-router-with-query";
|
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
||||||
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
||||||
|
|
||||||
export function createRouter() {
|
export function getRouter() {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
@@ -18,28 +18,32 @@ export function createRouter() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return routerWithQueryClient(
|
const router = createTanStackRouter({
|
||||||
createTanStackRouter({
|
routeTree,
|
||||||
routeTree,
|
context: {
|
||||||
context: {
|
queryClient,
|
||||||
queryClient,
|
auth: undefined!,
|
||||||
auth: undefined!,
|
header: defaultHeaderConfig,
|
||||||
header: defaultHeaderConfig,
|
refresh: [],
|
||||||
refresh: [],
|
withPadding: true,
|
||||||
withPadding: true,
|
fullWidth: false,
|
||||||
fullWidth: false,
|
},
|
||||||
},
|
defaultPreload: "intent",
|
||||||
defaultPreload: "intent",
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
defaultErrorComponent: DefaultCatchBoundary,
|
scrollRestoration: true,
|
||||||
scrollRestoration: true,
|
defaultViewTransition: false,
|
||||||
defaultViewTransition: false,
|
});
|
||||||
}),
|
|
||||||
|
setupRouterSsrQueryIntegration({
|
||||||
|
router,
|
||||||
queryClient
|
queryClient
|
||||||
);
|
})
|
||||||
|
|
||||||
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
interface Register {
|
interface Register {
|
||||||
router: ReturnType<typeof createRouter>;
|
router: ReturnType<typeof getRouter>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,12 +83,20 @@ export const Route = createRootRouteWithContext<{
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/TanStack/router/discussions/3531
|
if (location.pathname === '/login' || location.pathname === '/logout') {
|
||||||
const auth = await ensureServerQueryData(
|
return {};
|
||||||
context.queryClient,
|
}
|
||||||
playerQueries.auth()
|
|
||||||
);
|
try {
|
||||||
return { auth };
|
// https://github.com/TanStack/router/discussions/3531
|
||||||
|
const auth = await ensureServerQueryData(
|
||||||
|
context.queryClient,
|
||||||
|
playerQueries.auth()
|
||||||
|
);
|
||||||
|
return { auth };
|
||||||
|
} catch (error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
|
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { tournamentQueries } from "@/features/tournaments/queries";
|
|||||||
import ManageTournament from "@/features/tournaments/components/manage-tournament";
|
import ManageTournament from "@/features/tournaments/components/manage-tournament";
|
||||||
import { ensureServerQueryData } from "@/lib/tanstack-query/utils/ensure";
|
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 }) => {
|
beforeLoad: async ({ context, params }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
const tournament = await ensureServerQueryData(
|
const tournament = await ensureServerQueryData(
|
||||||
32
src/app/routes/_authed/admin/tournaments/$id/teams.tsx
Normal file
32
src/app/routes/_authed/admin/tournaments/$id/teams.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -27,7 +27,6 @@ export const Route = createFileRoute("/_authed/")({
|
|||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const { data: tournament } = useCurrentTournament();
|
const { data: tournament } = useCurrentTournament();
|
||||||
|
|
||||||
if (!tournament.matches || tournament.matches.length === 0) {
|
if (!tournament.matches || tournament.matches.length === 0) {
|
||||||
return <UpcomingTournament tournament={tournament} />;
|
return <UpcomingTournament tournament={tournament} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import TeamProfile from "@/features/teams/components/team-profile";
|
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 { teamKeys, teamQueries } from "@/features/teams/queries";
|
||||||
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
import { prefetchServerQuery } from "@/lib/tanstack-query/utils/prefetch";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { Suspense } from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
@@ -24,6 +26,8 @@ export const Route = createFileRoute("/_authed/teams/$teamId")({
|
|||||||
}),
|
}),
|
||||||
component: () => {
|
component: () => {
|
||||||
const { teamId } = Route.useParams();
|
const { teamId } = Route.useParams();
|
||||||
return <TeamProfile id={teamId} />;
|
return <Suspense fallback={<ProfileSkeleton />}>
|
||||||
|
<TeamProfile id={teamId} />
|
||||||
|
</Suspense>;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { tournamentQueries } from '@/features/tournaments/queries';
|
|||||||
import Profile from '@/features/tournaments/components/profile';
|
import Profile from '@/features/tournaments/components/profile';
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
import { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
import ProfileSkeleton from '@/features/tournaments/components/profile/skeleton';
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
tab: z.string().optional(),
|
tab: z.string().optional(),
|
||||||
@@ -10,9 +12,9 @@ const searchSchema = z.object({
|
|||||||
|
|
||||||
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
||||||
validateSearch: searchSchema,
|
validateSearch: searchSchema,
|
||||||
beforeLoad: async ({ context, params }) => {
|
beforeLoad: ({ context, params }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
await prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
|
prefetchServerQuery(queryClient, tournamentQueries.details(params.tournamentId))
|
||||||
},
|
},
|
||||||
loader: ({ params, context }) => ({
|
loader: ({ params, context }) => ({
|
||||||
header: {
|
header: {
|
||||||
@@ -28,5 +30,7 @@ export const Route = createFileRoute('/_authed/tournaments/$tournamentId')({
|
|||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const tournamentId = Route.useParams().tournamentId;
|
const tournamentId = Route.useParams().tournamentId;
|
||||||
return <Profile id={tournamentId} />
|
return <Suspense fallback={<ProfileSkeleton />}>
|
||||||
|
<Profile id={tournamentId} />
|
||||||
|
</Suspense>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,14 @@
|
|||||||
import Page from '@/components/page'
|
|
||||||
import { Stack } from '@mantine/core'
|
|
||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { TournamentCard } from '@/features/tournaments/components/tournament-card'
|
import { tournamentQueries } from '@/features/tournaments/queries'
|
||||||
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 { prefetchServerQuery } from '@/lib/tanstack-query/utils/prefetch'
|
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/')({
|
export const Route = createFileRoute('/_authed/tournaments/')({
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: async ({ context }) => {
|
||||||
const { queryClient } = context;
|
const { queryClient } = context;
|
||||||
await prefetchServerQuery(queryClient, tournamentQueries.list())
|
prefetchServerQuery(queryClient, tournamentQueries.list())
|
||||||
},
|
},
|
||||||
loader: () => ({
|
loader: () => ({
|
||||||
header: {
|
header: {
|
||||||
@@ -27,27 +21,11 @@ export const Route = createFileRoute('/_authed/tournaments/')({
|
|||||||
})
|
})
|
||||||
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
const { data: tournaments } = useTournaments();
|
return <Suspense fallback={<Stack gap="md">
|
||||||
const { roles } = useAuth();
|
{Array(10).fill(null).map((_, index) => (
|
||||||
const sheet = useSheet();
|
<Skeleton height="120px" w="100%" />
|
||||||
|
))}
|
||||||
return (
|
</Stack>}>
|
||||||
<Stack>
|
<TournamentCardList />
|
||||||
{
|
</Suspense>
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// API file that handles all supertokens auth routes
|
// API file that handles all supertokens auth routes
|
||||||
import { createServerFileRoute } from '@tanstack/react-start/server';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { handleAuthAPIRequest } from 'supertokens-node/custom'
|
import { handleAuthAPIRequest } from 'supertokens-node/custom'
|
||||||
import { ensureSuperTokensBackend } from '@/lib/supertokens/server'
|
import { ensureSuperTokensBackend } from '@/lib/supertokens/server'
|
||||||
|
|
||||||
@@ -12,12 +12,16 @@ const handleRequest = async ({ request }: {request: Request}) => {
|
|||||||
console.log("Handling auth request:", request.method, request.url);
|
console.log("Handling auth request:", request.method, request.url);
|
||||||
return superTokensHandler(request);
|
return superTokensHandler(request);
|
||||||
};
|
};
|
||||||
export const ServerRoute = createServerFileRoute('/api/auth/$').methods({
|
export const Route = createFileRoute('/api/auth/$')({
|
||||||
GET: handleRequest,
|
server: {
|
||||||
POST: handleRequest,
|
handlers: {
|
||||||
PUT: handleRequest,
|
GET: handleRequest,
|
||||||
DELETE: handleRequest,
|
POST: handleRequest,
|
||||||
PATCH: handleRequest,
|
PUT: handleRequest,
|
||||||
OPTIONS: handleRequest,
|
DELETE: handleRequest,
|
||||||
HEAD: handleRequest,
|
PATCH: handleRequest,
|
||||||
|
OPTIONS: handleRequest,
|
||||||
|
HEAD: handleRequest,
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,66 +1,61 @@
|
|||||||
import { createServerFileRoute } from "@tanstack/react-start/server";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
|
import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({
|
export const Route = createFileRoute("/api/events/$")({
|
||||||
GET: ({ request, context }) => {
|
server: {
|
||||||
logger.info('ServerEvents | New connection', context?.userAuthId);
|
middleware: [superTokensRequestMiddleware],
|
||||||
|
handlers: {
|
||||||
|
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 stream = new ReadableStream({
|
const handleEvent = (event: ServerEvent) => {
|
||||||
start(controller) {
|
logger.info("ServerEvents | Event received", event);
|
||||||
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
const message = `data: ${JSON.stringify(event)}\n\n`;
|
||||||
controller.enqueue(new TextEncoder().encode(connectMessage));
|
try {
|
||||||
|
controller.enqueue(new TextEncoder().encode(message));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("ServerEvents | Error sending SSE message", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleEvent = (event: ServerEvent) => {
|
serverEvents.on("test", handleEvent);
|
||||||
logger.info('ServerEvents | Event received', event);
|
serverEvents.on("match", handleEvent);
|
||||||
const message = `data: ${JSON.stringify(event)}\n\n`;
|
serverEvents.on("reaction", handleEvent);
|
||||||
try {
|
|
||||||
controller.enqueue(new TextEncoder().encode(message));
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("ServerEvents | Error sending SSE message", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
serverEvents.on("test", handleEvent);
|
const pingInterval = setInterval(() => {
|
||||||
serverEvents.on("match", handleEvent);
|
try {
|
||||||
serverEvents.on("reaction", handleEvent);
|
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
|
||||||
|
controller.enqueue(new TextEncoder().encode(pingMessage));
|
||||||
|
} catch (e) {
|
||||||
|
clearInterval(pingInterval);
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
const pingInterval = setInterval(() => {
|
const cleanup = () => {
|
||||||
try {
|
serverEvents.off("test", handleEvent);
|
||||||
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
|
clearInterval(pingInterval);
|
||||||
controller.enqueue(new TextEncoder().encode(pingMessage));
|
};
|
||||||
} catch (e) {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
controller.close();
|
|
||||||
}
|
|
||||||
}, 30000);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
request.signal?.addEventListener("abort", cleanup);
|
||||||
serverEvents.off("test", handleEvent);
|
return cleanup;
|
||||||
clearInterval(pingInterval);
|
},
|
||||||
try {
|
});
|
||||||
logger.info('ServerEvents | Closing connection', context?.userAuthId);
|
|
||||||
controller.close();
|
|
||||||
} catch (e) {
|
|
||||||
logger.error('ServerEvents | Error closing controller', e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
request.signal?.addEventListener("abort", cleanup);
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
return cleanup;
|
"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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,95 +1,113 @@
|
|||||||
import { createServerFileRoute } from "@tanstack/react-start/server";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute("/api/files/$collection/$recordId/$file").methods({
|
export const Route = createFileRoute(
|
||||||
GET: async ({ params, request }) => {
|
"/api/files/$collection/$recordId/$file"
|
||||||
try {
|
)({
|
||||||
const { collection, recordId, file } = params;
|
server: {
|
||||||
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://127.0.0.1:8090';
|
handlers: {
|
||||||
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
GET: async ({ params, request }) => {
|
||||||
|
try {
|
||||||
|
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', {
|
logger.info("File proxy", {
|
||||||
collection,
|
collection,
|
||||||
recordId,
|
recordId,
|
||||||
file,
|
file,
|
||||||
targetUrl: fileUrl
|
targetUrl: fileUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(fileUrl, {
|
const response = await fetch(fileUrl, {
|
||||||
method: 'GET',
|
method: "GET",
|
||||||
headers: {
|
headers: {
|
||||||
...(request.headers.get('range') && { 'Range': request.headers.get('range')! }),
|
...(request.headers.get("range") && {
|
||||||
...(request.headers.get('if-none-match') && { 'If-None-Match': request.headers.get('if-none-match')! }),
|
Range: request.headers.get("range")!,
|
||||||
...(request.headers.get('if-modified-since') && { 'If-Modified-Since': request.headers.get('if-modified-since')! }),
|
}),
|
||||||
},
|
...(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.ok) {
|
if (!response.ok) {
|
||||||
logger.error('PocketBase file request failed', {
|
logger.error("PocketBase file request failed", {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
url: fileUrl
|
url: fileUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
return new Response('File not found', { status: 404 });
|
return new Response("File not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(`PocketBase error: ${response.statusText}`, {
|
||||||
|
status: response.status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = response.body;
|
||||||
|
const responseHeaders = new Headers();
|
||||||
|
const headers = [
|
||||||
|
"content-type",
|
||||||
|
"content-length",
|
||||||
|
"content-disposition",
|
||||||
|
"etag",
|
||||||
|
"last-modified",
|
||||||
|
"cache-control",
|
||||||
|
"accept-ranges",
|
||||||
|
"content-range",
|
||||||
|
];
|
||||||
|
|
||||||
|
headers.forEach((header) => {
|
||||||
|
const value = response.headers.get(header);
|
||||||
|
if (value) {
|
||||||
|
responseHeaders.set(header, value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
responseHeaders.set("Access-Control-Allow-Origin", "*");
|
||||||
|
responseHeaders.set(
|
||||||
|
"Access-Control-Allow-Methods",
|
||||||
|
"GET, HEAD, OPTIONS"
|
||||||
|
);
|
||||||
|
responseHeaders.set(
|
||||||
|
"Access-Control-Allow-Headers",
|
||||||
|
"Range, If-None-Match, If-Modified-Since"
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("File proxy response", {
|
||||||
|
status: response.status,
|
||||||
|
contentType: response.headers.get("content-type"),
|
||||||
|
contentLength: response.headers.get("content-length"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("File proxy error", error);
|
||||||
|
return new Response("Internal server error", { status: 500 });
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
return new Response(`PocketBase error: ${response.statusText}`, {
|
OPTIONS: () => {
|
||||||
status: response.status
|
return new Response(null, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Max-Age": "86400",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
|
},
|
||||||
const body = response.body;
|
|
||||||
const responseHeaders = new Headers();
|
|
||||||
const headers = [
|
|
||||||
'content-type',
|
|
||||||
'content-length',
|
|
||||||
'content-disposition',
|
|
||||||
'etag',
|
|
||||||
'last-modified',
|
|
||||||
'cache-control',
|
|
||||||
'accept-ranges',
|
|
||||||
'content-range'
|
|
||||||
];
|
|
||||||
|
|
||||||
headers.forEach(header => {
|
|
||||||
const value = response.headers.get(header);
|
|
||||||
if (value) {
|
|
||||||
responseHeaders.set(header, value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
responseHeaders.set('Access-Control-Allow-Origin', '*');
|
|
||||||
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
|
||||||
responseHeaders.set('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Modified-Since');
|
|
||||||
|
|
||||||
logger.info('File proxy response', {
|
|
||||||
status: response.status,
|
|
||||||
contentType: response.headers.get('content-type'),
|
|
||||||
contentLength: response.headers.get('content-length')
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(body, {
|
|
||||||
status: response.status,
|
|
||||||
statusText: response.statusText,
|
|
||||||
headers: responseHeaders
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('File proxy error', error);
|
|
||||||
return new Response('Internal server error', { status: 500 });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
OPTIONS: () => {
|
|
||||||
return new Response(null, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
|
||||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
||||||
'Access-Control-Max-Age': '86400',
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
@@ -1,127 +1,145 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { SpotifyAuth } from '@/lib/spotify/auth'
|
|
||||||
|
|
||||||
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!
|
const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
|
||||||
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!
|
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
|
||||||
const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
|
const SPOTIFY_REDIRECT_URI = process.env.VITE_SPOTIFY_REDIRECT_URI!;
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/callback').methods({
|
export const Route = createFileRoute("/api/spotify/callback")({
|
||||||
GET: async ({ request }: { request: Request }) => {
|
server: {
|
||||||
const getReturnPath = (state: string | null): string => {
|
handlers: {
|
||||||
if (!state) return '/';
|
GET: async ({ request }: { request: Request }) => {
|
||||||
try {
|
const getReturnPath = (state: string | null): string => {
|
||||||
const decodedState = JSON.parse(atob(state));
|
if (!state) return "/";
|
||||||
return decodedState.returnPath || '/';
|
try {
|
||||||
} catch {
|
const decodedState = JSON.parse(atob(state));
|
||||||
return '/';
|
return decodedState.returnPath || "/";
|
||||||
}
|
} catch {
|
||||||
};
|
return "/";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url)
|
const url = new URL(request.url);
|
||||||
const code = url.searchParams.get('code')
|
const code = url.searchParams.get("code");
|
||||||
const state = url.searchParams.get('state')
|
const state = url.searchParams.get("state");
|
||||||
const error = url.searchParams.get('error')
|
const error = url.searchParams.get("error");
|
||||||
|
|
||||||
const returnPath = getReturnPath(state);
|
const returnPath = getReturnPath(state);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.error('Spotify OAuth error:', error)
|
console.error("Spotify OAuth error:", error);
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
'Location': returnPath + '?spotify_error=' + encodeURIComponent(error),
|
Location:
|
||||||
},
|
returnPath + "?spotify_error=" + encodeURIComponent(error),
|
||||||
})
|
},
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!code || !state) {
|
if (!code || !state) {
|
||||||
console.error('Missing code or state:', { code: !!code, state: !!state })
|
console.error("Missing code or state:", {
|
||||||
return new Response(null, {
|
code: !!code,
|
||||||
status: 302,
|
state: !!state,
|
||||||
headers: {
|
});
|
||||||
'Location': returnPath + '?spotify_error=missing_code_or_state',
|
return new Response(null, {
|
||||||
},
|
status: 302,
|
||||||
})
|
headers: {
|
||||||
}
|
Location: returnPath + "?spotify_error=missing_code_or_state",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log('Token exchange attempt:', {
|
console.log("Token exchange attempt:", {
|
||||||
client_id: SPOTIFY_CLIENT_ID,
|
client_id: SPOTIFY_CLIENT_ID,
|
||||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
has_code: !!code,
|
has_code: !!code,
|
||||||
has_state: !!state,
|
has_state: !!state,
|
||||||
})
|
});
|
||||||
|
|
||||||
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
const tokenResponse = await fetch(
|
||||||
method: 'POST',
|
"https://accounts.spotify.com/api/token",
|
||||||
headers: {
|
{
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
method: "POST",
|
||||||
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
|
headers: {
|
||||||
},
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
body: new URLSearchParams({
|
Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
|
||||||
grant_type: 'authorization_code',
|
},
|
||||||
code,
|
body: new URLSearchParams({
|
||||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
grant_type: "authorization_code",
|
||||||
}),
|
code,
|
||||||
})
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const errorText = await tokenResponse.text()
|
const errorText = await tokenResponse.text();
|
||||||
console.error('Token exchange error:', {
|
console.error("Token exchange error:", {
|
||||||
status: tokenResponse.status,
|
status: tokenResponse.status,
|
||||||
statusText: tokenResponse.statusText,
|
statusText: tokenResponse.statusText,
|
||||||
body: errorText,
|
body: errorText,
|
||||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||||
})
|
});
|
||||||
|
|
||||||
const errorParam = encodeURIComponent(`${tokenResponse.status}: ${errorText}`)
|
const errorParam = encodeURIComponent(
|
||||||
return new Response(null, {
|
`${tokenResponse.status}: ${errorText}`
|
||||||
status: 302,
|
);
|
||||||
headers: {
|
return new Response(null, {
|
||||||
'Location': `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
|
status: 302,
|
||||||
},
|
headers: {
|
||||||
})
|
Location: `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const tokens = await tokenResponse.json()
|
const tokens = await tokenResponse.json();
|
||||||
|
|
||||||
console.log('Token exchange successful:', {
|
console.log("Token exchange successful:", {
|
||||||
has_access_token: !!tokens.access_token,
|
has_access_token: !!tokens.access_token,
|
||||||
has_refresh_token: !!tokens.refresh_token,
|
has_refresh_token: !!tokens.refresh_token,
|
||||||
expires_in: tokens.expires_in,
|
expires_in: tokens.expires_in,
|
||||||
})
|
});
|
||||||
|
|
||||||
console.log('Decoded return path:', returnPath);
|
console.log("Decoded return path:", returnPath);
|
||||||
|
|
||||||
const response = new Response(null, {
|
const response = new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
'Location': returnPath + '?spotify_auth=success',
|
Location: returnPath + "?spotify_auth=success",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
const isSecure = process.env.NODE_ENV === 'production'
|
const isSecure = import.meta.env.NODE_ENV === "production";
|
||||||
const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}`
|
const cookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokens.expires_in}`;
|
||||||
|
|
||||||
response.headers.append('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`)
|
response.headers.append(
|
||||||
|
"Set-Cookie",
|
||||||
|
`spotify_access_token=${tokens.access_token}; ${cookieOptions}`
|
||||||
|
);
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const refreshCookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${60 * 60 * 24 * 30}` // 30 days
|
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}`)
|
response.headers.append(
|
||||||
}
|
"Set-Cookie",
|
||||||
|
`spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Spotify callback error:', error)
|
console.error("Spotify callback error:", error);
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const state = url.searchParams.get('state');
|
const state = url.searchParams.get("state");
|
||||||
const returnPath = getReturnPath(state);
|
const returnPath = getReturnPath(state);
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
'Location': returnPath + '?spotify_error=callback_failed',
|
Location: returnPath + "?spotify_error=callback_failed",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,59 +1,60 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { SpotifyWebApiClient } from '@/lib/spotify/client'
|
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||||
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types'
|
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/capture').methods({
|
export const Route = createFileRoute("/api/spotify/capture")({
|
||||||
POST: async ({ request }: { request: Request }) => {
|
server: {
|
||||||
try {
|
handlers: {
|
||||||
// Get access token from cookies
|
POST: async ({ request }: { request: Request }) => {
|
||||||
const cookies = request.headers.get('Cookie') || ''
|
try {
|
||||||
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/)
|
const cookies = request.headers.get("Cookie") || "";
|
||||||
|
const accessTokenMatch = cookies.match(
|
||||||
|
/spotify_access_token=([^;]+)/
|
||||||
|
);
|
||||||
|
|
||||||
if (!accessTokenMatch) {
|
if (!accessTokenMatch) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'No access token found' }),
|
JSON.stringify({ error: "No access token found" }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessToken = decodeURIComponent(accessTokenMatch[1])
|
const accessToken = decodeURIComponent(accessTokenMatch[1]);
|
||||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||||
|
|
||||||
// Create a snapshot of the current playback state
|
const snapshot = await spotifyClient.createPlaybackSnapshot();
|
||||||
const snapshot = await spotifyClient.createPlaybackSnapshot()
|
|
||||||
|
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'No active playback to capture' }),
|
JSON.stringify({ error: "No active playback to capture" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({ snapshot }), {
|
||||||
JSON.stringify({ snapshot }),
|
status: 200,
|
||||||
{
|
headers: { "Content-Type": "application/json" },
|
||||||
status: 200,
|
});
|
||||||
headers: { 'Content-Type': 'application/json' }
|
} catch (error) {
|
||||||
|
console.error("Spotify capture error:", error);
|
||||||
|
|
||||||
|
const errorMessage =
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to capture playback state";
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Spotify capture error:', error)
|
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : 'Failed to capture playback state'
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: errorMessage }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,195 +1,203 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { SpotifyWebApiClient } from '@/lib/spotify/client'
|
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||||
|
|
||||||
// Helper function to get access token from cookies
|
|
||||||
function getAccessTokenFromCookies(request: Request): string | null {
|
function getAccessTokenFromCookies(request: Request): string | null {
|
||||||
const cookieHeader = request.headers.get('cookie')
|
const cookieHeader = request.headers.get("cookie");
|
||||||
if (!cookieHeader) return null
|
if (!cookieHeader) return null;
|
||||||
|
|
||||||
const cookies = Object.fromEntries(
|
const cookies = Object.fromEntries(
|
||||||
cookieHeader.split('; ').map(c => c.split('='))
|
cookieHeader.split("; ").map((c) => c.split("="))
|
||||||
)
|
);
|
||||||
|
|
||||||
return cookies.spotify_access_token || null
|
return cookies.spotify_access_token || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/playback').methods({
|
export const Route = createFileRoute("/api/spotify/playback")({
|
||||||
POST: async ({ request }: { request: Request }) => {
|
server: {
|
||||||
try {
|
handlers: {
|
||||||
const accessToken = getAccessTokenFromCookies(request)
|
POST: async ({ request }: { request: Request }) => {
|
||||||
if (!accessToken) {
|
try {
|
||||||
return new Response(
|
const accessToken = getAccessTokenFromCookies(request);
|
||||||
JSON.stringify({ error: 'No access token found' }),
|
if (!accessToken) {
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = await request.json()
|
|
||||||
const { action, deviceId, volumePercent } = 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') {
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'volumePercent must be a number' }),
|
JSON.stringify({ error: "No access token found" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
await spotifyClient.setVolume(volumePercent)
|
|
||||||
break
|
const body = await request.json();
|
||||||
case 'transfer':
|
const { action, deviceId, volumePercent, trackId, positionMs } = body;
|
||||||
if (!deviceId) {
|
|
||||||
|
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(
|
return new Response(
|
||||||
JSON.stringify({ error: 'deviceId is required for transfer action' }),
|
JSON.stringify({ error: "No access token found" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
await spotifyClient.transferPlayback(deviceId)
|
|
||||||
break
|
const url = new URL(request.url);
|
||||||
default:
|
const type = url.searchParams.get("type");
|
||||||
|
|
||||||
|
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||||
|
|
||||||
|
if (type === "devices") {
|
||||||
|
const devices = await spotifyClient.getDevices();
|
||||||
|
return new Response(JSON.stringify({ devices }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} else if (type === "state") {
|
||||||
|
const playbackState = await spotifyClient.getPlaybackState();
|
||||||
|
return new Response(JSON.stringify({ playbackState }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const [devices, playbackState] = await Promise.all([
|
||||||
|
spotifyClient.getDevices(),
|
||||||
|
spotifyClient.getPlaybackState(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ devices, playbackState }), {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get playback data error:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Invalid action' }),
|
JSON.stringify({ error: "Failed to get playback data" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 500,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ success: true }),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Playback control error:', error)
|
|
||||||
|
|
||||||
// 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' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log the full error details for debugging
|
|
||||||
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 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' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,72 +1,71 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { SpotifyWebApiClient } from '@/lib/spotify/client'
|
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||||
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types'
|
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/resume').methods({
|
export const Route = createFileRoute("/api/spotify/resume")({
|
||||||
POST: async ({ request }: { request: Request }) => {
|
server: {
|
||||||
try {
|
handlers: {
|
||||||
// Get access token from cookies
|
POST: async ({ request }: { request: Request }) => {
|
||||||
const cookies = request.headers.get('Cookie') || ''
|
try {
|
||||||
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/)
|
const cookies = request.headers.get("Cookie") || "";
|
||||||
|
const accessTokenMatch = cookies.match(
|
||||||
|
/spotify_access_token=([^;]+)/
|
||||||
|
);
|
||||||
|
|
||||||
if (!accessTokenMatch) {
|
if (!accessTokenMatch) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'No access token found' }),
|
JSON.stringify({ error: "No access token found" }),
|
||||||
{
|
{
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const accessToken = decodeURIComponent(accessTokenMatch[1])
|
const accessToken = decodeURIComponent(accessTokenMatch[1]);
|
||||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||||
|
|
||||||
// Parse the request body to get the snapshot
|
const body = await request.json();
|
||||||
const body = await request.json()
|
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot };
|
||||||
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot }
|
|
||||||
|
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'No snapshot provided' }),
|
JSON.stringify({ error: "No snapshot provided" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore the playback state from the snapshot
|
await spotifyClient.restorePlaybackSnapshot(snapshot);
|
||||||
await spotifyClient.restorePlaybackSnapshot(snapshot)
|
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({ success: true }), {
|
||||||
JSON.stringify({ success: true }),
|
status: 200,
|
||||||
{
|
headers: { "Content-Type": "application/json" },
|
||||||
status: 200,
|
});
|
||||||
headers: { 'Content-Type': 'application/json' }
|
} catch (error) {
|
||||||
|
console.error("Spotify resume error:", error);
|
||||||
|
|
||||||
|
let errorMessage = "Failed to resume playback state";
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (
|
||||||
|
error.message.includes("Premium") ||
|
||||||
|
error.message.includes("403")
|
||||||
|
) {
|
||||||
|
errorMessage = "Spotify premium required";
|
||||||
|
} else {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Spotify resume error:', error)
|
|
||||||
|
|
||||||
let errorMessage = 'Failed to resume playback state'
|
|
||||||
|
|
||||||
// Handle common Spotify Premium requirement error
|
|
||||||
if (error instanceof Error) {
|
|
||||||
if (error.message.includes('Premium') || error.message.includes('403')) {
|
|
||||||
errorMessage = 'Spotify Premium required for playback control'
|
|
||||||
} else {
|
|
||||||
errorMessage = error.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: errorMessage }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,81 +1,87 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
// Function to get Client Credentials access token
|
|
||||||
async function getClientCredentialsToken(): Promise<string> {
|
async function getClientCredentialsToken(): Promise<string> {
|
||||||
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID
|
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID;
|
||||||
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET
|
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
|
||||||
|
|
||||||
if (!clientId || !clientSecret) {
|
if (!clientId || !clientSecret) {
|
||||||
throw new Error('Missing Spotify client credentials')
|
throw new Error("Missing Spotify client credentials");
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('https://accounts.spotify.com/api/token', {
|
const response = await fetch("https://accounts.spotify.com/api/token", {
|
||||||
method: 'POST',
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
|
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
|
||||||
},
|
},
|
||||||
body: 'grant_type=client_credentials',
|
body: "grant_type=client_credentials",
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to get Spotify access token')
|
throw new Error("Failed to get Spotify access token");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json();
|
||||||
return data.access_token
|
return data.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/search').methods({
|
export const Route = createFileRoute("/api/spotify/search")({
|
||||||
GET: async ({ request }: { request: Request }) => {
|
server: {
|
||||||
try {
|
handlers: {
|
||||||
const url = new URL(request.url)
|
GET: async ({ request }: { request: Request }) => {
|
||||||
const query = url.searchParams.get('q')
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const query = url.searchParams.get("q");
|
||||||
|
|
||||||
if (!query) {
|
if (!query) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Query parameter q is required' }),
|
JSON.stringify({ error: "Query parameter q is required" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get client credentials access token
|
// Get client credentials access token
|
||||||
const accessToken = await getClientCredentialsToken()
|
const accessToken = await getClientCredentialsToken();
|
||||||
|
|
||||||
// Search using Spotify API directly
|
// Search using Spotify API directly
|
||||||
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`
|
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`;
|
||||||
|
|
||||||
const searchResponse = await fetch(searchUrl, {
|
const searchResponse = await fetch(searchUrl, {
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${accessToken}`,
|
Authorization: `Bearer ${accessToken}`,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!searchResponse.ok) {
|
if (!searchResponse.ok) {
|
||||||
throw new Error('Spotify search request failed')
|
throw new Error("Spotify search request failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchResult = await searchResponse.json()
|
const searchResult = await searchResponse.json();
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ tracks: searchResult.tracks.items }),
|
JSON.stringify({ tracks: searchResult.tracks.items }),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Search error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Search failed",
|
||||||
|
details: error instanceof Error ? error.message : "Unknown error",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
} catch (error) {
|
},
|
||||||
console.error('Search error:', error)
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Search failed', details: error instanceof Error ? error.message : 'Unknown error' }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
|
||||||
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!
|
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
|
export const Route = createFileRoute("/api/spotify/token")({
|
||||||
POST: async ({ request }: { request: Request }) => {
|
server: {
|
||||||
try {
|
handlers: {
|
||||||
const body = await request.json()
|
POST: async ({ request }: { request: Request }) => {
|
||||||
const { refresh_token } = body
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { refresh_token } = body;
|
||||||
|
|
||||||
if (!refresh_token) {
|
if (!refresh_token) {
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'refresh_token is required' }),
|
JSON.stringify({ error: "refresh_token is required" }),
|
||||||
{
|
{
|
||||||
status: 400,
|
status: 400,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh access token
|
const tokenResponse = await fetch(
|
||||||
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
"https://accounts.spotify.com/api/token",
|
||||||
method: 'POST',
|
{
|
||||||
headers: {
|
method: "POST",
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
headers: {
|
||||||
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
|
"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',
|
body: new URLSearchParams({
|
||||||
refresh_token,
|
grant_type: "refresh_token",
|
||||||
}),
|
refresh_token,
|
||||||
})
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
if (!tokenResponse.ok) {
|
if (!tokenResponse.ok) {
|
||||||
const error = await tokenResponse.json()
|
const error = await tokenResponse.json();
|
||||||
console.error('Token refresh error:', error)
|
console.error("Token refresh error:", error);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ error: 'Failed to refresh token', details: error }),
|
JSON.stringify({
|
||||||
{
|
error: "Failed to refresh token",
|
||||||
status: tokenResponse.status,
|
details: error,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
}),
|
||||||
|
{
|
||||||
|
status: tokenResponse.status,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tokens = await tokenResponse.json()
|
const tokens = await tokenResponse.json();
|
||||||
|
|
||||||
// Return new tokens
|
return new Response(
|
||||||
return new Response(
|
JSON.stringify({
|
||||||
JSON.stringify({
|
access_token: tokens.access_token,
|
||||||
access_token: tokens.access_token,
|
expires_in: tokens.expires_in,
|
||||||
expires_in: tokens.expires_in,
|
scope: tokens.scope,
|
||||||
scope: tokens.scope,
|
token_type: tokens.token_type,
|
||||||
token_type: tokens.token_type,
|
}),
|
||||||
}),
|
{
|
||||||
{
|
status: 200,
|
||||||
status: 200,
|
headers: { "Content-Type": "application/json" },
|
||||||
headers: { 'Content-Type': 'application/json' },
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Token refresh endpoint error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Internal server error" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
} catch (error) {
|
|
||||||
console.error('Token refresh endpoint error:', error)
|
GET: async ({ request }: { request: Request }) => {
|
||||||
return new Response(
|
try {
|
||||||
JSON.stringify({ error: 'Internal server error' }),
|
const cookieHeader = request.headers.get("cookie");
|
||||||
{
|
if (!cookieHeader) {
|
||||||
status: 500,
|
return new Response(JSON.stringify({ error: "No cookies found" }), {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookies = Object.fromEntries(
|
||||||
|
cookieHeader.split("; ").map((c: string) => c.split("="))
|
||||||
|
);
|
||||||
|
|
||||||
|
const accessToken = cookies.spotify_access_token;
|
||||||
|
const refreshToken = cookies.spotify_refresh_token;
|
||||||
|
|
||||||
|
if (!accessToken && !refreshToken) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "No Spotify tokens found" }),
|
||||||
|
{
|
||||||
|
status: 401,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
access_token: accessToken || null,
|
||||||
|
refresh_token: refreshToken || null,
|
||||||
|
has_tokens: !!(accessToken || refreshToken),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Get tokens endpoint error:", error);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Internal server error" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
)
|
},
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
|
});
|
||||||
// GET endpoint to retrieve current tokens from cookies
|
|
||||||
GET: async ({ request }: { request: Request }) => {
|
|
||||||
try {
|
|
||||||
const cookieHeader = request.headers.get('cookie')
|
|
||||||
if (!cookieHeader) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'No cookies found' }),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookies = Object.fromEntries(
|
|
||||||
cookieHeader.split('; ').map((c: string) => c.split('='))
|
|
||||||
)
|
|
||||||
|
|
||||||
const accessToken = cookies.spotify_access_token
|
|
||||||
const refreshToken = cookies.spotify_refresh_token
|
|
||||||
|
|
||||||
if (!accessToken && !refreshToken) {
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'No Spotify tokens found' }),
|
|
||||||
{
|
|
||||||
status: 401,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({
|
|
||||||
access_token: accessToken || null,
|
|
||||||
refresh_token: refreshToken || null,
|
|
||||||
has_tokens: !!(accessToken || refreshToken),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Get tokens endpoint error:', error)
|
|
||||||
return new Response(
|
|
||||||
JSON.stringify({ error: 'Internal server error' }),
|
|
||||||
{
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,116 +1,148 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { superTokensRequestMiddleware } from '@/utils/supertokens';
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
import { pbAdmin } from '@/lib/pocketbase/client';
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from "@/lib/logger";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
const uploadSchema = z.object({
|
const uploadSchema = z.object({
|
||||||
teamId: z.string().min(1, 'Team ID is required'),
|
teamId: z.string().min(1, "Team ID is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/teams/upload-logo')
|
export const Route = createFileRoute("/api/teams/upload-logo")({
|
||||||
.middleware([superTokensRequestMiddleware])
|
server: {
|
||||||
.methods({
|
middleware: [superTokensRequestMiddleware],
|
||||||
POST: async ({ request, context }) => {
|
handlers: {
|
||||||
try {
|
POST: async ({ request, context }) => {
|
||||||
const userId = context.userAuthId;
|
try {
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const userId = context.userAuthId;
|
||||||
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
if (!userId) return new Response('Unauthenticated', { status: 401 });
|
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const teamId = formData.get('teamId') as string;
|
const teamId = formData.get("teamId") as string;
|
||||||
const logoFile = formData.get('logo') as File;
|
const logoFile = formData.get("logo") as File;
|
||||||
|
|
||||||
const validationResult = uploadSchema.safeParse({ teamId });
|
const validationResult = uploadSchema.safeParse({ teamId });
|
||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Invalid input',
|
JSON.stringify({
|
||||||
details: validationResult.error.issues
|
error: "Invalid input",
|
||||||
}), {
|
details: validationResult.error.issues,
|
||||||
status: 400,
|
}),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!logoFile || logoFile.size === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Logo file is required",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTypes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
];
|
||||||
|
if (!allowedTypes.includes(logoFile.type)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 10 * 1024 * 1024;
|
||||||
|
if (logoFile.size > maxSize) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "File too large. Maximum size is 10MB.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const team = await pbAdmin.getTeam(teamId);
|
||||||
|
if (!team) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Team not found",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await pbAdmin.getPlayerByAuthId(userId);
|
||||||
|
if (!team.players.map((p) => p.id).includes(user?.id!) && !isAdmin)
|
||||||
|
return new Response("Unauthorized", { status: 403 });
|
||||||
|
|
||||||
|
logger.info("Uploading team logo", {
|
||||||
|
teamId,
|
||||||
|
fileName: logoFile.name,
|
||||||
|
fileSize: logoFile.size,
|
||||||
|
userId,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (!logoFile || logoFile.size === 0) {
|
const pbFormData = new FormData();
|
||||||
return new Response(JSON.stringify({
|
pbFormData.append("logo", logoFile);
|
||||||
error: 'Logo file is required'
|
|
||||||
}), {
|
const updatedTeam = await pbAdmin.updateTeam(
|
||||||
status: 400,
|
teamId,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
pbFormData as any
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Team logo uploaded successfully", {
|
||||||
|
teamId,
|
||||||
|
logo: updatedTeam.logo,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
team: updatedTeam,
|
||||||
|
message: "Logo uploaded successfully",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("Error uploading team logo:", error);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to upload logo",
|
||||||
|
message: error.message || "Unknown error occurred",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
},
|
||||||
if (!allowedTypes.includes(logoFile.type)) {
|
},
|
||||||
return new Response(JSON.stringify({
|
});
|
||||||
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
|
|
||||||
}), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxSize = 10 * 1024 * 1024;
|
|
||||||
if (logoFile.size > maxSize) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'File too large. Maximum size is 10MB.'
|
|
||||||
}), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const team = await pbAdmin.getTeam(teamId);
|
|
||||||
if (!team) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Team not found'
|
|
||||||
}), {
|
|
||||||
status: 404,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,115 +1,145 @@
|
|||||||
import { createServerFileRoute } from '@tanstack/react-start/server';
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { superTokensRequestMiddleware } from '@/utils/supertokens';
|
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||||
import { pbAdmin } from '@/lib/pocketbase/client';
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from "@/lib/logger";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
|
|
||||||
const uploadSchema = z.object({
|
const uploadSchema = z.object({
|
||||||
tournamentId: z.string().min(1, 'Tournament ID is required'),
|
tournamentId: z.string().min(1, "Tournament ID is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ServerRoute = createServerFileRoute('/api/tournaments/upload-logo')
|
export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
||||||
.middleware([superTokensRequestMiddleware])
|
server: {
|
||||||
.methods({
|
middleware: [superTokensRequestMiddleware],
|
||||||
POST: async ({ request, context }) => {
|
handlers: {
|
||||||
try {
|
POST: async ({ request, context }) => {
|
||||||
const userId = context.userAuthId;
|
try {
|
||||||
const isAdmin = context.roles.includes("Admin");
|
const userId = context.userAuthId;
|
||||||
|
const isAdmin = context.roles.includes("Admin");
|
||||||
|
|
||||||
if (!userId) return new Response('Unauthenticated', { status: 401 });
|
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
||||||
if (!isAdmin) return new Response('Unauthorized', { status: 403 });
|
if (!isAdmin) return new Response("Unauthorized", { status: 403 });
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const tournamentId = formData.get('tournamentId') as string;
|
const tournamentId = formData.get("tournamentId") as string;
|
||||||
const logoFile = formData.get('logo') as File;
|
const logoFile = formData.get("logo") as File;
|
||||||
|
|
||||||
const validationResult = uploadSchema.safeParse({ tournamentId });
|
const validationResult = uploadSchema.safeParse({ tournamentId });
|
||||||
if (!validationResult.success) {
|
if (!validationResult.success) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(
|
||||||
error: 'Invalid input',
|
JSON.stringify({
|
||||||
details: validationResult.error.issues
|
error: "Invalid input",
|
||||||
}), {
|
details: validationResult.error.issues,
|
||||||
status: 400,
|
}),
|
||||||
headers: { 'Content-Type': 'application/json' }
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!logoFile || logoFile.size === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Logo file is required",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedTypes = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/gif",
|
||||||
|
];
|
||||||
|
if (!allowedTypes.includes(logoFile.type)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSize = 10 * 1024 * 1024;
|
||||||
|
if (logoFile.size > maxSize) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "File too large. Maximum size is 10MB.",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||||
|
if (!tournament) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Tournament not found",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 404,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("Uploading tournament logo", {
|
||||||
|
tournamentId,
|
||||||
|
fileName: logoFile.name,
|
||||||
|
fileSize: logoFile.size,
|
||||||
|
userId,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (!logoFile || logoFile.size === 0) {
|
const pbFormData = new FormData();
|
||||||
return new Response(JSON.stringify({
|
pbFormData.append("logo", logoFile);
|
||||||
error: 'Logo file is required'
|
|
||||||
}), {
|
const updatedTournament = await pbAdmin.updateTournament(
|
||||||
status: 400,
|
tournamentId,
|
||||||
headers: { 'Content-Type': 'application/json' }
|
pbFormData as any
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info("Tournament logo uploaded successfully", {
|
||||||
|
tournamentId,
|
||||||
|
logo: updatedTournament.logo,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
tournament: updatedTournament,
|
||||||
|
message: "Logo uploaded successfully",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error("Error uploading tournament logo:", error);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Failed to upload logo",
|
||||||
|
message: error.message || "Unknown error occurred",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
},
|
||||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
},
|
||||||
if (!allowedTypes.includes(logoFile.type)) {
|
},
|
||||||
return new Response(JSON.stringify({
|
});
|
||||||
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
|
|
||||||
}), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxSize = 10 * 1024 * 1024;
|
|
||||||
if (logoFile.size > maxSize) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'File too large. Maximum size is 10MB.'
|
|
||||||
}), {
|
|
||||||
status: 400,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
|
||||||
if (!tournament) {
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Tournament not found'
|
|
||||||
}), {
|
|
||||||
status: 404,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
logger.info('Uploading tournament logo', {
|
|
||||||
tournamentId,
|
|
||||||
fileName: logoFile.name,
|
|
||||||
fileSize: logoFile.size,
|
|
||||||
userId
|
|
||||||
});
|
|
||||||
|
|
||||||
const pbFormData = new FormData();
|
|
||||||
pbFormData.append('logo', logoFile);
|
|
||||||
|
|
||||||
const updatedTournament = await pbAdmin.updateTournament(tournamentId, pbFormData as any);
|
|
||||||
|
|
||||||
logger.info('Tournament logo uploaded successfully', {
|
|
||||||
tournamentId,
|
|
||||||
logo: updatedTournament.logo
|
|
||||||
});
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
success: true,
|
|
||||||
tournament: updatedTournament,
|
|
||||||
message: 'Logo uploaded successfully'
|
|
||||||
}), {
|
|
||||||
status: 200,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error('Error uploading tournament logo:', error);
|
|
||||||
|
|
||||||
return new Response(JSON.stringify({
|
|
||||||
error: 'Failed to upload logo',
|
|
||||||
message: error.message || 'Unknown error occurred'
|
|
||||||
}), {
|
|
||||||
status: 500,
|
|
||||||
headers: { 'Content-Type': 'application/json' }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,38 +1,33 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import FullScreenLoader from '@/components/full-screen-loader'
|
import FullScreenLoader from '@/components/full-screen-loader'
|
||||||
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
|
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
|
||||||
|
import { resetRefreshFlag } from '@/lib/supertokens/client'
|
||||||
|
|
||||||
export const Route = createFileRoute('/refresh-session')({
|
export const Route = createFileRoute('/refresh-session')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
// https://supertokens.com/docs/additional-verification/session-verification/ssr?uiType=custom
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
|
const hasAttemptedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasAttemptedRef.current) return;
|
||||||
|
hasAttemptedRef.current = true;
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
try {
|
try {
|
||||||
|
resetRefreshFlag();
|
||||||
const refreshed = await attemptRefreshingSession()
|
const refreshed = await attemptRefreshingSession()
|
||||||
|
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const redirect = urlParams.get('redirect')
|
const redirect = urlParams.get('redirect')
|
||||||
|
|
||||||
const isServerFunction = redirect && (
|
if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) {
|
||||||
redirect.startsWith('_serverFn') ||
|
|
||||||
redirect.startsWith('api/') ||
|
|
||||||
redirect.includes('_serverFn')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (redirect && !isServerFunction) {
|
|
||||||
window.location.href = decodeURIComponent(redirect)
|
window.location.href = decodeURIComponent(redirect)
|
||||||
} else {
|
} else {
|
||||||
const referrer = document.referrer;
|
window.location.href = '/';
|
||||||
const referrerUrl = referrer && !referrer.includes('/_serverFn') && !referrer.includes('/api/')
|
|
||||||
? referrer
|
|
||||||
: '/';
|
|
||||||
|
|
||||||
window.location.href = referrerUrl;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
@@ -42,8 +37,7 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = setTimeout(handleRefresh, 100)
|
setTimeout(handleRefresh, 100)
|
||||||
return () => clearTimeout(timeout)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return <FullScreenLoader />
|
return <FullScreenLoader />
|
||||||
|
|||||||
@@ -11,19 +11,18 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button as MantineButton,
|
Button as MantineButton,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
|
||||||
Stack,
|
Stack,
|
||||||
Group,
|
Group,
|
||||||
Alert,
|
|
||||||
Collapse,
|
Collapse,
|
||||||
Code,
|
Code,
|
||||||
ThemeIcon
|
Container,
|
||||||
|
Center
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { useDisclosure } from '@mantine/hooks'
|
import { useDisclosure } from '@mantine/hooks'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import toast from '@/lib/sonner'
|
import toast from '@/lib/sonner'
|
||||||
import { logger } from '@/lib/logger'
|
import { logger } from '@/lib/logger'
|
||||||
import { ExclamationMarkIcon, XCircleIcon } from '@phosphor-icons/react'
|
import { XCircleIcon, WarningIcon } from '@phosphor-icons/react'
|
||||||
import Button from './button'
|
import Button from './button'
|
||||||
|
|
||||||
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
||||||
@@ -50,112 +49,90 @@ export function DefaultCatchBoundary({ error }: ErrorComponentProps) {
|
|||||||
|
|
||||||
if (errorMessage.toLowerCase().includes('unauthorized')) {
|
if (errorMessage.toLowerCase().includes('unauthorized')) {
|
||||||
return (
|
return (
|
||||||
<Box
|
<Container size="sm" py="xl">
|
||||||
style={{
|
<Center>
|
||||||
display: 'flex',
|
<Stack align="center" gap="md">
|
||||||
flexDirection: 'column',
|
<XCircleIcon size={64} color="var(--mantine-color-red-6)" />
|
||||||
alignItems: 'center',
|
<Text size="xl" fw={600}>Access Denied</Text>
|
||||||
justifyContent: 'center',
|
<Text c="dimmed" ta="center">
|
||||||
minHeight: '50vh',
|
You don't have permission to access this page.
|
||||||
padding: 'var(--mantine-spacing-xl)',
|
</Text>
|
||||||
}}
|
<Group gap="sm" mt="md">
|
||||||
>
|
<Button
|
||||||
<Stack align="center" gap="lg">
|
variant="light"
|
||||||
<ThemeIcon color="red" size={80} radius="xl">
|
onClick={() => window.history.back()}
|
||||||
<XCircleIcon size={48} />
|
>
|
||||||
</ThemeIcon>
|
Go Back
|
||||||
<Title order={2} ta="center">Access Denied</Title>
|
</Button>
|
||||||
<Text size="lg" c="dimmed" ta="center">
|
<MantineButton
|
||||||
You don't have permission to access this.
|
component={Link}
|
||||||
</Text>
|
to="/"
|
||||||
<Group>
|
variant="filled"
|
||||||
<Button
|
>
|
||||||
variant="light"
|
Home
|
||||||
onClick={() => window.history.back()}
|
</MantineButton>
|
||||||
>
|
</Group>
|
||||||
Go Back
|
</Stack>
|
||||||
</Button>
|
</Center>
|
||||||
<MantineButton
|
</Container>
|
||||||
component={Link}
|
|
||||||
to="/"
|
|
||||||
variant="filled"
|
|
||||||
>
|
|
||||||
Home
|
|
||||||
</MantineButton>
|
|
||||||
</Group>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Container size="sm" py="xl">
|
||||||
style={{
|
<Center>
|
||||||
display: 'flex',
|
<Stack align="center" gap="md" w="100%">
|
||||||
flexDirection: 'column',
|
<WarningIcon size={64} color="var(--mantine-color-red-6)" />
|
||||||
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="xl" fw={600}>Something went wrong</Text>
|
||||||
|
|
||||||
<Text size="lg" c="dimmed" ta="center">
|
<Text c="dimmed" ta="center">
|
||||||
There was an unexpected error. Please try again later.
|
An error occurred while loading this page.
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Alert
|
<Box w="100%" mt="md">
|
||||||
variant="light"
|
<Text size="sm" c="dimmed" mb="xs">Error: {errorMessage}</Text>
|
||||||
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>
|
|
||||||
|
|
||||||
<Group>
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
onClick={() => router.invalidate()}
|
|
||||||
>
|
|
||||||
Try Again
|
|
||||||
</Button>
|
|
||||||
{isRoot ? (
|
|
||||||
<MantineButton
|
|
||||||
component={Link}
|
|
||||||
to="/"
|
|
||||||
variant="filled"
|
|
||||||
>
|
|
||||||
Home
|
|
||||||
</MantineButton>
|
|
||||||
) : (
|
|
||||||
<Button
|
<Button
|
||||||
variant="filled"
|
variant="subtle"
|
||||||
onClick={() => window.history.back()}
|
size="compact-sm"
|
||||||
|
onClick={toggleDetails}
|
||||||
|
fullWidth
|
||||||
>
|
>
|
||||||
Go Back
|
{detailsOpened ? 'Hide' : 'Show'} details
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
<Collapse in={detailsOpened}>
|
||||||
</Group>
|
<Code block mt="sm" p="sm" style={{ fontSize: '11px' }}>
|
||||||
</Stack>
|
{errorStack}
|
||||||
</Box>
|
</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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import {
|
|||||||
Avatar as MantineAvatar,
|
Avatar as MantineAvatar,
|
||||||
AvatarProps as MantineAvatarProps,
|
AvatarProps as MantineAvatarProps,
|
||||||
Paper,
|
Paper,
|
||||||
|
Modal,
|
||||||
|
Image,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
ActionIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { XIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
interface AvatarProps
|
interface AvatarProps
|
||||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||||
@@ -10,6 +17,8 @@ interface AvatarProps
|
|||||||
size?: number;
|
size?: number;
|
||||||
radius?: string | number;
|
radius?: string | number;
|
||||||
withBorder?: boolean;
|
withBorder?: boolean;
|
||||||
|
disableFullscreen?: boolean;
|
||||||
|
contain?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Avatar = ({
|
const Avatar = ({
|
||||||
@@ -17,26 +26,122 @@ const Avatar = ({
|
|||||||
size = 35,
|
size = 35,
|
||||||
radius = "100%",
|
radius = "100%",
|
||||||
withBorder = true,
|
withBorder = true,
|
||||||
|
disableFullscreen = false,
|
||||||
|
contain = false,
|
||||||
...props
|
...props
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
|
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||||
|
const hasImage = Boolean(props.src);
|
||||||
|
|
||||||
|
const handleAvatarClick = () => {
|
||||||
|
if (hasImage && !disableFullscreen) {
|
||||||
|
setIsFullscreenOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper p={size / 20} radius={radius} withBorder={withBorder}>
|
<>
|
||||||
<MantineAvatar
|
<Paper
|
||||||
alt={name}
|
p={size / 20}
|
||||||
key={name}
|
|
||||||
name={name}
|
|
||||||
color="initials"
|
|
||||||
size={size}
|
|
||||||
radius={radius}
|
radius={radius}
|
||||||
w={size}
|
withBorder={withBorder}
|
||||||
|
style={{
|
||||||
|
cursor: hasImage && !disableFullscreen ? 'pointer' : 'default',
|
||||||
|
transition: 'transform 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (hasImage && !disableFullscreen) {
|
||||||
|
e.currentTarget.style.transform = 'scale(1.02)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
}}
|
||||||
|
onClick={handleAvatarClick}
|
||||||
|
>
|
||||||
|
<MantineAvatar
|
||||||
|
alt={name}
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
color="initials"
|
||||||
|
size={size}
|
||||||
|
radius={radius}
|
||||||
|
w={size}
|
||||||
|
styles={{
|
||||||
|
image: {
|
||||||
|
objectFit: contain ? 'contain' : 'cover',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={isFullscreenOpen}
|
||||||
|
onClose={() => setIsFullscreenOpen(false)}
|
||||||
|
size="auto"
|
||||||
|
centered
|
||||||
|
withCloseButton={false}
|
||||||
|
overlayProps={{
|
||||||
|
backgroundOpacity: 0.9,
|
||||||
|
blur: 2,
|
||||||
|
}}
|
||||||
styles={{
|
styles={{
|
||||||
image: {
|
content: {
|
||||||
objectFit: "contain",
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: 0,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
>
|
||||||
/>
|
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
|
||||||
</Paper>
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="dark"
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -10,
|
||||||
|
right: -10,
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
}}
|
||||||
|
onClick={() => setIsFullscreenOpen(false)}
|
||||||
|
>
|
||||||
|
<XIcon size={18} color="white" />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
src={props.src}
|
||||||
|
alt={name}
|
||||||
|
fit="contain"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
maxWidth: '90vw',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
justify="center"
|
||||||
|
mt="md"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -50,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text c="white" size="sm" fw={500}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { Box, Container, Flex, Loader, useComputedColorScheme } from "@mantine/core";
|
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 { Drawer as VaulDrawer } from "vaul";
|
||||||
import styles from "./styles.module.css";
|
import styles from "./styles.module.css";
|
||||||
import FullScreenLoader from "../full-screen-loader";
|
|
||||||
|
|
||||||
interface DrawerProps extends PropsWithChildren {
|
interface DrawerProps extends PropsWithChildren {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -17,6 +16,7 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const colorScheme = useComputedColorScheme("light");
|
const colorScheme = useComputedColorScheme("light");
|
||||||
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const appElement = document.querySelector(".app") as HTMLElement;
|
const appElement = document.querySelector(".app") as HTMLElement;
|
||||||
@@ -59,11 +59,56 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
};
|
};
|
||||||
}, [opened, colorScheme]);
|
}, [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 (
|
return (
|
||||||
<VaulDrawer.Root open={opened} onOpenChange={onChange}>
|
<VaulDrawer.Root repositionInputs={false} open={opened} onOpenChange={onChange}>
|
||||||
<VaulDrawer.Portal>
|
<VaulDrawer.Portal>
|
||||||
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
||||||
<VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer">
|
<VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer" ref={contentRef}>
|
||||||
<Container flex={1} p="md">
|
<Container flex={1} p="md">
|
||||||
<Box
|
<Box
|
||||||
mb="sm"
|
mb="sm"
|
||||||
@@ -74,7 +119,7 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
mr="auto"
|
mr="auto"
|
||||||
style={{ borderRadius: "9999px" }}
|
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>
|
<VaulDrawer.Title>{title}</VaulDrawer.Title>
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<Flex justify='center' align='center' w='100%' h={400}>
|
<Flex justify='center' align='center' w='100%' h={400}>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { PropsWithChildren, useCallback } from "react";
|
|||||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
import Drawer from "./drawer";
|
import Drawer from "./drawer";
|
||||||
import Modal from "./modal";
|
import Modal from "./modal";
|
||||||
import { Box, ScrollArea } from "@mantine/core";
|
import { ScrollArea } from "@mantine/core";
|
||||||
|
|
||||||
interface SheetProps extends PropsWithChildren {
|
interface SheetProps extends PropsWithChildren {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -23,14 +23,14 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
>
|
>
|
||||||
<ScrollArea
|
<ScrollArea.Autosize
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1, maxHeight: '75dvh' }}
|
||||||
scrollbarSize={8}
|
scrollbarSize={8}
|
||||||
scrollbars="y"
|
scrollbars="y"
|
||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
<Box mah="70vh">{children}</Box>
|
{children}
|
||||||
</ScrollArea>
|
</ScrollArea.Autosize>
|
||||||
</SheetComponent>
|
</SheetComponent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,10 +11,12 @@
|
|||||||
border-top-left-radius: 20px;
|
border-top-left-radius: 20px;
|
||||||
border-top-right-radius: 20px;
|
border-top-right-radius: 20px;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
height: fit-content;
|
height: auto !important;
|
||||||
|
min-height: fit-content;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition: height 0.2s ease-out, max-height 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,9 +50,13 @@ const StatItem = ({
|
|||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="sm" fw={700} c="dimmed">
|
{value !== null ? (
|
||||||
{value !== null ? `${value}${suffix}` : <Skeleton width={20} height={20} />}
|
<Text size="sm" fw={700} c="dimmed">
|
||||||
</Text>
|
{`${value}${suffix}`}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Skeleton width={20} height={20} />
|
||||||
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -101,20 +101,23 @@ function SwipeableTabs({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = setTimeout(updateHeight, 0);
|
const timeoutId = setTimeout(updateHeight, 0);
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
});
|
}, [updateHeight]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const activeSlideRef = slideRefs.current[activeTab];
|
const activeSlideRef = slideRefs.current[activeTab];
|
||||||
if (!activeSlideRef) return;
|
if (!activeSlideRef) return;
|
||||||
|
|
||||||
|
let timeoutId: any;
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
updateHeight();
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(updateHeight, 16);
|
||||||
});
|
});
|
||||||
|
|
||||||
resizeObserver.observe(activeSlideRef);
|
resizeObserver.observe(activeSlideRef);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [activeTab, updateHeight]);
|
}, [activeTab, updateHeight]);
|
||||||
|
|
||||||
|
|||||||
175
src/components/typeahead.tsx
Normal file
175
src/components/typeahead.tsx
Normal 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;
|
||||||
@@ -19,7 +19,7 @@ interface AuthData {
|
|||||||
|
|
||||||
export const defaultAuthData: AuthData = {
|
export const defaultAuthData: AuthData = {
|
||||||
user: undefined,
|
user: undefined,
|
||||||
metadata: { accentColor: "blue", colorScheme: "auto" },
|
metadata: { accentColor: "blue", colorScheme: "dark" },
|
||||||
roles: [],
|
roles: [],
|
||||||
phone: ""
|
phone: ""
|
||||||
};
|
};
|
||||||
@@ -58,13 +58,13 @@ export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
user: data?.user || defaultAuthData.user,
|
user: data?.user,
|
||||||
metadata: data?.metadata || defaultAuthData.metadata,
|
metadata: data?.metadata || { accentColor: "blue" as MantineColor, colorScheme: "dark" as MantineColorScheme },
|
||||||
roles: data?.roles || defaultAuthData.roles,
|
roles: data?.roles || [],
|
||||||
phone: data?.phone || "",
|
phone: data?.phone || "",
|
||||||
set,
|
set,
|
||||||
}),
|
}),
|
||||||
[data, defaultAuthData]
|
[data, set]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext value={value}>{children}</AuthContext>;
|
return <AuthContext value={value}>{children}</AuthContext>;
|
||||||
|
|||||||
@@ -186,6 +186,29 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [authState.isAuthenticated]);
|
}, [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 () => {
|
const pause = useCallback(async () => {
|
||||||
if (!authState.isAuthenticated) return;
|
if (!authState.isAuthenticated) return;
|
||||||
|
|
||||||
@@ -422,6 +445,7 @@ export const SpotifyProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
login,
|
login,
|
||||||
logout,
|
logout,
|
||||||
play,
|
play,
|
||||||
|
playTrack,
|
||||||
pause,
|
pause,
|
||||||
skipNext,
|
skipNext,
|
||||||
skipPrevious,
|
skipPrevious,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import ListButton from "@/components/list-button";
|
|||||||
|
|
||||||
const AdminPage = () => {
|
const AdminPage = () => {
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
<ListLink
|
<ListLink
|
||||||
label="Manage Tournaments"
|
label="Manage Tournaments"
|
||||||
Icon={TrophyIcon}
|
Icon={TrophyIcon}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import ListLink from "@/components/list-link";
|
|||||||
const ManageTournaments = () => {
|
const ManageTournaments = () => {
|
||||||
const { data: tournaments } = useTournaments();
|
const { data: tournaments } = useTournaments();
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{tournaments.map((t) => (
|
{tournaments.map((t) => (
|
||||||
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
4
src/features/badges/server.ts
Normal file
4
src/features/badges/server.ts
Normal 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";
|
||||||
|
|
||||||
8
src/features/badges/util.ts
Normal file
8
src/features/badges/util.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
|
||||||
|
pb.collection("team_stats_per_tournament").getFullList({
|
||||||
|
filter: `tournament_id = "${id}"`,
|
||||||
|
sort: "-wins,-total_cups_made"
|
||||||
|
})
|
||||||
|
|
||||||
|
*/
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Flex } from "@mantine/core";
|
import { Flex, Box } from "@mantine/core";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import { MatchCard } from "./match-card";
|
import { MatchCard } from "./match-card";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface BracketProps {
|
interface BracketProps {
|
||||||
rounds: Match[][];
|
rounds: Match[][];
|
||||||
@@ -13,33 +14,131 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
<Flex direction="row" gap={24} justify="left">
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
{rounds.map((round, roundIndex) => (
|
|
||||||
<Flex
|
useEffect(() => {
|
||||||
key={roundIndex}
|
const updateConnectorLines = () => {
|
||||||
direction="column"
|
if (!containerRef.current || !svgRef.current) return;
|
||||||
align="center"
|
|
||||||
pos="relative"
|
const svg = svgRef.current;
|
||||||
gap={24}
|
const container = containerRef.current;
|
||||||
justify="space-around"
|
const flexContainer = container.querySelector('.bracket-flex-container') as HTMLElement;
|
||||||
p={24}
|
if (!flexContainer) return;
|
||||||
>
|
|
||||||
{round.map((match) =>
|
svg.innerHTML = '';
|
||||||
match.bye ? (
|
|
||||||
<div key={match.lid}></div>
|
const flexRect = flexContainer.getBoundingClientRect();
|
||||||
) : (
|
const containerRect = container.getBoundingClientRect();
|
||||||
<div key={match.lid}>
|
|
||||||
<MatchCard
|
svg.style.width = `${flexContainer.scrollWidth}px`;
|
||||||
match={match}
|
svg.style.height = `${flexContainer.scrollHeight}px`;
|
||||||
orders={orders}
|
|
||||||
showControls={showControls}
|
rounds.forEach((round, roundIndex) => {
|
||||||
/>
|
if (roundIndex === rounds.length - 1) return;
|
||||||
</div>
|
|
||||||
|
const nextRound = rounds[roundIndex + 1];
|
||||||
|
|
||||||
|
round.forEach((match, matchIndex) => {
|
||||||
|
if (match.bye) return;
|
||||||
|
|
||||||
|
const matchElement = container.querySelector(`[data-match-lid="${match.lid}"]`) as HTMLElement;
|
||||||
|
if (!matchElement) return;
|
||||||
|
|
||||||
|
const nextMatches = nextRound.filter(nextMatch =>
|
||||||
|
!nextMatch.bye && (
|
||||||
|
orders[nextMatch.home_from_lid] === match.order ||
|
||||||
|
orders[nextMatch.away_from_lid] === match.order
|
||||||
)
|
)
|
||||||
)}
|
);
|
||||||
</Flex>
|
|
||||||
))}
|
nextMatches.forEach(nextMatch => {
|
||||||
</Flex>
|
const nextMatchElement = container.querySelector(`[data-match-lid="${nextMatch.lid}"]`) as HTMLElement;
|
||||||
|
if (!nextMatchElement) return;
|
||||||
|
|
||||||
|
const matchRect = matchElement.getBoundingClientRect();
|
||||||
|
const nextMatchRect = nextMatchElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
const startX = matchRect.right - flexRect.left;
|
||||||
|
const startY = matchRect.top + matchRect.height / 2 - flexRect.top;
|
||||||
|
const endX = nextMatchRect.left - flexRect.left;
|
||||||
|
const endY = nextMatchRect.top + nextMatchRect.height / 2 - flexRect.top;
|
||||||
|
|
||||||
|
const midX = startX + (endX - startX) * 0.5;
|
||||||
|
|
||||||
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
const pathData = `M ${startX} ${startY} L ${midX} ${startY} L ${midX} ${endY} L ${endX} ${endY}`;
|
||||||
|
|
||||||
|
path.setAttribute('d', pathData);
|
||||||
|
path.setAttribute('stroke', 'var(--mantine-color-default-border)');
|
||||||
|
path.setAttribute('stroke-width', '2');
|
||||||
|
path.setAttribute('fill', 'none');
|
||||||
|
path.setAttribute('stroke-linecap', 'round');
|
||||||
|
path.setAttribute('stroke-linejoin', 'round');
|
||||||
|
|
||||||
|
svg.appendChild(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateConnectorLines();
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
requestAnimationFrame(updateConnectorLines);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollContainer = containerRef.current?.closest('.mantine-ScrollArea-viewport');
|
||||||
|
scrollContainer?.addEventListener('scroll', handleUpdate);
|
||||||
|
window.addEventListener('resize', handleUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollContainer?.removeEventListener('scroll', handleUpdate);
|
||||||
|
window.removeEventListener('resize', handleUpdate);
|
||||||
|
};
|
||||||
|
}, [rounds, orders]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box pos="relative" ref={containerRef}>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Flex direction="row" gap={24} justify="left" pos="relative" style={{ zIndex: 1 }} className="bracket-flex-container">
|
||||||
|
{rounds.map((round, roundIndex) => (
|
||||||
|
<Flex
|
||||||
|
key={roundIndex}
|
||||||
|
direction="column"
|
||||||
|
align="center"
|
||||||
|
pos="relative"
|
||||||
|
gap={24}
|
||||||
|
justify="space-around"
|
||||||
|
p={24}
|
||||||
|
>
|
||||||
|
{round.map((match) =>
|
||||||
|
match.bye ? (
|
||||||
|
<div key={match.lid}></div>
|
||||||
|
) : (
|
||||||
|
<div key={match.lid}>
|
||||||
|
<MatchCard
|
||||||
|
match={match}
|
||||||
|
orders={orders}
|
||||||
|
showControls={showControls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { PlayIcon, PencilIcon, SpeakerHighIcon } from "@phosphor-icons/react";
|
||||||
import React, { useCallback, useMemo } from "react";
|
import React, { useCallback, useMemo } from "react";
|
||||||
import { MatchSlot } from "./match-slot";
|
import { MatchSlot } from "./match-slot";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
|
import { Team } from "@/features/teams/types";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import { MatchForm } from "./match-form";
|
import { MatchForm } from "./match-form";
|
||||||
import Sheet from "@/components/sheet/sheet";
|
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 { endMatch, startMatch } from "@/features/matches/server";
|
||||||
import { tournamentKeys } from "@/features/tournaments/queries";
|
import { tournamentKeys } from "@/features/tournaments/queries";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useSpotifyPlayback } from "@/lib/spotify/hooks";
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
@@ -24,6 +26,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const editSheet = useSheet();
|
const editSheet = useSheet();
|
||||||
|
const { playTrack, pause } = useSpotifyPlayback();
|
||||||
const homeSlot = useMemo(
|
const homeSlot = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
from: orders[match.home_from_lid],
|
from: orders[match.home_from_lid],
|
||||||
@@ -65,6 +68,8 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
[showControls, match.status]
|
[showControls, match.status]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const hasWalkoutData = showControls && match.home && match.away && 'song_id' in match.home && 'song_id' in match.away;
|
||||||
|
|
||||||
const start = useServerMutation({
|
const start = useServerMutation({
|
||||||
mutationFn: startMatch,
|
mutationFn: startMatch,
|
||||||
successMessage: "Match started!",
|
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(
|
const handleFormSubmit = useCallback(
|
||||||
async (data: {
|
async (data: {
|
||||||
home_cups: number;
|
home_cups: number;
|
||||||
away_cups: number;
|
away_cups: number;
|
||||||
ot_count: number;
|
ot_count: number;
|
||||||
}) => {
|
}) => {
|
||||||
await end.mutate({
|
end.mutate({
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...data,
|
||||||
matchId: match.id,
|
matchId: match.id,
|
||||||
@@ -107,12 +106,14 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
[match.id, editSheet]
|
[match.id, editSheet]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSpeakerClick = useCallback(() => {
|
const speak = useCallback((text: string): Promise<void> => {
|
||||||
if ("speechSynthesis" in window && match.home?.name && match.away?.name) {
|
return new Promise((resolve) => {
|
||||||
const utterance = new SpeechSynthesisUtterance(
|
if (!("speechSynthesis" in window)) {
|
||||||
`${match.home.name} vs. ${match.away.name}`
|
resolve();
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const utterance = new SpeechSynthesisUtterance(text);
|
||||||
const voices = window.speechSynthesis.getVoices();
|
const voices = window.speechSynthesis.getVoices();
|
||||||
|
|
||||||
const preferredVoice =
|
const preferredVoice =
|
||||||
@@ -130,13 +131,83 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
utterance.volume = 0.8;
|
utterance.volume = 0.8;
|
||||||
utterance.pitch = 1.0;
|
utterance.pitch = 1.0;
|
||||||
|
|
||||||
|
utterance.onend = () => resolve();
|
||||||
|
utterance.onerror = () => resolve();
|
||||||
|
|
||||||
window.speechSynthesis.speak(utterance);
|
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 (
|
return (
|
||||||
<Flex direction="row" align="center" justify="end" gap={8}>
|
<Flex direction="row" align="center" justify="end" gap={8}>
|
||||||
<Text c="dimmed" fw="bolder">
|
<Text
|
||||||
|
c="dimmed"
|
||||||
|
fw="bolder"
|
||||||
|
px={6}
|
||||||
|
py={2}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-body)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
{match.order}
|
{match.order}
|
||||||
</Text>
|
</Text>
|
||||||
<Flex align="stretch">
|
<Flex align="stretch">
|
||||||
@@ -151,7 +222,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
w={showToolbar || showEditButton ? 200 : 220}
|
w={showToolbar || showEditButton ? 200 : 220}
|
||||||
withBorder
|
withBorder
|
||||||
pos="relative"
|
pos="relative"
|
||||||
style={{ overflow: "visible" }}
|
style={{
|
||||||
|
overflow: "visible",
|
||||||
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
|
borderColor: 'var(--mantine-color-default-border)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
}}
|
||||||
data-match-lid={match.lid}
|
data-match-lid={match.lid}
|
||||||
>
|
>
|
||||||
<Card.Section withBorder p={0}>
|
<Card.Section withBorder p={0}>
|
||||||
@@ -175,7 +251,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showControls && (
|
{showControls && match.status !== "tbd" && (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
pos="absolute"
|
pos="absolute"
|
||||||
bottom={-2}
|
bottom={-2}
|
||||||
@@ -210,6 +286,7 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{showEditButton && (
|
{showEditButton && (
|
||||||
<Flex direction="column" justify="center" align="center">
|
<Flex direction="column" justify="center" align="center">
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
{match.home?.name} Cups
|
{match.home?.name} Cups
|
||||||
</Text>
|
</Text>
|
||||||
{
|
{
|
||||||
match.home?.players.map(p => (<Text size='xs' c='dimmed'>
|
match.home?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
|
||||||
{p.first_name} {p.last_name}
|
{p.first_name} {p.last_name}
|
||||||
</Text>))
|
</Text>))
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
{match.away?.name} Cups
|
{match.away?.name} Cups
|
||||||
</Text>
|
</Text>
|
||||||
{
|
{
|
||||||
match.away?.players.map(p => (<Text size='xs' c='dimmed'>
|
match.away?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
|
||||||
{p.first_name} {p.last_name}
|
{p.first_name} {p.last_name}
|
||||||
</Text>))
|
</Text>))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,16 +21,23 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
cups,
|
cups,
|
||||||
isWinner
|
isWinner
|
||||||
}) => (
|
}) => (
|
||||||
<Flex align="stretch">
|
<Flex
|
||||||
|
align="stretch"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
|
||||||
|
borderRadius: 'var(--mantine-radius-sm)',
|
||||||
|
transition: 'background-color 200ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
|
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
|
||||||
<Flex p="4px 8px" w='100%' align="center">
|
<Flex p="6px 10px" w='100%' align="center">
|
||||||
<Flex align="center" gap={4} flex={1}>
|
<Flex align="center" gap={4} flex={1}>
|
||||||
{team ? (
|
{team ? (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text
|
||||||
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
||||||
truncate
|
truncate
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
||||||
>
|
>
|
||||||
{team.name}
|
{team.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
|||||||
const logger = new Logger("Bracket Generation");
|
const logger = new Logger("Bracket Generation");
|
||||||
|
|
||||||
export const previewBracket = createServerFn()
|
export const previewBracket = createServerFn()
|
||||||
.validator(z.number())
|
.inputValidator(z.number())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: teams }) =>
|
.handler(async ({ data: teams }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
|
|||||||
@@ -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 { HeaderConfig } from "../types/header-config";
|
||||||
import useRouterConfig from "../hooks/use-router-config";
|
|
||||||
import BackButton from "./back-button";
|
import BackButton from "./back-button";
|
||||||
|
|
||||||
interface HeaderProps extends HeaderConfig {}
|
interface HeaderProps extends HeaderConfig {}
|
||||||
|
|
||||||
const Header = ({ collapsed, title }: HeaderProps) => {
|
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
|
||||||
const { header } = useRouterConfig();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
|
<AppShell.Header
|
||||||
{ header.withBackButton && <BackButton /> }
|
id='app-header'
|
||||||
|
display={collapsed ? 'none' : 'block'}
|
||||||
|
>
|
||||||
|
{ withBackButton && <BackButton /> }
|
||||||
<Flex justify='center' align='center' h='100%' px='md'>
|
<Flex justify='center' align='center' h='100%' px='md'>
|
||||||
<Title order={2}>{title}</Title>
|
<Title order={2}>{title}</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -31,14 +31,14 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
pos='relative'
|
pos='relative'
|
||||||
h='100dvh'
|
h='100dvh'
|
||||||
mah='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} />
|
<Header {...header} />
|
||||||
<AppShell.Main
|
<AppShell.Main
|
||||||
pos='relative'
|
pos='relative'
|
||||||
h='100%'
|
h='100%'
|
||||||
mah='100%'
|
mah='100%'
|
||||||
pb={{ base: 70, md: 0 }}
|
pb={{ base: 65, md: 0 }}
|
||||||
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
px={{ base: 0.01, sm: 100, md: 200, lg: 300 }}
|
||||||
maw='100dvw'
|
maw='100dvw'
|
||||||
style={{ transition: 'none', overflow: 'hidden' }}
|
style={{ transition: 'none', overflow: 'hidden' }}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AppShell, ScrollArea, Stack, Group, Paper } from "@mantine/core";
|
import { AppShell, ScrollArea, Stack, Group, Paper, useMantineColorScheme } from "@mantine/core";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { NavLink } from "./nav-link";
|
import { NavLink } from "./nav-link";
|
||||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
@@ -9,11 +9,17 @@ import { memo } from "react";
|
|||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const { user, roles } = useAuth()
|
const { user, roles } = useAuth()
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
const links = useLinks(user?.id, roles);
|
const links = useLinks(user?.id, roles);
|
||||||
|
|
||||||
|
const isDark = colorScheme === 'dark';
|
||||||
|
const borderColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
|
||||||
|
const boxShadowColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
|
||||||
|
// boxShadow: `5px 5px ${boxShadowColor}`, borderColor
|
||||||
|
|
||||||
if (isMobile) return (
|
if (isMobile) return (
|
||||||
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 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 }}>
|
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<NavLink key={link.href} {...link} />
|
<NavLink key={link.href} {...link} />
|
||||||
@@ -30,9 +36,6 @@ const Navbar = () => {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</AppShell.Section>
|
</AppShell.Section>
|
||||||
<AppShell.Section>
|
|
||||||
<Link to="/logout">Logout</Link>
|
|
||||||
</AppShell.Section>
|
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import useAppShellHeight from "@/hooks/use-appshell-height";
|
|||||||
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
|
import { ArrowClockwiseIcon, SpinnerIcon } from "@phosphor-icons/react";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import useRouterConfig from "../hooks/use-router-config";
|
import useRouterConfig from "../hooks/use-router-config";
|
||||||
|
import { useLocation } from "@tanstack/react-router";
|
||||||
|
|
||||||
const THRESHOLD = 80;
|
const THRESHOLD = 80;
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
|||||||
const [scrolling, setScrolling] = useState(false);
|
const [scrolling, setScrolling] = useState(false);
|
||||||
const { refresh } = useRouterConfig();
|
const { refresh } = useRouterConfig();
|
||||||
const queryClient = useQueryClient();
|
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]);
|
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();
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -103,6 +121,7 @@ const Pullable: React.FC<PullableProps> = ({ children, scrollPosition, onScrollP
|
|||||||
/>
|
/>
|
||||||
</Flex>
|
</Flex>
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
|
ref={scrollAreaRef}
|
||||||
id='scroll-wrapper'
|
id='scroll-wrapper'
|
||||||
onScrollPositionChange={onScrollPositionChange}
|
onScrollPositionChange={onScrollPositionChange}
|
||||||
type='never' mah='100%' h='100%'
|
type='never' mah='100%' h='100%'
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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 { CrownIcon } from "@phosphor-icons/react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { Match } from "../types";
|
import { Match } from "../types";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
import EmojiBar from "@/features/reactions/components/emoji-bar";
|
import EmojiBar from "@/features/reactions/components/emoji-bar";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
interface MatchCardProps {
|
interface MatchCardProps {
|
||||||
match: Match;
|
match: Match;
|
||||||
@@ -88,15 +89,28 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text
|
<Tooltip
|
||||||
size="sm"
|
label={match.home?.name!}
|
||||||
fw={600}
|
disabled={!match.home?.name}
|
||||||
lineClamp={1}
|
events={{ hover: true, focus: true, touch: true }}
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
|
||||||
>
|
>
|
||||||
{match.home?.name!}
|
<Text
|
||||||
</Text>
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
lineClamp={1}
|
||||||
|
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{match.home?.name!}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</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
|
<Text
|
||||||
size="xl"
|
size="xl"
|
||||||
fw={700}
|
fw={700}
|
||||||
@@ -105,13 +119,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
>
|
>
|
||||||
{match.home_cups}
|
{match.home_cups}
|
||||||
</Text>
|
</Text>
|
||||||
<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>
|
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
@@ -144,15 +151,28 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text
|
<Tooltip
|
||||||
size="sm"
|
label={match.away?.name}
|
||||||
fw={600}
|
disabled={!match.away?.name}
|
||||||
lineClamp={1}
|
events={{ hover: true, focus: true, touch: true }}
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
|
||||||
>
|
>
|
||||||
{match.away?.name}
|
<Text
|
||||||
</Text>
|
size="sm"
|
||||||
|
fw={600}
|
||||||
|
lineClamp={1}
|
||||||
|
style={{ minWidth: 0, flex: 1, cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{match.away?.name}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
</Group>
|
</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
|
<Text
|
||||||
size="xl"
|
size="xl"
|
||||||
fw={700}
|
fw={700}
|
||||||
@@ -161,13 +181,6 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
>
|
>
|
||||||
{match.away_cups}
|
{match.away_cups}
|
||||||
</Text>
|
</Text>
|
||||||
<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>
|
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -187,7 +200,9 @@ const MatchCard = ({ match }: MatchCardProps) => {
|
|||||||
border: "1px solid var(--mantine-color-default-border)",
|
border: "1px solid var(--mantine-color-default-border)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmojiBar matchId={match.id} />
|
<Suspense>
|
||||||
|
<EmojiBar matchId={match.id} />
|
||||||
|
</Suspense>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
</Indicator>
|
</Indicator>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Stack } from "@mantine/core";
|
import { Stack } from "@mantine/core";
|
||||||
import { motion, AnimatePresence } from "framer-motion";
|
|
||||||
import { Match } from "../types";
|
import { Match } from "../types";
|
||||||
import MatchCard from "./match-card";
|
import MatchCard from "./match-card";
|
||||||
|
|
||||||
@@ -18,19 +17,13 @@ const MatchList = ({ matches }: MatchListProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack p="md" gap="sm">
|
<Stack p="md" gap="sm">
|
||||||
<AnimatePresence>
|
{filteredMatches.map((match, index) => (
|
||||||
{filteredMatches.map((match, index) => (
|
<div
|
||||||
<motion.div
|
key={`match-${match.id}-${index}`}
|
||||||
key={`match-${match.id}-${index}`}
|
>
|
||||||
initial={{ opacity: 0, y: 10 }}
|
<MatchCard match={match} />
|
||||||
animate={{ opacity: 1, y: 0 }}
|
</div>
|
||||||
exit={{ opacity: 0, y: -10 }}
|
))}
|
||||||
transition={{ duration: 0.2, delay: index * 0.01 }}
|
|
||||||
>
|
|
||||||
<MatchCard match={match} />
|
|
||||||
</motion.div>
|
|
||||||
))}
|
|
||||||
</AnimatePresence>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const orderedTeamsSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const generateTournamentBracket = createServerFn()
|
export const generateTournamentBracket = createServerFn()
|
||||||
.validator(orderedTeamsSchema)
|
.inputValidator(orderedTeamsSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -137,7 +137,7 @@ export const generateTournamentBracket = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const startMatch = createServerFn()
|
export const startMatch = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -170,7 +170,7 @@ const endMatchSchema = z.object({
|
|||||||
ot_count: z.number(),
|
ot_count: z.number(),
|
||||||
});
|
});
|
||||||
export const endMatch = createServerFn()
|
export const endMatch = createServerFn()
|
||||||
.validator(endMatchSchema)
|
.inputValidator(endMatchSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -252,7 +252,7 @@ const toggleReactionSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const toggleMatchReaction = createServerFn()
|
export const toggleMatchReaction = createServerFn()
|
||||||
.validator(toggleReactionSchema)
|
.inputValidator(toggleReactionSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: { matchId, emoji }, context }) =>
|
.handler(async ({ data: { matchId, emoji }, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -312,7 +312,7 @@ export interface Reaction {
|
|||||||
players: PlayerInfo[];
|
players: PlayerInfo[];
|
||||||
}
|
}
|
||||||
export const getMatchReactions = createServerFn()
|
export const getMatchReactions = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: matchId, context }) =>
|
.handler(async ({ data: matchId, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { TeamInfo } from "../teams/types";
|
import { TeamInfo, Team } from "../teams/types";
|
||||||
import { TournamentInfo } from "../tournaments/types";
|
import { TournamentInfo } from "../tournaments/types";
|
||||||
|
|
||||||
export type MatchStatus = "tbd" | "ready" | "started" | "ended";
|
export type MatchStatus = "tbd" | "ready" | "started" | "ended";
|
||||||
@@ -23,8 +23,8 @@ export interface Match {
|
|||||||
is_losers_bracket: boolean;
|
is_losers_bracket: boolean;
|
||||||
status: MatchStatus;
|
status: MatchStatus;
|
||||||
tournament: TournamentInfo;
|
tournament: TournamentInfo;
|
||||||
home?: TeamInfo;
|
home?: TeamInfo | Team;
|
||||||
away?: TeamInfo;
|
away?: TeamInfo | Team;
|
||||||
created: string;
|
created: string;
|
||||||
updated: string;
|
updated: string;
|
||||||
home_seed?: number;
|
home_seed?: number;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
|
|||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
return <List>
|
return <List p="0">
|
||||||
{players?.map((player) => (
|
{players?.map((player) => (
|
||||||
<ListItem key={player.id}
|
<ListItem key={player.id}
|
||||||
py='xs'
|
py='xs'
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Flex, Skeleton } from "@mantine/core";
|
|||||||
|
|
||||||
const HeaderSkeleton = () => {
|
const HeaderSkeleton = () => {
|
||||||
return (
|
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%" />
|
<Skeleton opacity={0} height={100} width={100} radius="50%" />
|
||||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||||
<Skeleton height={24} width={200} />
|
<Skeleton height={24} width={200} />
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const Header = ({ player }: HeaderProps) => {
|
|||||||
|
|
||||||
return (
|
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} />
|
<Avatar name={name} size={100} />
|
||||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||||
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>
|
<Title ta='center' style={{ fontSize, lineHeight: 1.2 }}>{name}</Title>
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import { Match } from "@/features/matches/types";
|
|||||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { logger } from ".";
|
import { logger } from ".";
|
||||||
import { getWebRequest } from "@tanstack/react-start/server";
|
import { getRequest } from "@tanstack/react-start/server";
|
||||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
|
||||||
export const fetchMe = createServerFn()
|
export const fetchMe = createServerFn()
|
||||||
.handler(async () =>
|
.handler(async () =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
const request = getWebRequest();
|
const request = getRequest();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const context = await getSessionContext(request);
|
const context = await getSessionContext(request);
|
||||||
@@ -25,7 +25,7 @@ export const fetchMe = createServerFn()
|
|||||||
phone: context.phone
|
phone: context.phone
|
||||||
};
|
};
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.info("FetchMe: Session error", error)
|
// logger.info("FetchMe: Session error", error)
|
||||||
if (error?.response?.status === 401) {
|
if (error?.response?.status === 401) {
|
||||||
const errorData = error?.response?.data;
|
const errorData = error?.response?.data;
|
||||||
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
||||||
@@ -38,14 +38,14 @@ export const fetchMe = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getPlayer = createServerFn()
|
export const getPlayer = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
|
toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const updatePlayer = createServerFn()
|
export const updatePlayer = createServerFn()
|
||||||
.validator(playerUpdateSchema)
|
.inputValidator(playerUpdateSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -72,7 +72,7 @@ export const updatePlayer = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const createPlayer = createServerFn()
|
export const createPlayer = createServerFn()
|
||||||
.validator(playerInputSchema)
|
.inputValidator(playerInputSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -97,7 +97,7 @@ export const createPlayer = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const associatePlayer = createServerFn()
|
export const associatePlayer = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -129,7 +129,7 @@ export const getUnassociatedPlayers = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getPlayerStats = createServerFn()
|
export const getPlayerStats = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data))
|
toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data))
|
||||||
@@ -142,14 +142,14 @@ export const getAllPlayerStats = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getPlayerMatches = createServerFn()
|
export const getPlayerMatches = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
|
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getUnenrolledPlayers = createServerFn()
|
export const getUnenrolledPlayers = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId }) =>
|
.handler(async ({ data: tournamentId }) =>
|
||||||
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
|
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
|
||||||
|
|||||||
@@ -78,8 +78,7 @@ const EmojiBar = ({
|
|||||||
{visibleReactions.map((reaction) => (
|
{visibleReactions.map((reaction) => (
|
||||||
<Button
|
<Button
|
||||||
key={reaction.emoji}
|
key={reaction.emoji}
|
||||||
variant={hasReacted(reaction) ? "filled" : "light"}
|
variant={"light"}
|
||||||
color="gray"
|
|
||||||
bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined}
|
bd={hasReacted(reaction) ? "1px solid var(--mantine-primary-color-filled)" : undefined}
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
@@ -109,8 +108,7 @@ const EmojiBar = ({
|
|||||||
|
|
||||||
{hasGrouped && (
|
{hasGrouped && (
|
||||||
<Button
|
<Button
|
||||||
variant={userHasReactedToGrouped ? "filled" : "light"}
|
variant={"light"}
|
||||||
color="gray"
|
|
||||||
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
|
bd={userHasReactedToGrouped ? "1px solid var(--mantine-primary-color-filled)" : undefined}
|
||||||
size="compact-xs"
|
size="compact-xs"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
|
|||||||
165
src/features/teams/components/manage-teams.tsx
Normal file
165
src/features/teams/components/manage-teams.tsx
Normal 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;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
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 {
|
interface DurationPickerProps {
|
||||||
songDurationMs: number;
|
songDurationMs: number;
|
||||||
@@ -9,6 +9,41 @@ interface DurationPickerProps {
|
|||||||
disabled?: boolean;
|
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 = ({
|
const DurationPicker = ({
|
||||||
songDurationMs,
|
songDurationMs,
|
||||||
initialStart = 0,
|
initialStart = 0,
|
||||||
@@ -17,11 +52,6 @@ const DurationPicker = ({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
}: DurationPickerProps) => {
|
}: DurationPickerProps) => {
|
||||||
const songDurationSeconds = Math.floor(songDurationMs / 1000);
|
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 formatTime = useCallback((seconds: number) => {
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
@@ -29,7 +59,26 @@ const DurationPicker = ({
|
|||||||
return `${minutes}:${remainingSeconds.toString().padStart(2, "0")}`;
|
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) => {
|
(start: number, end: number) => {
|
||||||
const duration = end - start;
|
const duration = end - start;
|
||||||
const withinBounds = start >= 0 && end <= songDurationSeconds;
|
const withinBounds = start >= 0 && end <= songDurationSeconds;
|
||||||
@@ -53,146 +102,150 @@ const DurationPicker = ({
|
|||||||
return null;
|
return null;
|
||||||
}, [songDurationSeconds]);
|
}, [songDurationSeconds]);
|
||||||
|
|
||||||
const handleRangeChange = useCallback(
|
const updateTimes = useCallback((newStart: number, newEnd: number) => {
|
||||||
(newRange: [number, number]) => {
|
const clampedStart = Math.max(0, Math.min(newStart, songDurationSeconds - 10));
|
||||||
setRange(newRange);
|
const clampedEnd = Math.min(songDurationSeconds, Math.max(newEnd, clampedStart + 10));
|
||||||
const [start, end] = newRange;
|
|
||||||
const valid = validateRange(start, end);
|
|
||||||
setIsValid(valid);
|
|
||||||
|
|
||||||
if (valid) {
|
setStartTime(clampedStart);
|
||||||
onChange(start, end);
|
setEndTime(clampedEnd);
|
||||||
}
|
setStartInputValue(formatTime(clampedStart));
|
||||||
},
|
setEndInputValue(formatTime(clampedEnd));
|
||||||
[onChange, validateRange]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRangeChangeEnd = useCallback(
|
const valid = validateTimes(clampedStart, clampedEnd);
|
||||||
(newRange: [number, number]) => {
|
setIsValid(valid);
|
||||||
let [start, end] = newRange;
|
|
||||||
let duration = end - start;
|
|
||||||
|
|
||||||
if (duration < 10) {
|
if (valid) {
|
||||||
if (start < songDurationSeconds / 2) {
|
onChange(clampedStart, clampedEnd);
|
||||||
end = Math.min(start + 10, songDurationSeconds);
|
}
|
||||||
} else {
|
}, [songDurationSeconds, validateTimes, onChange, formatTime]);
|
||||||
start = Math.max(end - 10, 0);
|
|
||||||
}
|
|
||||||
duration = end - start;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (duration > 15) {
|
const handleStartInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const startDiff = Math.abs(start - range[0]);
|
setStartInputValue(event.target.value);
|
||||||
const endDiff = Math.abs(end - range[1]);
|
}, []);
|
||||||
|
|
||||||
if (startDiff > endDiff) {
|
const handleEndInputChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
end = start + 15;
|
setEndInputValue(event.target.value);
|
||||||
if (end > songDurationSeconds) {
|
}, []);
|
||||||
end = songDurationSeconds;
|
|
||||||
start = end - 15;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
start = end - 15;
|
|
||||||
if (start < 0) {
|
|
||||||
start = 0;
|
|
||||||
end = start + 15;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
start = Math.max(0, start);
|
const handleStartBlur = useCallback(() => {
|
||||||
end = Math.min(songDurationSeconds, end);
|
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];
|
const handleEndBlur = useCallback(() => {
|
||||||
setRange(finalRange);
|
const parsed = parseTimeInput(endInputValue);
|
||||||
setIsValid(validateRange(start, end));
|
if (parsed !== null) {
|
||||||
onChange(start, end);
|
updateTimes(startTime, parsed);
|
||||||
},
|
} else {
|
||||||
[range, songDurationSeconds, onChange, validateRange]
|
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(() => {
|
useEffect(() => {
|
||||||
if (!validateRange(initialStart, initialEnd)) {
|
if (!validateTimes(initialStart, initialEnd)) {
|
||||||
const defaultStart = Math.min(30, Math.max(0, songDurationSeconds - 15));
|
const defaultStart = Math.min(30, Math.max(0, songDurationSeconds - 15));
|
||||||
const defaultEnd = Math.min(defaultStart + 15, songDurationSeconds);
|
const defaultEnd = Math.min(defaultStart + 15, songDurationSeconds);
|
||||||
const defaultRange: [number, number] = [defaultStart, defaultEnd];
|
updateTimes(defaultStart, defaultEnd);
|
||||||
setRange(defaultRange);
|
|
||||||
onChange(defaultStart, defaultEnd);
|
|
||||||
}
|
}
|
||||||
}, [initialStart, initialEnd, songDurationSeconds, validateRange, onChange]);
|
}, [initialStart, initialEnd, songDurationSeconds, validateTimes, updateTimes]);
|
||||||
|
|
||||||
const segmentDuration = range[1] - range[0];
|
const segmentDuration = endTime - startTime;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md" opacity={disabled ? 0.5 : 1}>
|
<Stack gap="sm" opacity={disabled ? 0.5 : 1}>
|
||||||
<div>
|
<Text size="sm" fw={500} c={disabled ? "dimmed" : undefined} ta="center">
|
||||||
<Text size="sm" fw={500} mb="xs" c={disabled ? "dimmed" : undefined}>
|
Walkout Segment ({segmentDuration}s)
|
||||||
Start and End
|
</Text>
|
||||||
</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>
|
|
||||||
|
|
||||||
<RangeSlider
|
<Stack gap="sm">
|
||||||
min={0}
|
<Stack gap={4}>
|
||||||
max={songDurationSeconds}
|
<Group justify="space-between" align="center">
|
||||||
step={1}
|
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
|
||||||
value={range}
|
Start
|
||||||
onChange={disabled ? undefined : handleRangeChange}
|
</Text>
|
||||||
onChangeEnd={disabled ? undefined : handleRangeChangeEnd}
|
<TextInput
|
||||||
marks={[
|
value={startInputValue}
|
||||||
{ value: 0, label: "0:00" },
|
onChange={handleStartInputChange}
|
||||||
{
|
onBlur={handleStartBlur}
|
||||||
value: songDurationSeconds,
|
disabled={disabled}
|
||||||
label: formatTime(songDurationSeconds),
|
size="xs"
|
||||||
},
|
w={70}
|
||||||
]}
|
placeholder="0:00"
|
||||||
size="lg"
|
ta="center"
|
||||||
m='xs'
|
styles={{
|
||||||
color={disabled ? "gray" : (isValid ? "blue" : "red")}
|
input: {
|
||||||
thumbSize={20}
|
fontWeight: 600,
|
||||||
label={disabled ? undefined : (value) => formatTime(value)}
|
fontSize: '12px'
|
||||||
disabled={disabled}
|
}
|
||||||
styles={{
|
}}
|
||||||
track: { height: 8 },
|
/>
|
||||||
}}
|
</Group>
|
||||||
/>
|
<Group gap={4}>
|
||||||
|
<IncrementButtons
|
||||||
<Divider />
|
onAdjust={adjustStartTime}
|
||||||
|
disabled={disabled || startTime <= 0}
|
||||||
<Group justify="space-between" align="center">
|
isPositive={false}
|
||||||
<Stack gap={2} align="center">
|
/>
|
||||||
<Text size="xs" c="dimmed">
|
<IncrementButtons
|
||||||
Start
|
onAdjust={adjustStartTime}
|
||||||
</Text>
|
disabled={disabled || startTime >= songDurationSeconds - 10}
|
||||||
<Text size="sm" fw={500}>
|
isPositive={true}
|
||||||
{formatTime(range[0])}
|
/>
|
||||||
</Text>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Stack gap={2} align="center">
|
<Stack gap={4}>
|
||||||
<Text size="xs" c="dimmed">
|
<Group justify="space-between" align="center">
|
||||||
Duration
|
<Text size="xs" fw={500} c={disabled ? "dimmed" : undefined}>
|
||||||
</Text>
|
End
|
||||||
<Text size="sm" fw={500} c={isValid ? undefined : "red"}>
|
</Text>
|
||||||
{segmentDuration}s
|
<TextInput
|
||||||
</Text>
|
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>
|
||||||
|
</Stack>
|
||||||
<Stack gap={2} align="center">
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
End
|
|
||||||
</Text>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{formatTime(range[1])}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
{!isValid && (
|
{!isValid && (
|
||||||
<Text size="xs" c="red" ta="center">
|
<Text size="xs" c="red" ta="center">
|
||||||
{getValidationMessage(range[0], range[1])}
|
{getValidationMessage(startTime, endTime)}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Badge, FileInput, Group, Stack, Text, TextInput } from "@mantine/core";
|
import { FileInput, Stack, TextInput } from "@mantine/core";
|
||||||
import { useForm, UseFormInput } from "@mantine/form";
|
import { useForm, UseFormInput } from "@mantine/form";
|
||||||
import { LinkIcon } from "@phosphor-icons/react";
|
import { LinkIcon } from "@phosphor-icons/react";
|
||||||
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
|
import SlidePanel from "@/components/sheet/slide-panel";
|
||||||
import { isNotEmpty } from "@mantine/form";
|
import { isNotEmpty } from "@mantine/form";
|
||||||
import useCreateTeam from "../../hooks/use-create-team";
|
import useCreateTeam from "../../hooks/use-create-team";
|
||||||
import useUpdateTeam from "../../hooks/use-update-team";
|
import useUpdateTeam from "../../hooks/use-update-team";
|
||||||
@@ -13,8 +13,8 @@ import { useCallback } from "react";
|
|||||||
import { TeamInput } from "../../types";
|
import { TeamInput } from "../../types";
|
||||||
import { teamKeys } from "../../queries";
|
import { teamKeys } from "../../queries";
|
||||||
import SongPicker from "./song-picker";
|
import SongPicker from "./song-picker";
|
||||||
import TeamColorPicker from "./color-picker";
|
|
||||||
import PlayersPicker from "./players-picker";
|
import PlayersPicker from "./players-picker";
|
||||||
|
import imageCompression from "browser-image-compression";
|
||||||
|
|
||||||
interface TeamFormProps {
|
interface TeamFormProps {
|
||||||
close: () => void;
|
close: () => void;
|
||||||
@@ -113,9 +113,32 @@ const TeamForm = ({
|
|||||||
|
|
||||||
if (logo && team) {
|
if (logo && team) {
|
||||||
try {
|
try {
|
||||||
|
let processedLogo = logo;
|
||||||
|
|
||||||
|
if (logo.size > 500 * 1024) {
|
||||||
|
const compressionOptions = {
|
||||||
|
maxSizeMB: 0.5,
|
||||||
|
maxWidthOrHeight: 800,
|
||||||
|
useWebWorker: true,
|
||||||
|
fileType: logo.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
processedLogo = await imageCompression(logo, compressionOptions);
|
||||||
|
logger.info("image compressed", {
|
||||||
|
originalSize: logo.size,
|
||||||
|
compressedSize: processedLogo.size,
|
||||||
|
reduction: ((logo.size - processedLogo.size) / logo.size * 100).toFixed(1) + "%"
|
||||||
|
});
|
||||||
|
} catch (compressionError) {
|
||||||
|
logger.warn("compression failed, falling back", compressionError);
|
||||||
|
processedLogo = logo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("teamId", team.id);
|
formData.append("teamId", team.id);
|
||||||
formData.append("logo", logo);
|
formData.append("logo", processedLogo);
|
||||||
|
|
||||||
const response = await fetch("/api/teams/upload-logo", {
|
const response = await fetch("/api/teams/upload-logo", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface Song {
|
|||||||
song_start?: number;
|
song_start?: number;
|
||||||
song_end?: number;
|
song_end?: number;
|
||||||
song_image_url: string;
|
song_image_url: string;
|
||||||
|
duration_ms?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SongPickerProps {
|
interface SongPickerProps {
|
||||||
@@ -62,7 +63,7 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
|
|||||||
}}
|
}}
|
||||||
error={error}
|
error={error}
|
||||||
Component={SongPickerComponent}
|
Component={SongPickerComponent}
|
||||||
componentProps={{ formValues: form.getValues() }}
|
componentProps={{}}
|
||||||
title={"Select Song"}
|
title={"Select Song"}
|
||||||
label={"Walkout Song"}
|
label={"Walkout Song"}
|
||||||
placeholder={"Select your walkout song"}
|
placeholder={"Select your walkout song"}
|
||||||
@@ -73,10 +74,9 @@ const SongPicker = ({ form, error }: SongPickerProps) => {
|
|||||||
interface SongPickerComponentProps {
|
interface SongPickerComponentProps {
|
||||||
value: Song | undefined;
|
value: Song | undefined;
|
||||||
onChange: (song: Song) => void;
|
onChange: (song: Song) => void;
|
||||||
formValues: any;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerComponentProps) => {
|
const SongPickerComponent = ({ value: song, onChange }: SongPickerComponentProps) => {
|
||||||
const handleSongSelect = (track: SpotifyTrack) => {
|
const handleSongSelect = (track: SpotifyTrack) => {
|
||||||
const defaultStart = 0;
|
const defaultStart = 0;
|
||||||
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
|
const defaultEnd = Math.min(15, Math.floor(track.duration_ms / 1000));
|
||||||
@@ -89,6 +89,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
|
|||||||
song_image_url: track.album.images[0]?.url || '',
|
song_image_url: track.album.images[0]?.url || '',
|
||||||
song_start: defaultStart,
|
song_start: defaultStart,
|
||||||
song_end: defaultEnd,
|
song_end: defaultEnd,
|
||||||
|
duration_ms: track.duration_ms,
|
||||||
};
|
};
|
||||||
|
|
||||||
onChange(newSong);
|
onChange(newSong);
|
||||||
@@ -135,7 +136,7 @@ const SongPickerComponent = ({ value: song, onChange, formValues }: SongPickerCo
|
|||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<DurationPicker
|
<DurationPicker
|
||||||
songDurationMs={180000}
|
songDurationMs={song?.duration_ms || 180000}
|
||||||
initialStart={song?.song_start || 0}
|
initialStart={song?.song_start || 0}
|
||||||
initialEnd={song?.song_end || 15}
|
initialEnd={song?.song_end || 15}
|
||||||
onChange={handleDurationChange}
|
onChange={handleDurationChange}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { Text, Group, Avatar, Box } from "@mantine/core";
|
||||||
import { Text, Combobox, InputBase, useCombobox, Group, Avatar, Loader } from "@mantine/core";
|
|
||||||
import { SpotifyTrack } from "@/lib/spotify/types";
|
import { SpotifyTrack } from "@/lib/spotify/types";
|
||||||
import { useDebouncedCallback } from "@mantine/hooks";
|
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||||
|
|
||||||
interface SongSearchProps {
|
interface SongSearchProps {
|
||||||
onChange: (track: SpotifyTrack) => void;
|
onChange: (track: SpotifyTrack) => void;
|
||||||
@@ -9,14 +8,7 @@ interface SongSearchProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
|
||||||
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const combobox = useCombobox();
|
|
||||||
|
|
||||||
// Standalone search function that doesn't require Spotify context
|
|
||||||
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
|
|
||||||
if (!query.trim()) return [];
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -27,95 +19,62 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.tracks || [];
|
const tracks = data.tracks || [];
|
||||||
|
|
||||||
|
return tracks.map((track: SpotifyTrack) => ({
|
||||||
|
id: track.id,
|
||||||
|
data: track
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to search tracks:', error);
|
console.error('Failed to search tracks:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
const handleSongSelect = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||||
if (!query.trim()) {
|
onChange(option.data);
|
||||||
setSearchResults([]);
|
|
||||||
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 = (track: SpotifyTrack) => {
|
const formatTrack = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||||
onChange(track);
|
const track = option.data;
|
||||||
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
|
return `${track.name} - ${track.artists.map(a => a.name).join(', ')}`;
|
||||||
combobox.closeDropdown();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = searchResults.map((track) => (
|
const renderOption = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||||
<Combobox.Option value={track.id} key={track.id}>
|
const track = option.data;
|
||||||
<Group gap="sm">
|
return (
|
||||||
{track.album.images[2] && (
|
<Box
|
||||||
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
p="sm"
|
||||||
)}
|
style={{
|
||||||
<div>
|
borderBottom: '1px solid var(--mantine-color-dimmed)'
|
||||||
<Text size="sm" fw={500}>
|
}}
|
||||||
{track.name}
|
>
|
||||||
</Text>
|
<Group gap="sm">
|
||||||
<Text size="xs" c="dimmed">
|
{track.album.images[2] && (
|
||||||
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
||||||
</Text>
|
)}
|
||||||
</div>
|
<div>
|
||||||
</Group>
|
<Text size="sm" fw={500}>
|
||||||
</Combobox.Option>
|
{track.name}
|
||||||
));
|
</Text>
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Combobox
|
<Typeahead
|
||||||
store={combobox}
|
onSelect={handleSongSelect}
|
||||||
onOptionSubmit={(value) => {
|
searchFn={searchSpotifyTracks}
|
||||||
const track = searchResults.find(t => t.id === value);
|
renderOption={renderOption}
|
||||||
if (track) handleSongSelect(track);
|
format={formatTrack}
|
||||||
}}
|
placeholder={placeholder}
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SongSearch;
|
export default SongSearch;
|
||||||
@@ -39,19 +39,26 @@ const TeamListItem = React.memo(({ team }: TeamListItemProps) => {
|
|||||||
interface TeamListProps {
|
interface TeamListProps {
|
||||||
teams: TeamInfo[];
|
teams: TeamInfo[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
onTeamClick?: (teamId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const handleClick = useCallback(
|
const handleClick = useCallback(
|
||||||
(teamId: string) => navigate({ to: `/teams/${teamId}` }),
|
(teamId: string) => {
|
||||||
[navigate]
|
if (onTeamClick) {
|
||||||
|
onTeamClick(teamId);
|
||||||
|
} else {
|
||||||
|
navigate({ to: `/teams/${teamId}` });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigate, onTeamClick]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading)
|
if (loading)
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={`skeleton-${i}`}
|
key={`skeleton-${i}`}
|
||||||
@@ -65,7 +72,7 @@ const TeamList = ({ teams, loading = false }: TeamListProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{teams?.map((team) => (
|
{teams?.map((team) => (
|
||||||
<div key={team.id}>
|
<div key={team.id}>
|
||||||
<ListItem
|
<ListItem
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -11,7 +11,7 @@ interface HeaderProps {
|
|||||||
const Header = ({ name, logo, id }: HeaderProps) => {
|
const Header = ({ name, logo, id }: HeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex px="xl" w="100%" align="self-end" gap="md">
|
<Flex h="20dvh" px="xl" w="100%" align="self-end" gap="md">
|
||||||
<Avatar
|
<Avatar
|
||||||
radius="sm"
|
radius="sm"
|
||||||
name={name}
|
name={name}
|
||||||
|
|||||||
37
src/features/teams/components/team-profile/skeleton.tsx
Normal file
37
src/features/teams/components/team-profile/skeleton.tsx
Normal 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;
|
||||||
@@ -15,21 +15,21 @@ export const listTeamInfos = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getTeam = createServerFn()
|
export const getTeam = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: teamId }) =>
|
.handler(async ({ data: teamId }) =>
|
||||||
toServerResult(() => pbAdmin.getTeam(teamId))
|
toServerResult(() => pbAdmin.getTeam(teamId))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getTeamInfo = createServerFn()
|
export const getTeamInfo = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: teamId }) =>
|
.handler(async ({ data: teamId }) =>
|
||||||
toServerResult(() => pbAdmin.getTeamInfo(teamId))
|
toServerResult(() => pbAdmin.getTeamInfo(teamId))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const createTeam = createServerFn()
|
export const createTeam = createServerFn()
|
||||||
.validator(teamInputSchema)
|
.inputValidator(teamInputSchema)
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data, context }) =>
|
.handler(async ({ data, context }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -46,7 +46,7 @@ export const createTeam = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const updateTeam = createServerFn()
|
export const updateTeam = createServerFn()
|
||||||
.validator(z.object({
|
.inputValidator(z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
updates: teamUpdateSchema
|
updates: teamUpdateSchema
|
||||||
}))
|
}))
|
||||||
@@ -72,14 +72,14 @@ export const updateTeam = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getTeamStats = createServerFn()
|
export const getTeamStats = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: teamId }) =>
|
.handler(async ({ data: teamId }) =>
|
||||||
toServerResult(() => pbAdmin.getTeamStats(teamId))
|
toServerResult(() => pbAdmin.getTeamStats(teamId))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getTeamMatches = createServerFn()
|
export const getTeamMatches = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult<Match[]>(async () => await pbAdmin.getTeamMatches(data))
|
toServerResult<Match[]>(async () => await pbAdmin.getTeamMatches(data))
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
Autocomplete,
|
|
||||||
Stack,
|
Stack,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Text,
|
Text,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||||
import { TrashIcon } from "@phosphor-icons/react";
|
import { TrashIcon } from "@phosphor-icons/react";
|
||||||
import { useState, useCallback, useMemo, memo } from "react";
|
import { useState, useCallback, useMemo, memo } from "react";
|
||||||
import { useTournament, useUnenrolledTeams } from "../queries";
|
import { useTournament, useUnenrolledTeams } from "../queries";
|
||||||
@@ -36,6 +36,7 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
|
|||||||
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
<Group py="xs" px="sm" w="100%" gap="sm" align="center">
|
||||||
<Avatar
|
<Avatar
|
||||||
size={32}
|
size={32}
|
||||||
|
radius="sm"
|
||||||
name={team.name}
|
name={team.name}
|
||||||
src={
|
src={
|
||||||
team.logo
|
team.logo
|
||||||
@@ -67,8 +68,6 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
const { data: tournament, isLoading: tournamentLoading } =
|
const { data: tournament, isLoading: tournamentLoading } =
|
||||||
useTournament(tournamentId);
|
useTournament(tournamentId);
|
||||||
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
||||||
@@ -77,27 +76,24 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|||||||
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
||||||
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
||||||
|
|
||||||
const autocompleteData = useMemo(
|
const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
|
||||||
() =>
|
if (!query.trim()) return [];
|
||||||
unenrolledTeams.map((team: Team) => ({
|
|
||||||
value: team.id,
|
const filtered = unenrolledTeams.filter((team: Team) =>
|
||||||
label: team.name,
|
team.name.toLowerCase().includes(query.toLowerCase())
|
||||||
})),
|
);
|
||||||
[unenrolledTeams]
|
|
||||||
);
|
return filtered.map((team: Team) => ({
|
||||||
|
id: team.id,
|
||||||
|
data: team
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleEnrollTeam = useCallback(
|
const handleEnrollTeam = useCallback(
|
||||||
(teamId: string) => {
|
(option: TypeaheadOption<Team>) => {
|
||||||
enrollTeam(
|
enrollTeam({ tournamentId, teamId: option.data.id });
|
||||||
{ tournamentId, teamId },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setSearch("");
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[enrollTeam, tournamentId, setSearch]
|
[enrollTeam, tournamentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUnenrollTeam = useCallback(
|
const handleUnenrollTeam = useCallback(
|
||||||
@@ -107,6 +103,31 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|||||||
[unenrollTeam, tournamentId]
|
[unenrollTeam, tournamentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderTeamOption = (option: TypeaheadOption<Team>) => {
|
||||||
|
const team = option.data;
|
||||||
|
return (
|
||||||
|
<Group py="xs" px="sm" gap="sm" align="center">
|
||||||
|
<Avatar
|
||||||
|
size={32}
|
||||||
|
radius="sm"
|
||||||
|
name={team.name}
|
||||||
|
src={
|
||||||
|
team.logo
|
||||||
|
? `/api/files/teams/${team.id}/${team.logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text fw={500} truncate>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTeam = (option: TypeaheadOption<Team>) => {
|
||||||
|
return option.data.name;
|
||||||
|
};
|
||||||
|
|
||||||
const isLoading = tournamentLoading || unenrolledLoading;
|
const isLoading = tournamentLoading || unenrolledLoading;
|
||||||
const enrolledTeams = tournament?.teams || [];
|
const enrolledTeams = tournament?.teams || [];
|
||||||
const hasEnrolledTeams = enrolledTeams.length > 0;
|
const hasEnrolledTeams = enrolledTeams.length > 0;
|
||||||
@@ -117,16 +138,13 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|||||||
<Text fw={600} size="sm">
|
<Text fw={600} size="sm">
|
||||||
Add Team
|
Add Team
|
||||||
</Text>
|
</Text>
|
||||||
<Autocomplete
|
<Typeahead
|
||||||
placeholder="Search for teams to enroll..."
|
placeholder="Search for teams to enroll..."
|
||||||
data={autocompleteData}
|
onSelect={handleEnrollTeam}
|
||||||
value={search}
|
searchFn={searchTeams}
|
||||||
onChange={setSearch}
|
renderOption={renderTeamOption}
|
||||||
onOptionSubmit={handleEnrollTeam}
|
format={formatTeam}
|
||||||
disabled={isEnrolling || unenrolledLoading}
|
disabled={isEnrolling || unenrolledLoading}
|
||||||
rightSection={isEnrolling ? <Loader size="xs" /> : null}
|
|
||||||
maxDropdownHeight={200}
|
|
||||||
limit={10}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
PencilLineIcon,
|
PencilLineIcon,
|
||||||
TreeStructureIcon,
|
TreeStructureIcon,
|
||||||
UsersThreeIcon,
|
UsersThreeIcon,
|
||||||
|
UsersIcon,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { useSheet } from "@/hooks/use-sheet";
|
import { useSheet } from "@/hooks/use-sheet";
|
||||||
import EditEnrolledTeams from "./edit-enrolled-teams";
|
import EditEnrolledTeams from "./edit-enrolled-teams";
|
||||||
@@ -44,7 +45,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<List>
|
<List p="0">
|
||||||
<ListButton
|
<ListButton
|
||||||
label="Edit Tournament"
|
label="Edit Tournament"
|
||||||
Icon={HardDrivesIcon}
|
Icon={HardDrivesIcon}
|
||||||
@@ -56,10 +57,15 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
onClick={openEditRules}
|
onClick={openEditRules}
|
||||||
/>
|
/>
|
||||||
<ListButton
|
<ListButton
|
||||||
label="Edit Enrolled Teams"
|
label="Edit Enrollments"
|
||||||
Icon={UsersThreeIcon}
|
Icon={UsersThreeIcon}
|
||||||
onClick={openEditTeams}
|
onClick={openEditTeams}
|
||||||
/>
|
/>
|
||||||
|
<ListLink
|
||||||
|
label="Manage Team Songs/Logos"
|
||||||
|
Icon={UsersIcon}
|
||||||
|
to={`/admin/tournaments/${tournamentId}/teams`}
|
||||||
|
/>
|
||||||
<ListLink
|
<ListLink
|
||||||
label="Run Tournament"
|
label="Run Tournament"
|
||||||
Icon={TreeStructureIcon}
|
Icon={TreeStructureIcon}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -10,8 +10,8 @@ const Header = ({ tournament }: HeaderProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex px='xl' w='100%' align='self-end' gap='md'>
|
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||||
<Avatar name={tournament.name} radius={0} withBorder={false} size={125} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
|
<Avatar contain name={tournament.name} radius={0} withBorder={false} size={150} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
|
||||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||||
<Title ta='center' order={2}>{tournament.name}</Title>
|
<Title ta='center' order={2}>{tournament.name}</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { Box, Text } from "@mantine/core";
|
import { useMemo } from "react";
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
import Header from "./header";
|
import Header from "./header";
|
||||||
import TeamList from "@/features/teams/components/team-list";
|
import TeamList from "@/features/teams/components/team-list";
|
||||||
import SwipeableTabs from "@/components/swipeable-tabs";
|
import SwipeableTabs from "@/components/swipeable-tabs";
|
||||||
import { useTournament } from "../../queries";
|
import { useTournament } from "../../queries";
|
||||||
import MatchList from "@/features/matches/components/match-list";
|
import MatchList from "@/features/matches/components/match-list";
|
||||||
|
import { TournamentStats } from "../tournament-stats";
|
||||||
|
|
||||||
interface ProfileProps {
|
interface ProfileProps {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,10 +15,10 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
const { data: tournament } = useTournament(id);
|
const { data: tournament } = useTournament(id);
|
||||||
if (!tournament) return null;
|
if (!tournament) return null;
|
||||||
|
|
||||||
const tabs = [
|
const tabs = useMemo(() => [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: "Overview",
|
||||||
content: <Text p="md">Stats/Badges will go here, bracket link</Text>
|
content: <TournamentStats tournament={tournament} />
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Matches",
|
label: "Matches",
|
||||||
@@ -28,7 +30,7 @@ const Profile = ({ id }: ProfileProps) => {
|
|||||||
<TeamList teams={tournament.teams || []} />
|
<TeamList teams={tournament.teams || []} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
];
|
], [tournament]);
|
||||||
|
|
||||||
return <>
|
return <>
|
||||||
<Header tournament={tournament} />
|
<Header tournament={tournament} />
|
||||||
|
|||||||
37
src/features/tournaments/components/profile/skeleton.tsx
Normal file
37
src/features/tournaments/components/profile/skeleton.tsx
Normal 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;
|
||||||
@@ -18,6 +18,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
<Stack px="sm" align="center" gap={0}>
|
<Stack px="sm" align="center" gap={0}>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
|
contain
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const StartedTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
>
|
>
|
||||||
{startedMatches.map((match, index) => (
|
{startedMatches.map((match, index) => (
|
||||||
<Carousel.Slide key={match.id}>
|
<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} />
|
<MatchCard match={match} />
|
||||||
</Box>
|
</Box>
|
||||||
</Carousel.Slide>
|
</Carousel.Slide>
|
||||||
|
|||||||
37
src/features/tournaments/components/tournament-card-list.tsx
Normal file
37
src/features/tournaments/components/tournament-card-list.tsx
Normal 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;
|
||||||
@@ -1,48 +1,27 @@
|
|||||||
import {
|
import {
|
||||||
Badge,
|
|
||||||
Card,
|
Card,
|
||||||
Text,
|
Text,
|
||||||
Stack,
|
Stack,
|
||||||
Group,
|
Group,
|
||||||
Box,
|
|
||||||
ThemeIcon,
|
|
||||||
UnstyledButton,
|
UnstyledButton,
|
||||||
|
Badge,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { Tournament } from "@/features/tournaments/types";
|
import { TournamentInfo } from "@/features/tournaments/types";
|
||||||
import { useMemo } from "react";
|
|
||||||
import {
|
import {
|
||||||
TrophyIcon,
|
TrophyIcon,
|
||||||
CalendarIcon,
|
CrownIcon,
|
||||||
MapPinIcon,
|
MedalIcon,
|
||||||
UsersIcon,
|
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import Avatar from "@/components/avatar";
|
import Avatar from "@/components/avatar";
|
||||||
|
|
||||||
interface TournamentCardProps {
|
interface TournamentCardProps {
|
||||||
tournament: Tournament;
|
tournament: TournamentInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
||||||
const navigate = useNavigate();
|
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 (
|
return (
|
||||||
<UnstyledButton
|
<UnstyledButton
|
||||||
w="100%"
|
w="100%"
|
||||||
@@ -78,9 +57,10 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
<Group justify="space-between" align="center">
|
<Group justify="space-between" align="center">
|
||||||
<Group gap="md" align="center">
|
<Group gap="md" align="center">
|
||||||
<Avatar
|
<Avatar
|
||||||
size={120}
|
size={90}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
|
contain
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
@@ -93,31 +73,62 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
<Text fw={600} size="lg" lineClamp={2}>
|
<Text fw={600} size="lg" lineClamp={2}>
|
||||||
{tournament.name}
|
{tournament.name}
|
||||||
</Text>
|
</Text>
|
||||||
{displayDate && (
|
{(tournament.first_place || tournament.second_place || tournament.third_place) && (
|
||||||
<Group gap="xs">
|
<Stack gap={6} >
|
||||||
<ThemeIcon
|
{tournament.first_place && (
|
||||||
size="sm"
|
<Badge
|
||||||
variant="light"
|
size="md"
|
||||||
radius="sm"
|
radius="md"
|
||||||
color="gray"
|
variant="filled"
|
||||||
>
|
color="yellow"
|
||||||
<CalendarIcon size={12} />
|
leftSection={
|
||||||
</ThemeIcon>
|
<CrownIcon size={16} />
|
||||||
<Text size="sm" c="dimmed">
|
}
|
||||||
{displayDate}
|
style={{
|
||||||
</Text>
|
textTransform: 'none',
|
||||||
</Group>
|
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>
|
</Stack>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={`skeleton-${i}`}
|
key={`skeleton-${i}`}
|
||||||
@@ -97,7 +97,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{tournaments.map((tournament) => (
|
{tournaments.map((tournament) => (
|
||||||
<>
|
<>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -108,6 +108,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
radius="sm"
|
radius="sm"
|
||||||
size={40}
|
size={40}
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
|
contain
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
|||||||
283
src/features/tournaments/components/tournament-stats.tsx
Normal file
283
src/features/tournaments/components/tournament-stats.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ const EnrollTeam = ({ tournamentId, onSubmit }: EnrollTeamProps) => {
|
|||||||
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
|
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
|
||||||
|
|
||||||
const { data: teamData } = useServerQuery({
|
const { data: teamData } = useServerQuery({
|
||||||
...teamQueries.details(selectedTeamId!),
|
...teamQueries.details(selectedTeamId || ''),
|
||||||
options: { enabled: !!selectedTeamId }
|
options: { enabled: !!selectedTeamId }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Stack, Button, Divider, Autocomplete, Group, ComboboxItem } from '@mantine/core';
|
import { Stack, Button, Divider, Group, ComboboxItem, Text } from '@mantine/core';
|
||||||
import { PlusIcon } from '@phosphor-icons/react';
|
import { PlusIcon } from '@phosphor-icons/react';
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import Typeahead, { TypeaheadOption } from '@/components/typeahead';
|
||||||
|
|
||||||
interface TeamSelectionViewProps {
|
interface TeamSelectionViewProps {
|
||||||
options: ComboboxItem[];
|
options: ComboboxItem[];
|
||||||
@@ -11,11 +12,39 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
|||||||
options,
|
options,
|
||||||
onSelect
|
onSelect
|
||||||
}) => {
|
}) => {
|
||||||
const [value, setValue] = useState<string>('');
|
const [selectedTeam, setSelectedTeam] = React.useState<ComboboxItem | null>(null);
|
||||||
const selectedOption = useMemo(() => options.find(option => option.label === value), [value, options])
|
|
||||||
|
const searchTeams = async (query: string): Promise<TypeaheadOption<ComboboxItem>[]> => {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
|
const filtered = options.filter(option =>
|
||||||
|
option.label.toLowerCase().includes(query.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return filtered.map(option => ({
|
||||||
|
id: String(option.value),
|
||||||
|
data: option
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTeamSelect = (option: TypeaheadOption<ComboboxItem>) => {
|
||||||
|
setSelectedTeam(option.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTeamOption = (option: TypeaheadOption<ComboboxItem>) => {
|
||||||
|
return (
|
||||||
|
<Group py="xs" px="sm" gap="sm">
|
||||||
|
<Text fw={500}>{option.data.label}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTeam = (option: TypeaheadOption<ComboboxItem>) => {
|
||||||
|
return option.data.label;
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateNewTeamClicked = () => onSelect(undefined);
|
const handleCreateNewTeamClicked = () => onSelect(undefined);
|
||||||
const handleSelectExistingTeam = () => onSelect(selectedOption?.value)
|
const handleSelectExistingTeam = () => onSelect(selectedTeam?.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -31,17 +60,17 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
|||||||
<Divider my="sm" label="or" />
|
<Divider my="sm" label="or" />
|
||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Autocomplete
|
<Typeahead
|
||||||
placeholder="Select one of your existing teams"
|
placeholder="Select one of your existing teams"
|
||||||
value={value}
|
onSelect={handleTeamSelect}
|
||||||
onChange={setValue}
|
searchFn={searchTeams}
|
||||||
data={options.map(option => option.label)}
|
renderOption={renderTeamOption}
|
||||||
comboboxProps={{ withinPortal: false }}
|
format={formatTeam}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSelectExistingTeam}
|
onClick={handleSelectExistingTeam}
|
||||||
disabled={!selectedOption}
|
disabled={!selectedTeam}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
Enroll Selected Team
|
Enroll Selected Team
|
||||||
|
|||||||
@@ -11,10 +11,30 @@ const EnrolledFreeAgent: React.FC<{ tournamentId: string }> = ({
|
|||||||
|
|
||||||
const copyToClipboard = async (phone: string) => {
|
const copyToClipboard = async (phone: string) => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(phone);
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
toast.success("Phone number copied!");
|
await navigator.clipboard.writeText(phone);
|
||||||
|
toast.success("Phone number copied!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textArea = document.createElement("textarea");
|
||||||
|
textArea.value = phone;
|
||||||
|
textArea.style.display = "hidden";
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
const successful = document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
|
||||||
|
if (successful) {
|
||||||
|
toast.success("Phone number copied!");
|
||||||
|
} else {
|
||||||
|
throw new Error("Copy command failed");
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.success("Failed to copy");
|
console.error("Failed to copy:", err);
|
||||||
|
toast.error("Failed to copy");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
<Stack align="center" gap={0}>
|
<Stack align="center" gap={0}>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
|
contain
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
@@ -31,7 +32,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
>
|
>
|
||||||
<TrophyIcon size={32} />
|
<TrophyIcon size={32} />
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Flex gap="xs" direction="row" wrap="wrap" justify="space-around">
|
<Flex gap="xs" direction="column" justify="space-around">
|
||||||
{tournament.location && (
|
{tournament.location && (
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<ThemeIcon size="sm" variant="light" radius="sm">
|
<ThemeIcon size="sm" variant="light" radius="sm">
|
||||||
|
|||||||
@@ -51,8 +51,8 @@ const UpcomingTournament: React.FC<{ tournament: Tournament }> = ({
|
|||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Header tournament={tournament} />
|
<Header tournament={tournament} />
|
||||||
|
|
||||||
<Stack px="md">
|
<Stack px="xs">
|
||||||
{tournament.desc && <Text size="sm">{tournament.desc}</Text>}
|
{tournament.desc && <Text px="md" size="sm">{tournament.desc}</Text>}
|
||||||
|
|
||||||
<Card withBorder radius="lg" p="lg">
|
<Card withBorder radius="lg" p="lg">
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
|
|||||||
@@ -4,37 +4,32 @@ const UpcomingTournamentSkeleton = () => {
|
|||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Flex px="md" justify="center" w="100%">
|
<Flex px="md" justify="center" w="100%">
|
||||||
<Skeleton height={240} width={240} radius="md" />
|
<Skeleton height={200} width={240} radius="md" />
|
||||||
<Stack justify="space-between" align="flex-start">
|
|
||||||
<Box style={{ flex: 1 }}>
|
|
||||||
<Skeleton height={32} mb="xs" />
|
|
||||||
<Skeleton height={16} />
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Flex>
|
</Flex>
|
||||||
|
<Stack align="center" gap={2}>
|
||||||
|
<Skeleton height={16} w="30%" mb="md" />
|
||||||
|
<Skeleton height={16} w="30%" />
|
||||||
|
</Stack>
|
||||||
|
|
||||||
<Stack px="md">
|
<Stack px="md">
|
||||||
<Skeleton height={14} width="80%" />
|
|
||||||
|
|
||||||
<Card withBorder radius="lg" p="lg">
|
<Card withBorder radius="lg" p="lg">
|
||||||
|
<Skeleton height={14} width="80%" mb={16} />
|
||||||
<Group mb="sm" gap="xs" align="center">
|
<Group mb="sm" gap="xs" align="center">
|
||||||
<Skeleton height={16} width={16} />
|
<Skeleton height={32} width={16} />
|
||||||
<Skeleton height={14} width="20%" />
|
<Skeleton height={32} width="20%" />
|
||||||
<Box ml="auto">
|
<Box ml="auto">
|
||||||
<Skeleton height={20} width={80} radius="sm" />
|
<Skeleton height={32} width={80} radius="sm" />
|
||||||
|
</Box>
|
||||||
|
</Group>
|
||||||
|
<Group mb="sm" gap="xs" align="center">
|
||||||
|
<Skeleton height={32} width={16} />
|
||||||
|
<Skeleton height={32} width="20%" />
|
||||||
|
<Box ml="auto">
|
||||||
|
<Skeleton height={32} width={80} radius="sm" />
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
</Card>
|
</Card>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
<Box>
|
|
||||||
<Divider />
|
|
||||||
<Stack gap={0}>
|
|
||||||
<Skeleton height={48} width="100%" />
|
|
||||||
<Skeleton height={48} width="100%" />
|
|
||||||
<Skeleton height={48} width="100%" />
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface UpdateTeamProps {
|
|||||||
|
|
||||||
const UpdateTeam = ({ tournamentId, teamId }: UpdateTeamProps) => {
|
const UpdateTeam = ({ tournamentId, teamId }: UpdateTeamProps) => {
|
||||||
const { open, isOpen, toggle } = useSheet();
|
const { open, isOpen, toggle } = useSheet();
|
||||||
|
|
||||||
const { data: team } = useTeam(teamId);
|
const { data: team } = useTeam(teamId);
|
||||||
|
|
||||||
const initialValues = useMemo(() => {
|
const initialValues = useMemo(() => {
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ export const listTournaments = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const createTournament = createServerFn()
|
export const createTournament = createServerFn()
|
||||||
.validator(tournamentInputSchema)
|
.inputValidator(tournamentInputSchema)
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data }) =>
|
.handler(async ({ data }) =>
|
||||||
toServerResult(() => pbAdmin.createTournament(data))
|
toServerResult(() => pbAdmin.createTournament(data))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const updateTournament = createServerFn()
|
export const updateTournament = createServerFn()
|
||||||
.validator(z.object({
|
.inputValidator(z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
updates: tournamentInputSchema.partial()
|
updates: tournamentInputSchema.partial()
|
||||||
}))
|
}))
|
||||||
@@ -30,11 +30,12 @@ export const updateTournament = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getTournament = createServerFn()
|
export const getTournament = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId }) =>
|
.handler(async ({ data: tournamentId, context }) => {
|
||||||
toServerResult(() => pbAdmin.getTournament(tournamentId))
|
const isAdmin = context.roles.includes("Admin");
|
||||||
);
|
return toServerResult(() => pbAdmin.getTournament(tournamentId, isAdmin));
|
||||||
|
});
|
||||||
|
|
||||||
export const getCurrentTournament = createServerFn()
|
export const getCurrentTournament = createServerFn()
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
@@ -43,7 +44,7 @@ export const getCurrentTournament = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const enrollTeam = createServerFn()
|
export const enrollTeam = createServerFn()
|
||||||
.validator(z.object({
|
.inputValidator(z.object({
|
||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
teamId: z.string()
|
teamId: z.string()
|
||||||
}))
|
}))
|
||||||
@@ -69,7 +70,7 @@ export const enrollTeam = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const unenrollTeam = createServerFn()
|
export const unenrollTeam = createServerFn()
|
||||||
.validator(z.object({
|
.inputValidator(z.object({
|
||||||
tournamentId: z.string(),
|
tournamentId: z.string(),
|
||||||
teamId: z.string()
|
teamId: z.string()
|
||||||
}))
|
}))
|
||||||
@@ -79,21 +80,21 @@ export const unenrollTeam = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getUnenrolledTeams = createServerFn()
|
export const getUnenrolledTeams = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensAdminFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId }) =>
|
.handler(async ({ data: tournamentId }) =>
|
||||||
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
|
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getFreeAgents = createServerFn()
|
export const getFreeAgents = createServerFn()
|
||||||
.validator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId }) =>
|
.handler(async ({ data: tournamentId }) =>
|
||||||
toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
|
toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const enrollFreeAgent = createServerFn()
|
export const enrollFreeAgent = createServerFn()
|
||||||
.validator(z.object({ phone: z.string(), tournamentId: z.string() }))
|
.inputValidator(z.object({ phone: z.string(), tournamentId: z.string() }))
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
@@ -107,7 +108,7 @@ export const enrollFreeAgent = createServerFn()
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const unenrollFreeAgent = createServerFn()
|
export const unenrollFreeAgent = createServerFn()
|
||||||
.validator(z.object({ tournamentId: z.string() }))
|
.inputValidator(z.object({ tournamentId: z.string() }))
|
||||||
.middleware([superTokensFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ context, data }) =>
|
.handler(async ({ context, data }) =>
|
||||||
toServerResult(async () => {
|
toServerResult(async () => {
|
||||||
|
|||||||
@@ -2,6 +2,22 @@ import { TeamInfo } from "@/features/teams/types";
|
|||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export interface TournamentTeamStats {
|
||||||
|
id: string;
|
||||||
|
team_id: string;
|
||||||
|
tournament_id: string;
|
||||||
|
team_name: string;
|
||||||
|
matches: number;
|
||||||
|
wins: number;
|
||||||
|
losses: number;
|
||||||
|
total_cups_made: number;
|
||||||
|
total_cups_against: number;
|
||||||
|
margin_of_victory: number;
|
||||||
|
margin_of_loss: number;
|
||||||
|
win_percentage: number;
|
||||||
|
avg_cups_per_match: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TournamentInfo {
|
export interface TournamentInfo {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -9,6 +25,9 @@ export interface TournamentInfo {
|
|||||||
start_time?: string;
|
start_time?: string;
|
||||||
end_time?: string;
|
end_time?: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
|
first_place?: TeamInfo;
|
||||||
|
second_place?: TeamInfo;
|
||||||
|
third_place?: TeamInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Tournament {
|
export interface Tournament {
|
||||||
@@ -25,6 +44,10 @@ export interface Tournament {
|
|||||||
updated: string;
|
updated: string;
|
||||||
teams?: TeamInfo[];
|
teams?: TeamInfo[];
|
||||||
matches?: Match[];
|
matches?: Match[];
|
||||||
|
first_place?: TeamInfo;
|
||||||
|
second_place?: TeamInfo;
|
||||||
|
third_place?: TeamInfo;
|
||||||
|
team_stats?: TournamentTeamStats[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tournamentInputSchema = z.object({
|
export const tournamentInputSchema = z.object({
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function useServerEvents() {
|
|||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
|
|
||||||
shouldConnectRef.current = true;
|
shouldConnectRef.current = true;
|
||||||
@@ -111,5 +112,5 @@ export function useServerEvents() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [user?.id, queryClient]);
|
}, [user?.id]);
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ class Logger {
|
|||||||
constructor(context?: string, options: LoggerOptions = {}) {
|
constructor(context?: string, options: LoggerOptions = {}) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.options = {
|
this.options = {
|
||||||
enabled: process.env.NODE_ENV !== "production",
|
enabled: import.meta.env.NODE_ENV !== "production",
|
||||||
showTimestamp: true,
|
showTimestamp: true,
|
||||||
collapsed: true,
|
collapsed: true,
|
||||||
colors: true,
|
colors: true,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ const MantineProvider = ({ children }: { children: React.ReactNode }) => {
|
|||||||
setIsHydrated(true);
|
setIsHydrated(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const colorScheme = isHydrated ? metadata.colorScheme || "auto" : "auto";
|
const colorScheme = isHydrated ? metadata.colorScheme || "dark" : "dark";
|
||||||
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
|
const primaryColor = isHydrated ? metadata.accentColor || "blue" : "blue";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { createTournamentsService } from "./services/tournaments";
|
|||||||
import { createTeamsService } from "./services/teams";
|
import { createTeamsService } from "./services/teams";
|
||||||
import { createMatchesService } from "./services/matches";
|
import { createMatchesService } from "./services/matches";
|
||||||
import { createReactionsService } from "./services/reactions";
|
import { createReactionsService } from "./services/reactions";
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
dotenv.config();
|
||||||
|
|
||||||
class PocketBaseAdminClient {
|
class PocketBaseAdminClient {
|
||||||
private pb: PocketBase;
|
private pb: PocketBase;
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function createPlayersService(pb: PocketBase) {
|
|||||||
expand: "tournament,home,away",
|
expand: "tournament,home,away",
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.map(transformMatch);
|
return result.map((match) => transformMatch(match));
|
||||||
},
|
},
|
||||||
|
|
||||||
async getUnenrolledPlayers(tournamentId: string): Promise<Player[]> {
|
async getUnenrolledPlayers(tournamentId: string): Promise<Player[]> {
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export function createTeamsService(pb: PocketBase) {
|
|||||||
expand: "tournament,home,away",
|
expand: "tournament,home,away",
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.map(transformMatch);
|
return result.map((match) => transformMatch(match));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,16 +9,24 @@ import type { Team } from "@/features/teams/types";
|
|||||||
import PocketBase from "pocketbase";
|
import PocketBase from "pocketbase";
|
||||||
import { transformFreeAgent, transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types";
|
import { transformFreeAgent, transformTournament, transformTournamentInfo } from "@/lib/pocketbase/util/transform-types";
|
||||||
import { transformTeam } from "@/lib/pocketbase/util/transform-types";
|
import { transformTeam } from "@/lib/pocketbase/util/transform-types";
|
||||||
import { getFreeAgents } from "@/features/tournaments/server";
|
|
||||||
import { PlayerInfo } from "@/features/players/types";
|
import { PlayerInfo } from "@/features/players/types";
|
||||||
|
|
||||||
export function createTournamentsService(pb: PocketBase) {
|
export function createTournamentsService(pb: PocketBase) {
|
||||||
return {
|
return {
|
||||||
async getTournament(id: string): Promise<Tournament> {
|
async getTournament(id: string, isAdmin: boolean = false): Promise<Tournament> {
|
||||||
const result = await pb.collection("tournaments").getOne(id, {
|
const [tournamentResult, teamStatsResult] = await Promise.all([
|
||||||
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
|
pb.collection("tournaments").getOne(id, {
|
||||||
});
|
expand: "teams, teams.players, matches, matches.tournament, matches.home, matches.away, matches.home.players, matches.away.players",
|
||||||
return transformTournament(result);
|
}),
|
||||||
|
pb.collection("team_stats_per_tournament").getFullList({
|
||||||
|
filter: `tournament_id = "${id}"`,
|
||||||
|
sort: "-wins,-total_cups_made"
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
tournamentResult.team_stats = teamStatsResult;
|
||||||
|
|
||||||
|
return transformTournament(tournamentResult, isAdmin);
|
||||||
},
|
},
|
||||||
async getMostRecentTournament(): Promise<Tournament> {
|
async getMostRecentTournament(): Promise<Tournament> {
|
||||||
const result = await pb
|
const result = await pb
|
||||||
@@ -29,17 +37,35 @@ export function createTournamentsService(pb: PocketBase) {
|
|||||||
sort: "-created",
|
sort: "-created",
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const teamStatsResult = await pb.collection("team_stats_per_tournament").getFullList({
|
||||||
|
filter: `tournament_id = "${result.id}"`,
|
||||||
|
sort: "-wins,-total_cups_made"
|
||||||
|
});
|
||||||
|
|
||||||
|
result.team_stats = teamStatsResult;
|
||||||
|
|
||||||
return transformTournament(result);
|
return transformTournament(result);
|
||||||
},
|
},
|
||||||
async listTournaments(): Promise<TournamentInfo[]> {
|
async listTournaments(): Promise<TournamentInfo[]> {
|
||||||
const result = await pb
|
const result = await pb
|
||||||
.collection("tournaments")
|
.collection("tournaments")
|
||||||
.getFullList({
|
.getFullList({
|
||||||
fields: "id,name,location,start_time,end_time,logo",
|
expand: "teams,teams.players,matches",
|
||||||
sort: "-created",
|
sort: "-created",
|
||||||
});
|
});
|
||||||
|
|
||||||
return result.map(transformTournamentInfo);
|
const tournamentsWithStats = await Promise.all(result.map(async (tournament) => {
|
||||||
|
const teamStats = await pb.collection("team_stats_per_tournament").getFullList({
|
||||||
|
filter: `tournament_id = "${tournament.id}"`,
|
||||||
|
sort: "-wins,-total_cups_made"
|
||||||
|
});
|
||||||
|
|
||||||
|
tournament.team_stats = teamStats;
|
||||||
|
return tournament;
|
||||||
|
}));
|
||||||
|
|
||||||
|
return tournamentsWithStats.map(transformTournamentInfo);
|
||||||
},
|
},
|
||||||
async createTournament(data: TournamentInput): Promise<Tournament> {
|
async createTournament(data: TournamentInput): Promise<Tournament> {
|
||||||
const result = await pb
|
const result = await pb
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function transformTeamInfo(record: any): TeamInfo {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const transformMatch = (record: any): Match => {
|
export const transformMatch = (record: any, isAdmin: boolean = false): Match => {
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
order: record.order,
|
order: record.order,
|
||||||
@@ -47,8 +47,8 @@ export const transformMatch = (record: any): Match => {
|
|||||||
is_losers_bracket: record.is_losers_bracket,
|
is_losers_bracket: record.is_losers_bracket,
|
||||||
status: record.status || "tbd",
|
status: record.status || "tbd",
|
||||||
tournament: record.expand?.tournament ? transformTournamentInfo(record.expand?.tournament) : record.tournament,
|
tournament: record.expand?.tournament ? transformTournamentInfo(record.expand?.tournament) : record.tournament,
|
||||||
home: record.expand?.home ? transformTeamInfo(record.expand.home) : record.home,
|
home: record.expand?.home ? (isAdmin ? transformTeam(record.expand.home) : transformTeamInfo(record.expand.home)) : record.home,
|
||||||
away: record.expand?.away ? transformTeamInfo(record.expand.away) : record.away,
|
away: record.expand?.away ? (isAdmin ? transformTeam(record.expand.away) : transformTeamInfo(record.expand.away)) : record.away,
|
||||||
created: record.created,
|
created: record.created,
|
||||||
updated: record.updated,
|
updated: record.updated,
|
||||||
home_seed: record.home_seed,
|
home_seed: record.home_seed,
|
||||||
@@ -57,12 +57,58 @@ export const transformMatch = (record: any): Match => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const transformTournamentInfo = (record: any): TournamentInfo => {
|
export const transformTournamentInfo = (record: any): TournamentInfo => {
|
||||||
|
// Check if tournament is complete by looking at matches
|
||||||
|
const matches = record.expand?.matches || [];
|
||||||
|
// Filter out bye matches (tbd status with bye=true) when checking completion
|
||||||
|
const nonByeMatches = matches.filter((match: any) => !(match.status === 'tbd' && match.bye === true));
|
||||||
|
const isComplete = nonByeMatches.length > 0 && nonByeMatches.every((match: any) => match.status === 'ended');
|
||||||
|
|
||||||
|
let first_place: TeamInfo | undefined = undefined;
|
||||||
|
let second_place: TeamInfo | undefined = undefined;
|
||||||
|
let third_place: TeamInfo | undefined = undefined;
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
const teams = record.expand?.teams || [];
|
||||||
|
const teamMap = new Map<string, TeamInfo>(teams.map((team: any) => [team.id, transformTeamInfo(team)]));
|
||||||
|
|
||||||
|
const winnersMatches = matches.filter((match: any) => !match.is_losers_bracket);
|
||||||
|
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest, null);
|
||||||
|
|
||||||
|
const losersMatches = matches.filter((match: any) => match.is_losers_bracket);
|
||||||
|
const losersFinale = losersMatches.reduce((highest: any, current: any) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest, null);
|
||||||
|
|
||||||
|
if (finalsMatch && finalsMatch.status === 'ended') {
|
||||||
|
const finalsWinner = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
|
||||||
|
const finalsLoser = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.away : finalsMatch.home;
|
||||||
|
|
||||||
|
const finalsWinnerId = typeof finalsWinner === 'string' ? finalsWinner : finalsWinner?.id;
|
||||||
|
const finalsLoserId = typeof finalsLoser === 'string' ? finalsLoser : finalsLoser?.id;
|
||||||
|
|
||||||
|
first_place = finalsWinnerId ? teamMap.get(finalsWinnerId) || undefined : undefined;
|
||||||
|
second_place = finalsLoserId ? teamMap.get(finalsLoserId) || undefined : undefined;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if (losersFinale && losersFinale.status === 'ended') {
|
||||||
|
const losersFinaleLoser = (losersFinale.home_cups > losersFinale.away_cups) ? losersFinale.away : losersFinale.home;
|
||||||
|
|
||||||
|
const losersFinaleloserId = typeof losersFinaleLoser === 'string' ? losersFinaleLoser : losersFinaleLoser?.id;
|
||||||
|
third_place = losersFinaleloserId ? teamMap.get(losersFinaleloserId) || undefined : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
name: record.name,
|
name: record.name,
|
||||||
location: record.location,
|
location: record.location,
|
||||||
start_time: record.start_time,
|
start_time: record.start_time,
|
||||||
|
end_time: record.end_time,
|
||||||
logo: record.logo,
|
logo: record.logo,
|
||||||
|
first_place,
|
||||||
|
second_place,
|
||||||
|
third_place,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,20 +181,73 @@ export function transformTeam(record: any): Team {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function transformTournament(record: any): Tournament {
|
export function transformTournament(record: any, isAdmin: boolean = false): Tournament {
|
||||||
const teams =
|
const teams =
|
||||||
record.expand?.teams
|
record.expand?.teams
|
||||||
?.sort((a: any, b: any) =>
|
?.sort((a: any, b: any) =>
|
||||||
new Date(a.created) < new Date(b.created) ? -1 : 0
|
new Date(a.created) < new Date(b.created) ? -1 : 0
|
||||||
)
|
)
|
||||||
?.map(transformTeamInfo) ?? [];
|
?.map(isAdmin ? transformTeam : transformTeamInfo) ?? [];
|
||||||
|
|
||||||
const matches =
|
const matches =
|
||||||
record.expand?.matches
|
record.expand?.matches
|
||||||
?.sort((a: any, b: any) =>
|
?.sort((a: any, b: any) =>
|
||||||
a.lid - b.lid ? -1 : 0
|
a.lid - b.lid ? -1 : 0
|
||||||
)
|
)
|
||||||
?.map(transformMatch) ?? [];
|
?.map((match: any) => transformMatch(match, isAdmin)) ?? [];
|
||||||
|
|
||||||
|
const team_stats = record.team_stats?.map((stat: any) => ({
|
||||||
|
id: stat.id,
|
||||||
|
team_id: stat.team_id,
|
||||||
|
tournament_id: stat.tournament_id,
|
||||||
|
team_name: stat.team_name,
|
||||||
|
matches: stat.matches,
|
||||||
|
wins: stat.wins,
|
||||||
|
losses: stat.losses,
|
||||||
|
total_cups_made: stat.total_cups_made,
|
||||||
|
total_cups_against: stat.total_cups_against,
|
||||||
|
margin_of_victory: stat.margin_of_victory,
|
||||||
|
margin_of_loss: stat.margin_of_loss,
|
||||||
|
win_percentage: (stat.wins / stat.matches) * 100,
|
||||||
|
avg_cups_per_match: stat.total_cups_made / stat.matches,
|
||||||
|
})) ?? [];
|
||||||
|
|
||||||
|
const nonByeMatches = matches.filter((match: any) => !(match.status === 'tbd' && match.bye === true));
|
||||||
|
const isComplete = nonByeMatches.length > 0 && nonByeMatches.every((match: any) => match.status === 'ended');
|
||||||
|
|
||||||
|
let first_place: TeamInfo | undefined = undefined;
|
||||||
|
let second_place: TeamInfo | undefined = undefined;
|
||||||
|
let third_place: TeamInfo | undefined = undefined;
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
const teamMap = new Map<string, TeamInfo>(teams.map((team: any) => [team.id, team]));
|
||||||
|
|
||||||
|
const winnersMatches = matches.filter((match: any) => !match.is_losers_bracket);
|
||||||
|
const finalsMatch = winnersMatches.reduce((highest: any, current: any) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest, null);
|
||||||
|
|
||||||
|
const losersMatches = matches.filter((match: any) => match.is_losers_bracket);
|
||||||
|
const losersFinale = losersMatches.reduce((highest: any, current: any) =>
|
||||||
|
(!highest || current.lid > highest.lid) ? current : highest, null);
|
||||||
|
|
||||||
|
if (finalsMatch && finalsMatch.status === 'ended') {
|
||||||
|
const finalsWinner = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.home : finalsMatch.away;
|
||||||
|
const finalsLoser = (finalsMatch.home_cups > finalsMatch.away_cups) ? finalsMatch.away : finalsMatch.home;
|
||||||
|
|
||||||
|
const finalsWinnerId = typeof finalsWinner === 'string' ? finalsWinner : finalsWinner?.id;
|
||||||
|
const finalsLoserId = typeof finalsLoser === 'string' ? finalsLoser : finalsLoser?.id;
|
||||||
|
|
||||||
|
first_place = finalsWinnerId ? teamMap.get(finalsWinnerId) || undefined : undefined;
|
||||||
|
second_place = finalsLoserId ? teamMap.get(finalsLoserId) || undefined : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (losersFinale && losersFinale.status === 'ended') {
|
||||||
|
const losersFinaleLoser = (losersFinale.home_cups > losersFinale.away_cups) ? losersFinale.away : losersFinale.home;
|
||||||
|
|
||||||
|
const losersFinaleloserId = typeof losersFinaleLoser === 'string' ? losersFinaleLoser : losersFinaleLoser?.id;
|
||||||
|
third_place = losersFinaleloserId ? teamMap.get(losersFinaleloserId) || undefined : undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: record.id,
|
id: record.id,
|
||||||
@@ -163,7 +262,11 @@ export function transformTournament(record: any): Tournament {
|
|||||||
created: record.created,
|
created: record.created,
|
||||||
updated: record.updated,
|
updated: record.updated,
|
||||||
teams,
|
teams,
|
||||||
matches
|
matches,
|
||||||
|
first_place,
|
||||||
|
second_place,
|
||||||
|
third_place,
|
||||||
|
team_stats,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PKCEState, SpotifyTokenResponse } from './types';
|
import type { SpotifyTokenResponse } from './types';
|
||||||
|
|
||||||
const SPOTIFY_AUTH_BASE = 'https://accounts.spotify.com';
|
const SPOTIFY_AUTH_BASE = 'https://accounts.spotify.com';
|
||||||
const SPOTIFY_SCOPES = [
|
const SPOTIFY_SCOPES = [
|
||||||
|
|||||||
@@ -96,6 +96,17 @@ export class SpotifyWebApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async playTrack(trackId: string, deviceId?: string, positionMs?: number): Promise<void> {
|
||||||
|
const endpoint = deviceId ? `/me/player/play?device_id=${deviceId}` : '/me/player/play';
|
||||||
|
await this.request(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify({
|
||||||
|
uris: [`spotify:track:${trackId}`],
|
||||||
|
position_ms: positionMs || 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async pause(): Promise<void> {
|
async pause(): Promise<void> {
|
||||||
await this.request('/me/player/pause', {
|
await this.request('/me/player/pause', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user