Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
732afaf623 | ||
|
|
48aeaabeea | ||
|
|
a4b9fe9065 | ||
|
|
31e50af593 | ||
|
|
39053cadaa | ||
|
|
ea6656aa33 | ||
|
|
92c4987372 | ||
|
|
b3ebf46afa | ||
|
|
c0ef535001 | ||
|
|
81329e4354 | ||
|
|
36f3bb77d4 | ||
|
|
6760ea46f9 | ||
|
|
e4164cbc71 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -19,4 +19,5 @@ yarn.lock
|
||||
/playwright/.cache/
|
||||
/scripts/
|
||||
/pb_data/
|
||||
/.tanstack/
|
||||
/.tanstack/
|
||||
/dist/
|
||||
12
package.json
12
package.json
@@ -6,7 +6,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev --host 0.0.0.0",
|
||||
"build": "vite build && tsc --noEmit",
|
||||
"start": "vite start"
|
||||
"start": "bun run .output/server/index.mjs",
|
||||
"start:node": "node .output/server/index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
@@ -24,12 +25,15 @@
|
||||
"@tanstack/react-router": "^1.130.12",
|
||||
"@tanstack/react-router-devtools": "^1.130.13",
|
||||
"@tanstack/react-router-with-query": "^1.130.12",
|
||||
"@tanstack/react-start": "^1.130.15",
|
||||
"@tanstack/react-start": "^1.132.2",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tiptap/pm": "^3.4.3",
|
||||
"@tiptap/react": "^3.4.3",
|
||||
"@tiptap/starter-kit": "^3.4.3",
|
||||
"@types/bun": "^1.2.22",
|
||||
"@types/ioredis": "^4.28.10",
|
||||
"browser-image-compression": "^2.0.2",
|
||||
"dotenv": "^17.2.2",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"framer-motion": "^12.23.12",
|
||||
"ioredis": "^5.7.0",
|
||||
@@ -51,6 +55,8 @@
|
||||
"zustand": "^5.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-router-ssr-query": "^1.132.2",
|
||||
"@tanstack/router-plugin": "^1.132.2",
|
||||
"@types/node": "^22.5.4",
|
||||
"@types/pg": "^8.15.5",
|
||||
"@types/react": "^19.0.8",
|
||||
@@ -63,7 +69,7 @@
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.3.5",
|
||||
"vite": "^7.1.7",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { createServerRootRoute } from '@tanstack/react-start/server'
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as RefreshSessionRouteImport } from './routes/refresh-session'
|
||||
import { Route as LogoutRouteImport } from './routes/logout'
|
||||
@@ -21,6 +19,16 @@ import { Route as AuthedSettingsRouteImport } from './routes/_authed/settings'
|
||||
import { Route as AuthedAdminRouteImport } from './routes/_authed/admin'
|
||||
import { Route as AuthedTournamentsIndexRouteImport } from './routes/_authed/tournaments/index'
|
||||
import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index'
|
||||
import { Route as ApiTournamentsUploadLogoRouteImport } from './routes/api/tournaments/upload-logo'
|
||||
import { Route as ApiTeamsUploadLogoRouteImport } from './routes/api/teams/upload-logo'
|
||||
import { Route as ApiSpotifyTokenRouteImport } from './routes/api/spotify/token'
|
||||
import { Route as ApiSpotifySearchRouteImport } from './routes/api/spotify/search'
|
||||
import { Route as ApiSpotifyResumeRouteImport } from './routes/api/spotify/resume'
|
||||
import { Route as ApiSpotifyPlaybackRouteImport } from './routes/api/spotify/playback'
|
||||
import { Route as ApiSpotifyCaptureRouteImport } from './routes/api/spotify/capture'
|
||||
import { Route as ApiSpotifyCallbackRouteImport } from './routes/api/spotify/callback'
|
||||
import { Route as ApiEventsSplatRouteImport } from './routes/api/events.$'
|
||||
import { Route as ApiAuthSplatRouteImport } from './routes/api/auth.$'
|
||||
import { Route as AuthedTournamentsTournamentIdRouteImport } from './routes/_authed/tournaments/$tournamentId'
|
||||
import { Route as AuthedTeamsTeamIdRouteImport } from './routes/_authed/teams.$teamId'
|
||||
import { Route as AuthedProfilePlayerIdRouteImport } from './routes/_authed/profile.$playerId'
|
||||
@@ -28,21 +36,9 @@ import { Route as AuthedAdminPreviewRouteImport } from './routes/_authed/admin/p
|
||||
import { Route as AuthedAdminTournamentsIndexRouteImport } from './routes/_authed/admin/tournaments/index'
|
||||
import { Route as AuthedTournamentsIdBracketRouteImport } from './routes/_authed/tournaments/$id.bracket'
|
||||
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 AuthedAdminTournamentsIdTeamsRouteImport } from './routes/_authed/admin/tournaments/$id/teams'
|
||||
import { ServerRoute as ApiTournamentsUploadLogoServerRouteImport } from './routes/api/tournaments/upload-logo'
|
||||
import { ServerRoute as ApiTeamsUploadLogoServerRouteImport } from './routes/api/teams/upload-logo'
|
||||
import { ServerRoute as ApiSpotifyTokenServerRouteImport } from './routes/api/spotify/token'
|
||||
import { ServerRoute as ApiSpotifySearchServerRouteImport } from './routes/api/spotify/search'
|
||||
import { ServerRoute as ApiSpotifyResumeServerRouteImport } from './routes/api/spotify/resume'
|
||||
import { ServerRoute as ApiSpotifyPlaybackServerRouteImport } from './routes/api/spotify/playback'
|
||||
import { ServerRoute as ApiSpotifyCaptureServerRouteImport } from './routes/api/spotify/capture'
|
||||
import { ServerRoute as ApiSpotifyCallbackServerRouteImport } from './routes/api/spotify/callback'
|
||||
import { ServerRoute as ApiEventsSplatServerRouteImport } from './routes/api/events.$'
|
||||
import { ServerRoute as ApiAuthSplatServerRouteImport } from './routes/api/auth.$'
|
||||
import { ServerRoute as ApiFilesCollectionRecordIdFileServerRouteImport } from './routes/api/files/$collection/$recordId/$file'
|
||||
|
||||
const rootServerRouteImport = createServerRootRoute()
|
||||
|
||||
const RefreshSessionRoute = RefreshSessionRouteImport.update({
|
||||
id: '/refresh-session',
|
||||
@@ -93,6 +89,57 @@ const AuthedAdminIndexRoute = AuthedAdminIndexRouteImport.update({
|
||||
path: '/',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const ApiTournamentsUploadLogoRoute =
|
||||
ApiTournamentsUploadLogoRouteImport.update({
|
||||
id: '/api/tournaments/upload-logo',
|
||||
path: '/api/tournaments/upload-logo',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiTeamsUploadLogoRoute = ApiTeamsUploadLogoRouteImport.update({
|
||||
id: '/api/teams/upload-logo',
|
||||
path: '/api/teams/upload-logo',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifyTokenRoute = ApiSpotifyTokenRouteImport.update({
|
||||
id: '/api/spotify/token',
|
||||
path: '/api/spotify/token',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifySearchRoute = ApiSpotifySearchRouteImport.update({
|
||||
id: '/api/spotify/search',
|
||||
path: '/api/spotify/search',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifyResumeRoute = ApiSpotifyResumeRouteImport.update({
|
||||
id: '/api/spotify/resume',
|
||||
path: '/api/spotify/resume',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifyPlaybackRoute = ApiSpotifyPlaybackRouteImport.update({
|
||||
id: '/api/spotify/playback',
|
||||
path: '/api/spotify/playback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifyCaptureRoute = ApiSpotifyCaptureRouteImport.update({
|
||||
id: '/api/spotify/capture',
|
||||
path: '/api/spotify/capture',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifyCallbackRoute = ApiSpotifyCallbackRouteImport.update({
|
||||
id: '/api/spotify/callback',
|
||||
path: '/api/spotify/callback',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiEventsSplatRoute = ApiEventsSplatRouteImport.update({
|
||||
id: '/api/events/$',
|
||||
path: '/api/events/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiAuthSplatRoute = ApiAuthSplatRouteImport.update({
|
||||
id: '/api/auth/$',
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthedTournamentsTournamentIdRoute =
|
||||
AuthedTournamentsTournamentIdRouteImport.update({
|
||||
id: '/tournaments/$tournamentId',
|
||||
@@ -132,6 +179,12 @@ const AuthedAdminTournamentsIdIndexRoute =
|
||||
path: '/tournaments/$id/',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const ApiFilesCollectionRecordIdFileRoute =
|
||||
ApiFilesCollectionRecordIdFileRouteImport.update({
|
||||
id: '/api/files/$collection/$recordId/$file',
|
||||
path: '/api/files/$collection/$recordId/$file',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthedAdminTournamentsRunIdRoute =
|
||||
AuthedAdminTournamentsRunIdRouteImport.update({
|
||||
id: '/tournaments/run/$id',
|
||||
@@ -144,66 +197,6 @@ const AuthedAdminTournamentsIdTeamsRoute =
|
||||
path: '/tournaments/$id/teams',
|
||||
getParentRoute: () => AuthedAdminRoute,
|
||||
} as any)
|
||||
const ApiTournamentsUploadLogoServerRoute =
|
||||
ApiTournamentsUploadLogoServerRouteImport.update({
|
||||
id: '/api/tournaments/upload-logo',
|
||||
path: '/api/tournaments/upload-logo',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiTeamsUploadLogoServerRoute =
|
||||
ApiTeamsUploadLogoServerRouteImport.update({
|
||||
id: '/api/teams/upload-logo',
|
||||
path: '/api/teams/upload-logo',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifyTokenServerRoute = ApiSpotifyTokenServerRouteImport.update({
|
||||
id: '/api/spotify/token',
|
||||
path: '/api/spotify/token',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifySearchServerRoute = ApiSpotifySearchServerRouteImport.update({
|
||||
id: '/api/spotify/search',
|
||||
path: '/api/spotify/search',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifyResumeServerRoute = ApiSpotifyResumeServerRouteImport.update({
|
||||
id: '/api/spotify/resume',
|
||||
path: '/api/spotify/resume',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifyPlaybackServerRoute =
|
||||
ApiSpotifyPlaybackServerRouteImport.update({
|
||||
id: '/api/spotify/playback',
|
||||
path: '/api/spotify/playback',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifyCaptureServerRoute = ApiSpotifyCaptureServerRouteImport.update({
|
||||
id: '/api/spotify/capture',
|
||||
path: '/api/spotify/capture',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiSpotifyCallbackServerRoute =
|
||||
ApiSpotifyCallbackServerRouteImport.update({
|
||||
id: '/api/spotify/callback',
|
||||
path: '/api/spotify/callback',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiEventsSplatServerRoute = ApiEventsSplatServerRouteImport.update({
|
||||
id: '/api/events/$',
|
||||
path: '/api/events/$',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiAuthSplatServerRoute = ApiAuthSplatServerRouteImport.update({
|
||||
id: '/api/auth/$',
|
||||
path: '/api/auth/$',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
const ApiFilesCollectionRecordIdFileServerRoute =
|
||||
ApiFilesCollectionRecordIdFileServerRouteImport.update({
|
||||
id: '/api/files/$collection/$recordId/$file',
|
||||
path: '/api/files/$collection/$recordId/$file',
|
||||
getParentRoute: () => rootServerRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/login': typeof LoginRoute
|
||||
@@ -217,12 +210,23 @@ export interface FileRoutesByFullPath {
|
||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/events/$': typeof ApiEventsSplatRoute
|
||||
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
|
||||
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||
'/admin/': typeof AuthedAdminIndexRoute
|
||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
@@ -236,12 +240,23 @@ export interface FileRoutesByTo {
|
||||
'/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||
'/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||
'/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/events/$': typeof ApiEventsSplatRoute
|
||||
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
|
||||
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||
'/admin': typeof AuthedAdminIndexRoute
|
||||
'/tournaments': typeof AuthedTournamentsIndexRoute
|
||||
'/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/admin/tournaments': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||
'/admin/tournaments/$id': typeof AuthedAdminTournamentsIdIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
@@ -258,12 +273,23 @@ export interface FileRoutesById {
|
||||
'/_authed/profile/$playerId': typeof AuthedProfilePlayerIdRoute
|
||||
'/_authed/teams/$teamId': typeof AuthedTeamsTeamIdRoute
|
||||
'/_authed/tournaments/$tournamentId': typeof AuthedTournamentsTournamentIdRoute
|
||||
'/api/auth/$': typeof ApiAuthSplatRoute
|
||||
'/api/events/$': typeof ApiEventsSplatRoute
|
||||
'/api/spotify/callback': typeof ApiSpotifyCallbackRoute
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackRoute
|
||||
'/api/spotify/resume': typeof ApiSpotifyResumeRoute
|
||||
'/api/spotify/search': typeof ApiSpotifySearchRoute
|
||||
'/api/spotify/token': typeof ApiSpotifyTokenRoute
|
||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoRoute
|
||||
'/_authed/admin/': typeof AuthedAdminIndexRoute
|
||||
'/_authed/tournaments/': typeof AuthedTournamentsIndexRoute
|
||||
'/_authed/tournaments/$id/bracket': typeof AuthedTournamentsIdBracketRoute
|
||||
'/_authed/admin/tournaments/': typeof AuthedAdminTournamentsIndexRoute
|
||||
'/_authed/admin/tournaments/$id/teams': typeof AuthedAdminTournamentsIdTeamsRoute
|
||||
'/_authed/admin/tournaments/run/$id': typeof AuthedAdminTournamentsRunIdRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileRoute
|
||||
'/_authed/admin/tournaments/$id/': typeof AuthedAdminTournamentsIdIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
@@ -280,12 +306,23 @@ export interface FileRouteTypes {
|
||||
| '/profile/$playerId'
|
||||
| '/teams/$teamId'
|
||||
| '/tournaments/$tournamentId'
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/spotify/callback'
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
| '/api/spotify/resume'
|
||||
| '/api/spotify/search'
|
||||
| '/api/spotify/token'
|
||||
| '/api/teams/upload-logo'
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/admin/'
|
||||
| '/tournaments'
|
||||
| '/tournaments/$id/bracket'
|
||||
| '/admin/tournaments'
|
||||
| '/admin/tournaments/$id/teams'
|
||||
| '/admin/tournaments/run/$id'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
| '/admin/tournaments/$id'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
@@ -299,12 +336,23 @@ export interface FileRouteTypes {
|
||||
| '/profile/$playerId'
|
||||
| '/teams/$teamId'
|
||||
| '/tournaments/$tournamentId'
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/spotify/callback'
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
| '/api/spotify/resume'
|
||||
| '/api/spotify/search'
|
||||
| '/api/spotify/token'
|
||||
| '/api/teams/upload-logo'
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/admin'
|
||||
| '/tournaments'
|
||||
| '/tournaments/$id/bracket'
|
||||
| '/admin/tournaments'
|
||||
| '/admin/tournaments/$id/teams'
|
||||
| '/admin/tournaments/run/$id'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
| '/admin/tournaments/$id'
|
||||
id:
|
||||
| '__root__'
|
||||
@@ -320,12 +368,23 @@ export interface FileRouteTypes {
|
||||
| '/_authed/profile/$playerId'
|
||||
| '/_authed/teams/$teamId'
|
||||
| '/_authed/tournaments/$tournamentId'
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/spotify/callback'
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
| '/api/spotify/resume'
|
||||
| '/api/spotify/search'
|
||||
| '/api/spotify/token'
|
||||
| '/api/teams/upload-logo'
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/_authed/admin/'
|
||||
| '/_authed/tournaments/'
|
||||
| '/_authed/tournaments/$id/bracket'
|
||||
| '/_authed/admin/tournaments/'
|
||||
| '/_authed/admin/tournaments/$id/teams'
|
||||
| '/_authed/admin/tournaments/run/$id'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
| '/_authed/admin/tournaments/$id/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
@@ -334,101 +393,17 @@ export interface RootRouteChildren {
|
||||
LoginRoute: typeof LoginRoute
|
||||
LogoutRoute: typeof LogoutRoute
|
||||
RefreshSessionRoute: typeof RefreshSessionRoute
|
||||
}
|
||||
export interface FileServerRoutesByFullPath {
|
||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||
}
|
||||
export interface FileServerRoutesByTo {
|
||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||
}
|
||||
export interface FileServerRoutesById {
|
||||
__root__: typeof rootServerRouteImport
|
||||
'/api/auth/$': typeof ApiAuthSplatServerRoute
|
||||
'/api/events/$': typeof ApiEventsSplatServerRoute
|
||||
'/api/spotify/callback': typeof ApiSpotifyCallbackServerRoute
|
||||
'/api/spotify/capture': typeof ApiSpotifyCaptureServerRoute
|
||||
'/api/spotify/playback': typeof ApiSpotifyPlaybackServerRoute
|
||||
'/api/spotify/resume': typeof ApiSpotifyResumeServerRoute
|
||||
'/api/spotify/search': typeof ApiSpotifySearchServerRoute
|
||||
'/api/spotify/token': typeof ApiSpotifyTokenServerRoute
|
||||
'/api/teams/upload-logo': typeof ApiTeamsUploadLogoServerRoute
|
||||
'/api/tournaments/upload-logo': typeof ApiTournamentsUploadLogoServerRoute
|
||||
'/api/files/$collection/$recordId/$file': typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||
}
|
||||
export interface FileServerRouteTypes {
|
||||
fileServerRoutesByFullPath: FileServerRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/spotify/callback'
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
| '/api/spotify/resume'
|
||||
| '/api/spotify/search'
|
||||
| '/api/spotify/token'
|
||||
| '/api/teams/upload-logo'
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
fileServerRoutesByTo: FileServerRoutesByTo
|
||||
to:
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/spotify/callback'
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
| '/api/spotify/resume'
|
||||
| '/api/spotify/search'
|
||||
| '/api/spotify/token'
|
||||
| '/api/teams/upload-logo'
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/api/auth/$'
|
||||
| '/api/events/$'
|
||||
| '/api/spotify/callback'
|
||||
| '/api/spotify/capture'
|
||||
| '/api/spotify/playback'
|
||||
| '/api/spotify/resume'
|
||||
| '/api/spotify/search'
|
||||
| '/api/spotify/token'
|
||||
| '/api/teams/upload-logo'
|
||||
| '/api/tournaments/upload-logo'
|
||||
| '/api/files/$collection/$recordId/$file'
|
||||
fileServerRoutesById: FileServerRoutesById
|
||||
}
|
||||
export interface RootServerRouteChildren {
|
||||
ApiAuthSplatServerRoute: typeof ApiAuthSplatServerRoute
|
||||
ApiEventsSplatServerRoute: typeof ApiEventsSplatServerRoute
|
||||
ApiSpotifyCallbackServerRoute: typeof ApiSpotifyCallbackServerRoute
|
||||
ApiSpotifyCaptureServerRoute: typeof ApiSpotifyCaptureServerRoute
|
||||
ApiSpotifyPlaybackServerRoute: typeof ApiSpotifyPlaybackServerRoute
|
||||
ApiSpotifyResumeServerRoute: typeof ApiSpotifyResumeServerRoute
|
||||
ApiSpotifySearchServerRoute: typeof ApiSpotifySearchServerRoute
|
||||
ApiSpotifyTokenServerRoute: typeof ApiSpotifyTokenServerRoute
|
||||
ApiTeamsUploadLogoServerRoute: typeof ApiTeamsUploadLogoServerRoute
|
||||
ApiTournamentsUploadLogoServerRoute: typeof ApiTournamentsUploadLogoServerRoute
|
||||
ApiFilesCollectionRecordIdFileServerRoute: typeof ApiFilesCollectionRecordIdFileServerRoute
|
||||
ApiAuthSplatRoute: typeof ApiAuthSplatRoute
|
||||
ApiEventsSplatRoute: typeof ApiEventsSplatRoute
|
||||
ApiSpotifyCallbackRoute: typeof ApiSpotifyCallbackRoute
|
||||
ApiSpotifyCaptureRoute: typeof ApiSpotifyCaptureRoute
|
||||
ApiSpotifyPlaybackRoute: typeof ApiSpotifyPlaybackRoute
|
||||
ApiSpotifyResumeRoute: typeof ApiSpotifyResumeRoute
|
||||
ApiSpotifySearchRoute: typeof ApiSpotifySearchRoute
|
||||
ApiSpotifyTokenRoute: typeof ApiSpotifyTokenRoute
|
||||
ApiTeamsUploadLogoRoute: typeof ApiTeamsUploadLogoRoute
|
||||
ApiTournamentsUploadLogoRoute: typeof ApiTournamentsUploadLogoRoute
|
||||
ApiFilesCollectionRecordIdFileRoute: typeof ApiFilesCollectionRecordIdFileRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
@@ -503,6 +478,76 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedAdminIndexRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/api/tournaments/upload-logo': {
|
||||
id: '/api/tournaments/upload-logo'
|
||||
path: '/api/tournaments/upload-logo'
|
||||
fullPath: '/api/tournaments/upload-logo'
|
||||
preLoaderRoute: typeof ApiTournamentsUploadLogoRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/teams/upload-logo': {
|
||||
id: '/api/teams/upload-logo'
|
||||
path: '/api/teams/upload-logo'
|
||||
fullPath: '/api/teams/upload-logo'
|
||||
preLoaderRoute: typeof ApiTeamsUploadLogoRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/spotify/token': {
|
||||
id: '/api/spotify/token'
|
||||
path: '/api/spotify/token'
|
||||
fullPath: '/api/spotify/token'
|
||||
preLoaderRoute: typeof ApiSpotifyTokenRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/spotify/search': {
|
||||
id: '/api/spotify/search'
|
||||
path: '/api/spotify/search'
|
||||
fullPath: '/api/spotify/search'
|
||||
preLoaderRoute: typeof ApiSpotifySearchRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/spotify/resume': {
|
||||
id: '/api/spotify/resume'
|
||||
path: '/api/spotify/resume'
|
||||
fullPath: '/api/spotify/resume'
|
||||
preLoaderRoute: typeof ApiSpotifyResumeRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/spotify/playback': {
|
||||
id: '/api/spotify/playback'
|
||||
path: '/api/spotify/playback'
|
||||
fullPath: '/api/spotify/playback'
|
||||
preLoaderRoute: typeof ApiSpotifyPlaybackRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/spotify/capture': {
|
||||
id: '/api/spotify/capture'
|
||||
path: '/api/spotify/capture'
|
||||
fullPath: '/api/spotify/capture'
|
||||
preLoaderRoute: typeof ApiSpotifyCaptureRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/spotify/callback': {
|
||||
id: '/api/spotify/callback'
|
||||
path: '/api/spotify/callback'
|
||||
fullPath: '/api/spotify/callback'
|
||||
preLoaderRoute: typeof ApiSpotifyCallbackRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/events/$': {
|
||||
id: '/api/events/$'
|
||||
path: '/api/events/$'
|
||||
fullPath: '/api/events/$'
|
||||
preLoaderRoute: typeof ApiEventsSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/auth/$': {
|
||||
id: '/api/auth/$'
|
||||
path: '/api/auth/$'
|
||||
fullPath: '/api/auth/$'
|
||||
preLoaderRoute: typeof ApiAuthSplatRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authed/tournaments/$tournamentId': {
|
||||
id: '/_authed/tournaments/$tournamentId'
|
||||
path: '/tournaments/$tournamentId'
|
||||
@@ -552,6 +597,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthedAdminTournamentsIdIndexRouteImport
|
||||
parentRoute: typeof AuthedAdminRoute
|
||||
}
|
||||
'/api/files/$collection/$recordId/$file': {
|
||||
id: '/api/files/$collection/$recordId/$file'
|
||||
path: '/api/files/$collection/$recordId/$file'
|
||||
fullPath: '/api/files/$collection/$recordId/$file'
|
||||
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authed/admin/tournaments/run/$id': {
|
||||
id: '/_authed/admin/tournaments/run/$id'
|
||||
path: '/tournaments/run/$id'
|
||||
@@ -568,87 +620,6 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
}
|
||||
declare module '@tanstack/react-start/server' {
|
||||
interface ServerFileRoutesByPath {
|
||||
'/api/tournaments/upload-logo': {
|
||||
id: '/api/tournaments/upload-logo'
|
||||
path: '/api/tournaments/upload-logo'
|
||||
fullPath: '/api/tournaments/upload-logo'
|
||||
preLoaderRoute: typeof ApiTournamentsUploadLogoServerRouteImport
|
||||
parentRoute: typeof rootServerRouteImport
|
||||
}
|
||||
'/api/teams/upload-logo': {
|
||||
id: '/api/teams/upload-logo'
|
||||
path: '/api/teams/upload-logo'
|
||||
fullPath: '/api/teams/upload-logo'
|
||||
preLoaderRoute: typeof ApiTeamsUploadLogoServerRouteImport
|
||||
parentRoute: typeof rootServerRouteImport
|
||||
}
|
||||
'/api/spotify/token': {
|
||||
id: '/api/spotify/token'
|
||||
path: '/api/spotify/token'
|
||||
fullPath: '/api/spotify/token'
|
||||
preLoaderRoute: typeof ApiSpotifyTokenServerRouteImport
|
||||
parentRoute: typeof rootServerRouteImport
|
||||
}
|
||||
'/api/spotify/search': {
|
||||
id: '/api/spotify/search'
|
||||
path: '/api/spotify/search'
|
||||
fullPath: '/api/spotify/search'
|
||||
preLoaderRoute: typeof ApiSpotifySearchServerRouteImport
|
||||
parentRoute: typeof rootServerRouteImport
|
||||
}
|
||||
'/api/spotify/resume': {
|
||||
id: '/api/spotify/resume'
|
||||
path: '/api/spotify/resume'
|
||||
fullPath: '/api/spotify/resume'
|
||||
preLoaderRoute: typeof ApiSpotifyResumeServerRouteImport
|
||||
parentRoute: typeof rootServerRouteImport
|
||||
}
|
||||
'/api/spotify/playback': {
|
||||
id: '/api/spotify/playback'
|
||||
path: '/api/spotify/playback'
|
||||
fullPath: '/api/spotify/playback'
|
||||
preLoaderRoute: typeof ApiSpotifyPlaybackServerRouteImport
|
||||
parentRoute: typeof rootServerRouteImport
|
||||
}
|
||||
'/api/spotify/capture': {
|
||||
id: '/api/spotify/capture'
|
||||
path: '/api/spotify/capture'
|
||||
fullPath: '/api/spotify/capture'
|
||||
preLoaderRoute: typeof ApiSpotifyCaptureServerRouteImport
|
||||
parentRoute: typeof rootServerRouteImport
|
||||
}
|
||||
'/api/spotify/callback': {
|
||||
id: '/api/spotify/callback'
|
||||
path: '/api/spotify/callback'
|
||||
fullPath: '/api/spotify/callback'
|
||||
preLoaderRoute: typeof ApiSpotifyCallbackServerRouteImport
|
||||
parentRoute: typeof rootServerRouteImport
|
||||
}
|
||||
'/api/events/$': {
|
||||
id: '/api/events/$'
|
||||
path: '/api/events/$'
|
||||
fullPath: '/api/events/$'
|
||||
preLoaderRoute: typeof ApiEventsSplatServerRouteImport
|
||||
parentRoute: typeof rootServerRouteImport
|
||||
}
|
||||
'/api/auth/$': {
|
||||
id: '/api/auth/$'
|
||||
path: '/api/auth/$'
|
||||
fullPath: '/api/auth/$'
|
||||
preLoaderRoute: typeof ApiAuthSplatServerRouteImport
|
||||
parentRoute: typeof rootServerRouteImport
|
||||
}
|
||||
'/api/files/$collection/$recordId/$file': {
|
||||
id: '/api/files/$collection/$recordId/$file'
|
||||
path: '/api/files/$collection/$recordId/$file'
|
||||
fullPath: '/api/files/$collection/$recordId/$file'
|
||||
preLoaderRoute: typeof ApiFilesCollectionRecordIdFileServerRouteImport
|
||||
parentRoute: typeof rootServerRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthedAdminRouteChildren {
|
||||
AuthedAdminPreviewRoute: typeof AuthedAdminPreviewRoute
|
||||
@@ -704,24 +675,27 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
LoginRoute: LoginRoute,
|
||||
LogoutRoute: LogoutRoute,
|
||||
RefreshSessionRoute: RefreshSessionRoute,
|
||||
ApiAuthSplatRoute: ApiAuthSplatRoute,
|
||||
ApiEventsSplatRoute: ApiEventsSplatRoute,
|
||||
ApiSpotifyCallbackRoute: ApiSpotifyCallbackRoute,
|
||||
ApiSpotifyCaptureRoute: ApiSpotifyCaptureRoute,
|
||||
ApiSpotifyPlaybackRoute: ApiSpotifyPlaybackRoute,
|
||||
ApiSpotifyResumeRoute: ApiSpotifyResumeRoute,
|
||||
ApiSpotifySearchRoute: ApiSpotifySearchRoute,
|
||||
ApiSpotifyTokenRoute: ApiSpotifyTokenRoute,
|
||||
ApiTeamsUploadLogoRoute: ApiTeamsUploadLogoRoute,
|
||||
ApiTournamentsUploadLogoRoute: ApiTournamentsUploadLogoRoute,
|
||||
ApiFilesCollectionRecordIdFileRoute: ApiFilesCollectionRecordIdFileRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
const rootServerRouteChildren: RootServerRouteChildren = {
|
||||
ApiAuthSplatServerRoute: ApiAuthSplatServerRoute,
|
||||
ApiEventsSplatServerRoute: ApiEventsSplatServerRoute,
|
||||
ApiSpotifyCallbackServerRoute: ApiSpotifyCallbackServerRoute,
|
||||
ApiSpotifyCaptureServerRoute: ApiSpotifyCaptureServerRoute,
|
||||
ApiSpotifyPlaybackServerRoute: ApiSpotifyPlaybackServerRoute,
|
||||
ApiSpotifyResumeServerRoute: ApiSpotifyResumeServerRoute,
|
||||
ApiSpotifySearchServerRoute: ApiSpotifySearchServerRoute,
|
||||
ApiSpotifyTokenServerRoute: ApiSpotifyTokenServerRoute,
|
||||
ApiTeamsUploadLogoServerRoute: ApiTeamsUploadLogoServerRoute,
|
||||
ApiTournamentsUploadLogoServerRoute: ApiTournamentsUploadLogoServerRoute,
|
||||
ApiFilesCollectionRecordIdFileServerRoute:
|
||||
ApiFilesCollectionRecordIdFileServerRoute,
|
||||
|
||||
import type { getRouter } from './router.tsx'
|
||||
import type { createStart } from '@tanstack/react-start'
|
||||
declare module '@tanstack/react-start' {
|
||||
interface Register {
|
||||
ssr: true
|
||||
router: Awaited<ReturnType<typeof getRouter>>
|
||||
}
|
||||
}
|
||||
export const serverRouteTree = rootServerRouteImport
|
||||
._addFileChildren(rootServerRouteChildren)
|
||||
._addFileTypes<FileServerRouteTypes>()
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||
import { routerWithQueryClient } from "@tanstack/react-router-with-query";
|
||||
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
||||
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
||||
|
||||
export function createRouter() {
|
||||
export function getRouter() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@@ -18,28 +18,32 @@ export function createRouter() {
|
||||
},
|
||||
});
|
||||
|
||||
return routerWithQueryClient(
|
||||
createTanStackRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
queryClient,
|
||||
auth: undefined!,
|
||||
header: defaultHeaderConfig,
|
||||
refresh: [],
|
||||
withPadding: true,
|
||||
fullWidth: false,
|
||||
},
|
||||
defaultPreload: "intent",
|
||||
defaultErrorComponent: DefaultCatchBoundary,
|
||||
scrollRestoration: true,
|
||||
defaultViewTransition: false,
|
||||
}),
|
||||
const router = createTanStackRouter({
|
||||
routeTree,
|
||||
context: {
|
||||
queryClient,
|
||||
auth: undefined!,
|
||||
header: defaultHeaderConfig,
|
||||
refresh: [],
|
||||
withPadding: true,
|
||||
fullWidth: false,
|
||||
},
|
||||
defaultPreload: "intent",
|
||||
defaultErrorComponent: DefaultCatchBoundary,
|
||||
scrollRestoration: true,
|
||||
defaultViewTransition: false,
|
||||
});
|
||||
|
||||
setupRouterSsrQueryIntegration({
|
||||
router,
|
||||
queryClient
|
||||
);
|
||||
})
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: ReturnType<typeof createRouter>;
|
||||
router: ReturnType<typeof getRouter>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,12 +83,20 @@ export const Route = createRootRouteWithContext<{
|
||||
return {};
|
||||
}
|
||||
|
||||
// https://github.com/TanStack/router/discussions/3531
|
||||
const auth = await ensureServerQueryData(
|
||||
context.queryClient,
|
||||
playerQueries.auth()
|
||||
);
|
||||
return { auth };
|
||||
if (location.pathname === '/login' || location.pathname === '/logout') {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
// https://github.com/TanStack/router/discussions/3531
|
||||
const auth = await ensureServerQueryData(
|
||||
context.queryClient,
|
||||
playerQueries.auth()
|
||||
);
|
||||
return { auth };
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// API file that handles all supertokens auth routes
|
||||
import { createServerFileRoute } from '@tanstack/react-start/server';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { handleAuthAPIRequest } from 'supertokens-node/custom'
|
||||
import { ensureSuperTokensBackend } from '@/lib/supertokens/server'
|
||||
|
||||
@@ -12,12 +12,16 @@ const handleRequest = async ({ request }: {request: Request}) => {
|
||||
console.log("Handling auth request:", request.method, request.url);
|
||||
return superTokensHandler(request);
|
||||
};
|
||||
export const ServerRoute = createServerFileRoute('/api/auth/$').methods({
|
||||
GET: handleRequest,
|
||||
POST: handleRequest,
|
||||
PUT: handleRequest,
|
||||
DELETE: handleRequest,
|
||||
PATCH: handleRequest,
|
||||
OPTIONS: handleRequest,
|
||||
HEAD: handleRequest,
|
||||
export const Route = createFileRoute('/api/auth/$')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: handleRequest,
|
||||
POST: handleRequest,
|
||||
PUT: handleRequest,
|
||||
DELETE: handleRequest,
|
||||
PATCH: handleRequest,
|
||||
OPTIONS: handleRequest,
|
||||
HEAD: handleRequest,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,66 +1,61 @@
|
||||
import { createServerFileRoute } from "@tanstack/react-start/server";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { serverEvents, type ServerEvent } from "@/lib/events/emitter";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
|
||||
export const ServerRoute = createServerFileRoute("/api/events/$").middleware([superTokensRequestMiddleware]).methods({
|
||||
GET: ({ request, context }) => {
|
||||
logger.info('ServerEvents | New connection', context?.userAuthId);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(connectMessage));
|
||||
export const Route = createFileRoute("/api/events/$")({
|
||||
server: {
|
||||
middleware: [superTokensRequestMiddleware],
|
||||
handlers: {
|
||||
GET: ({ request, context }) => {
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(connectMessage));
|
||||
|
||||
const handleEvent = (event: ServerEvent) => {
|
||||
logger.info('ServerEvents | Event received', event);
|
||||
const message = `data: ${JSON.stringify(event)}\n\n`;
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(message));
|
||||
} catch (error) {
|
||||
logger.error("ServerEvents | Error sending SSE message", error);
|
||||
}
|
||||
};
|
||||
const handleEvent = (event: ServerEvent) => {
|
||||
logger.info("ServerEvents | Event received", event);
|
||||
const message = `data: ${JSON.stringify(event)}\n\n`;
|
||||
try {
|
||||
controller.enqueue(new TextEncoder().encode(message));
|
||||
} catch (error) {
|
||||
logger.error("ServerEvents | Error sending SSE message", error);
|
||||
}
|
||||
};
|
||||
|
||||
serverEvents.on("test", handleEvent);
|
||||
serverEvents.on("match", handleEvent);
|
||||
serverEvents.on("reaction", handleEvent);
|
||||
serverEvents.on("test", handleEvent);
|
||||
serverEvents.on("match", handleEvent);
|
||||
serverEvents.on("reaction", handleEvent);
|
||||
|
||||
const pingInterval = setInterval(() => {
|
||||
try {
|
||||
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(pingMessage));
|
||||
} catch (e) {
|
||||
clearInterval(pingInterval);
|
||||
controller.close();
|
||||
}
|
||||
}, 30000);
|
||||
const pingInterval = setInterval(() => {
|
||||
try {
|
||||
const pingMessage = `data: ${JSON.stringify({ type: "ping" })}\n\n`;
|
||||
controller.enqueue(new TextEncoder().encode(pingMessage));
|
||||
} catch (e) {
|
||||
clearInterval(pingInterval);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
const cleanup = () => {
|
||||
serverEvents.off("test", handleEvent);
|
||||
clearInterval(pingInterval);
|
||||
try {
|
||||
logger.info('ServerEvents | Closing connection', context?.userAuthId);
|
||||
controller.close();
|
||||
} catch (e) {
|
||||
logger.error('ServerEvents | Error closing controller', e);
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
serverEvents.off("test", handleEvent);
|
||||
clearInterval(pingInterval);
|
||||
};
|
||||
|
||||
request.signal?.addEventListener("abort", cleanup);
|
||||
request.signal?.addEventListener("abort", cleanup);
|
||||
return cleanup;
|
||||
},
|
||||
});
|
||||
|
||||
return cleanup;
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Cache-Control",
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "Cache-Control",
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,95 +1,113 @@
|
||||
import { createServerFileRoute } from "@tanstack/react-start/server";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export const ServerRoute = createServerFileRoute("/api/files/$collection/$recordId/$file").methods({
|
||||
GET: async ({ params, request }) => {
|
||||
try {
|
||||
const { collection, recordId, file } = params;
|
||||
const pocketbaseUrl = process.env.POCKETBASE_URL || 'http://127.0.0.1:8090';
|
||||
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
||||
|
||||
logger.info('File proxy', {
|
||||
collection,
|
||||
recordId,
|
||||
file,
|
||||
targetUrl: fileUrl
|
||||
});
|
||||
export const Route = createFileRoute(
|
||||
"/api/files/$collection/$recordId/$file"
|
||||
)({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ params, request }) => {
|
||||
try {
|
||||
const { collection, recordId, file } = params;
|
||||
const pocketbaseUrl =
|
||||
process.env.POCKETBASE_URL || "http://127.0.0.1:8090";
|
||||
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
||||
|
||||
const response = await fetch(fileUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(request.headers.get('range') && { 'Range': request.headers.get('range')! }),
|
||||
...(request.headers.get('if-none-match') && { 'If-None-Match': request.headers.get('if-none-match')! }),
|
||||
...(request.headers.get('if-modified-since') && { 'If-Modified-Since': request.headers.get('if-modified-since')! }),
|
||||
},
|
||||
});
|
||||
logger.info("File proxy", {
|
||||
collection,
|
||||
recordId,
|
||||
file,
|
||||
targetUrl: fileUrl,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error('PocketBase file request failed', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: fileUrl
|
||||
});
|
||||
const response = await fetch(fileUrl, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
...(request.headers.get("range") && {
|
||||
Range: request.headers.get("range")!,
|
||||
}),
|
||||
...(request.headers.get("if-none-match") && {
|
||||
"If-None-Match": request.headers.get("if-none-match")!,
|
||||
}),
|
||||
...(request.headers.get("if-modified-since") && {
|
||||
"If-Modified-Since": request.headers.get("if-modified-since")!,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return new Response('File not found', { status: 404 });
|
||||
if (!response.ok) {
|
||||
logger.error("PocketBase file request failed", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: fileUrl,
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return new Response("File not found", { status: 404 });
|
||||
}
|
||||
|
||||
return new Response(`PocketBase error: ${response.statusText}`, {
|
||||
status: response.status,
|
||||
});
|
||||
}
|
||||
|
||||
const body = response.body;
|
||||
const responseHeaders = new Headers();
|
||||
const headers = [
|
||||
"content-type",
|
||||
"content-length",
|
||||
"content-disposition",
|
||||
"etag",
|
||||
"last-modified",
|
||||
"cache-control",
|
||||
"accept-ranges",
|
||||
"content-range",
|
||||
];
|
||||
|
||||
headers.forEach((header) => {
|
||||
const value = response.headers.get(header);
|
||||
if (value) {
|
||||
responseHeaders.set(header, value);
|
||||
}
|
||||
});
|
||||
|
||||
responseHeaders.set("Access-Control-Allow-Origin", "*");
|
||||
responseHeaders.set(
|
||||
"Access-Control-Allow-Methods",
|
||||
"GET, HEAD, OPTIONS"
|
||||
);
|
||||
responseHeaders.set(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Range, If-None-Match, If-Modified-Since"
|
||||
);
|
||||
|
||||
logger.info("File proxy response", {
|
||||
status: response.status,
|
||||
contentType: response.headers.get("content-type"),
|
||||
contentLength: response.headers.get("content-length"),
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("File proxy error", error);
|
||||
return new Response("Internal server error", { status: 500 });
|
||||
}
|
||||
},
|
||||
|
||||
return new Response(`PocketBase error: ${response.statusText}`, {
|
||||
status: response.status
|
||||
OPTIONS: () => {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Max-Age": "86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const body = response.body;
|
||||
const responseHeaders = new Headers();
|
||||
const headers = [
|
||||
'content-type',
|
||||
'content-length',
|
||||
'content-disposition',
|
||||
'etag',
|
||||
'last-modified',
|
||||
'cache-control',
|
||||
'accept-ranges',
|
||||
'content-range'
|
||||
];
|
||||
|
||||
headers.forEach(header => {
|
||||
const value = response.headers.get(header);
|
||||
if (value) {
|
||||
responseHeaders.set(header, value);
|
||||
}
|
||||
});
|
||||
|
||||
responseHeaders.set('Access-Control-Allow-Origin', '*');
|
||||
responseHeaders.set('Access-Control-Allow-Methods', 'GET, HEAD, OPTIONS');
|
||||
responseHeaders.set('Access-Control-Allow-Headers', 'Range, If-None-Match, If-Modified-Since');
|
||||
|
||||
logger.info('File proxy response', {
|
||||
status: response.status,
|
||||
contentType: response.headers.get('content-type'),
|
||||
contentLength: response.headers.get('content-length')
|
||||
});
|
||||
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('File proxy error', error);
|
||||
return new Response('Internal server error', { status: 500 });
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
OPTIONS: () => {
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,127 +1,145 @@
|
||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||
import { SpotifyAuth } from '@/lib/spotify/auth'
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!
|
||||
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!
|
||||
const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI!
|
||||
const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
|
||||
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
|
||||
const SPOTIFY_REDIRECT_URI = process.env.VITE_SPOTIFY_REDIRECT_URI!;
|
||||
|
||||
export const ServerRoute = createServerFileRoute('/api/spotify/callback').methods({
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
const getReturnPath = (state: string | null): string => {
|
||||
if (!state) return '/';
|
||||
try {
|
||||
const decodedState = JSON.parse(atob(state));
|
||||
return decodedState.returnPath || '/';
|
||||
} catch {
|
||||
return '/';
|
||||
}
|
||||
};
|
||||
export const Route = createFileRoute("/api/spotify/callback")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
const getReturnPath = (state: string | null): string => {
|
||||
if (!state) return "/";
|
||||
try {
|
||||
const decodedState = JSON.parse(atob(state));
|
||||
return decodedState.returnPath || "/";
|
||||
} catch {
|
||||
return "/";
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const code = url.searchParams.get('code')
|
||||
const state = url.searchParams.get('state')
|
||||
const error = url.searchParams.get('error')
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const code = url.searchParams.get("code");
|
||||
const state = url.searchParams.get("state");
|
||||
const error = url.searchParams.get("error");
|
||||
|
||||
const returnPath = getReturnPath(state);
|
||||
const returnPath = getReturnPath(state);
|
||||
|
||||
if (error) {
|
||||
console.error('Spotify OAuth error:', error)
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': returnPath + '?spotify_error=' + encodeURIComponent(error),
|
||||
},
|
||||
})
|
||||
}
|
||||
if (error) {
|
||||
console.error("Spotify OAuth error:", error);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location:
|
||||
returnPath + "?spotify_error=" + encodeURIComponent(error),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
console.error('Missing code or state:', { code: !!code, state: !!state })
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': returnPath + '?spotify_error=missing_code_or_state',
|
||||
},
|
||||
})
|
||||
}
|
||||
if (!code || !state) {
|
||||
console.error("Missing code or state:", {
|
||||
code: !!code,
|
||||
state: !!state,
|
||||
});
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: returnPath + "?spotify_error=missing_code_or_state",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Token exchange attempt:', {
|
||||
client_id: SPOTIFY_CLIENT_ID,
|
||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||
has_code: !!code,
|
||||
has_state: !!state,
|
||||
})
|
||||
console.log("Token exchange attempt:", {
|
||||
client_id: SPOTIFY_CLIENT_ID,
|
||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||
has_code: !!code,
|
||||
has_state: !!state,
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||
}),
|
||||
})
|
||||
const tokenResponse = await fetch(
|
||||
"https://accounts.spotify.com/api/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code,
|
||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text()
|
||||
console.error('Token exchange error:', {
|
||||
status: tokenResponse.status,
|
||||
statusText: tokenResponse.statusText,
|
||||
body: errorText,
|
||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||
})
|
||||
|
||||
const errorParam = encodeURIComponent(`${tokenResponse.status}: ${errorText}`)
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (!tokenResponse.ok) {
|
||||
const errorText = await tokenResponse.text();
|
||||
console.error("Token exchange error:", {
|
||||
status: tokenResponse.status,
|
||||
statusText: tokenResponse.statusText,
|
||||
body: errorText,
|
||||
redirect_uri: SPOTIFY_REDIRECT_URI,
|
||||
});
|
||||
|
||||
const tokens = await tokenResponse.json()
|
||||
const errorParam = encodeURIComponent(
|
||||
`${tokenResponse.status}: ${errorText}`
|
||||
);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `${returnPath}?spotify_error=token_exchange_failed&details=${errorParam}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Token exchange successful:', {
|
||||
has_access_token: !!tokens.access_token,
|
||||
has_refresh_token: !!tokens.refresh_token,
|
||||
expires_in: tokens.expires_in,
|
||||
})
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
console.log('Decoded return path:', returnPath);
|
||||
console.log("Token exchange successful:", {
|
||||
has_access_token: !!tokens.access_token,
|
||||
has_refresh_token: !!tokens.refresh_token,
|
||||
expires_in: tokens.expires_in,
|
||||
});
|
||||
|
||||
const response = new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': returnPath + '?spotify_auth=success',
|
||||
},
|
||||
})
|
||||
console.log("Decoded return path:", returnPath);
|
||||
|
||||
const isSecure = process.env.NODE_ENV === 'production'
|
||||
const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}`
|
||||
const response = new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: returnPath + "?spotify_auth=success",
|
||||
},
|
||||
});
|
||||
|
||||
response.headers.append('Set-Cookie', `spotify_access_token=${tokens.access_token}; ${cookieOptions}`)
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
const refreshCookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${60 * 60 * 24 * 30}` // 30 days
|
||||
response.headers.append('Set-Cookie', `spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`)
|
||||
}
|
||||
const isSecure = import.meta.env.NODE_ENV === "production";
|
||||
const cookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokens.expires_in}`;
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error('Spotify callback error:', error)
|
||||
const url = new URL(request.url);
|
||||
const state = url.searchParams.get('state');
|
||||
const returnPath = getReturnPath(state);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Location': returnPath + '?spotify_error=callback_failed',
|
||||
},
|
||||
})
|
||||
}
|
||||
response.headers.append(
|
||||
"Set-Cookie",
|
||||
`spotify_access_token=${tokens.access_token}; ${cookieOptions}`
|
||||
);
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
const refreshCookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 30}`; // 30 days
|
||||
response.headers.append(
|
||||
"Set-Cookie",
|
||||
`spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Spotify callback error:", error);
|
||||
const url = new URL(request.url);
|
||||
const state = url.searchParams.get("state");
|
||||
const returnPath = getReturnPath(state);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: returnPath + "?spotify_error=callback_failed",
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,59 +1,60 @@
|
||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||
import { SpotifyWebApiClient } from '@/lib/spotify/client'
|
||||
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types'
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
|
||||
|
||||
export const ServerRoute = createServerFileRoute('/api/spotify/capture').methods({
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
// Get access token from cookies
|
||||
const cookies = request.headers.get('Cookie') || ''
|
||||
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/)
|
||||
|
||||
if (!accessTokenMatch) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No access token found' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
export const Route = createFileRoute("/api/spotify/capture")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const cookies = request.headers.get("Cookie") || "";
|
||||
const accessTokenMatch = cookies.match(
|
||||
/spotify_access_token=([^;]+)/
|
||||
);
|
||||
|
||||
if (!accessTokenMatch) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No access token found" }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const accessToken = decodeURIComponent(accessTokenMatch[1])
|
||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
||||
const accessToken = decodeURIComponent(accessTokenMatch[1]);
|
||||
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||
|
||||
// Create a snapshot of the current playback state
|
||||
const snapshot = await spotifyClient.createPlaybackSnapshot()
|
||||
|
||||
if (!snapshot) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No active playback to capture' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
const snapshot = await spotifyClient.createPlaybackSnapshot();
|
||||
|
||||
if (!snapshot) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No active playback to capture" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ snapshot }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
return new Response(JSON.stringify({ snapshot }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Spotify capture error:", error);
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to capture playback state";
|
||||
|
||||
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Spotify capture error:', error)
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to capture playback state'
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: errorMessage }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,202 +1,203 @@
|
||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||
import { SpotifyWebApiClient } from '@/lib/spotify/client'
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||
|
||||
function getAccessTokenFromCookies(request: Request): string | null {
|
||||
const cookieHeader = request.headers.get('cookie')
|
||||
if (!cookieHeader) return null
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
if (!cookieHeader) return null;
|
||||
|
||||
const cookies = Object.fromEntries(
|
||||
cookieHeader.split('; ').map(c => c.split('='))
|
||||
)
|
||||
cookieHeader.split("; ").map((c) => c.split("="))
|
||||
);
|
||||
|
||||
return cookies.spotify_access_token || null
|
||||
return cookies.spotify_access_token || null;
|
||||
}
|
||||
|
||||
export const ServerRoute = createServerFileRoute('/api/spotify/playback').methods({
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const accessToken = getAccessTokenFromCookies(request)
|
||||
if (!accessToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No access token found' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const body = await request.json()
|
||||
const { action, deviceId, volumePercent, trackId, positionMs } = body
|
||||
|
||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
||||
|
||||
switch (action) {
|
||||
case 'play':
|
||||
await spotifyClient.play(deviceId)
|
||||
break
|
||||
case 'playTrack':
|
||||
if (!trackId) {
|
||||
export const Route = createFileRoute("/api/spotify/playback")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const accessToken = getAccessTokenFromCookies(request);
|
||||
if (!accessToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'trackId is required for playTrack action' }),
|
||||
JSON.stringify({ error: "No access token found" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 401,
|
||||
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') {
|
||||
|
||||
const body = await request.json();
|
||||
const { action, deviceId, volumePercent, trackId, positionMs } = body;
|
||||
|
||||
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||
|
||||
switch (action) {
|
||||
case "play":
|
||||
await spotifyClient.play(deviceId);
|
||||
break;
|
||||
case "playTrack":
|
||||
if (!trackId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "trackId is required for playTrack action",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
await spotifyClient.playTrack(trackId, deviceId, positionMs);
|
||||
break;
|
||||
case "pause":
|
||||
await spotifyClient.pause();
|
||||
break;
|
||||
case "next":
|
||||
await spotifyClient.skipToNext();
|
||||
break;
|
||||
case "previous":
|
||||
await spotifyClient.skipToPrevious();
|
||||
break;
|
||||
case "volume":
|
||||
if (typeof volumePercent !== "number") {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "volumePercent must be a number" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
await spotifyClient.setVolume(volumePercent);
|
||||
break;
|
||||
case "transfer":
|
||||
if (!deviceId) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "deviceId is required for transfer action",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
await spotifyClient.transferPlayback(deviceId);
|
||||
break;
|
||||
default:
|
||||
return new Response(JSON.stringify({ error: "Invalid action" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Playback control error:", error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("NO_ACTIVE_DEVICE")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error:
|
||||
"No active device found. Please select a device first.",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (error.message.includes("PREMIUM_REQUIRED")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Spotify Premium is required for playback control.",
|
||||
}),
|
||||
{
|
||||
status: 403,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
console.error("Full error details:", {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Playback control failed",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const accessToken = getAccessTokenFromCookies(request);
|
||||
if (!accessToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'volumePercent must be a number' }),
|
||||
JSON.stringify({ error: "No access token found" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
await spotifyClient.setVolume(volumePercent)
|
||||
break
|
||||
case 'transfer':
|
||||
if (!deviceId) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'deviceId is required for transfer action' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
|
||||
const url = new URL(request.url);
|
||||
const type = url.searchParams.get("type");
|
||||
|
||||
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||
|
||||
if (type === "devices") {
|
||||
const devices = await spotifyClient.getDevices();
|
||||
return new Response(JSON.stringify({ devices }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} else if (type === "state") {
|
||||
const playbackState = await spotifyClient.getPlaybackState();
|
||||
return new Response(JSON.stringify({ playbackState }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} else {
|
||||
const [devices, playbackState] = await Promise.all([
|
||||
spotifyClient.getDevices(),
|
||||
spotifyClient.getPlaybackState(),
|
||||
]);
|
||||
|
||||
return new Response(JSON.stringify({ devices, playbackState }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
await spotifyClient.transferPlayback(deviceId)
|
||||
break
|
||||
default:
|
||||
} catch (error) {
|
||||
console.error("Get playback data error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Invalid action' }),
|
||||
JSON.stringify({ error: "Failed to get playback data" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
);
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Playback control error:', error)
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('NO_ACTIVE_DEVICE')) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No active device found. Please select a device first.' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
if (error.message.includes('PREMIUM_REQUIRED')) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Spotify Premium is required for playback control.' }),
|
||||
{
|
||||
status: 403,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
console.error('Full error details:', {
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
name: error.name,
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Playback control failed', details: error instanceof Error ? error.message : 'Unknown error' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const accessToken = getAccessTokenFromCookies(request)
|
||||
if (!accessToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No access token found' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const url = new URL(request.url)
|
||||
const type = url.searchParams.get('type')
|
||||
|
||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
||||
|
||||
if (type === 'devices') {
|
||||
const devices = await spotifyClient.getDevices()
|
||||
return new Response(
|
||||
JSON.stringify({ devices }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
} else if (type === 'state') {
|
||||
const playbackState = await spotifyClient.getPlaybackState()
|
||||
return new Response(
|
||||
JSON.stringify({ playbackState }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
} else {
|
||||
const [devices, playbackState] = await Promise.all([
|
||||
spotifyClient.getDevices(),
|
||||
spotifyClient.getPlaybackState(),
|
||||
])
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ devices, playbackState }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Get playback data error:', error)
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to get playback data' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,72 +1,71 @@
|
||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||
import { SpotifyWebApiClient } from '@/lib/spotify/client'
|
||||
import type { SpotifyPlaybackSnapshot } from '@/lib/spotify/types'
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { SpotifyWebApiClient } from "@/lib/spotify/client";
|
||||
import type { SpotifyPlaybackSnapshot } from "@/lib/spotify/types";
|
||||
|
||||
export const ServerRoute = createServerFileRoute('/api/spotify/resume').methods({
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
// Get access token from cookies
|
||||
const cookies = request.headers.get('Cookie') || ''
|
||||
const accessTokenMatch = cookies.match(/spotify_access_token=([^;]+)/)
|
||||
|
||||
if (!accessTokenMatch) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No access token found' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
export const Route = createFileRoute("/api/spotify/resume")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const cookies = request.headers.get("Cookie") || "";
|
||||
const accessTokenMatch = cookies.match(
|
||||
/spotify_access_token=([^;]+)/
|
||||
);
|
||||
|
||||
if (!accessTokenMatch) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No access token found" }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const accessToken = decodeURIComponent(accessTokenMatch[1])
|
||||
const spotifyClient = new SpotifyWebApiClient(accessToken)
|
||||
const accessToken = decodeURIComponent(accessTokenMatch[1]);
|
||||
const spotifyClient = new SpotifyWebApiClient(accessToken);
|
||||
|
||||
// Parse the request body to get the snapshot
|
||||
const body = await request.json()
|
||||
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot }
|
||||
|
||||
if (!snapshot) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No snapshot provided' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
const body = await request.json();
|
||||
const { snapshot } = body as { snapshot: SpotifyPlaybackSnapshot };
|
||||
|
||||
if (!snapshot) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No snapshot provided" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Restore the playback state from the snapshot
|
||||
await spotifyClient.restorePlaybackSnapshot(snapshot)
|
||||
await spotifyClient.restorePlaybackSnapshot(snapshot);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
return new Response(JSON.stringify({ success: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Spotify resume error:", error);
|
||||
|
||||
let errorMessage = "Failed to resume playback state";
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (
|
||||
error.message.includes("Premium") ||
|
||||
error.message.includes("403")
|
||||
) {
|
||||
errorMessage = "Spotify premium required";
|
||||
} else {
|
||||
errorMessage = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ error: errorMessage }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Spotify resume error:', error)
|
||||
|
||||
let errorMessage = 'Failed to resume playback state'
|
||||
|
||||
// Handle common Spotify Premium requirement error
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('Premium') || error.message.includes('403')) {
|
||||
errorMessage = 'Spotify Premium required for playback control'
|
||||
} else {
|
||||
errorMessage = error.message
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ error: errorMessage }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,81 +1,87 @@
|
||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
// Function to get Client Credentials access token
|
||||
async function getClientCredentialsToken(): Promise<string> {
|
||||
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID
|
||||
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET
|
||||
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID;
|
||||
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error('Missing Spotify client credentials')
|
||||
throw new Error("Missing Spotify client credentials");
|
||||
}
|
||||
|
||||
const response = await fetch('https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
const response = await fetch("https://accounts.spotify.com/api/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString("base64")}`,
|
||||
},
|
||||
body: 'grant_type=client_credentials',
|
||||
})
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get Spotify access token')
|
||||
throw new Error("Failed to get Spotify access token");
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data.access_token
|
||||
const data = await response.json();
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
export const ServerRoute = createServerFileRoute('/api/spotify/search').methods({
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const url = new URL(request.url)
|
||||
const query = url.searchParams.get('q')
|
||||
export const Route = createFileRoute("/api/spotify/search")({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const query = url.searchParams.get("q");
|
||||
|
||||
if (!query) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Query parameter q is required' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
if (!query) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Query parameter q is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Get client credentials access token
|
||||
const accessToken = await getClientCredentialsToken()
|
||||
// Get client credentials access token
|
||||
const accessToken = await getClientCredentialsToken();
|
||||
|
||||
// Search using Spotify API directly
|
||||
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`
|
||||
// Search using Spotify API directly
|
||||
const searchUrl = `https://api.spotify.com/v1/search?q=${encodeURIComponent(query)}&type=track&limit=20`;
|
||||
|
||||
const searchResponse = await fetch(searchUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
},
|
||||
})
|
||||
const searchResponse = await fetch(searchUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!searchResponse.ok) {
|
||||
throw new Error('Spotify search request failed')
|
||||
}
|
||||
if (!searchResponse.ok) {
|
||||
throw new Error("Spotify search request failed");
|
||||
}
|
||||
|
||||
const searchResult = await searchResponse.json()
|
||||
const searchResult = await searchResponse.json();
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ tracks: searchResult.tracks.items }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
return new Response(
|
||||
JSON.stringify({ tracks: searchResult.tracks.items }),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Search error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Search failed",
|
||||
details: error instanceof Error ? error.message : "Unknown error",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Search failed', details: error instanceof Error ? error.message : 'Unknown error' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,127 +1,131 @@
|
||||
import { createServerFileRoute } from '@tanstack/react-start/server'
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!
|
||||
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!
|
||||
const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
|
||||
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
|
||||
|
||||
export const ServerRoute = createServerFileRoute('/api/spotify/token').methods({
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const { refresh_token } = body
|
||||
export const Route = createFileRoute("/api/spotify/token")({
|
||||
server: {
|
||||
handlers: {
|
||||
POST: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { refresh_token } = body;
|
||||
|
||||
if (!refresh_token) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'refresh_token is required' }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
if (!refresh_token) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "refresh_token is required" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Refresh access token
|
||||
const tokenResponse = await fetch('https://accounts.spotify.com/api/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Authorization': `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString('base64')}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token,
|
||||
}),
|
||||
})
|
||||
const tokenResponse = await fetch(
|
||||
"https://accounts.spotify.com/api/token",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
Authorization: `Basic ${Buffer.from(`${SPOTIFY_CLIENT_ID}:${SPOTIFY_CLIENT_SECRET}`).toString("base64")}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.json()
|
||||
console.error('Token refresh error:', error)
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Failed to refresh token', details: error }),
|
||||
{
|
||||
status: tokenResponse.status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
if (!tokenResponse.ok) {
|
||||
const error = await tokenResponse.json();
|
||||
console.error("Token refresh error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Failed to refresh token",
|
||||
details: error,
|
||||
}),
|
||||
{
|
||||
status: tokenResponse.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json()
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
// Return new tokens
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
token_type: tokens.token_type,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
scope: tokens.scope,
|
||||
token_type: tokens.token_type,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Token refresh endpoint error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Token refresh endpoint error:', error)
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Internal server error' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
if (!cookieHeader) {
|
||||
return new Response(JSON.stringify({ error: "No cookies found" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
const cookies = Object.fromEntries(
|
||||
cookieHeader.split("; ").map((c: string) => c.split("="))
|
||||
);
|
||||
|
||||
const accessToken = cookies.spotify_access_token;
|
||||
const refreshToken = cookies.spotify_refresh_token;
|
||||
|
||||
if (!accessToken && !refreshToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "No Spotify tokens found" }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: accessToken || null,
|
||||
refresh_token: refreshToken || null,
|
||||
has_tokens: !!(accessToken || refreshToken),
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Get tokens endpoint error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// GET endpoint to retrieve current tokens from cookies
|
||||
GET: async ({ request }: { request: Request }) => {
|
||||
try {
|
||||
const cookieHeader = request.headers.get('cookie')
|
||||
if (!cookieHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No cookies found' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
const cookies = Object.fromEntries(
|
||||
cookieHeader.split('; ').map((c: string) => c.split('='))
|
||||
)
|
||||
|
||||
const accessToken = cookies.spotify_access_token
|
||||
const refreshToken = cookies.spotify_refresh_token
|
||||
|
||||
if (!accessToken && !refreshToken) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'No Spotify tokens found' }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: accessToken || null,
|
||||
refresh_token: refreshToken || null,
|
||||
has_tokens: !!(accessToken || refreshToken),
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Get tokens endpoint error:', error)
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Internal server error' }),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,116 +1,148 @@
|
||||
import { createServerFileRoute } from '@tanstack/react-start/server';
|
||||
import { superTokensRequestMiddleware } from '@/utils/supertokens';
|
||||
import { pbAdmin } from '@/lib/pocketbase/client';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { z } from 'zod';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { z } from "zod";
|
||||
|
||||
const uploadSchema = z.object({
|
||||
teamId: z.string().min(1, 'Team ID is required'),
|
||||
teamId: z.string().min(1, "Team ID is required"),
|
||||
});
|
||||
|
||||
export const ServerRoute = createServerFileRoute('/api/teams/upload-logo')
|
||||
.middleware([superTokensRequestMiddleware])
|
||||
.methods({
|
||||
POST: async ({ request, context }) => {
|
||||
try {
|
||||
const userId = context.userAuthId;
|
||||
const isAdmin = context.roles.includes("Admin");
|
||||
export const Route = createFileRoute("/api/teams/upload-logo")({
|
||||
server: {
|
||||
middleware: [superTokensRequestMiddleware],
|
||||
handlers: {
|
||||
POST: async ({ request, context }) => {
|
||||
try {
|
||||
const userId = context.userAuthId;
|
||||
const isAdmin = context.roles.includes("Admin");
|
||||
|
||||
if (!userId) return new Response('Unauthenticated', { status: 401 });
|
||||
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
||||
|
||||
const formData = await request.formData();
|
||||
const teamId = formData.get('teamId') as string;
|
||||
const logoFile = formData.get('logo') as File;
|
||||
const formData = await request.formData();
|
||||
const teamId = formData.get("teamId") as string;
|
||||
const logoFile = formData.get("logo") as File;
|
||||
|
||||
const validationResult = uploadSchema.safeParse({ teamId });
|
||||
if (!validationResult.success) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid input',
|
||||
details: validationResult.error.issues
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
const validationResult = uploadSchema.safeParse({ teamId });
|
||||
if (!validationResult.success) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid input",
|
||||
details: validationResult.error.issues,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!logoFile || logoFile.size === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Logo file is required",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const allowedTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
];
|
||||
if (!allowedTypes.includes(logoFile.type)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (logoFile.size > maxSize) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "File too large. Maximum size is 10MB.",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const team = await pbAdmin.getTeam(teamId);
|
||||
if (!team) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Team not found",
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const user = await pbAdmin.getPlayerByAuthId(userId);
|
||||
if (!team.players.map((p) => p.id).includes(user?.id!) && !isAdmin)
|
||||
return new Response("Unauthorized", { status: 403 });
|
||||
|
||||
logger.info("Uploading team logo", {
|
||||
teamId,
|
||||
fileName: logoFile.name,
|
||||
fileSize: logoFile.size,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!logoFile || logoFile.size === 0) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Logo file is required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
const pbFormData = new FormData();
|
||||
pbFormData.append("logo", logoFile);
|
||||
|
||||
const updatedTeam = await pbAdmin.updateTeam(
|
||||
teamId,
|
||||
pbFormData as any
|
||||
);
|
||||
|
||||
logger.info("Team logo uploaded successfully", {
|
||||
teamId,
|
||||
logo: updatedTeam.logo,
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
team: updatedTeam,
|
||||
message: "Logo uploaded successfully",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.error("Error uploading team logo:", error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Failed to upload logo",
|
||||
message: error.message || "Unknown error occurred",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
||||
if (!allowedTypes.includes(logoFile.type)) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (logoFile.size > maxSize) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'File too large. Maximum size is 10MB.'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const team = await pbAdmin.getTeam(teamId);
|
||||
if (!team) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Team not found'
|
||||
}), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
if (!team.players.map(p => p.id).includes(context.userId) && !isAdmin)
|
||||
return new Response('Unauthorized', { status: 403 });
|
||||
|
||||
logger.info('Uploading team logo', {
|
||||
teamId,
|
||||
fileName: logoFile.name,
|
||||
fileSize: logoFile.size,
|
||||
userId
|
||||
});
|
||||
|
||||
const pbFormData = new FormData();
|
||||
pbFormData.append('logo', logoFile);
|
||||
|
||||
const updatedTeam= await pbAdmin.updateTeam(teamId, pbFormData as any);
|
||||
|
||||
logger.info('Team logo uploaded successfully', {
|
||||
teamId,
|
||||
logo: updatedTeam.logo
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
team: updatedTeam,
|
||||
message: 'Logo uploaded successfully'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('Error uploading team logo:', error);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to upload logo',
|
||||
message: error.message || 'Unknown error occurred'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,115 +1,145 @@
|
||||
import { createServerFileRoute } from '@tanstack/react-start/server';
|
||||
import { superTokensRequestMiddleware } from '@/utils/supertokens';
|
||||
import { pbAdmin } from '@/lib/pocketbase/client';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { z } from 'zod';
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { superTokensRequestMiddleware } from "@/utils/supertokens";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { z } from "zod";
|
||||
|
||||
const uploadSchema = z.object({
|
||||
tournamentId: z.string().min(1, 'Tournament ID is required'),
|
||||
tournamentId: z.string().min(1, "Tournament ID is required"),
|
||||
});
|
||||
|
||||
export const ServerRoute = createServerFileRoute('/api/tournaments/upload-logo')
|
||||
.middleware([superTokensRequestMiddleware])
|
||||
.methods({
|
||||
POST: async ({ request, context }) => {
|
||||
try {
|
||||
const userId = context.userAuthId;
|
||||
const isAdmin = context.roles.includes("Admin");
|
||||
export const Route = createFileRoute("/api/tournaments/upload-logo")({
|
||||
server: {
|
||||
middleware: [superTokensRequestMiddleware],
|
||||
handlers: {
|
||||
POST: async ({ request, context }) => {
|
||||
try {
|
||||
const userId = context.userAuthId;
|
||||
const isAdmin = context.roles.includes("Admin");
|
||||
|
||||
if (!userId) return new Response('Unauthenticated', { status: 401 });
|
||||
if (!isAdmin) return new Response('Unauthorized', { status: 403 });
|
||||
if (!userId) return new Response("Unauthenticated", { status: 401 });
|
||||
if (!isAdmin) return new Response("Unauthorized", { status: 403 });
|
||||
|
||||
const formData = await request.formData();
|
||||
const tournamentId = formData.get('tournamentId') as string;
|
||||
const logoFile = formData.get('logo') as File;
|
||||
const formData = await request.formData();
|
||||
const tournamentId = formData.get("tournamentId") as string;
|
||||
const logoFile = formData.get("logo") as File;
|
||||
|
||||
const validationResult = uploadSchema.safeParse({ tournamentId });
|
||||
if (!validationResult.success) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid input',
|
||||
details: validationResult.error.issues
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
const validationResult = uploadSchema.safeParse({ tournamentId });
|
||||
if (!validationResult.success) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid input",
|
||||
details: validationResult.error.issues,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (!logoFile || logoFile.size === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Logo file is required",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const allowedTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
];
|
||||
if (!allowedTypes.includes(logoFile.type)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid file type. Only JPEG, PNG and GIF are allowed.",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (logoFile.size > maxSize) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "File too large. Maximum size is 10MB.",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||
if (!tournament) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Tournament not found",
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Uploading tournament logo", {
|
||||
tournamentId,
|
||||
fileName: logoFile.name,
|
||||
fileSize: logoFile.size,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!logoFile || logoFile.size === 0) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Logo file is required'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
const pbFormData = new FormData();
|
||||
pbFormData.append("logo", logoFile);
|
||||
|
||||
const updatedTournament = await pbAdmin.updateTournament(
|
||||
tournamentId,
|
||||
pbFormData as any
|
||||
);
|
||||
|
||||
logger.info("Tournament logo uploaded successfully", {
|
||||
tournamentId,
|
||||
logo: updatedTournament.logo,
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
tournament: updatedTournament,
|
||||
message: "Logo uploaded successfully",
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
} catch (error: any) {
|
||||
logger.error("Error uploading tournament logo:", error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Failed to upload logo",
|
||||
message: error.message || "Unknown error occurred",
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
|
||||
if (!allowedTypes.includes(logoFile.type)) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Invalid file type. Only JPEG, PNG and GIF are allowed.'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const maxSize = 10 * 1024 * 1024;
|
||||
if (logoFile.size > maxSize) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'File too large. Maximum size is 10MB.'
|
||||
}), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const tournament = await pbAdmin.getTournament(tournamentId);
|
||||
if (!tournament) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Tournament not found'
|
||||
}), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
logger.info('Uploading tournament logo', {
|
||||
tournamentId,
|
||||
fileName: logoFile.name,
|
||||
fileSize: logoFile.size,
|
||||
userId
|
||||
});
|
||||
|
||||
const pbFormData = new FormData();
|
||||
pbFormData.append('logo', logoFile);
|
||||
|
||||
const updatedTournament = await pbAdmin.updateTournament(tournamentId, pbFormData as any);
|
||||
|
||||
logger.info('Tournament logo uploaded successfully', {
|
||||
tournamentId,
|
||||
logo: updatedTournament.logo
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
tournament: updatedTournament,
|
||||
message: 'Logo uploaded successfully'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
} catch (error: any) {
|
||||
logger.error('Error uploading tournament logo:', error);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to upload logo',
|
||||
message: error.message || 'Unknown error occurred'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,38 +1,33 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import FullScreenLoader from '@/components/full-screen-loader'
|
||||
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
|
||||
import { resetRefreshFlag } from '@/lib/supertokens/client'
|
||||
|
||||
export const Route = createFileRoute('/refresh-session')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
// https://supertokens.com/docs/additional-verification/session-verification/ssr?uiType=custom
|
||||
function RouteComponent() {
|
||||
const hasAttemptedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasAttemptedRef.current) return;
|
||||
hasAttemptedRef.current = true;
|
||||
|
||||
const handleRefresh = async () => {
|
||||
try {
|
||||
try {
|
||||
resetRefreshFlag();
|
||||
const refreshed = await attemptRefreshingSession()
|
||||
|
||||
|
||||
if (refreshed) {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const redirect = urlParams.get('redirect')
|
||||
|
||||
const isServerFunction = redirect && (
|
||||
redirect.startsWith('_serverFn') ||
|
||||
redirect.startsWith('api/') ||
|
||||
redirect.includes('_serverFn')
|
||||
);
|
||||
|
||||
if (redirect && !isServerFunction) {
|
||||
|
||||
if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) {
|
||||
window.location.href = decodeURIComponent(redirect)
|
||||
} else {
|
||||
const referrer = document.referrer;
|
||||
const referrerUrl = referrer && !referrer.includes('/_serverFn') && !referrer.includes('/api/')
|
||||
? referrer
|
||||
: '/';
|
||||
|
||||
window.location.href = referrerUrl;
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else {
|
||||
window.location.href = '/login'
|
||||
@@ -42,8 +37,7 @@ function RouteComponent() {
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = setTimeout(handleRefresh, 100)
|
||||
return () => clearTimeout(timeout)
|
||||
setTimeout(handleRefresh, 100)
|
||||
}, [])
|
||||
|
||||
return <FullScreenLoader />
|
||||
|
||||
@@ -2,7 +2,14 @@ import {
|
||||
Avatar as MantineAvatar,
|
||||
AvatarProps as MantineAvatarProps,
|
||||
Paper,
|
||||
Modal,
|
||||
Image,
|
||||
Group,
|
||||
Text,
|
||||
ActionIcon,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import { XIcon } from "@phosphor-icons/react";
|
||||
|
||||
interface AvatarProps
|
||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||
@@ -10,6 +17,8 @@ interface AvatarProps
|
||||
size?: number;
|
||||
radius?: string | number;
|
||||
withBorder?: boolean;
|
||||
disableFullscreen?: boolean;
|
||||
contain?: boolean;
|
||||
}
|
||||
|
||||
const Avatar = ({
|
||||
@@ -17,26 +26,122 @@ const Avatar = ({
|
||||
size = 35,
|
||||
radius = "100%",
|
||||
withBorder = true,
|
||||
disableFullscreen = false,
|
||||
contain = false,
|
||||
...props
|
||||
}: AvatarProps) => {
|
||||
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||
const hasImage = Boolean(props.src);
|
||||
|
||||
const handleAvatarClick = () => {
|
||||
if (hasImage && !disableFullscreen) {
|
||||
setIsFullscreenOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper p={size / 20} radius={radius} withBorder={withBorder}>
|
||||
<MantineAvatar
|
||||
alt={name}
|
||||
key={name}
|
||||
name={name}
|
||||
color="initials"
|
||||
size={size}
|
||||
<>
|
||||
<Paper
|
||||
p={size / 20}
|
||||
radius={radius}
|
||||
w={size}
|
||||
withBorder={withBorder}
|
||||
style={{
|
||||
cursor: hasImage && !disableFullscreen ? 'pointer' : 'default',
|
||||
transition: 'transform 0.15s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (hasImage && !disableFullscreen) {
|
||||
e.currentTarget.style.transform = 'scale(1.02)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
onClick={handleAvatarClick}
|
||||
>
|
||||
<MantineAvatar
|
||||
alt={name}
|
||||
key={name}
|
||||
name={name}
|
||||
color="initials"
|
||||
size={size}
|
||||
radius={radius}
|
||||
w={size}
|
||||
styles={{
|
||||
image: {
|
||||
objectFit: contain ? 'contain' : 'cover',
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
<Modal
|
||||
opened={isFullscreenOpen}
|
||||
onClose={() => setIsFullscreenOpen(false)}
|
||||
size="auto"
|
||||
centered
|
||||
withCloseButton={false}
|
||||
overlayProps={{
|
||||
backgroundOpacity: 0.9,
|
||||
blur: 2,
|
||||
}}
|
||||
styles={{
|
||||
image: {
|
||||
objectFit: "contain",
|
||||
content: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
},
|
||||
body: {
|
||||
padding: 0,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Paper>
|
||||
>
|
||||
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="dark"
|
||||
size="lg"
|
||||
radius="xl"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -10,
|
||||
right: -10,
|
||||
zIndex: 1000,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
}}
|
||||
onClick={() => setIsFullscreenOpen(false)}
|
||||
>
|
||||
<XIcon size={18} color="white" />
|
||||
</ActionIcon>
|
||||
|
||||
<Image
|
||||
src={props.src}
|
||||
alt={name}
|
||||
fit="contain"
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
maxWidth: '90vw',
|
||||
maxHeight: '90vh',
|
||||
}}
|
||||
/>
|
||||
|
||||
<Group
|
||||
justify="center"
|
||||
mt="md"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: -50,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
}}
|
||||
>
|
||||
<Text c="white" size="sm" fw={500}>
|
||||
{name}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -62,6 +62,22 @@ const Drawer: React.FC<DrawerProps> = ({
|
||||
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]');
|
||||
@@ -72,15 +88,24 @@ const Drawer: React.FC<DrawerProps> = ({
|
||||
}
|
||||
});
|
||||
|
||||
updateDrawerHeight();
|
||||
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', updateDrawerHeight);
|
||||
}
|
||||
|
||||
resizeObserver.observe(contentRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.removeEventListener('resize', updateDrawerHeight);
|
||||
}
|
||||
};
|
||||
}, [opened, children]);
|
||||
|
||||
return (
|
||||
<VaulDrawer.Root open={opened} onOpenChange={onChange}>
|
||||
<VaulDrawer.Root repositionInputs={false} open={opened} onOpenChange={onChange}>
|
||||
<VaulDrawer.Portal>
|
||||
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
||||
<VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer" ref={contentRef}>
|
||||
|
||||
@@ -23,14 +23,14 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
||||
onChange={onChange}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<ScrollArea
|
||||
style={{ flex: 1 }}
|
||||
<ScrollArea.Autosize
|
||||
style={{ flex: 1, maxHeight: '75dvh' }}
|
||||
scrollbarSize={8}
|
||||
scrollbars="y"
|
||||
type="scroll"
|
||||
>
|
||||
{children}
|
||||
</ScrollArea>
|
||||
</ScrollArea.Autosize>
|
||||
</SheetComponent>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,11 +13,10 @@
|
||||
margin-top: 24px;
|
||||
height: auto !important;
|
||||
min-height: fit-content;
|
||||
max-height: 100dvh;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
outline: none;
|
||||
transition: height 0.2s ease-out;
|
||||
transition: height 0.2s ease-out, max-height 0.2s ease-out;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -58,13 +58,13 @@ export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
user: data?.user || defaultAuthData.user,
|
||||
metadata: data?.metadata || defaultAuthData.metadata,
|
||||
roles: data?.roles || defaultAuthData.roles,
|
||||
user: data?.user,
|
||||
metadata: data?.metadata || { accentColor: "blue" as MantineColor, colorScheme: "dark" as MantineColorScheme },
|
||||
roles: data?.roles || [],
|
||||
phone: data?.phone || "",
|
||||
set,
|
||||
}),
|
||||
[data, defaultAuthData]
|
||||
[data, set]
|
||||
);
|
||||
|
||||
return <AuthContext value={value}>{children}</AuthContext>;
|
||||
|
||||
@@ -9,7 +9,7 @@ import ListButton from "@/components/list-button";
|
||||
|
||||
const AdminPage = () => {
|
||||
return (
|
||||
<List>
|
||||
<List p="0">
|
||||
<ListLink
|
||||
label="Manage Tournaments"
|
||||
Icon={TrophyIcon}
|
||||
|
||||
@@ -5,7 +5,7 @@ import ListLink from "@/components/list-link";
|
||||
const ManageTournaments = () => {
|
||||
const { data: tournaments } = useTournaments();
|
||||
return (
|
||||
<List>
|
||||
<List p="0">
|
||||
{tournaments.map((t) => (
|
||||
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
||||
))}
|
||||
|
||||
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 { MatchCard } from "./match-card";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface BracketProps {
|
||||
rounds: Match[][];
|
||||
@@ -13,33 +14,131 @@ export const Bracket: React.FC<BracketProps> = ({
|
||||
orders,
|
||||
showControls,
|
||||
}) => {
|
||||
return (
|
||||
<Flex direction="row" gap={24} justify="left">
|
||||
{rounds.map((round, roundIndex) => (
|
||||
<Flex
|
||||
key={roundIndex}
|
||||
direction="column"
|
||||
align="center"
|
||||
pos="relative"
|
||||
gap={24}
|
||||
justify="space-around"
|
||||
p={24}
|
||||
>
|
||||
{round.map((match) =>
|
||||
match.bye ? (
|
||||
<div key={match.lid}></div>
|
||||
) : (
|
||||
<div key={match.lid}>
|
||||
<MatchCard
|
||||
match={match}
|
||||
orders={orders}
|
||||
showControls={showControls}
|
||||
/>
|
||||
</div>
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const updateConnectorLines = () => {
|
||||
if (!containerRef.current || !svgRef.current) return;
|
||||
|
||||
const svg = svgRef.current;
|
||||
const container = containerRef.current;
|
||||
const flexContainer = container.querySelector('.bracket-flex-container') as HTMLElement;
|
||||
if (!flexContainer) return;
|
||||
|
||||
svg.innerHTML = '';
|
||||
|
||||
const flexRect = flexContainer.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
|
||||
svg.style.width = `${flexContainer.scrollWidth}px`;
|
||||
svg.style.height = `${flexContainer.scrollHeight}px`;
|
||||
|
||||
rounds.forEach((round, roundIndex) => {
|
||||
if (roundIndex === rounds.length - 1) return;
|
||||
|
||||
const nextRound = rounds[roundIndex + 1];
|
||||
|
||||
round.forEach((match, matchIndex) => {
|
||||
if (match.bye) return;
|
||||
|
||||
const matchElement = container.querySelector(`[data-match-lid="${match.lid}"]`) as HTMLElement;
|
||||
if (!matchElement) return;
|
||||
|
||||
const nextMatches = nextRound.filter(nextMatch =>
|
||||
!nextMatch.bye && (
|
||||
orders[nextMatch.home_from_lid] === match.order ||
|
||||
orders[nextMatch.away_from_lid] === match.order
|
||||
)
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
|
||||
nextMatches.forEach(nextMatch => {
|
||||
const nextMatchElement = container.querySelector(`[data-match-lid="${nextMatch.lid}"]`) as HTMLElement;
|
||||
if (!nextMatchElement) return;
|
||||
|
||||
const matchRect = matchElement.getBoundingClientRect();
|
||||
const nextMatchRect = nextMatchElement.getBoundingClientRect();
|
||||
|
||||
const startX = matchRect.right - flexRect.left;
|
||||
const startY = matchRect.top + matchRect.height / 2 - flexRect.top;
|
||||
const endX = nextMatchRect.left - flexRect.left;
|
||||
const endY = nextMatchRect.top + nextMatchRect.height / 2 - flexRect.top;
|
||||
|
||||
const midX = startX + (endX - startX) * 0.5;
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
const pathData = `M ${startX} ${startY} L ${midX} ${startY} L ${midX} ${endY} L ${endX} ${endY}`;
|
||||
|
||||
path.setAttribute('d', pathData);
|
||||
path.setAttribute('stroke', 'var(--mantine-color-default-border)');
|
||||
path.setAttribute('stroke-width', '2');
|
||||
path.setAttribute('fill', 'none');
|
||||
path.setAttribute('stroke-linecap', 'round');
|
||||
path.setAttribute('stroke-linejoin', 'round');
|
||||
|
||||
svg.appendChild(path);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
updateConnectorLines();
|
||||
|
||||
const handleUpdate = () => {
|
||||
requestAnimationFrame(updateConnectorLines);
|
||||
};
|
||||
|
||||
const scrollContainer = containerRef.current?.closest('.mantine-ScrollArea-viewport');
|
||||
scrollContainer?.addEventListener('scroll', handleUpdate);
|
||||
window.addEventListener('resize', handleUpdate);
|
||||
|
||||
return () => {
|
||||
scrollContainer?.removeEventListener('scroll', handleUpdate);
|
||||
window.removeEventListener('resize', handleUpdate);
|
||||
};
|
||||
}, [rounds, orders]);
|
||||
|
||||
return (
|
||||
<Box pos="relative" ref={containerRef}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 0,
|
||||
}}
|
||||
/>
|
||||
<Flex direction="row" gap={24} justify="left" pos="relative" style={{ zIndex: 1 }} className="bracket-flex-container">
|
||||
{rounds.map((round, roundIndex) => (
|
||||
<Flex
|
||||
key={roundIndex}
|
||||
direction="column"
|
||||
align="center"
|
||||
pos="relative"
|
||||
gap={24}
|
||||
justify="space-around"
|
||||
p={24}
|
||||
>
|
||||
{round.map((match) =>
|
||||
match.bye ? (
|
||||
<div key={match.lid}></div>
|
||||
) : (
|
||||
<div key={match.lid}>
|
||||
<MatchCard
|
||||
match={match}
|
||||
orders={orders}
|
||||
showControls={showControls}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -199,7 +199,15 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
|
||||
return (
|
||||
<Flex direction="row" align="center" justify="end" gap={8}>
|
||||
<Text c="dimmed" fw="bolder">
|
||||
<Text
|
||||
c="dimmed"
|
||||
fw="bolder"
|
||||
px={6}
|
||||
py={2}
|
||||
style={{
|
||||
backgroundColor: 'var(--mantine-color-body)'
|
||||
}}
|
||||
>
|
||||
{match.order}
|
||||
</Text>
|
||||
<Flex align="stretch">
|
||||
@@ -214,7 +222,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
||||
w={showToolbar || showEditButton ? 200 : 220}
|
||||
withBorder
|
||||
pos="relative"
|
||||
style={{ overflow: "visible" }}
|
||||
style={{
|
||||
overflow: "visible",
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
borderColor: 'var(--mantine-color-default-border)',
|
||||
boxShadow: 'var(--mantine-shadow-sm)',
|
||||
}}
|
||||
data-match-lid={match.lid}
|
||||
>
|
||||
<Card.Section withBorder p={0}>
|
||||
|
||||
@@ -87,7 +87,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
||||
{match.home?.name} Cups
|
||||
</Text>
|
||||
{
|
||||
match.home?.players.map(p => (<Text size='xs' c='dimmed'>
|
||||
match.home?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
|
||||
{p.first_name} {p.last_name}
|
||||
</Text>))
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
||||
{match.away?.name} Cups
|
||||
</Text>
|
||||
{
|
||||
match.away?.players.map(p => (<Text size='xs' c='dimmed'>
|
||||
match.away?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
|
||||
{p.first_name} {p.last_name}
|
||||
</Text>))
|
||||
}
|
||||
|
||||
@@ -21,16 +21,23 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
||||
cups,
|
||||
isWinner
|
||||
}) => (
|
||||
<Flex align="stretch">
|
||||
<Flex
|
||||
align="stretch"
|
||||
style={{
|
||||
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
|
||||
borderRadius: 'var(--mantine-radius-sm)',
|
||||
transition: 'background-color 200ms ease',
|
||||
}}
|
||||
>
|
||||
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
|
||||
<Flex p="4px 8px" w='100%' align="center">
|
||||
<Flex p="6px 10px" w='100%' align="center">
|
||||
<Flex align="center" gap={4} flex={1}>
|
||||
{team ? (
|
||||
<>
|
||||
<Text
|
||||
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
||||
truncate
|
||||
style={{ minWidth: 0, flex: 1 }}
|
||||
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
||||
>
|
||||
{team.name}
|
||||
</Text>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
const logger = new Logger("Bracket Generation");
|
||||
|
||||
export const previewBracket = createServerFn()
|
||||
.validator(z.number())
|
||||
.inputValidator(z.number())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: teams }) =>
|
||||
toServerResult(async () => {
|
||||
|
||||
@@ -6,7 +6,10 @@ interface HeaderProps extends HeaderConfig {}
|
||||
|
||||
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
|
||||
return (
|
||||
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
|
||||
<AppShell.Header
|
||||
id='app-header'
|
||||
display={collapsed ? 'none' : 'block'}
|
||||
>
|
||||
{ withBackButton && <BackButton /> }
|
||||
<Flex justify='center' align='center' h='100%' px='md'>
|
||||
<Title order={2}>{title}</Title>
|
||||
|
||||
@@ -31,11 +31,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
pos='relative'
|
||||
h='100dvh'
|
||||
mah='100dvh'
|
||||
style={{
|
||||
top: 0,
|
||||
minHeight: '100dvh',
|
||||
maxHeight: '100dvh'
|
||||
}}
|
||||
// style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
|
||||
>
|
||||
<Header {...header} />
|
||||
<AppShell.Main
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AppShell, ScrollArea, Stack, Group, Paper } from "@mantine/core";
|
||||
import { AppShell, ScrollArea, Stack, Group, Paper, useMantineColorScheme } from "@mantine/core";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { NavLink } from "./nav-link";
|
||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||
@@ -9,11 +9,17 @@ import { memo } from "react";
|
||||
const Navbar = () => {
|
||||
const { user, roles } = useAuth()
|
||||
const isMobile = useIsMobile();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
|
||||
const links = useLinks(user?.id, roles);
|
||||
|
||||
const isDark = colorScheme === 'dark';
|
||||
const borderColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
|
||||
const boxShadowColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
|
||||
// boxShadow: `5px 5px ${boxShadowColor}`, borderColor
|
||||
|
||||
if (isMobile) return (
|
||||
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 1rem)' shadow='sm' pos='fixed' m='0.5rem' bottom='0' style={{ zIndex: 10 }}>
|
||||
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1.5rem)' pos='fixed' m='0.75rem' bottom='0' style={{ zIndex: 10 }}>
|
||||
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||
{links.map((link) => (
|
||||
<NavLink key={link.href} {...link} />
|
||||
@@ -30,9 +36,6 @@ const Navbar = () => {
|
||||
))}
|
||||
</Stack>
|
||||
</AppShell.Section>
|
||||
<AppShell.Section>
|
||||
<Link to="/logout">Logout</Link>
|
||||
</AppShell.Section>
|
||||
</AppShell.Navbar>
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const orderedTeamsSchema = z.object({
|
||||
});
|
||||
|
||||
export const generateTournamentBracket = createServerFn()
|
||||
.validator(orderedTeamsSchema)
|
||||
.inputValidator(orderedTeamsSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: { tournamentId, orderedTeamIds } }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -137,7 +137,7 @@ export const generateTournamentBracket = createServerFn()
|
||||
);
|
||||
|
||||
export const startMatch = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -170,7 +170,7 @@ const endMatchSchema = z.object({
|
||||
ot_count: z.number(),
|
||||
});
|
||||
export const endMatch = createServerFn()
|
||||
.validator(endMatchSchema)
|
||||
.inputValidator(endMatchSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: { matchId, home_cups, away_cups, ot_count } }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -252,7 +252,7 @@ const toggleReactionSchema = z.object({
|
||||
});
|
||||
|
||||
export const toggleMatchReaction = createServerFn()
|
||||
.validator(toggleReactionSchema)
|
||||
.inputValidator(toggleReactionSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: { matchId, emoji }, context }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -312,7 +312,7 @@ export interface Reaction {
|
||||
players: PlayerInfo[];
|
||||
}
|
||||
export const getMatchReactions = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: matchId, context }) =>
|
||||
toServerResult(async () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
|
||||
))}
|
||||
</List>
|
||||
|
||||
return <List>
|
||||
return <List p="0">
|
||||
{players?.map((player) => (
|
||||
<ListItem key={player.id}
|
||||
py='xs'
|
||||
|
||||
@@ -5,13 +5,13 @@ import { Match } from "@/features/matches/types";
|
||||
import { pbAdmin } from "@/lib/pocketbase/client";
|
||||
import { z } from "zod";
|
||||
import { logger } from ".";
|
||||
import { getWebRequest } from "@tanstack/react-start/server";
|
||||
import { getRequest } from "@tanstack/react-start/server";
|
||||
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||
|
||||
export const fetchMe = createServerFn()
|
||||
.handler(async () =>
|
||||
toServerResult(async () => {
|
||||
const request = getWebRequest();
|
||||
const request = getRequest();
|
||||
|
||||
try {
|
||||
const context = await getSessionContext(request);
|
||||
@@ -25,7 +25,7 @@ export const fetchMe = createServerFn()
|
||||
phone: context.phone
|
||||
};
|
||||
} catch (error: any) {
|
||||
logger.info("FetchMe: Session error", error)
|
||||
// logger.info("FetchMe: Session error", error)
|
||||
if (error?.response?.status === 401) {
|
||||
const errorData = error?.response?.data;
|
||||
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
||||
@@ -38,14 +38,14 @@ export const fetchMe = createServerFn()
|
||||
);
|
||||
|
||||
export const getPlayer = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<Player>(async () => await pbAdmin.getPlayer(data))
|
||||
);
|
||||
|
||||
export const updatePlayer = createServerFn()
|
||||
.validator(playerUpdateSchema)
|
||||
.inputValidator(playerUpdateSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -72,7 +72,7 @@ export const updatePlayer = createServerFn()
|
||||
);
|
||||
|
||||
export const createPlayer = createServerFn()
|
||||
.validator(playerInputSchema)
|
||||
.inputValidator(playerInputSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -97,7 +97,7 @@ export const createPlayer = createServerFn()
|
||||
);
|
||||
|
||||
export const associatePlayer = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -129,7 +129,7 @@ export const getUnassociatedPlayers = createServerFn()
|
||||
);
|
||||
|
||||
export const getPlayerStats = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<PlayerStats>(async () => await pbAdmin.getPlayerStats(data))
|
||||
@@ -142,14 +142,14 @@ export const getAllPlayerStats = createServerFn()
|
||||
);
|
||||
|
||||
export const getPlayerMatches = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<Match[]>(async () => await pbAdmin.getPlayerMatches(data))
|
||||
);
|
||||
|
||||
export const getUnenrolledPlayers = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: tournamentId }) =>
|
||||
toServerResult(async () => await pbAdmin.getUnenrolledPlayers(tournamentId))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Badge, FileInput, Group, Stack, Text, TextInput } from "@mantine/core";
|
||||
import { FileInput, Stack, TextInput } from "@mantine/core";
|
||||
import { useForm, UseFormInput } from "@mantine/form";
|
||||
import { LinkIcon } from "@phosphor-icons/react";
|
||||
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
|
||||
import SlidePanel from "@/components/sheet/slide-panel";
|
||||
import { isNotEmpty } from "@mantine/form";
|
||||
import useCreateTeam from "../../hooks/use-create-team";
|
||||
import useUpdateTeam from "../../hooks/use-update-team";
|
||||
@@ -13,8 +13,8 @@ import { useCallback } from "react";
|
||||
import { TeamInput } from "../../types";
|
||||
import { teamKeys } from "../../queries";
|
||||
import SongPicker from "./song-picker";
|
||||
import TeamColorPicker from "./color-picker";
|
||||
import PlayersPicker from "./players-picker";
|
||||
import imageCompression from "browser-image-compression";
|
||||
|
||||
interface TeamFormProps {
|
||||
close: () => void;
|
||||
@@ -113,9 +113,32 @@ const TeamForm = ({
|
||||
|
||||
if (logo && team) {
|
||||
try {
|
||||
let processedLogo = logo;
|
||||
|
||||
if (logo.size > 500 * 1024) {
|
||||
const compressionOptions = {
|
||||
maxSizeMB: 0.5,
|
||||
maxWidthOrHeight: 800,
|
||||
useWebWorker: true,
|
||||
fileType: logo.type,
|
||||
};
|
||||
|
||||
try {
|
||||
processedLogo = await imageCompression(logo, compressionOptions);
|
||||
logger.info("image compressed", {
|
||||
originalSize: logo.size,
|
||||
compressedSize: processedLogo.size,
|
||||
reduction: ((logo.size - processedLogo.size) / logo.size * 100).toFixed(1) + "%"
|
||||
});
|
||||
} catch (compressionError) {
|
||||
logger.warn("compression failed, falling back", compressionError);
|
||||
processedLogo = logo;
|
||||
}
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("teamId", team.id);
|
||||
formData.append("logo", logo);
|
||||
formData.append("logo", processedLogo);
|
||||
|
||||
const response = await fetch("/api/teams/upload-logo", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Text, TextInput, Group, Avatar, Loader, Paper, Stack, Box } from "@mantine/core";
|
||||
import { Text, Group, Avatar, Box } from "@mantine/core";
|
||||
import { SpotifyTrack } from "@/lib/spotify/types";
|
||||
import { useDebouncedCallback } from "@mantine/hooks";
|
||||
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||
|
||||
interface SongSearchProps {
|
||||
onChange: (track: SpotifyTrack) => void;
|
||||
@@ -9,15 +8,7 @@ interface SongSearchProps {
|
||||
}
|
||||
|
||||
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
|
||||
const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
try {
|
||||
@@ -28,155 +19,62 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.tracks || [];
|
||||
const tracks = data.tracks || [];
|
||||
|
||||
return tracks.map((track: SpotifyTrack) => ({
|
||||
id: track.id,
|
||||
data: track
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Failed to search tracks:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
||||
if (!query.trim()) {
|
||||
setSearchResults([]);
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const results = await searchSpotifyTracks(query);
|
||||
setSearchResults(results);
|
||||
setIsOpen(results.length > 0);
|
||||
setSelectedIndex(-1);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
setSearchResults([]);
|
||||
setIsOpen(false);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearchQuery(value);
|
||||
debouncedSearch(value);
|
||||
const handleSongSelect = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||
onChange(option.data);
|
||||
};
|
||||
|
||||
const handleSongSelect = (track: SpotifyTrack) => {
|
||||
onChange(track);
|
||||
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
const formatTrack = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||
const track = option.data;
|
||||
return `${track.name} - ${track.artists.map(a => a.name).join(', ')}`;
|
||||
};
|
||||
|
||||
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]) {
|
||||
handleSongSelect(searchResults[selectedIndex]);
|
||||
}
|
||||
break;
|
||||
case 'Escape':
|
||||
setIsOpen(false);
|
||||
setSelectedIndex(-1);
|
||||
break;
|
||||
}
|
||||
const renderOption = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||
const track = option.data;
|
||||
return (
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
borderBottom: '1px solid var(--mantine-color-dimmed)'
|
||||
}}
|
||||
>
|
||||
<Group gap="sm">
|
||||
{track.album.images[2] && (
|
||||
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
||||
)}
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{track.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<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}
|
||||
/>
|
||||
|
||||
{isOpen && (
|
||||
<Paper
|
||||
shadow="md"
|
||||
p={0}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9999,
|
||||
maxHeight: '160px',
|
||||
overflowY: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
touchAction: 'pan-y'
|
||||
}}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
{searchResults.length > 0 ? (
|
||||
<Stack gap={0}>
|
||||
{searchResults.map((track, index) => (
|
||||
<Box
|
||||
key={track.id}
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
|
||||
borderBottom: index < searchResults.length - 1 ? '1px solid var(--mantine-color-gray-3)' : 'none'
|
||||
}}
|
||||
onClick={() => handleSongSelect(track)}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
>
|
||||
<Group gap="sm">
|
||||
{track.album.images[2] && (
|
||||
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
||||
)}
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{track.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<Box p="md">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
<Typeahead
|
||||
onSelect={handleSongSelect}
|
||||
searchFn={searchSpotifyTracks}
|
||||
renderOption={renderOption}
|
||||
format={formatTrack}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)
|
||||
};
|
||||
|
||||
export default SongSearch;
|
||||
@@ -58,7 +58,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<List>
|
||||
<List p="0">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<ListItem
|
||||
key={`skeleton-${i}`}
|
||||
@@ -72,7 +72,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<List>
|
||||
<List p="0">
|
||||
{teams?.map((team) => (
|
||||
<div key={team.id}>
|
||||
<ListItem
|
||||
|
||||
@@ -15,21 +15,21 @@ export const listTeamInfos = createServerFn()
|
||||
);
|
||||
|
||||
export const getTeam = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: teamId }) =>
|
||||
toServerResult(() => pbAdmin.getTeam(teamId))
|
||||
);
|
||||
|
||||
export const getTeamInfo = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: teamId }) =>
|
||||
toServerResult(() => pbAdmin.getTeamInfo(teamId))
|
||||
);
|
||||
|
||||
export const createTeam = createServerFn()
|
||||
.validator(teamInputSchema)
|
||||
.inputValidator(teamInputSchema)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data, context }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -46,7 +46,7 @@ export const createTeam = createServerFn()
|
||||
);
|
||||
|
||||
export const updateTeam = createServerFn()
|
||||
.validator(z.object({
|
||||
.inputValidator(z.object({
|
||||
id: z.string(),
|
||||
updates: teamUpdateSchema
|
||||
}))
|
||||
@@ -72,14 +72,14 @@ export const updateTeam = createServerFn()
|
||||
);
|
||||
|
||||
export const getTeamStats = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: teamId }) =>
|
||||
toServerResult(() => pbAdmin.getTeamStats(teamId))
|
||||
);
|
||||
|
||||
export const getTeamMatches = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult<Match[]>(async () => await pbAdmin.getTeamMatches(data))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {
|
||||
Autocomplete,
|
||||
Stack,
|
||||
ActionIcon,
|
||||
Text,
|
||||
Group,
|
||||
Loader,
|
||||
} from "@mantine/core";
|
||||
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||
import { TrashIcon } from "@phosphor-icons/react";
|
||||
import { useState, useCallback, useMemo, memo } from "react";
|
||||
import { useTournament, useUnenrolledTeams } from "../queries";
|
||||
@@ -68,8 +68,6 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
|
||||
});
|
||||
|
||||
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: tournament, isLoading: tournamentLoading } =
|
||||
useTournament(tournamentId);
|
||||
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
||||
@@ -78,27 +76,24 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
||||
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
||||
|
||||
const autocompleteData = useMemo(
|
||||
() =>
|
||||
unenrolledTeams.map((team: Team) => ({
|
||||
value: team.id,
|
||||
label: team.name,
|
||||
})),
|
||||
[unenrolledTeams]
|
||||
);
|
||||
const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
|
||||
if (!query.trim()) return [];
|
||||
|
||||
const filtered = unenrolledTeams.filter((team: Team) =>
|
||||
team.name.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
return filtered.map((team: Team) => ({
|
||||
id: team.id,
|
||||
data: team
|
||||
}));
|
||||
};
|
||||
|
||||
const handleEnrollTeam = useCallback(
|
||||
(teamId: string) => {
|
||||
enrollTeam(
|
||||
{ tournamentId, teamId },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSearch("");
|
||||
},
|
||||
}
|
||||
);
|
||||
(option: TypeaheadOption<Team>) => {
|
||||
enrollTeam({ tournamentId, teamId: option.data.id });
|
||||
},
|
||||
[enrollTeam, tournamentId, setSearch]
|
||||
[enrollTeam, tournamentId]
|
||||
);
|
||||
|
||||
const handleUnenrollTeam = useCallback(
|
||||
@@ -108,6 +103,31 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||
[unenrollTeam, tournamentId]
|
||||
);
|
||||
|
||||
const renderTeamOption = (option: TypeaheadOption<Team>) => {
|
||||
const team = option.data;
|
||||
return (
|
||||
<Group py="xs" px="sm" gap="sm" align="center">
|
||||
<Avatar
|
||||
size={32}
|
||||
radius="sm"
|
||||
name={team.name}
|
||||
src={
|
||||
team.logo
|
||||
? `/api/files/teams/${team.id}/${team.logo}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Text fw={500} truncate>
|
||||
{team.name}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
const formatTeam = (option: TypeaheadOption<Team>) => {
|
||||
return option.data.name;
|
||||
};
|
||||
|
||||
const isLoading = tournamentLoading || unenrolledLoading;
|
||||
const enrolledTeams = tournament?.teams || [];
|
||||
const hasEnrolledTeams = enrolledTeams.length > 0;
|
||||
@@ -118,16 +138,13 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||
<Text fw={600} size="sm">
|
||||
Add Team
|
||||
</Text>
|
||||
<Autocomplete
|
||||
<Typeahead
|
||||
placeholder="Search for teams to enroll..."
|
||||
data={autocompleteData}
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
onOptionSubmit={handleEnrollTeam}
|
||||
onSelect={handleEnrollTeam}
|
||||
searchFn={searchTeams}
|
||||
renderOption={renderTeamOption}
|
||||
format={formatTeam}
|
||||
disabled={isEnrolling || unenrolledLoading}
|
||||
rightSection={isEnrolling ? <Loader size="xs" /> : null}
|
||||
maxDropdownHeight={200}
|
||||
limit={10}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<List>
|
||||
<List p="0">
|
||||
<ListButton
|
||||
label="Edit Tournament"
|
||||
Icon={HardDrivesIcon}
|
||||
|
||||
@@ -11,7 +11,7 @@ const Header = ({ tournament }: HeaderProps) => {
|
||||
return (
|
||||
<>
|
||||
<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%'>
|
||||
<Title ta='center' order={2}>{tournament.name}</Title>
|
||||
</Flex>
|
||||
|
||||
@@ -18,6 +18,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
<Stack px="sm" align="center" gap={0}>
|
||||
<Avatar
|
||||
name={tournament.name}
|
||||
contain
|
||||
src={
|
||||
tournament.logo
|
||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||
|
||||
@@ -60,6 +60,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
||||
size={90}
|
||||
radius="sm"
|
||||
name={tournament.name}
|
||||
contain
|
||||
src={
|
||||
tournament.logo
|
||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||
|
||||
@@ -63,7 +63,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<List>
|
||||
<List p="0">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<ListItem
|
||||
key={`skeleton-${i}`}
|
||||
@@ -97,7 +97,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
||||
}
|
||||
|
||||
return (
|
||||
<List>
|
||||
<List p="0">
|
||||
{tournaments.map((tournament) => (
|
||||
<>
|
||||
<ListItem
|
||||
@@ -108,6 +108,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
||||
radius="sm"
|
||||
size={40}
|
||||
name={tournament.name}
|
||||
contain
|
||||
src={
|
||||
tournament.logo
|
||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||
|
||||
@@ -281,5 +281,3 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
||||
</Container>
|
||||
);
|
||||
});
|
||||
|
||||
TournamentStats.displayName = 'TournamentStats';
|
||||
@@ -27,7 +27,7 @@ const EnrollTeam = ({ tournamentId, onSubmit }: EnrollTeamProps) => {
|
||||
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
|
||||
|
||||
const { data: teamData } = useServerQuery({
|
||||
...teamQueries.details(selectedTeamId!),
|
||||
...teamQueries.details(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 React, { useMemo, useState } from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import Typeahead, { TypeaheadOption } from '@/components/typeahead';
|
||||
|
||||
interface TeamSelectionViewProps {
|
||||
options: ComboboxItem[];
|
||||
@@ -11,11 +12,39 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
||||
options,
|
||||
onSelect
|
||||
}) => {
|
||||
const [value, setValue] = useState<string>('');
|
||||
const selectedOption = useMemo(() => options.find(option => option.label === value), [value, options])
|
||||
const [selectedTeam, setSelectedTeam] = React.useState<ComboboxItem | null>(null);
|
||||
|
||||
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 handleSelectExistingTeam = () => onSelect(selectedOption?.value)
|
||||
const handleSelectExistingTeam = () => onSelect(selectedTeam?.value);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -31,17 +60,17 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
||||
<Divider my="sm" label="or" />
|
||||
|
||||
<Stack gap="sm">
|
||||
<Autocomplete
|
||||
<Typeahead
|
||||
placeholder="Select one of your existing teams"
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
data={options.map(option => option.label)}
|
||||
comboboxProps={{ withinPortal: false }}
|
||||
onSelect={handleTeamSelect}
|
||||
searchFn={searchTeams}
|
||||
renderOption={renderTeamOption}
|
||||
format={formatTeam}
|
||||
/>
|
||||
|
||||
<Button
|
||||
onClick={handleSelectExistingTeam}
|
||||
disabled={!selectedOption}
|
||||
disabled={!selectedTeam}
|
||||
fullWidth
|
||||
>
|
||||
Enroll Selected Team
|
||||
|
||||
@@ -19,6 +19,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
||||
<Stack align="center" gap={0}>
|
||||
<Avatar
|
||||
name={tournament.name}
|
||||
contain
|
||||
src={
|
||||
tournament.logo
|
||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||
|
||||
@@ -18,7 +18,6 @@ interface UpdateTeamProps {
|
||||
|
||||
const UpdateTeam = ({ tournamentId, teamId }: UpdateTeamProps) => {
|
||||
const { open, isOpen, toggle } = useSheet();
|
||||
|
||||
const { data: team } = useTeam(teamId);
|
||||
|
||||
const initialValues = useMemo(() => {
|
||||
|
||||
@@ -13,14 +13,14 @@ export const listTournaments = createServerFn()
|
||||
);
|
||||
|
||||
export const createTournament = createServerFn()
|
||||
.validator(tournamentInputSchema)
|
||||
.inputValidator(tournamentInputSchema)
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data }) =>
|
||||
toServerResult(() => pbAdmin.createTournament(data))
|
||||
);
|
||||
|
||||
export const updateTournament = createServerFn()
|
||||
.validator(z.object({
|
||||
.inputValidator(z.object({
|
||||
id: z.string(),
|
||||
updates: tournamentInputSchema.partial()
|
||||
}))
|
||||
@@ -30,7 +30,7 @@ export const updateTournament = createServerFn()
|
||||
);
|
||||
|
||||
export const getTournament = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: tournamentId, context }) => {
|
||||
const isAdmin = context.roles.includes("Admin");
|
||||
@@ -44,7 +44,7 @@ export const getCurrentTournament = createServerFn()
|
||||
);
|
||||
|
||||
export const enrollTeam = createServerFn()
|
||||
.validator(z.object({
|
||||
.inputValidator(z.object({
|
||||
tournamentId: z.string(),
|
||||
teamId: z.string()
|
||||
}))
|
||||
@@ -70,7 +70,7 @@ export const enrollTeam = createServerFn()
|
||||
);
|
||||
|
||||
export const unenrollTeam = createServerFn()
|
||||
.validator(z.object({
|
||||
.inputValidator(z.object({
|
||||
tournamentId: z.string(),
|
||||
teamId: z.string()
|
||||
}))
|
||||
@@ -80,21 +80,21 @@ export const unenrollTeam = createServerFn()
|
||||
);
|
||||
|
||||
export const getUnenrolledTeams = createServerFn()
|
||||
.validator(z.string())
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.handler(async ({ data: tournamentId }) =>
|
||||
toServerResult(() => pbAdmin.getUnenrolledTeams(tournamentId))
|
||||
);
|
||||
|
||||
export const getFreeAgents = createServerFn()
|
||||
.validator(z.string())
|
||||
.middleware([superTokensAdminFunctionMiddleware])
|
||||
.inputValidator(z.string())
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ data: tournamentId }) =>
|
||||
toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
|
||||
);
|
||||
|
||||
export const enrollFreeAgent = createServerFn()
|
||||
.validator(z.object({ phone: z.string(), tournamentId: z.string() }))
|
||||
.inputValidator(z.object({ phone: z.string(), tournamentId: z.string() }))
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
@@ -108,7 +108,7 @@ export const enrollFreeAgent = createServerFn()
|
||||
);
|
||||
|
||||
export const unenrollFreeAgent = createServerFn()
|
||||
.validator(z.object({ tournamentId: z.string() }))
|
||||
.inputValidator(z.object({ tournamentId: z.string() }))
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) =>
|
||||
toServerResult(async () => {
|
||||
|
||||
@@ -37,6 +37,7 @@ export function useServerEvents() {
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!user?.id) return;
|
||||
|
||||
shouldConnectRef.current = true;
|
||||
@@ -111,5 +112,5 @@ export function useServerEvents() {
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
}, [user?.id, queryClient]);
|
||||
}, [user?.id]);
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class Logger {
|
||||
constructor(context?: string, options: LoggerOptions = {}) {
|
||||
this.context = context;
|
||||
this.options = {
|
||||
enabled: process.env.NODE_ENV !== "production",
|
||||
enabled: import.meta.env.NODE_ENV !== "production",
|
||||
showTimestamp: true,
|
||||
collapsed: true,
|
||||
colors: true,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { createTournamentsService } from "./services/tournaments";
|
||||
import { createTeamsService } from "./services/teams";
|
||||
import { createMatchesService } from "./services/matches";
|
||||
import { createReactionsService } from "./services/reactions";
|
||||
import dotenv from 'dotenv';
|
||||
dotenv.config();
|
||||
|
||||
class PocketBaseAdminClient {
|
||||
private pb: PocketBase;
|
||||
|
||||
@@ -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_SCOPES = [
|
||||
|
||||
@@ -4,6 +4,32 @@ import Passwordless from "supertokens-web-js/recipe/passwordless";
|
||||
import { appInfo } from "./config";
|
||||
import { logger } from "./";
|
||||
|
||||
let refreshAttemptCount = 0;
|
||||
|
||||
export const resetRefreshFlag = () => {
|
||||
refreshAttemptCount = 0;
|
||||
};
|
||||
|
||||
const setupFetchInterceptor = () => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const originalFetch = window.fetch;
|
||||
//@ts-ignore
|
||||
window.fetch = async (resource: RequestInfo | URL, options?: RequestInit) => {
|
||||
const url = typeof resource === 'string' ? resource :
|
||||
resource instanceof URL ? resource.toString() : resource.url;
|
||||
|
||||
if (url.includes('/api/auth/session/refresh')) {
|
||||
refreshAttemptCount++;
|
||||
if (refreshAttemptCount > 1) {
|
||||
throw new Error('Duplicate refresh attempt blocked');
|
||||
}
|
||||
}
|
||||
|
||||
return originalFetch.call(window, resource, options);
|
||||
};
|
||||
};
|
||||
|
||||
export const frontendConfig = () => {
|
||||
return {
|
||||
appInfo,
|
||||
@@ -12,7 +38,6 @@ export const frontendConfig = () => {
|
||||
Session.init({
|
||||
tokenTransferMethod: "cookie",
|
||||
sessionTokenBackendDomain: undefined,
|
||||
|
||||
preAPIHook: async (context) => {
|
||||
context.requestInit.credentials = "include";
|
||||
return context;
|
||||
@@ -23,16 +48,14 @@ export const frontendConfig = () => {
|
||||
};
|
||||
|
||||
let initialized = false;
|
||||
|
||||
export function ensureSuperTokensFrontend() {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
if (!initialized) {
|
||||
setupFetchInterceptor();
|
||||
SuperTokens.init(frontendConfig());
|
||||
initialized = true;
|
||||
logger.info("Initialized");
|
||||
|
||||
Session.doesSessionExist().then((exists) => {
|
||||
logger.info(`Session does${exists ? "" : "NOT"} exist on load!`);
|
||||
});
|
||||
logger.info("SuperTokens initialized");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import UserRoles from "supertokens-node/recipe/userroles";
|
||||
import { appInfo } from "./config";
|
||||
import PasswordlessDevelopmentMode from "./recipes/passwordless-development-mode";
|
||||
import { logger } from "./";
|
||||
import passwordlessTwilioVerify from "./recipes/passwordless-twilio-verify";
|
||||
|
||||
export const backendConfig = (): TypeInput => {
|
||||
return {
|
||||
@@ -16,13 +17,13 @@ export const backendConfig = (): TypeInput => {
|
||||
},
|
||||
appInfo,
|
||||
recipeList: [
|
||||
PasswordlessDevelopmentMode.init(),
|
||||
passwordlessTwilioVerify.init(),
|
||||
Session.init({
|
||||
cookieSameSite: "lax",
|
||||
cookieSecure: process.env.NODE_ENV === "production",
|
||||
cookieSecure: import.meta.env.NODE_ENV === "production",
|
||||
cookieDomain:
|
||||
process.env.NODE_ENV === "production" ? ".example.com" : undefined,
|
||||
antiCsrf: process.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
|
||||
import.meta.env.NODE_ENV === "production" ? ".example.com" : undefined,
|
||||
antiCsrf: import.meta.env.NODE_ENV === "production" ? "VIA_TOKEN" : "NONE",
|
||||
|
||||
// Debug only
|
||||
exposeAccessTokenToFrontendInCookieBasedAuth: true,
|
||||
@@ -30,13 +31,13 @@ export const backendConfig = (): TypeInput => {
|
||||
Dashboard.init(),
|
||||
UserRoles.init(),
|
||||
],
|
||||
telemetry: process.env.NODE_ENV !== "production",
|
||||
telemetry: import.meta.env.NODE_ENV !== "production",
|
||||
};
|
||||
};
|
||||
|
||||
let initialized = false;
|
||||
export function ensureSuperTokensBackend() {
|
||||
if (!initialized) {
|
||||
if (!initialized && typeof window === 'undefined') {
|
||||
SuperTokens.init(backendConfig());
|
||||
initialized = true;
|
||||
logger.simple("Backend initialized");
|
||||
|
||||
@@ -23,15 +23,15 @@ export function useOptimisticMutation<TData, TVariables = unknown>(
|
||||
|
||||
return { previousData };
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
if (context && typeof context === 'object' && 'previousData' in context && context.previousData) {
|
||||
queryClient.setQueryData(queryKey, context.previousData);
|
||||
}
|
||||
mutationOptions.onError?.(error, variables, context);
|
||||
mutationOptions.onError?.(error, variables, onMutateResult, context);
|
||||
},
|
||||
onSettled: (data, error, variables, context) => {
|
||||
onSettled: (data, error, variables, onMutateResult, context) => {
|
||||
queryClient.invalidateQueries({ queryKey });
|
||||
mutationOptions.onSettled?.(data, error, variables, context);
|
||||
mutationOptions.onSettled?.(data, error, variables, onMutateResult, context);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useMutation, UseMutationOptions } from "@tanstack/react-query";
|
||||
import { ServerResult } from "../types";
|
||||
import toast from '@/lib/sonner'
|
||||
|
||||
let isMutationRefreshingSession = false;
|
||||
|
||||
export function useServerMutation<TData, TVariables = unknown>(
|
||||
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
|
||||
mutationFn: (variables: TVariables) => Promise<ServerResult<TData>>;
|
||||
@@ -42,8 +44,14 @@ export function useServerMutation<TData, TVariables = unknown>(
|
||||
: error.response.data;
|
||||
|
||||
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
||||
const currentUrl = window.location.pathname + window.location.search;
|
||||
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
||||
if (!isMutationRefreshingSession) {
|
||||
isMutationRefreshingSession = true;
|
||||
const currentUrl = window.location.pathname + window.location.search;
|
||||
setTimeout(() => {
|
||||
isMutationRefreshingSession = false;
|
||||
}, 1000);
|
||||
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
||||
}
|
||||
throw new Error("SESSION_REFRESH_REQUIRED");
|
||||
}
|
||||
} catch (parseError) {}
|
||||
@@ -52,14 +60,14 @@ export function useServerMutation<TData, TVariables = unknown>(
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
onSuccess: (data, variables, context) => {
|
||||
onSuccess: (data, variables, onMutateResult, context) => {
|
||||
if (showSuccessToast && successMessage) {
|
||||
toast.success(successMessage);
|
||||
}
|
||||
onSuccess?.(data, variables, context);
|
||||
onSuccess?.(data, variables, onMutateResult, context);
|
||||
},
|
||||
onError: (error, variables, context) => {
|
||||
onError?.(error, variables, context);
|
||||
onError: (error, variables, onMutateResult, context) => {
|
||||
onError?.(error, variables, onMutateResult, context);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
import twilio from "twilio";
|
||||
import twilio, { type Twilio } from "twilio";
|
||||
|
||||
const accountSid = process.env.TWILIO_ACCOUNT_SID!;
|
||||
const authToken = process.env.TWILIO_AUTH_TOKEN!;
|
||||
const serviceSid = process.env.TWILIO_SERVICE_SID!;
|
||||
|
||||
const client = twilio(accountSid, authToken);
|
||||
let client: Twilio;
|
||||
|
||||
function getTwilioClient() {
|
||||
if (!client) {
|
||||
client = twilio(accountSid, authToken);
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function sendVerifyCode(phoneNumber: string, code: string) {
|
||||
const verification = await client.verify.v2
|
||||
const twilioClient = getTwilioClient();
|
||||
|
||||
const verification = await twilioClient!.verify.v2
|
||||
.services(serviceSid)
|
||||
.verifications.create({
|
||||
channel: "sms",
|
||||
@@ -23,7 +32,9 @@ export async function sendVerifyCode(phoneNumber: string, code: string) {
|
||||
}
|
||||
|
||||
export async function updateVerify(sid: string) {
|
||||
const verification = await client.verify.v2
|
||||
const twilioClient = getTwilioClient();
|
||||
|
||||
const verification = await twilioClient!.verify.v2
|
||||
.services(serviceSid)
|
||||
.verifications(sid)
|
||||
.update({ status: "approved" });
|
||||
@@ -31,4 +42,4 @@ export async function updateVerify(sid: string) {
|
||||
if (verification.status !== "approved") {
|
||||
throw new Error("Unknown error updating verification");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,25 @@
|
||||
import {
|
||||
createMiddleware,
|
||||
createServerFn,
|
||||
ServerFnResponseType,
|
||||
createServerOnlyFn,
|
||||
} from "@tanstack/react-start";
|
||||
import { getWebRequest } from "@tanstack/react-start/server";
|
||||
import { getRequest, setResponseHeader } from "@tanstack/react-start/server";
|
||||
import { redirect as redirect } from "@tanstack/react-router";
|
||||
import UserRoles from "supertokens-node/recipe/userroles";
|
||||
import UserMetadata from "supertokens-node/recipe/usermetadata";
|
||||
import { getSessionForStart } from "@/lib/supertokens/recipes/start-session";
|
||||
import { Logger } from "@/lib/logger";
|
||||
import z from "zod";
|
||||
import { refreshSession } from "supertokens-node/recipe/session";
|
||||
|
||||
const logger = new Logger("Middleware");
|
||||
|
||||
export const verifySuperTokensSession = async (
|
||||
request: Request,
|
||||
response?: ServerFnResponseType
|
||||
const verifySuperTokensSession = async (
|
||||
request: Request
|
||||
) => {
|
||||
let session = await getSessionForStart(request, { sessionRequired: false });
|
||||
|
||||
if (session?.needsRefresh) {
|
||||
logger.info("Session needs refresh");
|
||||
|
||||
try {
|
||||
if (response) {
|
||||
const refreshedSession = await refreshSession(request, response);
|
||||
if (refreshedSession) {
|
||||
session = await getSessionForStart(request, { sessionRequired: false });
|
||||
}
|
||||
}
|
||||
|
||||
if (session?.needsRefresh) {
|
||||
return { context: { session: { tryRefresh: true } } };
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error("Session refresh error", error);
|
||||
|
||||
if (error.type === 'UNAUTHORISED' || error.type === 'TOKEN_THEFT_DETECTED') {
|
||||
return { context: { userAuthId: null, roles: [] } };
|
||||
}
|
||||
|
||||
return { context: { session: { tryRefresh: true } } };
|
||||
}
|
||||
logger.info("Session needs refresh - redirecting to client");
|
||||
return { context: { session: { tryRefresh: true } } };
|
||||
}
|
||||
|
||||
const userAuthId = session?.userId;
|
||||
@@ -69,7 +46,7 @@ export const verifySuperTokensSession = async (
|
||||
};
|
||||
};
|
||||
|
||||
export const getSessionContext = async (request: Request, options?: { isServerFunction?: boolean }) => {
|
||||
export const getSessionContext = createServerOnlyFn(async (request: Request, options?: { isServerFunction?: boolean }) => {
|
||||
const session = await verifySuperTokensSession(request);
|
||||
|
||||
if (session.context.session?.tryRefresh) {
|
||||
@@ -98,7 +75,7 @@ export const getSessionContext = async (request: Request, options?: { isServerFu
|
||||
};
|
||||
|
||||
return context;
|
||||
};
|
||||
});
|
||||
|
||||
export const superTokensRequestMiddleware = createMiddleware({
|
||||
type: "request",
|
||||
@@ -109,8 +86,8 @@ export const superTokensRequestMiddleware = createMiddleware({
|
||||
|
||||
export const superTokensFunctionMiddleware = createMiddleware({
|
||||
type: "function",
|
||||
}).server(async ({ next, response }) => {
|
||||
const request = getWebRequest();
|
||||
}).server(async ({ next }) => {
|
||||
const request = getRequest();
|
||||
|
||||
try {
|
||||
const context = await getSessionContext(request, { isServerFunction: true });
|
||||
@@ -135,7 +112,7 @@ export const superTokensFunctionMiddleware = createMiddleware({
|
||||
export const superTokensAdminFunctionMiddleware = createMiddleware({
|
||||
type: "function",
|
||||
}).server(async ({ next }) => {
|
||||
const request = getWebRequest();
|
||||
const request = getRequest();
|
||||
|
||||
try {
|
||||
const context = await getSessionContext(request, { isServerFunction: true });
|
||||
@@ -169,7 +146,7 @@ export const fetchUserRoles = async (userAuthId: string) => {
|
||||
};
|
||||
|
||||
export const setUserMetadata = createServerFn({ method: "POST" })
|
||||
.validator(
|
||||
.inputValidator(
|
||||
z
|
||||
.object({
|
||||
first_name: z
|
||||
@@ -212,7 +189,7 @@ export const setUserMetadata = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const updateUserColorScheme = createServerFn({ method: "POST" })
|
||||
.validator((data: string) => data)
|
||||
.inputValidator((data: string) => data)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context;
|
||||
@@ -231,7 +208,7 @@ export const updateUserColorScheme = createServerFn({ method: "POST" })
|
||||
});
|
||||
|
||||
export const updateUserAccentColor = createServerFn({ method: "POST" })
|
||||
.validator((data: string) => data)
|
||||
.inputValidator((data: string) => data)
|
||||
.middleware([superTokensFunctionMiddleware])
|
||||
.handler(async ({ context, data }) => {
|
||||
const { userAuthId, metadata } = context;
|
||||
|
||||
@@ -6,16 +6,14 @@ import react from '@vitejs/plugin-react';
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 3000,
|
||||
allowedHosts: ["dev.flexxon.app"]
|
||||
},
|
||||
plugins: [
|
||||
tsConfigPaths({
|
||||
projects: ['./tsconfig.json'],
|
||||
}),
|
||||
tanstackStart({
|
||||
customViteReactPlugin: true,
|
||||
tsr: {
|
||||
srcDirectory: 'src/app',
|
||||
},
|
||||
srcDirectory: 'src/app',
|
||||
}),
|
||||
react()
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user