Compare commits
12 Commits
e4164cbc71
...
upgrade
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
732afaf623 | ||
|
|
48aeaabeea | ||
|
|
a4b9fe9065 | ||
|
|
31e50af593 | ||
|
|
39053cadaa | ||
|
|
ea6656aa33 | ||
|
|
92c4987372 | ||
|
|
b3ebf46afa | ||
|
|
c0ef535001 | ||
|
|
81329e4354 | ||
|
|
36f3bb77d4 | ||
|
|
6760ea46f9 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,3 +20,4 @@ yarn.lock
|
|||||||
/scripts/
|
/scripts/
|
||||||
/pb_data/
|
/pb_data/
|
||||||
/.tanstack/
|
/.tanstack/
|
||||||
|
/dist/
|
||||||
@@ -6,7 +6,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --host 0.0.0.0",
|
"dev": "vite dev --host 0.0.0.0",
|
||||||
"build": "vite build && tsc --noEmit",
|
"build": "vite build && tsc --noEmit",
|
||||||
"start": "vite start --host 0.0.0.0"
|
"start": "bun run .output/server/index.mjs",
|
||||||
|
"start:node": "node .output/server/index.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hello-pangea/dnd": "^18.0.1",
|
"@hello-pangea/dnd": "^18.0.1",
|
||||||
@@ -29,7 +30,9 @@
|
|||||||
"@tiptap/pm": "^3.4.3",
|
"@tiptap/pm": "^3.4.3",
|
||||||
"@tiptap/react": "^3.4.3",
|
"@tiptap/react": "^3.4.3",
|
||||||
"@tiptap/starter-kit": "^3.4.3",
|
"@tiptap/starter-kit": "^3.4.3",
|
||||||
|
"@types/bun": "^1.2.22",
|
||||||
"@types/ioredis": "^4.28.10",
|
"@types/ioredis": "^4.28.10",
|
||||||
|
"browser-image-compression": "^2.0.2",
|
||||||
"dotenv": "^17.2.2",
|
"dotenv": "^17.2.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"framer-motion": "^12.23.12",
|
"framer-motion": "^12.23.12",
|
||||||
@@ -52,6 +55,7 @@
|
|||||||
"zustand": "^5.0.7"
|
"zustand": "^5.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@tanstack/react-router-ssr-query": "^1.132.2",
|
||||||
"@tanstack/router-plugin": "^1.132.2",
|
"@tanstack/router-plugin": "^1.132.2",
|
||||||
"@types/node": "^22.5.4",
|
"@types/node": "^22.5.4",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
|
|||||||
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)
|
||||||
|
})
|
||||||
@@ -695,6 +695,7 @@ import type { getRouter } from './router.tsx'
|
|||||||
import type { createStart } from '@tanstack/react-start'
|
import type { createStart } from '@tanstack/react-start'
|
||||||
declare module '@tanstack/react-start' {
|
declare module '@tanstack/react-start' {
|
||||||
interface Register {
|
interface Register {
|
||||||
|
ssr: true
|
||||||
router: Awaited<ReturnType<typeof getRouter>>
|
router: Awaited<ReturnType<typeof getRouter>>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
import { createRouter as createTanStackRouter } from "@tanstack/react-router";
|
||||||
import { routerWithQueryClient } from "@tanstack/react-router-with-query";
|
import { setupRouterSsrQueryIntegration } from "@tanstack/react-router-ssr-query";
|
||||||
import { routeTree } from "./routeTree.gen";
|
import { routeTree } from "./routeTree.gen";
|
||||||
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
import { DefaultCatchBoundary } from "../components/DefaultCatchBoundary";
|
||||||
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
import { defaultHeaderConfig } from "@/features/core/hooks/use-router-config";
|
||||||
@@ -18,24 +18,28 @@ export function getRouter() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return routerWithQueryClient(
|
const router = createTanStackRouter({
|
||||||
createTanStackRouter({
|
routeTree,
|
||||||
routeTree,
|
context: {
|
||||||
context: {
|
queryClient,
|
||||||
queryClient,
|
auth: undefined!,
|
||||||
auth: undefined!,
|
header: defaultHeaderConfig,
|
||||||
header: defaultHeaderConfig,
|
refresh: [],
|
||||||
refresh: [],
|
withPadding: true,
|
||||||
withPadding: true,
|
fullWidth: false,
|
||||||
fullWidth: false,
|
},
|
||||||
},
|
defaultPreload: "intent",
|
||||||
defaultPreload: "intent",
|
defaultErrorComponent: DefaultCatchBoundary,
|
||||||
defaultErrorComponent: DefaultCatchBoundary,
|
scrollRestoration: true,
|
||||||
scrollRestoration: true,
|
defaultViewTransition: false,
|
||||||
defaultViewTransition: false,
|
});
|
||||||
}),
|
|
||||||
|
setupRouterSsrQueryIntegration({
|
||||||
|
router,
|
||||||
queryClient
|
queryClient
|
||||||
);
|
})
|
||||||
|
|
||||||
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "@tanstack/react-router" {
|
declare module "@tanstack/react-router" {
|
||||||
|
|||||||
@@ -83,12 +83,20 @@ export const Route = createRootRouteWithContext<{
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/TanStack/router/discussions/3531
|
if (location.pathname === '/login' || location.pathname === '/logout') {
|
||||||
const auth = await ensureServerQueryData(
|
return {};
|
||||||
context.queryClient,
|
}
|
||||||
playerQueries.auth()
|
|
||||||
);
|
try {
|
||||||
return { auth };
|
// https://github.com/TanStack/router/discussions/3531
|
||||||
|
const auth = await ensureServerQueryData(
|
||||||
|
context.queryClient,
|
||||||
|
playerQueries.auth()
|
||||||
|
);
|
||||||
|
return { auth };
|
||||||
|
} catch (error) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
},
|
},
|
||||||
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
|
pendingComponent: () => <Providers><FullScreenLoader /></Providers>,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
middleware: [superTokensRequestMiddleware],
|
middleware: [superTokensRequestMiddleware],
|
||||||
handlers: {
|
handlers: {
|
||||||
GET: ({ request, context }) => {
|
GET: ({ request, context }) => {
|
||||||
logger.info("ServerEvents | New connection", context?.userAuthId);
|
|
||||||
|
|
||||||
const stream = new ReadableStream({
|
const stream = new ReadableStream({
|
||||||
start(controller) {
|
start(controller) {
|
||||||
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
const connectMessage = `data: ${JSON.stringify({ type: "connected" })}\n\n`;
|
||||||
@@ -35,26 +33,15 @@ export const Route = createFileRoute("/api/events/$")({
|
|||||||
controller.enqueue(new TextEncoder().encode(pingMessage));
|
controller.enqueue(new TextEncoder().encode(pingMessage));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
controller.close();
|
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
serverEvents.off("test", handleEvent);
|
serverEvents.off("test", handleEvent);
|
||||||
clearInterval(pingInterval);
|
clearInterval(pingInterval);
|
||||||
try {
|
|
||||||
logger.info(
|
|
||||||
"ServerEvents | Closing connection",
|
|
||||||
context?.userAuthId
|
|
||||||
);
|
|
||||||
controller.close();
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("ServerEvents | Error closing controller", e);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
request.signal?.addEventListener("abort", cleanup);
|
request.signal?.addEventListener("abort", cleanup);
|
||||||
|
|
||||||
return cleanup;
|
return cleanup;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const Route = createFileRoute(
|
|||||||
try {
|
try {
|
||||||
const { collection, recordId, file } = params;
|
const { collection, recordId, file } = params;
|
||||||
const pocketbaseUrl =
|
const pocketbaseUrl =
|
||||||
import.meta.env.POCKETBASE_URL || "http://127.0.0.1:8090";
|
process.env.POCKETBASE_URL || "http://127.0.0.1:8090";
|
||||||
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
const fileUrl = `${pocketbaseUrl}/api/files/${collection}/${recordId}/${file}`;
|
||||||
|
|
||||||
logger.info("File proxy", {
|
logger.info("File proxy", {
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
import { SpotifyAuth } from "@/lib/spotify/auth";
|
|
||||||
|
|
||||||
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!;
|
const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
|
||||||
const SPOTIFY_CLIENT_SECRET = import.meta.env.SPOTIFY_CLIENT_SECRET!;
|
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
|
||||||
const SPOTIFY_REDIRECT_URI = import.meta.env.VITE_SPOTIFY_REDIRECT_URI!;
|
const SPOTIFY_REDIRECT_URI = process.env.VITE_SPOTIFY_REDIRECT_URI!;
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/spotify/callback")({
|
export const Route = createFileRoute("/api/spotify/callback")({
|
||||||
server: {
|
server: {
|
||||||
@@ -112,7 +111,7 @@ export const Route = createFileRoute("/api/spotify/callback")({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isSecure = import.meta.env.NODE_ENV === "production";
|
const isSecure = import.meta.env.NODE_ENV === "production";
|
||||||
const cookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${tokens.expires_in}`;
|
const cookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${tokens.expires_in}`;
|
||||||
|
|
||||||
response.headers.append(
|
response.headers.append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
@@ -120,7 +119,7 @@ export const Route = createFileRoute("/api/spotify/callback")({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const refreshCookieOptions = `HttpOnly; Secure=${isSecure}; SameSite=Strict; Path=/; Max-Age=${60 * 60 * 24 * 30}`; // 30 days
|
const refreshCookieOptions = `HttpOnly; ${isSecure ? 'Secure; ' : ''}SameSite=Lax; Path=/; Max-Age=${60 * 60 * 24 * 30}`; // 30 days
|
||||||
response.headers.append(
|
response.headers.append(
|
||||||
"Set-Cookie",
|
"Set-Cookie",
|
||||||
`spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`
|
`spotify_refresh_token=${tokens.refresh_token}; ${refreshCookieOptions}`
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
async function getClientCredentialsToken(): Promise<string> {
|
async function getClientCredentialsToken(): Promise<string> {
|
||||||
const clientId = import.meta.env.VITE_SPOTIFY_CLIENT_ID;
|
const clientId = process.env.VITE_SPOTIFY_CLIENT_ID;
|
||||||
const clientSecret = import.meta.env.SPOTIFY_CLIENT_SECRET;
|
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
|
||||||
|
|
||||||
if (!clientId || !clientSecret) {
|
if (!clientId || !clientSecret) {
|
||||||
throw new Error("Missing Spotify client credentials");
|
throw new Error("Missing Spotify client credentials");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
const SPOTIFY_CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID!;
|
const SPOTIFY_CLIENT_ID = process.env.VITE_SPOTIFY_CLIENT_ID!;
|
||||||
const SPOTIFY_CLIENT_SECRET = import.meta.env.SPOTIFY_CLIENT_SECRET!;
|
const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/spotify/token")({
|
export const Route = createFileRoute("/api/spotify/token")({
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
@@ -1,38 +1,33 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useEffect } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import FullScreenLoader from '@/components/full-screen-loader'
|
import FullScreenLoader from '@/components/full-screen-loader'
|
||||||
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
|
import { attemptRefreshingSession } from 'supertokens-web-js/recipe/session'
|
||||||
|
import { resetRefreshFlag } from '@/lib/supertokens/client'
|
||||||
|
|
||||||
export const Route = createFileRoute('/refresh-session')({
|
export const Route = createFileRoute('/refresh-session')({
|
||||||
component: RouteComponent,
|
component: RouteComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
// https://supertokens.com/docs/additional-verification/session-verification/ssr?uiType=custom
|
|
||||||
function RouteComponent() {
|
function RouteComponent() {
|
||||||
|
const hasAttemptedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (hasAttemptedRef.current) return;
|
||||||
|
hasAttemptedRef.current = true;
|
||||||
|
|
||||||
const handleRefresh = async () => {
|
const handleRefresh = async () => {
|
||||||
try {
|
try {
|
||||||
|
resetRefreshFlag();
|
||||||
const refreshed = await attemptRefreshingSession()
|
const refreshed = await attemptRefreshingSession()
|
||||||
|
|
||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
const urlParams = new URLSearchParams(window.location.search)
|
const urlParams = new URLSearchParams(window.location.search)
|
||||||
const redirect = urlParams.get('redirect')
|
const redirect = urlParams.get('redirect')
|
||||||
|
|
||||||
const isServerFunction = redirect && (
|
if (redirect && !redirect.includes('_serverFn') && !redirect.includes('/api/')) {
|
||||||
redirect.startsWith('_serverFn') ||
|
|
||||||
redirect.startsWith('api/') ||
|
|
||||||
redirect.includes('_serverFn')
|
|
||||||
);
|
|
||||||
|
|
||||||
if (redirect && !isServerFunction) {
|
|
||||||
window.location.href = decodeURIComponent(redirect)
|
window.location.href = decodeURIComponent(redirect)
|
||||||
} else {
|
} else {
|
||||||
const referrer = document.referrer;
|
window.location.href = '/';
|
||||||
const referrerUrl = referrer && !referrer.includes('/_serverFn') && !referrer.includes('/api/')
|
|
||||||
? referrer
|
|
||||||
: '/';
|
|
||||||
|
|
||||||
window.location.href = referrerUrl;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
@@ -42,8 +37,7 @@ function RouteComponent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const timeout = setTimeout(handleRefresh, 100)
|
setTimeout(handleRefresh, 100)
|
||||||
return () => clearTimeout(timeout)
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return <FullScreenLoader />
|
return <FullScreenLoader />
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import {
|
|||||||
Avatar as MantineAvatar,
|
Avatar as MantineAvatar,
|
||||||
AvatarProps as MantineAvatarProps,
|
AvatarProps as MantineAvatarProps,
|
||||||
Paper,
|
Paper,
|
||||||
|
Modal,
|
||||||
|
Image,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
ActionIcon,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { XIcon } from "@phosphor-icons/react";
|
||||||
|
|
||||||
interface AvatarProps
|
interface AvatarProps
|
||||||
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
extends Omit<MantineAvatarProps, "radius" | "color" | "size"> {
|
||||||
@@ -10,6 +17,8 @@ interface AvatarProps
|
|||||||
size?: number;
|
size?: number;
|
||||||
radius?: string | number;
|
radius?: string | number;
|
||||||
withBorder?: boolean;
|
withBorder?: boolean;
|
||||||
|
disableFullscreen?: boolean;
|
||||||
|
contain?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Avatar = ({
|
const Avatar = ({
|
||||||
@@ -17,26 +26,122 @@ const Avatar = ({
|
|||||||
size = 35,
|
size = 35,
|
||||||
radius = "100%",
|
radius = "100%",
|
||||||
withBorder = true,
|
withBorder = true,
|
||||||
|
disableFullscreen = false,
|
||||||
|
contain = false,
|
||||||
...props
|
...props
|
||||||
}: AvatarProps) => {
|
}: AvatarProps) => {
|
||||||
|
const [isFullscreenOpen, setIsFullscreenOpen] = useState(false);
|
||||||
|
const hasImage = Boolean(props.src);
|
||||||
|
|
||||||
|
const handleAvatarClick = () => {
|
||||||
|
if (hasImage && !disableFullscreen) {
|
||||||
|
setIsFullscreenOpen(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper p={size / 20} radius={radius} withBorder={withBorder}>
|
<>
|
||||||
<MantineAvatar
|
<Paper
|
||||||
alt={name}
|
p={size / 20}
|
||||||
key={name}
|
|
||||||
name={name}
|
|
||||||
color="initials"
|
|
||||||
size={size}
|
|
||||||
radius={radius}
|
radius={radius}
|
||||||
w={size}
|
withBorder={withBorder}
|
||||||
|
style={{
|
||||||
|
cursor: hasImage && !disableFullscreen ? 'pointer' : 'default',
|
||||||
|
transition: 'transform 0.15s ease',
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
if (hasImage && !disableFullscreen) {
|
||||||
|
e.currentTarget.style.transform = 'scale(1.02)';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
e.currentTarget.style.transform = 'scale(1)';
|
||||||
|
}}
|
||||||
|
onClick={handleAvatarClick}
|
||||||
|
>
|
||||||
|
<MantineAvatar
|
||||||
|
alt={name}
|
||||||
|
key={name}
|
||||||
|
name={name}
|
||||||
|
color="initials"
|
||||||
|
size={size}
|
||||||
|
radius={radius}
|
||||||
|
w={size}
|
||||||
|
styles={{
|
||||||
|
image: {
|
||||||
|
objectFit: contain ? 'contain' : 'cover',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
opened={isFullscreenOpen}
|
||||||
|
onClose={() => setIsFullscreenOpen(false)}
|
||||||
|
size="auto"
|
||||||
|
centered
|
||||||
|
withCloseButton={false}
|
||||||
|
overlayProps={{
|
||||||
|
backgroundOpacity: 0.9,
|
||||||
|
blur: 2,
|
||||||
|
}}
|
||||||
styles={{
|
styles={{
|
||||||
image: {
|
content: {
|
||||||
objectFit: "contain",
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
padding: 0,
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
{...props}
|
>
|
||||||
/>
|
<div style={{ position: 'relative', maxWidth: '90vw', maxHeight: '90vh' }}>
|
||||||
</Paper>
|
<ActionIcon
|
||||||
|
variant="filled"
|
||||||
|
color="dark"
|
||||||
|
size="lg"
|
||||||
|
radius="xl"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -10,
|
||||||
|
right: -10,
|
||||||
|
zIndex: 1000,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
}}
|
||||||
|
onClick={() => setIsFullscreenOpen(false)}
|
||||||
|
>
|
||||||
|
<XIcon size={18} color="white" />
|
||||||
|
</ActionIcon>
|
||||||
|
|
||||||
|
<Image
|
||||||
|
src={props.src}
|
||||||
|
alt={name}
|
||||||
|
fit="contain"
|
||||||
|
style={{
|
||||||
|
borderRadius: 8,
|
||||||
|
maxWidth: '90vw',
|
||||||
|
maxHeight: '90vh',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group
|
||||||
|
justify="center"
|
||||||
|
mt="md"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: -50,
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text c="white" size="sm" fw={500}>
|
||||||
|
{name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,22 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!opened || !contentRef.current) return;
|
if (!opened || !contentRef.current) return;
|
||||||
|
|
||||||
|
const updateDrawerHeight = () => {
|
||||||
|
if (contentRef.current) {
|
||||||
|
const drawerContent = contentRef.current;
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
|
|
||||||
|
if (visualViewport) {
|
||||||
|
const availableHeight = visualViewport.height;
|
||||||
|
const maxDrawerHeight = Math.min(availableHeight * 0.75, window.innerHeight * 0.75);
|
||||||
|
|
||||||
|
drawerContent.style.maxHeight = `${maxDrawerHeight}px`;
|
||||||
|
} else {
|
||||||
|
drawerContent.style.maxHeight = '75vh';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const resizeObserver = new ResizeObserver(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
if (contentRef.current) {
|
if (contentRef.current) {
|
||||||
const drawerContent = contentRef.current.closest('[data-vaul-drawer-wrapper]');
|
const drawerContent = contentRef.current.closest('[data-vaul-drawer-wrapper]');
|
||||||
@@ -72,15 +88,24 @@ const Drawer: React.FC<DrawerProps> = ({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateDrawerHeight();
|
||||||
|
|
||||||
|
if (window.visualViewport) {
|
||||||
|
window.visualViewport.addEventListener('resize', updateDrawerHeight);
|
||||||
|
}
|
||||||
|
|
||||||
resizeObserver.observe(contentRef.current);
|
resizeObserver.observe(contentRef.current);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
|
if (window.visualViewport) {
|
||||||
|
window.visualViewport.removeEventListener('resize', updateDrawerHeight);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [opened, children]);
|
}, [opened, children]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VaulDrawer.Root open={opened} onOpenChange={onChange}>
|
<VaulDrawer.Root repositionInputs={false} open={opened} onOpenChange={onChange}>
|
||||||
<VaulDrawer.Portal>
|
<VaulDrawer.Portal>
|
||||||
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
<VaulDrawer.Overlay className={styles.drawerOverlay} />
|
||||||
<VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer" ref={contentRef}>
|
<VaulDrawer.Content className={styles.drawerContent} aria-describedby="drawer" ref={contentRef}>
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ const Sheet: React.FC<SheetProps> = ({ title, children, opened, onChange }) => {
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
>
|
>
|
||||||
<ScrollArea
|
<ScrollArea.Autosize
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1, maxHeight: '75dvh' }}
|
||||||
scrollbarSize={8}
|
scrollbarSize={8}
|
||||||
scrollbars="y"
|
scrollbars="y"
|
||||||
type="scroll"
|
type="scroll"
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ScrollArea>
|
</ScrollArea.Autosize>
|
||||||
</SheetComponent>
|
</SheetComponent>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,11 +13,10 @@
|
|||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
height: auto !important;
|
height: auto !important;
|
||||||
min-height: fit-content;
|
min-height: fit-content;
|
||||||
max-height: 100dvh;
|
|
||||||
position: fixed;
|
position: fixed;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: height 0.2s ease-out;
|
transition: height 0.2s ease-out, max-height 0.2s ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
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(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
user: data?.user || defaultAuthData.user,
|
user: data?.user,
|
||||||
metadata: data?.metadata || defaultAuthData.metadata,
|
metadata: data?.metadata || { accentColor: "blue" as MantineColor, colorScheme: "dark" as MantineColorScheme },
|
||||||
roles: data?.roles || defaultAuthData.roles,
|
roles: data?.roles || [],
|
||||||
phone: data?.phone || "",
|
phone: data?.phone || "",
|
||||||
set,
|
set,
|
||||||
}),
|
}),
|
||||||
[data, defaultAuthData]
|
[data, set]
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AuthContext value={value}>{children}</AuthContext>;
|
return <AuthContext value={value}>{children}</AuthContext>;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import ListButton from "@/components/list-button";
|
|||||||
|
|
||||||
const AdminPage = () => {
|
const AdminPage = () => {
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
<ListLink
|
<ListLink
|
||||||
label="Manage Tournaments"
|
label="Manage Tournaments"
|
||||||
Icon={TrophyIcon}
|
Icon={TrophyIcon}
|
||||||
@@ -19,7 +19,7 @@ const AdminPage = () => {
|
|||||||
label="Open Pocketbase"
|
label="Open Pocketbase"
|
||||||
Icon={DatabaseIcon}
|
Icon={DatabaseIcon}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
window.location.replace(import.meta.env.POCKETBASE_URL! + "/_/")
|
window.location.replace(process.env.POCKETBASE_URL! + "/_/")
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ListLink
|
<ListLink
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import ListLink from "@/components/list-link";
|
|||||||
const ManageTournaments = () => {
|
const ManageTournaments = () => {
|
||||||
const { data: tournaments } = useTournaments();
|
const { data: tournaments } = useTournaments();
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{tournaments.map((t) => (
|
{tournaments.map((t) => (
|
||||||
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
<ListLink label={t.name} to={`/admin/tournaments/${t.id}`} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
4
src/features/badges/server.ts
Normal file
4
src/features/badges/server.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { toServerResult } from "@/lib/tanstack-query/utils/to-server-result";
|
||||||
|
import { superTokensAdminFunctionMiddleware } from "@/utils/supertokens";
|
||||||
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
|
||||||
8
src/features/badges/util.ts
Normal file
8
src/features/badges/util.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
|
||||||
|
pb.collection("team_stats_per_tournament").getFullList({
|
||||||
|
filter: `tournament_id = "${id}"`,
|
||||||
|
sort: "-wins,-total_cups_made"
|
||||||
|
})
|
||||||
|
|
||||||
|
*/
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Flex } from "@mantine/core";
|
import { Flex, Box } from "@mantine/core";
|
||||||
import { Match } from "@/features/matches/types";
|
import { Match } from "@/features/matches/types";
|
||||||
import { MatchCard } from "./match-card";
|
import { MatchCard } from "./match-card";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
interface BracketProps {
|
interface BracketProps {
|
||||||
rounds: Match[][];
|
rounds: Match[][];
|
||||||
@@ -13,33 +14,131 @@ export const Bracket: React.FC<BracketProps> = ({
|
|||||||
orders,
|
orders,
|
||||||
showControls,
|
showControls,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
<Flex direction="row" gap={24} justify="left">
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
{rounds.map((round, roundIndex) => (
|
|
||||||
<Flex
|
useEffect(() => {
|
||||||
key={roundIndex}
|
const updateConnectorLines = () => {
|
||||||
direction="column"
|
if (!containerRef.current || !svgRef.current) return;
|
||||||
align="center"
|
|
||||||
pos="relative"
|
const svg = svgRef.current;
|
||||||
gap={24}
|
const container = containerRef.current;
|
||||||
justify="space-around"
|
const flexContainer = container.querySelector('.bracket-flex-container') as HTMLElement;
|
||||||
p={24}
|
if (!flexContainer) return;
|
||||||
>
|
|
||||||
{round.map((match) =>
|
svg.innerHTML = '';
|
||||||
match.bye ? (
|
|
||||||
<div key={match.lid}></div>
|
const flexRect = flexContainer.getBoundingClientRect();
|
||||||
) : (
|
const containerRect = container.getBoundingClientRect();
|
||||||
<div key={match.lid}>
|
|
||||||
<MatchCard
|
svg.style.width = `${flexContainer.scrollWidth}px`;
|
||||||
match={match}
|
svg.style.height = `${flexContainer.scrollHeight}px`;
|
||||||
orders={orders}
|
|
||||||
showControls={showControls}
|
rounds.forEach((round, roundIndex) => {
|
||||||
/>
|
if (roundIndex === rounds.length - 1) return;
|
||||||
</div>
|
|
||||||
|
const nextRound = rounds[roundIndex + 1];
|
||||||
|
|
||||||
|
round.forEach((match, matchIndex) => {
|
||||||
|
if (match.bye) return;
|
||||||
|
|
||||||
|
const matchElement = container.querySelector(`[data-match-lid="${match.lid}"]`) as HTMLElement;
|
||||||
|
if (!matchElement) return;
|
||||||
|
|
||||||
|
const nextMatches = nextRound.filter(nextMatch =>
|
||||||
|
!nextMatch.bye && (
|
||||||
|
orders[nextMatch.home_from_lid] === match.order ||
|
||||||
|
orders[nextMatch.away_from_lid] === match.order
|
||||||
)
|
)
|
||||||
)}
|
);
|
||||||
</Flex>
|
|
||||||
))}
|
nextMatches.forEach(nextMatch => {
|
||||||
</Flex>
|
const nextMatchElement = container.querySelector(`[data-match-lid="${nextMatch.lid}"]`) as HTMLElement;
|
||||||
|
if (!nextMatchElement) return;
|
||||||
|
|
||||||
|
const matchRect = matchElement.getBoundingClientRect();
|
||||||
|
const nextMatchRect = nextMatchElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
const startX = matchRect.right - flexRect.left;
|
||||||
|
const startY = matchRect.top + matchRect.height / 2 - flexRect.top;
|
||||||
|
const endX = nextMatchRect.left - flexRect.left;
|
||||||
|
const endY = nextMatchRect.top + nextMatchRect.height / 2 - flexRect.top;
|
||||||
|
|
||||||
|
const midX = startX + (endX - startX) * 0.5;
|
||||||
|
|
||||||
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||||
|
const pathData = `M ${startX} ${startY} L ${midX} ${startY} L ${midX} ${endY} L ${endX} ${endY}`;
|
||||||
|
|
||||||
|
path.setAttribute('d', pathData);
|
||||||
|
path.setAttribute('stroke', 'var(--mantine-color-default-border)');
|
||||||
|
path.setAttribute('stroke-width', '2');
|
||||||
|
path.setAttribute('fill', 'none');
|
||||||
|
path.setAttribute('stroke-linecap', 'round');
|
||||||
|
path.setAttribute('stroke-linejoin', 'round');
|
||||||
|
|
||||||
|
svg.appendChild(path);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateConnectorLines();
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
requestAnimationFrame(updateConnectorLines);
|
||||||
|
};
|
||||||
|
|
||||||
|
const scrollContainer = containerRef.current?.closest('.mantine-ScrollArea-viewport');
|
||||||
|
scrollContainer?.addEventListener('scroll', handleUpdate);
|
||||||
|
window.addEventListener('resize', handleUpdate);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scrollContainer?.removeEventListener('scroll', handleUpdate);
|
||||||
|
window.removeEventListener('resize', handleUpdate);
|
||||||
|
};
|
||||||
|
}, [rounds, orders]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box pos="relative" ref={containerRef}>
|
||||||
|
<svg
|
||||||
|
ref={svgRef}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Flex direction="row" gap={24} justify="left" pos="relative" style={{ zIndex: 1 }} className="bracket-flex-container">
|
||||||
|
{rounds.map((round, roundIndex) => (
|
||||||
|
<Flex
|
||||||
|
key={roundIndex}
|
||||||
|
direction="column"
|
||||||
|
align="center"
|
||||||
|
pos="relative"
|
||||||
|
gap={24}
|
||||||
|
justify="space-around"
|
||||||
|
p={24}
|
||||||
|
>
|
||||||
|
{round.map((match) =>
|
||||||
|
match.bye ? (
|
||||||
|
<div key={match.lid}></div>
|
||||||
|
) : (
|
||||||
|
<div key={match.lid}>
|
||||||
|
<MatchCard
|
||||||
|
match={match}
|
||||||
|
orders={orders}
|
||||||
|
showControls={showControls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -199,7 +199,15 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex direction="row" align="center" justify="end" gap={8}>
|
<Flex direction="row" align="center" justify="end" gap={8}>
|
||||||
<Text c="dimmed" fw="bolder">
|
<Text
|
||||||
|
c="dimmed"
|
||||||
|
fw="bolder"
|
||||||
|
px={6}
|
||||||
|
py={2}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'var(--mantine-color-body)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
{match.order}
|
{match.order}
|
||||||
</Text>
|
</Text>
|
||||||
<Flex align="stretch">
|
<Flex align="stretch">
|
||||||
@@ -214,7 +222,12 @@ export const MatchCard: React.FC<MatchCardProps> = ({
|
|||||||
w={showToolbar || showEditButton ? 200 : 220}
|
w={showToolbar || showEditButton ? 200 : 220}
|
||||||
withBorder
|
withBorder
|
||||||
pos="relative"
|
pos="relative"
|
||||||
style={{ overflow: "visible" }}
|
style={{
|
||||||
|
overflow: "visible",
|
||||||
|
backgroundColor: 'var(--mantine-color-body)',
|
||||||
|
borderColor: 'var(--mantine-color-default-border)',
|
||||||
|
boxShadow: 'var(--mantine-shadow-sm)',
|
||||||
|
}}
|
||||||
data-match-lid={match.lid}
|
data-match-lid={match.lid}
|
||||||
>
|
>
|
||||||
<Card.Section withBorder p={0}>
|
<Card.Section withBorder p={0}>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
{match.home?.name} Cups
|
{match.home?.name} Cups
|
||||||
</Text>
|
</Text>
|
||||||
{
|
{
|
||||||
match.home?.players.map(p => (<Text size='xs' c='dimmed'>
|
match.home?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
|
||||||
{p.first_name} {p.last_name}
|
{p.first_name} {p.last_name}
|
||||||
</Text>))
|
</Text>))
|
||||||
}
|
}
|
||||||
@@ -110,7 +110,7 @@ export const MatchForm: React.FC<MatchFormProps> = ({
|
|||||||
{match.away?.name} Cups
|
{match.away?.name} Cups
|
||||||
</Text>
|
</Text>
|
||||||
{
|
{
|
||||||
match.away?.players.map(p => (<Text size='xs' c='dimmed'>
|
match.away?.players?.map(p => (<Text key={p.id} size='xs' c='dimmed'>
|
||||||
{p.first_name} {p.last_name}
|
{p.first_name} {p.last_name}
|
||||||
</Text>))
|
</Text>))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,16 +21,23 @@ export const MatchSlot: React.FC<MatchSlotProps> = ({
|
|||||||
cups,
|
cups,
|
||||||
isWinner
|
isWinner
|
||||||
}) => (
|
}) => (
|
||||||
<Flex align="stretch">
|
<Flex
|
||||||
|
align="stretch"
|
||||||
|
style={{
|
||||||
|
backgroundColor: isWinner ? 'var(--mantine-color-green-light)' : 'transparent',
|
||||||
|
borderRadius: 'var(--mantine-radius-sm)',
|
||||||
|
transition: 'background-color 200ms ease',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
|
{(seed && seed > 0) ? <SeedBadge seed={seed} /> : undefined}
|
||||||
<Flex p="4px 8px" w='100%' align="center">
|
<Flex p="6px 10px" w='100%' align="center">
|
||||||
<Flex align="center" gap={4} flex={1}>
|
<Flex align="center" gap={4} flex={1}>
|
||||||
{team ? (
|
{team ? (
|
||||||
<>
|
<>
|
||||||
<Text
|
<Text
|
||||||
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
size={team.name.length > 12 ? (team.name.length > 18 ? '10px' : '11px') : 'xs'}
|
||||||
truncate
|
truncate
|
||||||
style={{ minWidth: 0, flex: 1 }}
|
style={{ minWidth: 0, flex: 1, lineHeight: "12px" }}
|
||||||
>
|
>
|
||||||
{team.name}
|
{team.name}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ interface HeaderProps extends HeaderConfig {}
|
|||||||
|
|
||||||
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
|
const Header = ({ collapsed, title, withBackButton }: HeaderProps) => {
|
||||||
return (
|
return (
|
||||||
<AppShell.Header id='app-header' display={collapsed ? 'none' : 'block'}>
|
<AppShell.Header
|
||||||
|
id='app-header'
|
||||||
|
display={collapsed ? 'none' : 'block'}
|
||||||
|
>
|
||||||
{ withBackButton && <BackButton /> }
|
{ withBackButton && <BackButton /> }
|
||||||
<Flex justify='center' align='center' h='100%' px='md'>
|
<Flex justify='center' align='center' h='100%' px='md'>
|
||||||
<Title order={2}>{title}</Title>
|
<Title order={2}>{title}</Title>
|
||||||
|
|||||||
@@ -31,11 +31,7 @@ const Layout: React.FC<PropsWithChildren> = ({ children }) => {
|
|||||||
pos='relative'
|
pos='relative'
|
||||||
h='100dvh'
|
h='100dvh'
|
||||||
mah='100dvh'
|
mah='100dvh'
|
||||||
style={{
|
// style={{ top: viewport.top }} //, transition: 'top 0.1s ease-in-out' }}
|
||||||
top: 0,
|
|
||||||
minHeight: '100dvh',
|
|
||||||
maxHeight: '100dvh'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Header {...header} />
|
<Header {...header} />
|
||||||
<AppShell.Main
|
<AppShell.Main
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { AppShell, ScrollArea, Stack, Group, Paper } from "@mantine/core";
|
import { AppShell, ScrollArea, Stack, Group, Paper, useMantineColorScheme } from "@mantine/core";
|
||||||
import { Link } from "@tanstack/react-router";
|
import { Link } from "@tanstack/react-router";
|
||||||
import { NavLink } from "./nav-link";
|
import { NavLink } from "./nav-link";
|
||||||
import { useIsMobile } from "@/hooks/use-is-mobile";
|
import { useIsMobile } from "@/hooks/use-is-mobile";
|
||||||
@@ -9,11 +9,17 @@ import { memo } from "react";
|
|||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
const { user, roles } = useAuth()
|
const { user, roles } = useAuth()
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const { colorScheme } = useMantineColorScheme();
|
||||||
|
|
||||||
const links = useLinks(user?.id, roles);
|
const links = useLinks(user?.id, roles);
|
||||||
|
|
||||||
|
const isDark = colorScheme === 'dark';
|
||||||
|
const borderColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
|
||||||
|
const boxShadowColor = isDark ? 'var(--mantine-color-dimmed)' : 'black';
|
||||||
|
// boxShadow: `5px 5px ${boxShadowColor}`, borderColor
|
||||||
|
|
||||||
if (isMobile) return (
|
if (isMobile) return (
|
||||||
<Paper component='nav' role='navigation' withBorder radius='lg' h='4rem' w='calc(100% - 1rem)' shadow='sm' pos='fixed' m='0.5rem' bottom='0' style={{ zIndex: 10 }}>
|
<Paper component='nav' role='navigation' withBorder shadow="sm" radius='lg' h='4rem' w='calc(100% - 1.5rem)' pos='fixed' m='0.75rem' bottom='0' style={{ zIndex: 10 }}>
|
||||||
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
<Group gap='xs' justify='space-around' h='100%' w='100%' px={{ base: 12, sm: 0 }}>
|
||||||
{links.map((link) => (
|
{links.map((link) => (
|
||||||
<NavLink key={link.href} {...link} />
|
<NavLink key={link.href} {...link} />
|
||||||
@@ -30,9 +36,6 @@ const Navbar = () => {
|
|||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</AppShell.Section>
|
</AppShell.Section>
|
||||||
<AppShell.Section>
|
|
||||||
<Link to="/logout">Logout</Link>
|
|
||||||
</AppShell.Section>
|
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const PlayerList = ({ players, loading = false }: PlayerListProps) => {
|
|||||||
))}
|
))}
|
||||||
</List>
|
</List>
|
||||||
|
|
||||||
return <List>
|
return <List p="0">
|
||||||
{players?.map((player) => (
|
{players?.map((player) => (
|
||||||
<ListItem key={player.id}
|
<ListItem key={player.id}
|
||||||
py='xs'
|
py='xs'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Badge, FileInput, Group, Stack, Text, TextInput } from "@mantine/core";
|
import { FileInput, Stack, TextInput } from "@mantine/core";
|
||||||
import { useForm, UseFormInput } from "@mantine/form";
|
import { useForm, UseFormInput } from "@mantine/form";
|
||||||
import { LinkIcon } from "@phosphor-icons/react";
|
import { LinkIcon } from "@phosphor-icons/react";
|
||||||
import SlidePanel, { SlidePanelField } from "@/components/sheet/slide-panel";
|
import SlidePanel from "@/components/sheet/slide-panel";
|
||||||
import { isNotEmpty } from "@mantine/form";
|
import { isNotEmpty } from "@mantine/form";
|
||||||
import useCreateTeam from "../../hooks/use-create-team";
|
import useCreateTeam from "../../hooks/use-create-team";
|
||||||
import useUpdateTeam from "../../hooks/use-update-team";
|
import useUpdateTeam from "../../hooks/use-update-team";
|
||||||
@@ -13,8 +13,8 @@ import { useCallback } from "react";
|
|||||||
import { TeamInput } from "../../types";
|
import { TeamInput } from "../../types";
|
||||||
import { teamKeys } from "../../queries";
|
import { teamKeys } from "../../queries";
|
||||||
import SongPicker from "./song-picker";
|
import SongPicker from "./song-picker";
|
||||||
import TeamColorPicker from "./color-picker";
|
|
||||||
import PlayersPicker from "./players-picker";
|
import PlayersPicker from "./players-picker";
|
||||||
|
import imageCompression from "browser-image-compression";
|
||||||
|
|
||||||
interface TeamFormProps {
|
interface TeamFormProps {
|
||||||
close: () => void;
|
close: () => void;
|
||||||
@@ -113,9 +113,32 @@ const TeamForm = ({
|
|||||||
|
|
||||||
if (logo && team) {
|
if (logo && team) {
|
||||||
try {
|
try {
|
||||||
|
let processedLogo = logo;
|
||||||
|
|
||||||
|
if (logo.size > 500 * 1024) {
|
||||||
|
const compressionOptions = {
|
||||||
|
maxSizeMB: 0.5,
|
||||||
|
maxWidthOrHeight: 800,
|
||||||
|
useWebWorker: true,
|
||||||
|
fileType: logo.type,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
processedLogo = await imageCompression(logo, compressionOptions);
|
||||||
|
logger.info("image compressed", {
|
||||||
|
originalSize: logo.size,
|
||||||
|
compressedSize: processedLogo.size,
|
||||||
|
reduction: ((logo.size - processedLogo.size) / logo.size * 100).toFixed(1) + "%"
|
||||||
|
});
|
||||||
|
} catch (compressionError) {
|
||||||
|
logger.warn("compression failed, falling back", compressionError);
|
||||||
|
processedLogo = logo;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("teamId", team.id);
|
formData.append("teamId", team.id);
|
||||||
formData.append("logo", logo);
|
formData.append("logo", processedLogo);
|
||||||
|
|
||||||
const response = await fetch("/api/teams/upload-logo", {
|
const response = await fetch("/api/teams/upload-logo", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from "react";
|
import { Text, Group, Avatar, Box } from "@mantine/core";
|
||||||
import { Text, TextInput, Group, Avatar, Loader, Paper, Stack, Box } from "@mantine/core";
|
|
||||||
import { SpotifyTrack } from "@/lib/spotify/types";
|
import { SpotifyTrack } from "@/lib/spotify/types";
|
||||||
import { useDebouncedCallback } from "@mantine/hooks";
|
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||||
|
|
||||||
interface SongSearchProps {
|
interface SongSearchProps {
|
||||||
onChange: (track: SpotifyTrack) => void;
|
onChange: (track: SpotifyTrack) => void;
|
||||||
@@ -9,15 +8,7 @@ interface SongSearchProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearchProps) => {
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const searchSpotifyTracks = async (query: string): Promise<TypeaheadOption<SpotifyTrack>[]> => {
|
||||||
const [searchResults, setSearchResults] = useState<SpotifyTrack[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
|
||||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const searchSpotifyTracks = async (query: string): Promise<SpotifyTrack[]> => {
|
|
||||||
if (!query.trim()) return [];
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -28,155 +19,62 @@ const SongSearch = ({ onChange, placeholder = "Search for songs..." }: SongSearc
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return data.tracks || [];
|
const tracks = data.tracks || [];
|
||||||
|
|
||||||
|
return tracks.map((track: SpotifyTrack) => ({
|
||||||
|
id: track.id,
|
||||||
|
data: track
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to search tracks:', error);
|
console.error('Failed to search tracks:', error);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const debouncedSearch = useDebouncedCallback(async (query: string) => {
|
const handleSongSelect = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||||
if (!query.trim()) {
|
onChange(option.data);
|
||||||
setSearchResults([]);
|
|
||||||
setIsOpen(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsLoading(true);
|
|
||||||
try {
|
|
||||||
const results = await searchSpotifyTracks(query);
|
|
||||||
setSearchResults(results);
|
|
||||||
setIsOpen(results.length > 0);
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Search failed:', error);
|
|
||||||
setSearchResults([]);
|
|
||||||
setIsOpen(false);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
|
||||||
setSearchQuery(value);
|
|
||||||
debouncedSearch(value);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSongSelect = (track: SpotifyTrack) => {
|
const formatTrack = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||||
onChange(track);
|
const track = option.data;
|
||||||
setSearchQuery(`${track.name} - ${track.artists.map(a => a.name).join(', ')}`);
|
return `${track.name} - ${track.artists.map(a => a.name).join(', ')}`;
|
||||||
setIsOpen(false);
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
const renderOption = (option: TypeaheadOption<SpotifyTrack>) => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const track = option.data;
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
return (
|
||||||
setIsOpen(false);
|
<Box
|
||||||
}
|
p="sm"
|
||||||
};
|
style={{
|
||||||
|
borderBottom: '1px solid var(--mantine-color-dimmed)'
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
}}
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
>
|
||||||
}, []);
|
<Group gap="sm">
|
||||||
|
{track.album.images[2] && (
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
||||||
if (!isOpen || searchResults.length === 0) return;
|
)}
|
||||||
|
<div>
|
||||||
switch (e.key) {
|
<Text size="sm" fw={500}>
|
||||||
case 'ArrowDown':
|
{track.name}
|
||||||
e.preventDefault();
|
</Text>
|
||||||
setSelectedIndex(prev => (prev < searchResults.length - 1 ? prev + 1 : prev));
|
<Text size="xs" c="dimmed">
|
||||||
break;
|
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
||||||
case 'ArrowUp':
|
</Text>
|
||||||
e.preventDefault();
|
</div>
|
||||||
setSelectedIndex(prev => (prev > 0 ? prev - 1 : prev));
|
</Group>
|
||||||
break;
|
</Box>
|
||||||
case 'Enter':
|
);
|
||||||
e.preventDefault();
|
|
||||||
if (selectedIndex >= 0 && searchResults[selectedIndex]) {
|
|
||||||
handleSongSelect(searchResults[selectedIndex]);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'Escape':
|
|
||||||
setIsOpen(false);
|
|
||||||
setSelectedIndex(-1);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box ref={containerRef} pos="relative" w="100%">
|
<Typeahead
|
||||||
<TextInput
|
onSelect={handleSongSelect}
|
||||||
ref={inputRef}
|
searchFn={searchSpotifyTracks}
|
||||||
value={searchQuery}
|
renderOption={renderOption}
|
||||||
onChange={(event) => handleSearchChange(event.currentTarget.value)}
|
format={formatTrack}
|
||||||
onKeyDown={handleKeyDown}
|
placeholder={placeholder}
|
||||||
onFocus={() => {
|
/>
|
||||||
if (searchResults.length > 0) setIsOpen(true);
|
)
|
||||||
}}
|
|
||||||
placeholder={placeholder}
|
|
||||||
rightSection={isLoading ? <Loader size="xs" /> : null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<Paper
|
|
||||||
shadow="md"
|
|
||||||
p={0}
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '100%',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: 9999,
|
|
||||||
maxHeight: '160px',
|
|
||||||
overflowY: 'auto',
|
|
||||||
WebkitOverflowScrolling: 'touch',
|
|
||||||
touchAction: 'pan-y'
|
|
||||||
}}
|
|
||||||
onTouchMove={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{searchResults.length > 0 ? (
|
|
||||||
<Stack gap={0}>
|
|
||||||
{searchResults.map((track, index) => (
|
|
||||||
<Box
|
|
||||||
key={track.id}
|
|
||||||
p="sm"
|
|
||||||
style={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
backgroundColor: selectedIndex === index ? 'var(--mantine-color-gray-1)' : 'transparent',
|
|
||||||
borderBottom: index < searchResults.length - 1 ? '1px solid var(--mantine-color-gray-3)' : 'none'
|
|
||||||
}}
|
|
||||||
onClick={() => handleSongSelect(track)}
|
|
||||||
onMouseEnter={() => setSelectedIndex(index)}
|
|
||||||
>
|
|
||||||
<Group gap="sm">
|
|
||||||
{track.album.images[2] && (
|
|
||||||
<Avatar src={track.album.images[2].url} size={40} radius="sm" />
|
|
||||||
)}
|
|
||||||
<div>
|
|
||||||
<Text size="sm" fw={500}>
|
|
||||||
{track.name}
|
|
||||||
</Text>
|
|
||||||
<Text size="xs" c="dimmed">
|
|
||||||
{track.artists.map(a => a.name).join(', ')} • {track.album.name}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Group>
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<Box p="md">
|
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
|
||||||
{searchQuery.trim() ? 'No songs found' : 'Start typing to search...'}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default SongSearch;
|
export default SongSearch;
|
||||||
@@ -58,7 +58,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
|
|||||||
|
|
||||||
if (loading)
|
if (loading)
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
{Array.from({ length: 10 }).map((_, i) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={`skeleton-${i}`}
|
key={`skeleton-${i}`}
|
||||||
@@ -72,7 +72,7 @@ const TeamList = ({ teams, loading = false, onTeamClick }: TeamListProps) => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{teams?.map((team) => (
|
{teams?.map((team) => (
|
||||||
<div key={team.id}>
|
<div key={team.id}>
|
||||||
<ListItem
|
<ListItem
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
Autocomplete,
|
|
||||||
Stack,
|
Stack,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Text,
|
Text,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
|
import Typeahead, { TypeaheadOption } from "@/components/typeahead";
|
||||||
import { TrashIcon } from "@phosphor-icons/react";
|
import { TrashIcon } from "@phosphor-icons/react";
|
||||||
import { useState, useCallback, useMemo, memo } from "react";
|
import { useState, useCallback, useMemo, memo } from "react";
|
||||||
import { useTournament, useUnenrolledTeams } from "../queries";
|
import { useTournament, useUnenrolledTeams } from "../queries";
|
||||||
@@ -68,8 +68,6 @@ const TeamItem = memo(({ team, onUnenroll, disabled }: TeamItemProps) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
|
|
||||||
const { data: tournament, isLoading: tournamentLoading } =
|
const { data: tournament, isLoading: tournamentLoading } =
|
||||||
useTournament(tournamentId);
|
useTournament(tournamentId);
|
||||||
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
const { data: unenrolledTeams = [], isLoading: unenrolledLoading } =
|
||||||
@@ -78,27 +76,24 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|||||||
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
const { mutate: enrollTeam, isPending: isEnrolling } = useEnrollTeam();
|
||||||
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
const { mutate: unenrollTeam, isPending: isUnenrolling } = useUnenrollTeam();
|
||||||
|
|
||||||
const autocompleteData = useMemo(
|
const searchTeams = async (query: string): Promise<TypeaheadOption<Team>[]> => {
|
||||||
() =>
|
if (!query.trim()) return [];
|
||||||
unenrolledTeams.map((team: Team) => ({
|
|
||||||
value: team.id,
|
const filtered = unenrolledTeams.filter((team: Team) =>
|
||||||
label: team.name,
|
team.name.toLowerCase().includes(query.toLowerCase())
|
||||||
})),
|
);
|
||||||
[unenrolledTeams]
|
|
||||||
);
|
return filtered.map((team: Team) => ({
|
||||||
|
id: team.id,
|
||||||
|
data: team
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
const handleEnrollTeam = useCallback(
|
const handleEnrollTeam = useCallback(
|
||||||
(teamId: string) => {
|
(option: TypeaheadOption<Team>) => {
|
||||||
enrollTeam(
|
enrollTeam({ tournamentId, teamId: option.data.id });
|
||||||
{ tournamentId, teamId },
|
|
||||||
{
|
|
||||||
onSuccess: () => {
|
|
||||||
setSearch("");
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[enrollTeam, tournamentId, setSearch]
|
[enrollTeam, tournamentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUnenrollTeam = useCallback(
|
const handleUnenrollTeam = useCallback(
|
||||||
@@ -108,6 +103,31 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|||||||
[unenrollTeam, tournamentId]
|
[unenrollTeam, tournamentId]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderTeamOption = (option: TypeaheadOption<Team>) => {
|
||||||
|
const team = option.data;
|
||||||
|
return (
|
||||||
|
<Group py="xs" px="sm" gap="sm" align="center">
|
||||||
|
<Avatar
|
||||||
|
size={32}
|
||||||
|
radius="sm"
|
||||||
|
name={team.name}
|
||||||
|
src={
|
||||||
|
team.logo
|
||||||
|
? `/api/files/teams/${team.id}/${team.logo}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Text fw={500} truncate>
|
||||||
|
{team.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTeam = (option: TypeaheadOption<Team>) => {
|
||||||
|
return option.data.name;
|
||||||
|
};
|
||||||
|
|
||||||
const isLoading = tournamentLoading || unenrolledLoading;
|
const isLoading = tournamentLoading || unenrolledLoading;
|
||||||
const enrolledTeams = tournament?.teams || [];
|
const enrolledTeams = tournament?.teams || [];
|
||||||
const hasEnrolledTeams = enrolledTeams.length > 0;
|
const hasEnrolledTeams = enrolledTeams.length > 0;
|
||||||
@@ -118,16 +138,13 @@ const EditEnrolledTeams = ({ tournamentId }: EditEnrolledTeamsProps) => {
|
|||||||
<Text fw={600} size="sm">
|
<Text fw={600} size="sm">
|
||||||
Add Team
|
Add Team
|
||||||
</Text>
|
</Text>
|
||||||
<Autocomplete
|
<Typeahead
|
||||||
placeholder="Search for teams to enroll..."
|
placeholder="Search for teams to enroll..."
|
||||||
data={autocompleteData}
|
onSelect={handleEnrollTeam}
|
||||||
value={search}
|
searchFn={searchTeams}
|
||||||
onChange={setSearch}
|
renderOption={renderTeamOption}
|
||||||
onOptionSubmit={handleEnrollTeam}
|
format={formatTeam}
|
||||||
disabled={isEnrolling || unenrolledLoading}
|
disabled={isEnrolling || unenrolledLoading}
|
||||||
rightSection={isEnrolling ? <Loader size="xs" /> : null}
|
|
||||||
maxDropdownHeight={200}
|
|
||||||
limit={10}
|
|
||||||
/>
|
/>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ const ManageTournament = ({ tournamentId }: ManageTournamentProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<List>
|
<List p="0">
|
||||||
<ListButton
|
<ListButton
|
||||||
label="Edit Tournament"
|
label="Edit Tournament"
|
||||||
Icon={HardDrivesIcon}
|
Icon={HardDrivesIcon}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const Header = ({ tournament }: HeaderProps) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
|
<Flex h="20dvh" px='xl' w='100%' align='self-end' gap='md'>
|
||||||
<Avatar name={tournament.name} radius={0} withBorder={false} size={125} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
|
<Avatar contain name={tournament.name} radius={0} withBorder={false} size={150} src={`/api/files/tournaments/${tournament.id}/${tournament.logo}`} />
|
||||||
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
<Flex align='center' justify='center' gap={4} pb={20} w='100%'>
|
||||||
<Title ta='center' order={2}>{tournament.name}</Title>
|
<Title ta='center' order={2}>{tournament.name}</Title>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
<Stack px="sm" align="center" gap={0}>
|
<Stack px="sm" align="center" gap={0}>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
|
contain
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export const TournamentCard = ({ tournament }: TournamentCardProps) => {
|
|||||||
size={90}
|
size={90}
|
||||||
radius="sm"
|
radius="sm"
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
|
contain
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={`skeleton-${i}`}
|
key={`skeleton-${i}`}
|
||||||
@@ -97,7 +97,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<List>
|
<List p="0">
|
||||||
{tournaments.map((tournament) => (
|
{tournaments.map((tournament) => (
|
||||||
<>
|
<>
|
||||||
<ListItem
|
<ListItem
|
||||||
@@ -108,6 +108,7 @@ const TournamentList = ({ tournaments, loading = false }: TournamentListProps) =
|
|||||||
radius="sm"
|
radius="sm"
|
||||||
size={40}
|
size={40}
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
|
contain
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
|||||||
@@ -281,5 +281,3 @@ export const TournamentStats = memo(({ tournament }: TournamentStatsProps) => {
|
|||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
TournamentStats.displayName = 'TournamentStats';
|
|
||||||
@@ -27,7 +27,7 @@ const EnrollTeam = ({ tournamentId, onSubmit }: EnrollTeamProps) => {
|
|||||||
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
|
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
|
||||||
|
|
||||||
const { data: teamData } = useServerQuery({
|
const { data: teamData } = useServerQuery({
|
||||||
...teamQueries.details(selectedTeamId!),
|
...teamQueries.details(selectedTeamId || ''),
|
||||||
options: { enabled: !!selectedTeamId }
|
options: { enabled: !!selectedTeamId }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Stack, Button, Divider, Autocomplete, Group, ComboboxItem } from '@mantine/core';
|
import { Stack, Button, Divider, Group, ComboboxItem, Text } from '@mantine/core';
|
||||||
import { PlusIcon } from '@phosphor-icons/react';
|
import { PlusIcon } from '@phosphor-icons/react';
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import Typeahead, { TypeaheadOption } from '@/components/typeahead';
|
||||||
|
|
||||||
interface TeamSelectionViewProps {
|
interface TeamSelectionViewProps {
|
||||||
options: ComboboxItem[];
|
options: ComboboxItem[];
|
||||||
@@ -11,11 +12,39 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
|||||||
options,
|
options,
|
||||||
onSelect
|
onSelect
|
||||||
}) => {
|
}) => {
|
||||||
const [value, setValue] = useState<string>('');
|
const [selectedTeam, setSelectedTeam] = React.useState<ComboboxItem | null>(null);
|
||||||
const selectedOption = useMemo(() => options.find(option => option.label === value), [value, options])
|
|
||||||
|
const searchTeams = async (query: string): Promise<TypeaheadOption<ComboboxItem>[]> => {
|
||||||
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
|
const filtered = options.filter(option =>
|
||||||
|
option.label.toLowerCase().includes(query.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return filtered.map(option => ({
|
||||||
|
id: String(option.value),
|
||||||
|
data: option
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTeamSelect = (option: TypeaheadOption<ComboboxItem>) => {
|
||||||
|
setSelectedTeam(option.data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderTeamOption = (option: TypeaheadOption<ComboboxItem>) => {
|
||||||
|
return (
|
||||||
|
<Group py="xs" px="sm" gap="sm">
|
||||||
|
<Text fw={500}>{option.data.label}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTeam = (option: TypeaheadOption<ComboboxItem>) => {
|
||||||
|
return option.data.label;
|
||||||
|
};
|
||||||
|
|
||||||
const handleCreateNewTeamClicked = () => onSelect(undefined);
|
const handleCreateNewTeamClicked = () => onSelect(undefined);
|
||||||
const handleSelectExistingTeam = () => onSelect(selectedOption?.value)
|
const handleSelectExistingTeam = () => onSelect(selectedTeam?.value);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
@@ -31,17 +60,17 @@ const TeamSelectionView: React.FC<TeamSelectionViewProps> = React.memo(({
|
|||||||
<Divider my="sm" label="or" />
|
<Divider my="sm" label="or" />
|
||||||
|
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Autocomplete
|
<Typeahead
|
||||||
placeholder="Select one of your existing teams"
|
placeholder="Select one of your existing teams"
|
||||||
value={value}
|
onSelect={handleTeamSelect}
|
||||||
onChange={setValue}
|
searchFn={searchTeams}
|
||||||
data={options.map(option => option.label)}
|
renderOption={renderTeamOption}
|
||||||
comboboxProps={{ withinPortal: false }}
|
format={formatTeam}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSelectExistingTeam}
|
onClick={handleSelectExistingTeam}
|
||||||
disabled={!selectedOption}
|
disabled={!selectedTeam}
|
||||||
fullWidth
|
fullWidth
|
||||||
>
|
>
|
||||||
Enroll Selected Team
|
Enroll Selected Team
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const Header = ({ tournament }: { tournament: Tournament }) => {
|
|||||||
<Stack align="center" gap={0}>
|
<Stack align="center" gap={0}>
|
||||||
<Avatar
|
<Avatar
|
||||||
name={tournament.name}
|
name={tournament.name}
|
||||||
|
contain
|
||||||
src={
|
src={
|
||||||
tournament.logo
|
tournament.logo
|
||||||
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
? `/api/files/tournaments/${tournament.id}/${tournament.logo}`
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ interface UpdateTeamProps {
|
|||||||
|
|
||||||
const UpdateTeam = ({ tournamentId, teamId }: UpdateTeamProps) => {
|
const UpdateTeam = ({ tournamentId, teamId }: UpdateTeamProps) => {
|
||||||
const { open, isOpen, toggle } = useSheet();
|
const { open, isOpen, toggle } = useSheet();
|
||||||
|
|
||||||
const { data: team } = useTeam(teamId);
|
const { data: team } = useTeam(teamId);
|
||||||
|
|
||||||
const initialValues = useMemo(() => {
|
const initialValues = useMemo(() => {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export const getUnenrolledTeams = createServerFn()
|
|||||||
|
|
||||||
export const getFreeAgents = createServerFn()
|
export const getFreeAgents = createServerFn()
|
||||||
.inputValidator(z.string())
|
.inputValidator(z.string())
|
||||||
.middleware([superTokensAdminFunctionMiddleware])
|
.middleware([superTokensFunctionMiddleware])
|
||||||
.handler(async ({ data: tournamentId }) =>
|
.handler(async ({ data: tournamentId }) =>
|
||||||
toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
|
toServerResult(() => pbAdmin.getFreeAgents(tournamentId))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function useServerEvents() {
|
|||||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
|
|
||||||
shouldConnectRef.current = true;
|
shouldConnectRef.current = true;
|
||||||
@@ -111,5 +112,5 @@ export function useServerEvents() {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [user?.id, queryClient]);
|
}, [user?.id]);
|
||||||
}
|
}
|
||||||
@@ -12,11 +12,6 @@ class PocketBaseAdminClient {
|
|||||||
public authPromise: Promise<void>;
|
public authPromise: Promise<void>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
console.log('Environment variables loaded:', {
|
|
||||||
POCKETBASE_URL: process.env.POCKETBASE_URL,
|
|
||||||
POCKETBASE_ADMIN_EMAIL: process.env.POCKETBASE_ADMIN_EMAIL,
|
|
||||||
POCKETBASE_ADMIN_PASSWORD: process.env.POCKETBASE_ADMIN_PASSWORD,
|
|
||||||
});
|
|
||||||
this.pb = new PocketBase(process.env.POCKETBASE_URL);
|
this.pb = new PocketBase(process.env.POCKETBASE_URL);
|
||||||
|
|
||||||
this.pb.beforeSend = (url, options) => {
|
this.pb.beforeSend = (url, options) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { PKCEState, SpotifyTokenResponse } from './types';
|
import type { SpotifyTokenResponse } from './types';
|
||||||
|
|
||||||
const SPOTIFY_AUTH_BASE = 'https://accounts.spotify.com';
|
const SPOTIFY_AUTH_BASE = 'https://accounts.spotify.com';
|
||||||
const SPOTIFY_SCOPES = [
|
const SPOTIFY_SCOPES = [
|
||||||
|
|||||||
@@ -4,6 +4,32 @@ import Passwordless from "supertokens-web-js/recipe/passwordless";
|
|||||||
import { appInfo } from "./config";
|
import { appInfo } from "./config";
|
||||||
import { logger } from "./";
|
import { logger } from "./";
|
||||||
|
|
||||||
|
let refreshAttemptCount = 0;
|
||||||
|
|
||||||
|
export const resetRefreshFlag = () => {
|
||||||
|
refreshAttemptCount = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setupFetchInterceptor = () => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
//@ts-ignore
|
||||||
|
window.fetch = async (resource: RequestInfo | URL, options?: RequestInit) => {
|
||||||
|
const url = typeof resource === 'string' ? resource :
|
||||||
|
resource instanceof URL ? resource.toString() : resource.url;
|
||||||
|
|
||||||
|
if (url.includes('/api/auth/session/refresh')) {
|
||||||
|
refreshAttemptCount++;
|
||||||
|
if (refreshAttemptCount > 1) {
|
||||||
|
throw new Error('Duplicate refresh attempt blocked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalFetch.call(window, resource, options);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const frontendConfig = () => {
|
export const frontendConfig = () => {
|
||||||
return {
|
return {
|
||||||
appInfo,
|
appInfo,
|
||||||
@@ -12,7 +38,6 @@ export const frontendConfig = () => {
|
|||||||
Session.init({
|
Session.init({
|
||||||
tokenTransferMethod: "cookie",
|
tokenTransferMethod: "cookie",
|
||||||
sessionTokenBackendDomain: undefined,
|
sessionTokenBackendDomain: undefined,
|
||||||
|
|
||||||
preAPIHook: async (context) => {
|
preAPIHook: async (context) => {
|
||||||
context.requestInit.credentials = "include";
|
context.requestInit.credentials = "include";
|
||||||
return context;
|
return context;
|
||||||
@@ -23,16 +48,14 @@ export const frontendConfig = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
|
|
||||||
export function ensureSuperTokensFrontend() {
|
export function ensureSuperTokensFrontend() {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
if (!initialized) {
|
if (!initialized) {
|
||||||
|
setupFetchInterceptor();
|
||||||
SuperTokens.init(frontendConfig());
|
SuperTokens.init(frontendConfig());
|
||||||
initialized = true;
|
initialized = true;
|
||||||
logger.info("Initialized");
|
logger.info("SuperTokens initialized");
|
||||||
|
|
||||||
Session.doesSessionExist().then((exists) => {
|
|
||||||
logger.info(`Session does${exists ? "" : "NOT"} exist on load!`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ export const backendConfig = (): TypeInput => {
|
|||||||
framework: "custom",
|
framework: "custom",
|
||||||
supertokens: {
|
supertokens: {
|
||||||
connectionURI:
|
connectionURI:
|
||||||
import.meta.env.SUPERTOKENS_URI || "https://try.supertokens.io",
|
process.env.SUPERTOKENS_URI || "https://try.supertokens.io",
|
||||||
},
|
},
|
||||||
appInfo,
|
appInfo,
|
||||||
recipeList: [
|
recipeList: [
|
||||||
PasswordlessDevelopmentMode.init(),
|
passwordlessTwilioVerify.init(),
|
||||||
Session.init({
|
Session.init({
|
||||||
cookieSameSite: "lax",
|
cookieSameSite: "lax",
|
||||||
cookieSecure: import.meta.env.NODE_ENV === "production",
|
cookieSecure: import.meta.env.NODE_ENV === "production",
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ export function useOptimisticMutation<TData, TVariables = unknown>(
|
|||||||
|
|
||||||
return { previousData };
|
return { previousData };
|
||||||
},
|
},
|
||||||
onError: (error, variables, context) => {
|
onError: (error, variables, onMutateResult, context) => {
|
||||||
if (context && typeof context === 'object' && 'previousData' in context && context.previousData) {
|
if (context && typeof context === 'object' && 'previousData' in context && context.previousData) {
|
||||||
queryClient.setQueryData(queryKey, 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 });
|
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 { ServerResult } from "../types";
|
||||||
import toast from '@/lib/sonner'
|
import toast from '@/lib/sonner'
|
||||||
|
|
||||||
|
let isMutationRefreshingSession = false;
|
||||||
|
|
||||||
export function useServerMutation<TData, TVariables = unknown>(
|
export function useServerMutation<TData, TVariables = unknown>(
|
||||||
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
|
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
|
||||||
mutationFn: (variables: TVariables) => Promise<ServerResult<TData>>;
|
mutationFn: (variables: TVariables) => Promise<ServerResult<TData>>;
|
||||||
@@ -42,8 +44,14 @@ export function useServerMutation<TData, TVariables = unknown>(
|
|||||||
: error.response.data;
|
: error.response.data;
|
||||||
|
|
||||||
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
if (errorData?.error === "SESSION_REFRESH_REQUIRED") {
|
||||||
const currentUrl = window.location.pathname + window.location.search;
|
if (!isMutationRefreshingSession) {
|
||||||
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
isMutationRefreshingSession = true;
|
||||||
|
const currentUrl = window.location.pathname + window.location.search;
|
||||||
|
setTimeout(() => {
|
||||||
|
isMutationRefreshingSession = false;
|
||||||
|
}, 1000);
|
||||||
|
window.location.href = `/refresh-session?redirect=${encodeURIComponent(currentUrl)}`;
|
||||||
|
}
|
||||||
throw new Error("SESSION_REFRESH_REQUIRED");
|
throw new Error("SESSION_REFRESH_REQUIRED");
|
||||||
}
|
}
|
||||||
} catch (parseError) {}
|
} catch (parseError) {}
|
||||||
@@ -52,14 +60,14 @@ export function useServerMutation<TData, TVariables = unknown>(
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSuccess: (data, variables, context) => {
|
onSuccess: (data, variables, onMutateResult, context) => {
|
||||||
if (showSuccessToast && successMessage) {
|
if (showSuccessToast && successMessage) {
|
||||||
toast.success(successMessage);
|
toast.success(successMessage);
|
||||||
}
|
}
|
||||||
onSuccess?.(data, variables, context);
|
onSuccess?.(data, variables, onMutateResult, context);
|
||||||
},
|
},
|
||||||
onError: (error, variables, context) => {
|
onError: (error, variables, onMutateResult, context) => {
|
||||||
onError?.(error, variables, context);
|
onError?.(error, variables, onMutateResult, context);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import type { Twilio } from "twilio";
|
import twilio, { type Twilio } from "twilio";
|
||||||
|
|
||||||
const accountSid = import.meta.env.TWILIO_ACCOUNT_SID!;
|
const accountSid = process.env.TWILIO_ACCOUNT_SID!;
|
||||||
const authToken = import.meta.env.TWILIO_AUTH_TOKEN!;
|
const authToken = process.env.TWILIO_AUTH_TOKEN!;
|
||||||
const serviceSid = import.meta.env.TWILIO_SERVICE_SID!;
|
const serviceSid = process.env.TWILIO_SERVICE_SID!;
|
||||||
|
|
||||||
let client: Twilio;
|
let client: Twilio;
|
||||||
|
|
||||||
function getTwilioClient() {
|
function getTwilioClient() {
|
||||||
if (!client) {
|
if (!client) {
|
||||||
const twilio = require("twilio");
|
|
||||||
client = twilio(accountSid, authToken);
|
client = twilio(accountSid, authToken);
|
||||||
}
|
}
|
||||||
return client;
|
return client;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createMiddleware,
|
createMiddleware,
|
||||||
createServerFn,
|
createServerFn,
|
||||||
|
createServerOnlyFn,
|
||||||
} from "@tanstack/react-start";
|
} from "@tanstack/react-start";
|
||||||
import { getRequest, setResponseHeader } from "@tanstack/react-start/server";
|
import { getRequest, setResponseHeader } from "@tanstack/react-start/server";
|
||||||
import { redirect as redirect } from "@tanstack/react-router";
|
import { redirect as redirect } from "@tanstack/react-router";
|
||||||
@@ -9,42 +10,16 @@ import UserMetadata from "supertokens-node/recipe/usermetadata";
|
|||||||
import { getSessionForStart } from "@/lib/supertokens/recipes/start-session";
|
import { getSessionForStart } from "@/lib/supertokens/recipes/start-session";
|
||||||
import { Logger } from "@/lib/logger";
|
import { Logger } from "@/lib/logger";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { refreshSession } from "supertokens-node/recipe/session";
|
|
||||||
|
|
||||||
const logger = new Logger("Middleware");
|
const logger = new Logger("Middleware");
|
||||||
|
|
||||||
export const verifySuperTokensSession = async (
|
const verifySuperTokensSession = async (
|
||||||
request: Request
|
request: Request
|
||||||
) => {
|
) => {
|
||||||
let session = await getSessionForStart(request, { sessionRequired: false });
|
let session = await getSessionForStart(request, { sessionRequired: false });
|
||||||
|
|
||||||
if (session?.needsRefresh) {
|
if (session?.needsRefresh) {
|
||||||
logger.info("Session needs refresh");
|
logger.info("Session needs refresh - redirecting to client");
|
||||||
|
return { context: { session: { tryRefresh: true } } };
|
||||||
try {
|
|
||||||
const refreshedSession = await refreshSession(request, {
|
|
||||||
setHeader: (key: string, value: string) => {
|
|
||||||
setResponseHeader(key, value);
|
|
||||||
},
|
|
||||||
setCookie: (cookie: string) => {
|
|
||||||
setResponseHeader('Set-Cookie', cookie);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (refreshedSession) {
|
|
||||||
session = await getSessionForStart(request, { sessionRequired: false });
|
|
||||||
}
|
|
||||||
if (session?.needsRefresh) {
|
|
||||||
return { context: { session: { tryRefresh: true } } };
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
logger.error("Session refresh error", error);
|
|
||||||
|
|
||||||
if (error.type === 'UNAUTHORISED' || error.type === 'TOKEN_THEFT_DETECTED') {
|
|
||||||
return { context: { userAuthId: null, roles: [] } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { context: { session: { tryRefresh: true } } };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userAuthId = session?.userId;
|
const userAuthId = session?.userId;
|
||||||
@@ -71,7 +46,7 @@ export const verifySuperTokensSession = async (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSessionContext = async (request: Request, options?: { isServerFunction?: boolean }) => {
|
export const getSessionContext = createServerOnlyFn(async (request: Request, options?: { isServerFunction?: boolean }) => {
|
||||||
const session = await verifySuperTokensSession(request);
|
const session = await verifySuperTokensSession(request);
|
||||||
|
|
||||||
if (session.context.session?.tryRefresh) {
|
if (session.context.session?.tryRefresh) {
|
||||||
@@ -100,7 +75,7 @@ export const getSessionContext = async (request: Request, options?: { isServerFu
|
|||||||
};
|
};
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
};
|
});
|
||||||
|
|
||||||
export const superTokensRequestMiddleware = createMiddleware({
|
export const superTokensRequestMiddleware = createMiddleware({
|
||||||
type: "request",
|
type: "request",
|
||||||
|
|||||||
Reference in New Issue
Block a user